الاختبار باستخدام JUnit 5 وMockito

مشروع: بناء ميزة صغيرة بأسلوب TDD

15 دقيقة الدرس 10 من 13

مشروع: بناء ميزة صغيرة بأسلوب TDD

النظرية لا قيمة لها إلا حين تُطبّقها تحت الضغط. في هذا الدرس الختامي ستبني ميزة صغيرة لكنّها واقعية — محرك تسعير مع خصومات — بأسلوب test-first تمامًا، مستخدمًا JUnit 5 وMockito. كل فئة وكل دالة تنشأ من اختبار فاشل. بنهاية الدرس ستمتلك دورة red-green-refactor كاملة، ومتعاونين مُحاكَين، وحالات حافة مُعاملَة، وتصميمًا نظيفًا للإنتاج يُقاد بالاختبارات وحدها.

مواصفات الميزة

قواعد العمل لمحرك التسعير:

  • يحسب PricingService السعر النهائي لمنتج بالنسبة لعميل معيّن.
  • العملاء المميّزون (premium) يحصلون على خصم 20 %.
  • أي منتج في التخفيضات يحصل على خصم إضافي 10 % من السعر المخفَّض أصلًا.
  • السعر النهائي لا يكون أبدًا سالبًا — الحدّ الأدنى هو 0.
  • تُجلَب قواعد الخصم من DiscountRepository خارجي (استدعاء قاعدة بيانات — يجب محاكاته).
لماذا متعاون مستودع؟ الميزات الحقيقية تعتمد دائمًا تقريبًا على I/O. إدخال اعتمادية مُحاكاة يُجبرك على التفكير في الحدّ بين منطق الدومين والبنية التحتية منذ أول اختبار — وهنا يتألّق TDD تمامًا.

الخطوة 1 — اكتب الاختبار الفاشل أولًا (Red)

أنشئ فئة الاختبار قبل أي كود إنتاج. دع أخطاء المُترجم ترشدك نحو الأنواع التي تحتاج إلى تعريفها.

// src/test/java/com/example/pricing/PricingServiceTest.java package com.example.pricing; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class PricingServiceTest { @Mock private DiscountRepository discountRepository; @InjectMocks private PricingService pricingService; private Customer regularCustomer; private Customer premiumCustomer; private Product regularProduct; private Product saleProduct; @BeforeEach void setUp() { regularCustomer = new Customer("alice", false); premiumCustomer = new Customer("bob", true); regularProduct = new Product("WIDGET", 100.0, false); saleProduct = new Product("GADGET", 100.0, true); } // --- RED: هذا الاختبار لن يُترجَم بعد --- @Test void regularCustomer_regularProduct_paysFullPrice() { when(discountRepository.isPremium(regularCustomer)).thenReturn(false); when(discountRepository.isOnSale(regularProduct)).thenReturn(false); double price = pricingService.calculatePrice(regularCustomer, regularProduct); assertEquals(100.0, price, 0.001); } }

شغّل الاختبار — سيفشل في الترجمة. ممتاز. الآن أنشئ الأنواع الإنتاجية الدنيا لجعله يُترجَم ويمرّ.

الخطوة 2 — اجعله يمرّ بالكود الأدنى (Green)

// Customer.java package com.example.pricing; public record Customer(String id, boolean premium) {} // Product.java public record Product(String sku, double basePrice, boolean onSale) {} // DiscountRepository.java public interface DiscountRepository { boolean isPremium(Customer customer); boolean isOnSale(Product product); } // PricingService.java public class PricingService { private final DiscountRepository discountRepository; public PricingService(DiscountRepository discountRepository) { this.discountRepository = discountRepository; } public double calculatePrice(Customer customer, Product product) { boolean premium = discountRepository.isPremium(customer); boolean onSale = discountRepository.isOnSale(product); double price = product.basePrice(); if (premium) price *= 0.80; if (onSale) price *= 0.90; return Math.max(0, price); } }

أصبح الاختبار الأول أخضر. لاحظ أن التنفيذ دنيوي بقصد — تكتب فقط ما تطلبه الاختبارات.

قاوم الإغراء للبناء الزائد. عند رؤية الشريط الأخضر، توقّف. لا تُضِف تعقيدًا إلا حين يُجبرك اختبار فاشل جديد. هذا يُبقي قاعدة الكود نظيفة وكل سطر قابلًا للتتبّع نحو متطلّب.

الخطوة 3 — إضافة اختبارات السلوك (دورة Red ← Green)

أضِف قواعد العمل المتبقية اختبارًا واحدًا في كل مرة:

@Test void premiumCustomer_regularProduct_gets20PercentOff() { when(discountRepository.isPremium(premiumCustomer)).thenReturn(true); when(discountRepository.isOnSale(regularProduct)).thenReturn(false); double price = pricingService.calculatePrice(premiumCustomer, regularProduct); assertEquals(80.0, price, 0.001); } @Test void premiumCustomer_saleProduct_getsStackedDiscount() { when(discountRepository.isPremium(premiumCustomer)).thenReturn(true); when(discountRepository.isOnSale(saleProduct)).thenReturn(true); // 100 * 0.80 * 0.90 = 72.0 double price = pricingService.calculatePrice(premiumCustomer, saleProduct); assertEquals(72.0, price, 0.001); } @Test void price_neverGoesNegative() { Product freeProduct = new Product("FREE", -50.0, false); when(discountRepository.isPremium(regularCustomer)).thenReturn(false); when(discountRepository.isOnSale(freeProduct)).thenReturn(false); double price = pricingService.calculatePrice(regularCustomer, freeProduct); assertTrue(price >= 0, "السعر النهائي يجب أن يكون غير سالب"); }

الخطوة 4 — التحقّق من التفاعلات

متطلّب العمل: يجب استشارة المستودع مرة واحدة بالضبط لكل استدعاء تسعير لكل سؤال. اختبر العقد لا المخرج فحسب:

@Test void repositoryIsQueriedExactlyOncePerCall() { when(discountRepository.isPremium(regularCustomer)).thenReturn(false); when(discountRepository.isOnSale(regularProduct)).thenReturn(false); pricingService.calculatePrice(regularCustomer, regularProduct); verify(discountRepository, times(1)).isPremium(regularCustomer); verify(discountRepository, times(1)).isOnSale(regularProduct); verifyNoMoreInteractions(discountRepository); }

الخطوة 5 — الحالات الحافة المُعاملَة (مرحلة Refactor)

بدلًا من نسخ اختبارات متشابهة لنقاط سعر مختلفة، استخدم @ParameterizedTest للتعبير عن جدول الحقيقة الكامل بنظافة:

import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; @ParameterizedTest(name = "base={0}, premium={1}, onSale={2} => {3}") @CsvSource({ "100.0, false, false, 100.0", "100.0, true, false, 80.0", "100.0, false, true, 90.0", "100.0, true, true, 72.0", " 0.0, true, true, 0.0", }) void pricingTruthTable(double base, boolean premium, boolean onSale, double expected) { Customer customer = new Customer("test", premium); Product product = new Product("TEST", base, onSale); when(discountRepository.isPremium(customer)).thenReturn(premium); when(discountRepository.isOnSale(product)).thenReturn(onSale); double actual = pricingService.calculatePrice(customer, product); assertEquals(expected, actual, 0.001); }
الاختبارات المُعاملَة ليست اختصارًا — بل أداة تصميم. التعبير عن خمسة سيناريوهات في جدول واحد يُجبرك على التفكير في فضاء الإدخال الكامل. الثغرات في الجدول ثغرات في فهمك للمواصفات.

الخطوة 6 — إعادة الهيكلة بثقة

مع مجموعة اختبارات خضراء كاملة يمكنك إعادة هيكلة الكود بأمان. استخرج حساب الخصم إلى دالة مخصّصة وتحقّق أن لا شيء ينكسر:

// PricingService.java بعد إعادة الهيكلة public double calculatePrice(Customer customer, Product product) { boolean premium = discountRepository.isPremium(customer); boolean onSale = discountRepository.isOnSale(product); return Math.max(0, applyDiscounts(product.basePrice(), premium, onSale)); } private double applyDiscounts(double price, boolean premium, boolean onSale) { if (premium) price *= 0.80; if (onSale) price *= 0.90; return price; }

شغّل المجموعة الكاملة — كل الاختبارات لا تزال خضراء. إعادة الهيكلة آمنة. الاختبارات أدّت دورها كشبكة أمان.

خطوة إعادة الهيكلة ليست اختيارية. Red-green بدون refactor يراكم الديون التقنية بنفس سرعة الكود المكتوب بدون اختبارات. الكود النظيف والاختبارات الشاملة يجب أن يسيرا معًا.

ما يُظهره هذا المشروع

  • التصميم الناشئ — أنواع الدومين (Customer وProduct وDiscountRepository) اكتُشِفت بكتابة الاختبارات لا بتصميم مسبق.
  • عزل الوصلات — واجهة DiscountRepository هي وصلة: تتيح تبديل تنفيذ قاعدة بيانات حقيقي بمحاكاة دون لمس PricingService.
  • المواصفات كاختبارات — مجموعة الاختبارات هي المواصفات. مطوّر جديد يقرأ الاختبارات ويفهم قواعد التسعير كاملة دون قراءة كلمة واحدة من التوثيق.
  • إعادة هيكلة آمنة — المجموعة الخضراء سمحت بتغيير هيكلي لـapplyDiscounts بدون أي خطر.

الخلاصة

TDD انضباط لا مجرد تقنية. دورة red-green-refactor مقترنة بحدود مُحاكاة وحالات حافة مُعاملة تُنتج كودًا صحيحًا بالبناء، ودنيويًا بالضرورة، وقابلًا للصيانة بالتصميم. طبّق هذه الدورة على كل ميزة جديدة وستجد جلسات تصحيح الأخطاء نادرة، ومراجعات التصميم أسهل، والثقة في النشر تنمو باستمرار.