مشروع: بناء ميزة صغيرة بأسلوب 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 مقترنة بحدود مُحاكاة وحالات حافة مُعاملة تُنتج كودًا صحيحًا بالبناء، ودنيويًا بالضرورة، وقابلًا للصيانة بالتصميم. طبّق هذه الدورة على كل ميزة جديدة وستجد جلسات تصحيح الأخطاء نادرة، ومراجعات التصميم أسهل، والثقة في النشر تنمو باستمرار.