المحاكاة باستخدام Mockito
الكائنات في التطبيقات الحقيقية نادرًا ما تعمل منفردة. فـ OrderService تتحدث إلى PaymentGateway، وUserService تتحدث إلى UserRepository. عند اختبار OrderService وحدها تريد التحقق من منطقها بمعزل عن الآخرين — دون الاتصال بواجهة دفع حقيقية أو قاعدة بيانات. هذا هو بالضبط ما تتيحه مكتبة Mockito: إنشاء كائنات بديلة تتحكم في سلوكها بالكامل.
ما هو الـ Mock؟
الـ Mock هو تنفيذ يُولَّد ديناميكيًا لواجهة أو فئة. كل دالة في الـ mock لا تفعل شيئًا بشكل افتراضي (تُعيد null، أو صفرًا، أو مجموعة فارغة حسب نوع الإرجاع). ثم تقوم بـ stub للدوال الفردية لإعادة القيم التي يحتاجها الاختبار، ولاحقًا يمكنك التحقق من أن الاستدعاءات المطلوبة حدثت فعلًا. يتناول هذا الدرس إنشاء الـ mocks والـ stubbing؛ أما التحقق (verification) فيُغطَّى في الدرس السابع.
Mock مقابل Stub مقابل Spy: في مصطلحات Mockito، الـ mock بديل كامل حيث كل الدوال وهمية ما لم تُضبَط؛ والـ spy يُغلّف كائنًا حقيقيًا فتُستدعى الدوال الحقيقية افتراضيًا ما لم تُتجاوز؛ والـ stub هو ببساطة قيمة إرجاع مضبوطة مسبقًا. التعليق التوضيحي @Mock يمنحك mock خالصًا.
إضافة Mockito إلى مشروعك
إذا كنت تستخدم Spring Boot فإن spring-boot-starter-test يتضمن Mockito بالفعل. أما في مشروع Maven عادي فأضف مكتبة التكامل مع JUnit 5:
<!-- pom.xml -->
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<version>5.11.0</version>
<scope>test</scope>
</dependency>
إنشاء الـ Mocks
هناك طريقتان متكافئتان لإنشاء الـ mock في JUnit 5.
1. برمجيًا (inline): الأنسب عند الحاجة إلى mock أو اثنين وتفضّل وضوح الكود في موضع الاستخدام.
import org.mockito.Mockito;
UserRepository repo = Mockito.mock(UserRepository.class);
2. عبر التعليقات التوضيحية (Annotations): الخيار المعتاد للفئات الأكبر. زيّن الفئة بـ @ExtendWith(MockitoExtension.class) وأعلن الحقول بـ @Mock، وستُهيئها Mockito تلقائيًا قبل كل اختبار.
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;
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock
PaymentGateway paymentGateway; // بديل عن البوابة الحقيقية
@Mock
OrderRepository orderRepository; // بديل عن طبقة قاعدة البيانات
@InjectMocks
OrderService orderService; // الفئة التي نختبرها — تحقن Mockito الـ mocks فيها
}
يخبر @InjectMocks مكتبة Mockito بإنشاء OrderService وحقن الـ mocks المعلنة عبر حقن المنشئ (الأولوية القصوى)، ثم حقن الـ setter، ثم حقن الحقل. هذا يعني أنك لن تستدعي new OrderService(...) بنفسك في الاختبار.
فضّل حقن المنشئ في كود الإنتاج. حين تعلن OrderService منشئًا يقبل اعتمادياتها، يستدعي @InjectMocks ذلك المنشئ ويكون الحقن صريحًا وواضحًا. أما حقن الحقل فيجعل الاعتمادية مخفية وأصعب في الفهم.
الـ Stubbing باستخدام when / thenReturn
الـ Stubbing هو عملية إخبار الـ mock بما يلي: "حين تُستدعى هذه الدالة بهذه الوسيطات، أعِد هذه القيمة". الصياغة مقروءة عمدًا:
import static org.mockito.Mockito.when;
// Stub: حين يُستدعى findById("o99") أعِد Order معروفة
when(orderRepository.findById("o99")).thenReturn(Optional.of(new Order("o99", 150.00)));
اقرأها من اليسار لليمين: when يُستدعى orderRepository.findById("o99")، then return Optional يحتوي على طلب اختباري. سيُعيد الـ mock هذه القيمة في كل مرة تُستدعى فيها تلك الدالة بـ "o99" خلال الاختبار.
مثال متكامل
// فئات الإنتاج (مبسّطة)
public record Order(String id, double amount) {}
public interface OrderRepository {
Optional<Order> findById(String id);
void save(Order order);
}
public interface PaymentGateway {
boolean charge(String orderId, double amount);
}
public class OrderService {
private final OrderRepository repository;
private final PaymentGateway gateway;
public OrderService(OrderRepository repository, PaymentGateway gateway) {
this.repository = repository;
this.gateway = gateway;
}
public boolean processOrder(String orderId) {
Order order = repository.findById(orderId)
.orElseThrow(() -> new IllegalArgumentException("Order not found: " + orderId));
return gateway.charge(order.id(), order.amount());
}
}
// فئة الاختبار
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 java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock OrderRepository orderRepository;
@Mock PaymentGateway paymentGateway;
@InjectMocks OrderService orderService;
@Test
void processOrder_chargesWhenOrderExists() {
// الترتيب: إعداد الـ stub للمتعاونَين
Order order = new Order("o99", 150.00);
when(orderRepository.findById("o99")).thenReturn(Optional.of(order));
when(paymentGateway.charge("o99", 150.00)).thenReturn(true);
// التنفيذ
boolean result = orderService.processOrder("o99");
// التأكيد
assertTrue(result);
}
@Test
void processOrder_returnsFalseWhenGatewayDeclines() {
Order order = new Order("o99", 150.00);
when(orderRepository.findById("o99")).thenReturn(Optional.of(order));
when(paymentGateway.charge("o99", 150.00)).thenReturn(false); // البوابة ترفض
assertFalse(orderService.processOrder("o99"));
}
}
مطابقو الوسيطات (Argument Matchers)
أحيانًا لا يهمك الوسيط الدقيق — تريد تفعيل الـ stub لأي String، أو لأي قيمة غير null. توفر Mockito argument matchers من org.mockito.ArgumentMatchers:
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
// مطابقة أي وسيط من نوع String
when(orderRepository.findById(anyString())).thenReturn(Optional.empty());
// مطابقة قيمة محددة مع أي وسيط ثانٍ
when(paymentGateway.charge(eq("o99"), any(Double.class))).thenReturn(true);
خلط المطابقات مع القيم الحرفية محظور. إذا استخدمت matcher لوسيط واحد يجب أن تستخدم matchers لجميع الوسيطات في ذلك الاستدعاء. when(gateway.charge("o99", anyDouble())) ستُلقي InvalidUseOfMatchersException في وقت التشغيل. استخدم eq("o99") لتغليف القيمة الحرفية.
الـ Stubbing للاستثناءات والاستدعاءات المتتالية
الـ stubs لا تقتصر على إعادة القيم، يمكنها أيضًا رمي استثناءات أو إعادة قيم مختلفة في استدعاءات متتالية:
import static org.mockito.Mockito.when;
// رمي في الاستدعاء الأول، إعادة قيمة في الثاني
when(orderRepository.findById("bad"))
.thenThrow(new RuntimeException("DB timeout"))
.thenReturn(Optional.empty()); // قيمة احتياطية عند إعادة المحاولة
// الأبسط: رمي دائمًا
when(paymentGateway.charge(anyString(), anyDouble()))
.thenThrow(new PaymentException("Gateway unreachable"));
الـ Stubbing للدوال التي تُعيد void
الدالة التي تُعيد void لا يمكن أن تظهر على يسار when(...).thenReturn(). استخدم doThrow (أو doNothing) بدلًا من ذلك:
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.doThrow;
// لا تفعل شيئًا بشكل صريح (الافتراضي، لكنه مفيد أحيانًا للوضوح)
doNothing().when(orderRepository).save(any(Order.class));
// اجعل الدالة void ترمي استثناءً
doThrow(new RuntimeException("Write failed"))
.when(orderRepository).save(any(Order.class));
قيم الإرجاع الافتراضية
الدوال غير المُضبَطة تُعيد قيم Mockito الافتراضية: null للكائنات، و0 / false للأنواع الأولية، ومجموعات فارغة لـ List وMap وSet وما شابهها. الاعتماد الصامت على هذه القيم قد يُخفي أخطاء — أجرِ الـ stub بشكل صريح حين تكون قيمة الإرجاع مهمة للتأكيد.
الخلاصة
تتيح Mockito عزل الوحدة المختبَرة باستبدال المتعاونين الحقيقيين بـ mocks. استخدم @ExtendWith(MockitoExtension.class) مع @Mock و@InjectMocks للإعداد عبر التعليقات التوضيحية. أجرِ الـ stub للدوال بـ when(mock.method(args)).thenReturn(value) واستخدم argument matchers للمطابقة المرنة. أما للدوال التي تُعيد void فاستخدم صيغة doXxx().when(mock).method(). في الدرس القادم ستتعلم كيف تتحقق من أن الاستدعاءات الصحيحة قد وقعت على الـ mocks الخاصة بك.