هل سبق لك أن كان هذا الشرط؟
أريد أن أوضح لك كيف يمكن لـ TDD تحسين جودة الشفرة باستخدام مثال محدد.
لأن كل ما التقيت به أثناء دراسة القضية كان نظريًا تمامًا.
لقد حدث أن كتبت تطبيقين متطابقين تقريبًا: أحدهما كتب على الطراز الكلاسيكي ، حيث أنني لم أكن أعرف TDD بعد ذلك ، والثاني - فقط باستخدام TDD.
أدناه سوف تظهر حيث كانت أكبر الاختلافات.
شخصياً ، كان هذا الأمر مهمًا بالنسبة لي ، لأنه في كل مرة يعثر فيها شخص ما على خطأ في التعليمات البرمجية الخاصة بي ، لاحظت انخفاضًا كبيرًا في تقدير الذات. نعم ، فهمت أن الأخطاء طبيعية ، فالجميع يكتبونها ، لكن الشعور بالنقص لم يختف. أيضًا ، أثناء عملية تطور الخدمة ، أدركت في بعض الأحيان أنني شخصياً كتبت واحدة تحث حكة يدي على التخلص من كل شيء وإعادة كتابته مرة أخرى. وكيف حدث ذلك غير مفهوم. كان كل شيء على ما يرام في البداية ، ولكن بعد بضع ميزات وبعد فترة لا يمكنك إلقاء نظرة على الهندسة المعمارية دون دموع. على الرغم من أن كل خطوة من خطوات التغيير كانت منطقية. الشعور الذي لم يعجبني ناتج عملي كان يتدفق بسلاسة إلى الشعور بأن المبرمج كان مني ، أعذرني ، مثل رصاصة من القرف.
اتضح أنني لست الوحيد والكثير من زملائي لديهم أحاسيس مماثلة. ثم قررت إما أن أتعلم الكتابة بشكل طبيعي ، أو حان الوقت لتغيير مهنتي. حاولت تطويرًا معتمدًا على الاختبار في محاولة لتغيير شيء ما في طريقة البرمجة الخاصة بي.
بالنظر إلى المستقبل ، استنادًا إلى نتائج العديد من المشاريع ، يمكنني القول أن TDD توفر بنية أنظف ، لكنها تبطئ عملية التطوير. وانها ليست دائما مناسبة وليس للجميع.
ما هو TDD مرة أخرى
TDD - التنمية من خلال الاختبار. مقالة ويكي
هنا .
تتمثل الطريقة الكلاسيكية في كتابة طلب أولاً ، ثم تغطيته باختبارات.
نهج TDD - نكتب أولاً اختبارات للفصل ، ثم التنفيذ. ننتقل عبر مستويات التجريد - من الأعلى إلى التطبيقي ، وفي نفس الوقت نقسم التطبيق إلى طبقات طبقات نرتب منها السلوك الذي نحتاج إليه ، ونتحرر من تطبيق معين.
وإذا قرأت هذا للمرة الأولى ، فلن أفهم شيئًا أيضًا.
الكثير من الكلمات المجردة: دعنا ننظر إلى مثال.
سنكتب تطبيقًا ربيعيًا حقيقيًا في Java ، وسنكتبه في TDD ، وسأحاول إظهار عملية تفكيري أثناء عملية التطوير واستخلاص استنتاجات في النهاية سواء كان من المنطقي قضاء بعض الوقت على TDD أم لا.
مهمة عملية
لنفترض أننا محظوظون للغاية بحيث لدينا الشروط المرجعية لما نحتاج إلى تطويره. عادة ، لا يهتم المحللون بها ، ويبدو الأمر كما يلي:
من الضروري تطوير خدمة microservice تحسب إمكانية بيع البضائع مع التسليم اللاحق للعميل في المنزل. يجب إرسال معلومات حول هذه الميزة إلى نظام بيانات جهة خارجية.يكون منطق العمل كما يلي: يتوفر عنصر للبيع مع التسليم إذا:
- المنتج هو في المخزون
- لدى المقاول (على سبيل المثال ، شركة DostavchenKO) الفرصة لنقلها إلى العميل
- لون المنتج - وليس الأزرق (لا نحب الأزرق)
سيتم إخطار خدماتنا الميكروية بالتغيير في كمية البضائع على رف المتجر عبر طلب http.
هذا الإشعار هو مشغل لحساب توافر.
زائد ، بحيث لا يبدو أن الحياة هي العسل:
- يجب أن يكون المستخدم قادرًا على تعطيل بعض المنتجات يدويًا.
- من أجل عدم البريد العشوائي ، تحتاج فقط إلى إرسال بيانات التوفر لتلك المنتجات التي تغيرت.
نقرأ بضع مرات المعارف التقليدية - ونذهب.
اختبار التكامل
في TDD ، أحد أهم الأسئلة التي يجب عليك طرحها على كل ما تكتبه هو: "ماذا أريد من ...؟"
والسؤال الأول الذي نطرحه هو فقط للتطبيق بأكمله.
السؤال هو:
ماذا أريد من خدمة microservice الخاصة بي؟الجواب هو:
في الواقع الكثير من الأشياء. حتى هذا المنطق البسيط يعطي الكثير من الخيارات ، محاولة لكتابة والتي ، وحتى أكثر من ذلك لإنشاء اختبارات للجميع ، يمكن أن تكون مهمة مستحيلة. لذلك ، للإجابة على السؤال على مستوى التطبيق ، سنختار فقط حالات الاختبار الرئيسية.
أي أننا نفترض أن جميع بيانات الإدخال صحيحة ، وأنظمة الجهات الخارجية تستجيب بشكل طبيعي ، ولم تكن هناك في السابق معلومات عن المنتج.
لذلك ، أريد أن:- لقد وصل حدث أنه لا يوجد منتج على الرف. يخطر أن التسليم غير متوفر.
- جاء الحدث أن المنتج الأصفر في المخزون ، DostavchenKO على استعداد لاتخاذ ذلك. يخطر حول توافر السلع.
- جاءت رسالتان على التوالي - مع وجود كمية موجبة من البضائع في المتجر. أرسلت رسالة واحدة فقط.
- وصلت رسالتان: في الأولى يوجد منتج في المتجر ، في الثانية - لم يعد هناك. نرسل رسالتين: الأولى - متوفرة ، ثم - لا.
- يمكنني تعطيل المنتج يدويًا ، ولم يعد يتم إرسال الإشعارات.
- ...
الشيء الرئيسي هنا هو التوقف في الوقت المحدد: كما كتبت بالفعل ، هناك العديد من الخيارات ، وليس من المنطقي وصفها جميعًا هنا - فقط الخيارات الأساسية. في المستقبل ، عندما نكتب اختبارات لمنطق الأعمال ، من المحتمل أن تغطي توليفاتها كل شيء نأتي إليه هنا. الدافع الرئيسي هنا هو التأكد من أنه إذا نجحت هذه الاختبارات ، فإن التطبيق يعمل حسب حاجتنا.
كل هذه الامنيات سنقوم الآن بالتقطير في الاختبارات. علاوة على ذلك ، نظرًا لأن هذا هو قائمة الامنيات على مستوى التطبيق ، فسوف نجري اختبارات لرفع سياق الربيع ، وهذا ثقيل للغاية.
وهذا ، للأسف ، بالنسبة للعديد من نهايات TDD ، لأن كتابة اختبار الاندماج هذا ، تحتاج إلى الكثير من الجهد الذي لا يرغب الناس دائمًا في إنفاقه. ونعم ، هذه هي الخطوة الأكثر صعوبة ، ولكن ، صدقوني ، بعد أن تمر بها ، ستكتب الشفرة نفسها تقريبًا ، وسوف تكون متأكدًا من أن التطبيق الخاص بك سيعمل بالطريقة التي تريدها.
في عملية الإجابة على السؤال ، يمكنك بالفعل بدء كتابة التعليمات البرمجية في فصل الربيع الأولي الذي تم إنشاؤه. أسماء الاختبار هي مجرد قائمة الأمنيات لدينا. الآن ، فقط قم بإنشاء طرق فارغة:
@Test public void notifyNotAvailableIfProductQuantityIsZero() {} @Test public void notifyAvailableYellowProductIfPositiveQuantityAndDostavchenkoApproved() {} @Test public void notifyOnceOnSeveralEqualProductMessages() {} @Test public void notifyFirstAvailableThenNotIfProductQuantityMovedFromPositiveToZero() {} @Test public void noNotificationOnDisabledProduct() {}
فيما يتعلق بتسمية الأساليب: أنصحك بشدة أن تجعلها مفيدة ، بدلاً من test1 () ، test2 () ، لأنه في وقت لاحق ، عندما تنسى الفصل الذي كتبته وما هي المسؤولة عنه ، ستتاح لك الفرصة بدلاً من حاول تحليل الرمز مباشرة ، فقط افتح الاختبار وقراءة طريقة العقد التي يرضيها الفصل.
البدء في ملء الاختبارات
الفكرة الرئيسية هي محاكاة كل شيء خارجي من أجل التحقق مما يحدث في الداخل.
"الخارجية" فيما يتعلق بخدمتنا هي كل ما لا يمثل الخدمة الميكروية نفسها ، ولكنه يتصل به مباشرة.
في هذه الحالة ، يكون الخارجي هو:
- النظام الذي ستقوم خدمتنا بإخطاره حول التغييرات في كمية البضائع
- العميل الذي سيقوم بفصل البضائع يدويًا
- نظام DostavchenKO الطرف الثالث
لمضاهاة طلبات الأولين ، نستخدم springing MockMvc.
لمحاكاة DostavchenKO نستخدم wiremock أو MockRestServiceServer.
نتيجة لذلك ، يبدو اختبار الاندماج لدينا كما يلي:
اختبار التكامل @RunWith(SpringRunner.class) @SpringBootTest @AutoConfigureMockMvc @AutoConfigureWireMock(port = 8090) public class TddExampleApplicationTests { @Autowired private MockMvc mockMvc; @Before public void init() { WireMock.reset(); } @Test public void notifyNotAvailableIfProductQuantityIsZero() throws Exception { stubNotification(
ماذا حدث للتو؟
لقد كتبنا اختبار تكامل ، والذي يضمن لنا مروره إمكانية تشغيل النظام وفقًا لقصص المستخدم الرئيسية. وفعلنا ذلك قبل أن نبدأ في تنفيذ الخدمة.
واحدة من مزايا هذا النهج هو أنه أثناء عملية الكتابة اضطررت للذهاب إلى DostavchenKO
الحقيقي والحصول على إجابة
حقيقية من هناك إلى الطلب
الحقيقي الذي قدمناه في كعب روتيننا. إنه لأمر جيد للغاية أننا اعتنىنا بهذا في بداية التطوير ، وليس بعد كتابة الكود. وهنا اتضح أن التنسيق ليس هو التنسيق المحدد في TOR ، أو أن الخدمة غير متوفرة بشكل عام ، أو أي شيء آخر.
أود أيضًا أن أشير إلى أننا لم نكتب فقط سطرًا
واحدًا من الكود الذي سينتقل لاحقًا إلى المنتج ، لكننا لم نقم حتى بافتراض
واحد حول كيفية ترتيب خدماتنا الميكروية في الداخل: ما هي الطبقات الموجودة ، سواء نحن نستخدم الأساس ، إذا كان الأمر كذلك ، أي واحد ، وما إلى ذلك. في وقت كتابة هذا الاختبار ، نحن مستخلصون من التطبيق ، وكما سنرى لاحقًا ، يمكن أن يوفر هذا عددًا من المزايا المعمارية.
على النقيض من TDD الكنسي ، حيث يتم كتابة التنفيذ مباشرة بعد الاختبار ، لن يستغرق اختبار التكامل وقتًا طويلاً للغاية. في الواقع ، لن تتحول إلى اللون الأخضر حتى نهاية التطوير ، حتى يتم كتابة كل شيء على الإطلاق ، بما في ذلك الملفات.
نحن نذهب أبعد من ذلك.
مراقب
بعد أن كتبنا اختبار التكامل وثقة الآن أنه بعد اجتياز المهمة ، يمكننا النوم بسلام في الليل ، فقد حان الوقت لبدء برمجة الطبقات. والطبقة الأولى التي سننفذها هي وحدة التحكم. لماذا بالضبط له؟ لأن هذا هو نقطة الدخول إلى البرنامج. نحتاج إلى الانتقال من الأعلى إلى الأسفل ، من الطبقة الأولى التي سيتفاعل معها المستخدم ، إلى آخرها.
هذا مهم.
ومرة أخرى ، كل هذا يبدأ بنفس السؤال:
ماذا أريد من وحدة التحكم؟الجواب هو:
نحن نعلم أن وحدة التحكم منخرطة في التواصل مع المستخدم والتحقق من صحة وتحويل بيانات الإدخال ولا تحتوي على منطق العمل. لذلك قد يكون الجواب على هذا السؤال شيء من هذا القبيل:
أريد أن:- عاد BAD_REQUEST إلى المستخدم عند محاولة قطع اتصال منتج بمعرف غير صالح
- BAD_REQUEST عند محاولة الإبلاغ عن تغيير البضائع ذات المعرف غير صالح
- BAD_REQUEST عند محاولة الإعلام بكمية سالبة
- INTERNAL_SERVER_ERROR إذا كان DostavchenKO غير متوفر
- INTERNAL_SERVER_ERROR ، إذا تعذر إرسالها إلى DATA
نظرًا لأننا نريد أن نكون سهل الاستخدام ، بالنسبة لجميع العناصر المذكورة أعلاه ، بالإضافة إلى رمز http ، تحتاج إلى عرض رسالة مخصصة تصف المشكلة حتى يفهم المستخدم ماهية المشكلة.
- 200 إذا كانت المعالجة ناجحة
- INTERNAL_SERVER_ERROR مع رسالة افتراضية في جميع الحالات الأخرى ، حتى لا تتألق
إلى أن بدأت الكتابة على TDD ، كان آخر شيء كنت أفكر فيه هو ما الذي سيظهره نظامي للمستخدم في بعض الحالات الخاصة ، ومن غير المرجح للوهلة الأولى. لم أفكر لسبب واحد بسيط - من الصعب للغاية كتابة تنفيذ ، من أجل أن نأخذ في الاعتبار جميع الحالات المطلقة ، وأحيانًا لا توجد ذاكرة RAM كافية في المخ. وبعد التنفيذ الكتابي ، لا يزال تحليل الشفرة لشيء ما لم تفكر فيه مقدمًا من دواعي سروري: نعتقد جميعًا أننا نكتب الشفرة المثالية على الفور). على الرغم من عدم وجود تطبيق ، ليست هناك حاجة للتفكير فيه ، ولا يوجد أي ألم لتغييره ، إذا كان ذلك. بعد كتابة الاختبار أولاً ، لن تضطر إلى الانتظار حتى تتقارب النجوم ، وبعد الانسحاب إلى المنتج ، سوف يفشل عدد معين من الأنظمة ، وسيأتي العميل إليك بطلب لطلب إصلاح شيء ما. وهذا ينطبق ليس فقط على وحدة تحكم.
ابدأ في كتابة الاختبارات
كل شيء واضح مع الثلاثة الأولى: نستخدم التحقق من الربيع ، إذا وصل طلب غير صالح ، فسيقوم التطبيق بإلغاء استثناء ، والذي سنلاحظه في معالج استثناء. هنا ، كما يقولون ، كل شيء يعمل من تلقاء نفسه ، ولكن كيف تعرف وحدة التحكم أن بعض نظام الطرف الثالث غير متوفر؟
فمن الواضح أن وحدة التحكم نفسها يجب أن لا تعرف أي شيء عن أنظمة الطرف الثالث ، لأنه ما هو النظام الذي يجب طرحه وما هو منطق العمل ، أي أنه يجب أن يكون هناك نوع من الوسيط. هذا الوسيط هو الخدمة. وسوف نكتب اختبارات على وحدة التحكم باستخدام وهمي من هذه الخدمة ، ومحاكاة سلوكها في بعض الحالات. لذلك ، يجب أن تخبر الخدمة وحدة التحكم بطريقة ما أن النظام غير متوفر. يمكنك القيام بذلك بطرق مختلفة ، ولكن أسهل طريقة لرمي التنفيذ المخصص. سنكتب اختبارًا لسلوك جهاز التحكم هذا.
اختبار لخطأ الاتصال مع نظام بيانات جهة خارجية @RunWith(SpringRunner.class) @WebMvcTest @AutoConfigureMockMvc public class ControllerTest { @MockBean private UpdateProcessorService updateProcessorService; @Test public void returnServerErrorOnDataCommunicationError() throws Exception { doThrow(new DataCommunicationException()).when(updateProcessorService).processUpdate(any(Update.class)); performUpdate(
في هذه المرحلة ، ظهرت عدة أشياء في حد ذاتها:
- خدمة يتم حقنها في وحدة التحكم وسيتم تفويضها لمعالجة رسالة واردة لكمية جديدة من البضائع.
- طريقة هذه الخدمة ، وبالتالي توقيعها ، والتي ستجري هذه المعالجة.
- إدراك أن الأسلوب يجب أن يرمي تنفيذ مخصص عندما يكون النظام غير متوفر.
- هذا التنفيذ مخصص نفسه.
لماذا لوحدهم؟ لأنه ، كما تتذكر ، لم نكتب تطبيقًا بعد. وظهرت كل هذه الكيانات في عملية اختبار البرنامج. حتى لا يقسم المترجم ، في الكود الحقيقي ، سيتعين علينا إنشاء كل ما هو موضح أعلاه. لحسن الحظ ، سوف يساعدنا أي IDE تقريبًا على إنشاء الكيانات اللازمة. وبالتالي ، نحن نوع من اختبار الكتابة - ويتم ملء التطبيق مع الطبقات والأساليب.
في المجموع ، اختبارات وحدة التحكم هي كما يلي:
اختبارات @RunWith(SpringRunner.class) @WebMvcTest @AutoConfigureMockMvc public class ControllerTest { @InjectMocks private Controller controller; @MockBean private UpdateProcessorService updateProcessorService; @Autowired private MockMvc mvc; @Test public void returnBadRequestOnDisableWithInvalidProductId() throws Exception { mvc.perform( post("/disableProduct?productId=-443") ).andDo( print() ).andExpect( status().isBadRequest() ).andExpect( content().json(getInvalidProductIdJsonContent()) ); } @Test public void returnBadRequestOnNotifyWithInvalidProductId() throws Exception { performUpdate(
الآن يمكننا كتابة التنفيذ والتأكد من نجاح جميع الاختبارات:
تطبيق @RestController @AllArgsConstructor @Validated @Slf4j public class Controller { private final UpdateProcessorService updateProcessorService; @PostMapping("/product-quantity-update") public void updateQuantity(@RequestBody @Valid Update update) { updateProcessorService.processUpdate(update); } @PostMapping("/disableProduct") public void disableProduct(@RequestParam("productId") @Min(0) Long productId) { updateProcessorService.disableProduct(Long.valueOf(productId)); } }
معالج استثناء @ControllerAdvice @Slf4j public class ApplicationExceptionHandler { @ExceptionHandler(ConstraintViolationException.class) @ResponseBody @ResponseStatus(HttpStatus.BAD_REQUEST) public ErrorResponse onConstraintViolationException(ConstraintViolationException exception) { log.info("Constraint Violation", exception); return new ErrorResponse(exception.getConstraintViolations().stream() .map(constraintViolation -> new ErrorResponse.Message( ((PathImpl) constraintViolation.getPropertyPath()).getLeafNode().toString() + " is invalid")) .collect(Collectors.toList())); } @ExceptionHandler(value = MethodArgumentNotValidException.class) @ResponseBody @ResponseStatus(value = HttpStatus.BAD_REQUEST) public ErrorResponse onMethodArgumentNotValidException(MethodArgumentNotValidException exception) { log.info(exception.getMessage()); List<ErrorResponse.Message> fieldErrors = exception.getBindingResult().getFieldErrors().stream() .map(fieldError -> new ErrorResponse.Message(fieldError.getField() + " is invalid")) .collect(Collectors.toList()); return new ErrorResponse(fieldErrors); } @ExceptionHandler(DostavchenkoException.class) @ResponseBody @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) public ErrorResponse onDostavchenkoCommunicationException(DostavchenkoException exception) { log.error("DostavchenKO communication exception", exception); return new ErrorResponse(Collections.singletonList( new ErrorResponse.Message("DostavchenKO communication exception"))); } @ExceptionHandler(DataCommunicationException.class) @ResponseBody @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) public ErrorResponse onDataCommunicationException(DataCommunicationException exception) { log.error("DostavchenKO communication exception", exception); return new ErrorResponse(Collections.singletonList( new ErrorResponse.Message("Can't communicate with Data system"))); } @ExceptionHandler(Exception.class) @ResponseBody @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) public ErrorResponse onException(Exception exception) { log.error("Error while processing", exception); return new ErrorResponse(Collections.singletonList( new ErrorResponse.Message(HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase()))); } }
ماذا حدث للتو؟
في TDD ، لا يتعين عليك الاحتفاظ بجميع الشفرات في رأسك.دعنا مرة أخرى: لا تبقي الهيكل بأكمله في ذاكرة الوصول العشوائي. مجرد إلقاء نظرة على طبقة واحدة. إنه بسيط.
في العملية المعتادة ، لا يكفي الدماغ ، لأن هناك الكثير من التطبيقات. إذا كنت بطلًا خارقًا يمكنه أن يأخذ في الاعتبار جميع الفروق الدقيقة في مشروع كبير في رأسك ، فإن TDD ليس ضروريًا. لا اعرف كيف كلما كان المشروع أكبر ، كلما كنت مخطئًا.
بعد إدراك أنك بحاجة إلى فهم ما تحتاجه الطبقة التالية فقط ، يأتي التنوير في الحياة. والحقيقة هي أن هذا النهج يسمح لك بعدم القيام بأشياء غير ضرورية. هنا تتحدث مع فتاة. تقول شيئا عن مشكلة في العمل. وكنت تفكر في كيفية حلها ، أنت رف عقلك. وهي لا تحتاج إلى حلها ، إنها بحاجة فقط إلى أن تخبرها. وهذا كل شيء. أرادت فقط مشاركة شيء ما. تعلم هذا في المرحلة الأولى من الاستماع () لا يقدر بثمن. لكل شيء آخر ... حسنا ، أنت تعرف.
خدمة
بعد ذلك نقوم بتنفيذ الخدمة.
ماذا نريد من الخدمة؟نريده أن يتعامل مع منطق العمل ، أي:
- كان يعرف كيفية فصل البضائع ، كما أعلم حول :
- التوفر ، إذا كان المنتج غير مفصول ، يكون في المخزون ، ولون المنتج أصفر ، و DostavchenKO جاهز للتسليم.
- عدم إمكانية الوصول ، إذا كانت البضاعة غير متوفرة بغض النظر عن أي شيء.
- عدم إمكانية الوصول ، إذا كان المنتج باللون الأزرق.
- عدم إمكانية الوصول إذا رفض DostavchenKO حمله.
- عدم إمكانية الوصول إذا تم فصل البضاعة يدويًا.
- بعد ذلك ، نريد من الخدمة تنفيذ عملية التنفيذ في حالة عدم توفر أي من الأنظمة.
- وأيضًا ، حتى لا تفلت البيانات غير المرغوب فيها ، يلزمك تنظيم الرسائل المرسلة ، وهي:
- إذا اعتدنا أن نرسل البضائع المتاحة للبضائع ، والآن قمنا بحساب ما هو متاح ، فلن نرسل أي شيء.
- وإذا لم يكن متاحًا من قبل ، ولكنه الآن متاح ، فسنرسله.
- وتحتاج إلى كتابتها في مكان ما ...
إيقاف!ألا تعتقد أن خدمتنا بدأت تفعل الكثير؟
انطلاقًا من قائمة الأمنيات لدينا ، فهو يعرف كيفية إيقاف تشغيل البضائع ، وينظر في إمكانية الوصول ، ويضمن أنه لا يرسل الرسائل المرسلة مسبقًا. هذا ليس التماسك عالية. من الضروري نقل الوظائف غير المتجانسة إلى فئات مختلفة ، وبالتالي يجب أن يكون هناك بالفعل ثلاث خدمات: واحدة تتعامل مع فصل البضائع ، والآخر سيحسب إمكانية التسليم ويمررها إلى خدمة تقرر إرسالها أو عدم إرسالها. بالمناسبة ، وبهذه الطريقة ، لن تعرف خدمة منطق الأعمال أي شيء عن نظام البيانات ، والذي يعد أيضًا ميزة إضافية محددة.
في تجربتي ، في كثير من الأحيان ، بعد أن دخلت حيز التنفيذ ، من السهل التغاضي عن اللحظات المعمارية. إذا كتبنا الخدمة على الفور ، دون التفكير في ما ينبغي أن تفعله ، والأهم من ذلك ، يجب ألا يزيد ذلك ، فإن احتمال تداخل مجالات المسؤولية سيزداد. أود أن أضيف نيابة عني أن هذا المثال هو ما حدث لي في الممارسة الفعلية والفرق النوعي بين نتائج TDD وأساليب البرمجة المتسلسلة التي ألهمتني لكتابة هذا المنشور.
منطق العمل
بالتفكير في خدمة منطق الأعمال لنفس أسباب التماسك العالي ، نحن نفهم أننا بحاجة إلى مستوى أكثر من التجريد بينها وبين DostavchenKO الحقيقي. وبما أننا نقوم بتصميم الخدمة
أولاً ، يمكننا أن نطلب من عميل DostavchenKO مثل هذا العقد الداخلي الذي نريده. في عملية كتابة اختبار لمنطق العمل ، سوف نفهم ما نريد من عميل التوقيع التالي:
public boolean isAvailableForTransportation(Long productId) {...}
على مستوى الخدمة ، لا يهمنا كيف تجيب DostavchenKO الحقيقية: في المستقبل ، ستحصل مهمة العميل بطريقة ما على هذه المعلومات منه. بمجرد أن يكون الأمر بسيطًا ، لكن في وقت ما سيكون من الضروري تقديم عدة طلبات: في الوقت الحالي ، يتم خلاصنا من هذا.
نريد توقيعًا مشابهًا من خدمة تتعامل مع البضائع غير المتصلة:
public boolean isProductEnabled(Long productId) {...}
لذا ، فإن الأسئلة "ماذا أريد من خدمة منطق الأعمال؟" المسجلة في الاختبارات تبدو كما يلي:
اختبارات الخدمة @RunWith(MockitoJUnitRunner.class) public class UpdateProcessorServiceTest { @InjectMocks private UpdateProcessorService updateProcessorService; @Mock private ManualExclusionService manualExclusionService; @Mock private DostavchenkoClient dostavchenkoClient; @Mock private AvailabilityNotifier availabilityNotifier; @Test public void notifyAvailableIfYellowProductIsEnabledAndReadyForTransportation() { final Update testProduct = new Update(1L, 10L, "Yellow"); when(dostavchenkoClient.isAvailableForTransportation(testProduct.getProductId())).thenReturn(true); when(manualExclusionService.isProductEnabled(testProduct.getProductId())).thenReturn(true); updateProcessorService.processUpdate(testProduct); verify(availabilityNotifier, only()).notify(eq(new ProductAvailability(testProduct.getProductId(), true))); } @Test public void notifyNotAvailableIfProductIsAbsent() { final Update testProduct = new Update(1L, 0L, "Yellow"); updateProcessorService.processUpdate(testProduct); verify(availabilityNotifier, only()).notify(eq(new ProductAvailability(testProduct.getProductId(), false))); verifyNoMoreInteractions(manualExclusionService); verifyNoMoreInteractions(dostavchenkoClient); } @Test public void notifyNotAvailableIfProductIsBlue() { final Update testProduct = new Update(1L, 10L, "Blue"); updateProcessorService.processUpdate(testProduct); verify(availabilityNotifier, only()).notify(eq(new ProductAvailability(testProduct.getProductId(), false))); verifyNoMoreInteractions(manualExclusionService); verifyNoMoreInteractions(dostavchenkoClient); } @Test public void notifyNotAvailableIfProductIsDisabled() { final Update testProduct = new Update(1L, 10L, "Yellow"); when(manualExclusionService.isProductEnabled(testProduct.getProductId())).thenReturn(false); updateProcessorService.processUpdate(testProduct); verify(availabilityNotifier, only()).notify(eq(new ProductAvailability(testProduct.getProductId(), false))); verifyNoMoreInteractions(dostavchenkoClient); } @Test public void notifyNotAvailableIfProductIsNotReadyForTransportation() { final Update testProduct = new Update(1L, 10L, "Yellow"); when(dostavchenkoClient.isAvailableForTransportation(testProduct.getProductId())).thenReturn(false); when(manualExclusionService.isProductEnabled(testProduct.getProductId())).thenReturn(true); updateProcessorService.processUpdate(testProduct); verify(availabilityNotifier, only()).notify(eq(new ProductAvailability(testProduct.getProductId(), false))); } @Test(expected = DostavchenkoException.class) public void throwCustomExceptionIfDostavchenkoCommunicationFailed() { final Update testProduct = new Update(1L, 10L, "Yellow"); when(dostavchenkoClient.isAvailableForTransportation(testProduct.getProductId())) .thenThrow(new RestClientException("Something's wrong")); when(manualExclusionService.isProductEnabled(testProduct.getProductId())).thenReturn(true); updateProcessorService.processUpdate(testProduct); } }
في هذه المرحلة ، ولدوا بأنفسهم:- عميل DostavchenKO مع singatura الصديقة للخدمة
- خدمة يكون من الضروري فيها تنفيذ منطق الإرسال البطيء ، الذي سترسل إليه الخدمة المصممة نتائج عملها
- خدمة البضائع المنفصلة وتوقيعها
التنفيذ:تطبيق @RequiredArgsConstructor @Service @Slf4j public class UpdateProcessorService { private final AvailabilityNotifier availabilityNotifier; private final DostavchenkoClient dostavchenkoClient; private final ManualExclusionService manualExclusionService; public void processUpdate(Update update) { if (update.getProductQuantity() <= 0) { availabilityNotifier.notify(getNotAvailableProduct(update.getProductId())); return; } if ("Blue".equals(update.getColor())) { availabilityNotifier.notify(getNotAvailableProduct(update.getProductId())); return; } if (!manualExclusionService.isProductEnabled(update.getProductId())) { availabilityNotifier.notify(getNotAvailableProduct(update.getProductId())); return; } try { final boolean availableForTransportation = dostavchenkoClient.isAvailableForTransportation(update.getProductId()); availabilityNotifier.notify(new ProductAvailability(update.getProductId(), availableForTransportation)); } catch (Exception exception) { log.warn("Problems communicating with DostavchenKO", exception); throw new DostavchenkoException(); } } private ProductAvailability getNotAvailableProduct(Long productId) { return new ProductAvailability(productId, false); } }
تعطيل المنتجات
لقد حان الوقت لإحدى مراحل TDD الحتمية - إعادة البناء. إذا كنت تتذكر ، بعد تنفيذ وحدة التحكم ، بدا عقد الخدمة كما يلي: public void disableProduct(long productId)
والآن قررنا نقل منطق الانفصال إلى خدمة منفصلة.من هذه الخدمة في هذه المرحلة نريد ما يلي:- القدرة على إيقاف البضائع.
- نريده أن يعود إلى أن البضائع مفصولة إذا تم فصله في وقت سابق.
- نريده أن يعود إلى أن البضائع متوفرة إذا لم يكن هناك انقطاع من قبل.
بالنظر إلى قائمة الأمنيات ، والتي هي نتيجة مباشرة للعقد بين خدمة منطق الأعمال والخدمة المتوقعة ، أود أن أشير إلى ما يلي:- أولاً ، من الواضح على الفور أن التطبيق قد يواجه مشكلات إذا أراد شخص ما إيقاف تشغيل المنتج الذي تم قطع اتصاله ، لأن هذه الخدمة لا تعرف في الوقت الحالي كيفية القيام بذلك. وهذا يعني أنه ربما يستحق الأمر مناقشة هذه المسألة مع المحلل الذي حدد مهمة التطوير. أدرك أنه في هذه الحالة كان يجب أن يكون هذا السؤال قد نشأ مباشرة بعد القراءة الأولى لـ ToR ، لكننا نصمم نظامًا بسيطًا إلى حد ما ، قد لا يكون هذا واضحًا في المشروعات الكبيرة. علاوة على ذلك ، لم نكن نعرف أنه سيكون لدينا كيان مسؤول فقط عن وظيفة فصل البضائع: أذكر أننا ولدنا فقط في عملية التطوير.
- -, . — , . , , , , , , . . ProductAvailability. , . . ., , god object, , , , TDD, , . , , «» — : « ...» , , TDD, .
الاختبارات والتنفيذ بسيطة للغاية:اختبارات @SpringBootTest @RunWith(SpringRunner.class) public class ManualExclusionServiceTest { @Autowired private ManualExclusionService service; @Autowired private ManualExclusionRepository manualExclusionRepository; @Before public void clearDb() { manualExclusionRepository.deleteAll(); } @Test public void disableItem() { Long productId = 100L; service.disableProduct(productId); assertThat(service.isProductEnabled(productId), is(false)); } @Test public void returnEnabledIfProductWasNotDisabled() { assertThat(service.isProductEnabled(100L), is(true)); assertThat(service.isProductEnabled(200L), is(true)); } }
تطبيق @Service @AllArgsConstructor public class ManualExclusionService { private final ManualExclusionRepository manualExclusionRepository; public boolean isProductEnabled(Long productId) { return !manualExclusionRepository.exists(productId); } public void disableProduct(long productId) { manualExclusionRepository.save(new ManualExclusion(productId)); } }
كسول تقديم الخدمة
لذلك ، وصلنا إلى الخدمة الأخيرة ، والتي ستضمن أن نظام البيانات غير مرغوب فيه بنفس الرسائل.اسمحوا لي أن أذكرك بأن نتيجة عمل خدمة منطق الأعمال ، أي كائن ProductAvailable ، حيث يوجد فقط حقلان: productId و isAvailable ، تم نقلهما بالفعل.وفقًا للتقاليد القديمة الجيدة ، نبدأ في التفكير فيما نريد من هذه الخدمة:- إرسال إشعار لأول مرة في أي حال.
- إرسال إشعار إذا كان توافر المنتج قد تغير.
- نحن لا نرسل أي شيء إن لم يكن.
- إذا انتهى الإرسال إلى نظام جهة خارجية مع استثناء ، فيجب ألا يتم تضمين الإشعار الذي تسبب في الاستثناء في قاعدة بيانات الإشعارات المرسلة.
- أيضًا ، عند التنفيذ من جانب DATA ، تحتاج الخدمة إلى رمي DataCommunicationException الخاص به.
كل شيء هنا بسيط نسبيًا ، لكن أود الإشارة إلى نقطة واحدة:نحن بحاجة إلى معلومات حول ما أرسلناه مسبقًا ، مما يعني أنه سيكون لدينا مستودع نوفر فيه الحسابات السابقة على توفر السلع.كائن ProductAvailable غير مناسب للحفظ ، لأنه على الأقل لا يوجد معرّف ، مما يعني أنه من المنطقي إنشاء معرف آخر. الشيء الرئيسي هنا هو عدم الخوف وعدم إضافة هذا المعرف معDocument (سنستخدم MongoDb كقاعدة) والفهارس في ProductAvailable نفسها.يجب أن تفهم أن كائن ProductAvailable مع جميع الحقول القليلة تم إنشاؤه في مرحلة تصميم فئات أعلى في التسلسل الهرمي للمكالمات من تلك التي نقوم بتصميمها حاليًا. لا تحتاج هذه الفئات إلى معرفة أي شيء عن الحقول الخاصة بقاعدة البيانات ، نظرًا لأن هذه المعلومات لم تكن مطلوبة عند التصميم.ولكن هذا كل الكلام.ومن المثير للاهتمام ، نظرًا لأننا قد كتبنا بالفعل مجموعة من الاختبارات مع ProductAvailable التي نقوم بنقلها إلى الخدمة الآن ، فإن إضافة حقول جديدة إليها يعني أن هذه الاختبارات ستحتاج أيضًا إلى إعادة تشكيلها ، الأمر الذي قد يتطلب بعض الجهد. هذا يعني أنه سيكون هناك عدد أقل بكثير من الأشخاص الذين يرغبون في جعل إله يعترض على ProductAvailable مما لو أنهم كتبوا التنفيذ على الفور: على العكس من ذلك ، فإن إضافة حقل إلى كائن موجود سيكون أسهل من إنشاء فئة أخرى.اختبارات @RunWith(SpringRunner.class) @SpringBootTest public class LazyAvailabilityNotifierTest { @Autowired private LazyAvailabilityNotifier lazyAvailabilityNotifier; @MockBean @Qualifier("dataClient") private AvailabilityNotifier availabilityNotifier; @Autowired private AvailabilityRepository availabilityRepository; @Before public void clearDb() { availabilityRepository.deleteAll(); } @Test public void notifyIfFirstTime() { sendNotificationAndVerifyDataBase(new ProductAvailability(1L, false)); } @Test public void notifyIfAvailabilityChanged() { final ProductAvailability oldProductAvailability = new ProductAvailability(1L, false); sendNotificationAndVerifyDataBase(oldProductAvailability); final ProductAvailability newProductAvailability = new ProductAvailability(1L, true); sendNotificationAndVerifyDataBase(newProductAvailability); } @Test public void doNotNotifyIfAvailabilityDoesNotChanged() { final ProductAvailability productAvailability = new ProductAvailability(1L, false); sendNotificationAndVerifyDataBase(productAvailability); sendNotificationAndVerifyDataBase(productAvailability); sendNotificationAndVerifyDataBase(productAvailability); sendNotificationAndVerifyDataBase(productAvailability); verify(availabilityNotifier, only()).notify(eq(productAvailability)); } @Test public void doNotSaveIfSentWithException() { doThrow(new RuntimeException()).when(availabilityNotifier).notify(anyObject()); boolean exceptionThrown = false; try { availabilityNotifier.notify(new ProductAvailability(1L, false)); } catch (RuntimeException exception) { exceptionThrown = true; } assertTrue("Exception was not thrown", exceptionThrown); assertThat(availabilityRepository.findAll(), hasSize(0)); } @Test(expected = DataCommunicationException.class) public void wrapDataException() { doThrow(new RestClientException("Something wrong")).when(availabilityNotifier).notify(anyObject()); lazyAvailabilityNotifier.notify(new ProductAvailability(1L, false)); } private void sendNotificationAndVerifyDataBase(ProductAvailability productAvailability) { lazyAvailabilityNotifier.notify(productAvailability); verify(availabilityNotifier).notify(eq(productAvailability)); assertThat(availabilityRepository.findAll(), hasSize(1)); assertThat(availabilityRepository.findAll().get(0), hasProperty("productId", is(productAvailability.getProductId()))); assertThat(availabilityRepository.findAll().get(0), hasProperty("availability", is(productAvailability.isAvailable()))); } }
تطبيق @Component @AllArgsConstructor @Slf4j public class LazyAvailabilityNotifier implements AvailabilityNotifier { private final AvailabilityRepository availabilityRepository; private final AvailabilityNotifier availabilityNotifier; @Override public void notify(ProductAvailability productAvailability) { final AvailabilityPersistenceObject persistedProductAvailability = availabilityRepository .findByProductId(productAvailability.getProductId()); if (persistedProductAvailability == null) { notifyWith(productAvailability); availabilityRepository.save(createObjectFromProductAvailability(productAvailability)); } else if (persistedProductAvailability.isAvailability() != productAvailability.isAvailable()) { notifyWith(productAvailability); persistedProductAvailability.setAvailability(productAvailability.isAvailable()); availabilityRepository.save(persistedProductAvailability); } } private void notifyWith(ProductAvailability productAvailability) { try { availabilityNotifier.notify(productAvailability); } catch (RestClientException exception) { log.error("Couldn't notify", exception); throw new DataCommunicationException(); } } private AvailabilityPersistenceObject createObjectFromProductAvailability(ProductAvailability productAvailability) { return new AvailabilityPersistenceObject(productAvailability.getProductId(), productAvailability.isAvailable()); } }
استنتاج
تطبيق مماثل كان لابد من كتابتها في الممارسة. واتضح أنه في البداية كان مكتوبًا بدون TDD ، ثم قال العمل إنه لم يكن ضروريًا ، وبعد ستة أشهر تغيرت المتطلبات ، وتقرر إعادة كتابتها مرة أخرى من نقطة الصفر (الفائدة هي بنية microservice ، ولم يكن أمرًا مخيفًا التخلص من شيء ما) .عن طريق كتابة نفس التطبيق باستخدام تقنيات مختلفة ، يمكنني أن أقدر خلافاتهم. في ممارستي ، رأيت كيف يساعد TDD في بناء البنية ، كما يبدو لي ، بشكل صحيح.يمكنني أن أفترض أن السبب في ذلك ليس إنشاء اختبارات قبل التنفيذ ، ولكن بعد كتابة الاختبارات في البداية ، نفكر أولاً في ما ستفعله الطبقة التي تم إنشاؤها. أيضًا ، على الرغم من عدم وجود تطبيق ، يمكننا حقًا "ترتيب" في الكائنات المسماة العقد الدقيق الذي يحتاجه الكائن ، دون إغراء لإضافة شيء بسرعة في مكان ما والحصول على كيان يتعامل مع العديد من المهام في نفس الوقت.بالإضافة إلى ذلك ، واحدة من أهم مزايا TDD بنفسي ، يمكنني أن أؤكد أنني أصبحت بالفعل أكثر ثقة في المنتج الذي أنتجه. قد يكون هذا بسبب حقيقة أن الشفرة المتوسطة المكتوبة على TDD ربما تكون مغطاة بشكل أفضل من خلال الاختبارات ، ولكن بعد أن بدأت الكتابة على TDD تم تخفيض عدد التعديلات التي أجريتها على الشفرة بعد أن قدمت اختبارها تقريبا إلى الصفر.وبشكل عام ، كان هناك شعور بأنني كمطور أصبحت أفضل.رمز التطبيق يمكن العثور عليه هنا . بالنسبة لأولئك الذين يريدون أن يفهموا كيف تم إنشاؤه في خطوات ، أوصي بإيلاء الاهتمام لتاريخ الإلتزامات ، بعد تحليل أي عملية إنشاء تطبيق TDD نموذجي ، وآمل أن تكون أكثر قابلية للفهم.هنا مفيد جدامقطع فيديو أوصي بمشاهدته لأي شخص يريد الانغماس في عالم TDD.يعيد رمز التطبيق استخدام سلسلة منسقة مثل json. يعد ذلك ضروريًا للتحقق من كيفية تحليل التطبيق json على كائنات POJO. إذا كنت تستخدم IDEA ، فبشكل سريع وبدون ألم ، يمكن تحقيق التنسيق اللازم باستخدام حقن لغة JSON.ما هي عيوب النهج؟
إنه وقت طويل لتطوير. البرمجة في النموذج القياسي ، يمكن لزميلي تحمل وضع الخدمة للمختبرين للاختبار دون اختبارات على الإطلاق ، إضافتهم على طول الطريق. كان سريع جدا. على TDD هذا لن ينجح. إذا كان لديك مواعيد نهائية ضيقة ، فلن يكون مديرك راضين. هنا ، المفاضلة بين الأداء الجيد على الفور ، ولكن لفترة طويلة وليس جيدة جدا ، ولكن بسرعة. اخترت الأول لنفسي ، لأن الثانية كنتيجة أطول. ومع الأعصاب الكبيرة.وفقًا لمشاعري ، فإن TDD غير مناسب إذا كنت بحاجة إلى القيام بالعديد من إعادة البناء: لأنه بخلاف تطبيق تم إنشاؤه من نقطة الصفر ، ليس من الواضح ما هي الطريقة التي يجب اتباعها والبدء في القيام بها أولاً. قد يتضح أنك تعمل على اختبار صفي ، ونتيجة لذلك ، قم بحذفه.TDD ليس رصاصة فضية. هذه قصة تدل على الشفرة الواضحة والقابلة للقراءة والتي يمكن أن تخلق مشاكل في الأداء. على سبيل المثال ، قمت بإنشاء فئات N ، والتي ، كما هو الحال في Fowler ، يقوم كل منها بعمل شيء خاص به. ثم اتضح أنه من أجل القيام بعملهم ، يحتاجون إلى الجميع للذهاب إلى القاعدة. وسيكون لديك استفسارات N في قاعدة البيانات. بدلا من صنع ، على سبيل المثال ، كائن إله واحد والذهاب 1 مرة. إذا كنت تقاتل من أجل ميلي ثانية ، ثم باستخدام TDD ، يجب أن تأخذ ذلك في الاعتبار: الكود القابل للقراءة ليس هو الأسرع دائمًا.أخيرًا ، من الصعب التبديل إلى هذه المنهجية - تحتاج إلى تعليم نفسك أن تفكر بطريقة مختلفة. معظم الألم في المرحلة الأولى. اختبار التكامل الأول كتبت 1.5 أيام.حسنا ، الماضي. إذا كنت تستخدم TDD والرمز الخاص بك لا يزال غير جدا، ثم قد لا تكون المسألة في المنهجية. لكنها ساعدتني.