اختبار تطبيقات Spring Boot

محاكاة الـ Beans باستخدام @MockBean

18 دقيقة الدرس 7 من 13

محاكاة الـ Beans باستخدام @MockBean

كل تطبيق Spring Boot هو رسم بياني من الـ beans المتعاونة. عندما تكتب اختبار شريحة (slice test) — مثل @WebMvcTest لأحد الـ controllers — لا يُهيّئ Spring سوى جزء من هذا الرسم. الـ beans التي لا يحمّلها الاختبار (services، repositories، clients خارجية) لا تزال تظهر كاعتماديات. @MockBean يحل هذه المشكلة تحديدًا: فهو يُسجّل كائن mock من Mockito بوصفه bean داخل سياق التطبيق الاختباري، مُرضيًا الاعتمادية دون تحميل التنفيذ الحقيقي.

الفرق بين @MockBean و @Mock

تنشئ الإضافة التوضيحية @Mock في Mockito (أو Mockito.mock()) كائن mock يعيش خارج سياق Spring تمامًا. تربطه بالكلاس المُختبَر يدويًا عادةً عبر المُنشئ أو setter. هذا يعمل جيدًا في اختبارات الوحدة الخالصة حيث تُنشئ الكلاس بنفسك.

أما @MockBean فيأمر دعم اختبار Spring بـاستبدال (أو إنشاء) bean من النوع المعطى داخل ApplicationContext. أي bean آخر يحقن ذلك النوع سيتلقى الـ mock تلقائيًا — دون الحاجة إلى ربط يدوي. تُنشئ Mockito الـ mock الأساسي، لذا تعمل جميع واجهات when(…).thenReturn(…) وverify(…) المألوفة بالضبط كما تتوقع.

القاعدة الأساسية: استخدم @Mock في اختبارات الوحدة الخالصة (بدون سياق Spring). استخدم @MockBean في كل مرة تحتاج فيها Spring لربط الاعتماديات نيابةً عنك — داخل @WebMvcTest أو @DataJpaTest أو شرائح @SpringBootTest.

مثال واقعي: شريحة Controller مع Service محاكى

لنفترض أن لديك OrderController يُفوّض إلى OrderService. تحميل الـ OrderService الحقيقي سيستدعي repository من JPA واتصالًا بقاعدة بيانات وربما عميل بريد إلكتروني. لا مكان لأيٍّ من ذلك في اختبار controller. إليك النمط الكامل:

// OrderService.java package com.example.orders; import java.util.List; public interface OrderService { List<OrderDto> findAllForUser(Long userId); OrderDto getById(Long id); }
// OrderController.java package com.example.orders; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import java.util.List; @RestController @RequestMapping("/api/orders") public class OrderController { private final OrderService orderService; public OrderController(OrderService orderService) { this.orderService = orderService; } @GetMapping("/user/{userId}") public ResponseEntity<List<OrderDto>> listForUser(@PathVariable Long userId) { return ResponseEntity.ok(orderService.findAllForUser(userId)); } @GetMapping("/{id}") public ResponseEntity<OrderDto> getOrder(@PathVariable Long id) { return ResponseEntity.ok(orderService.getById(id)); } }
// OrderControllerTest.java package com.example.orders; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import java.util.List; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.verify; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @WebMvcTest(OrderController.class) class OrderControllerTest { @Autowired private MockMvc mockMvc; @MockBean // يستبدل OrderService في السياق private OrderService orderService; @Test void listForUser_returnsOrdersAsJson() throws Exception { // الترتيب OrderDto dto = new OrderDto(1L, "PENDING", 49.99); given(orderService.findAllForUser(42L)).willReturn(List.of(dto)); // التنفيذ والتحقق mockMvc.perform(get("/api/orders/user/42").accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(jsonPath("$[0].id").value(1)) .andExpect(jsonPath("$[0].status").value("PENDING")); verify(orderService).findAllForUser(42L); } }

لاحظ ثلاثة أمور: الإضافة التوضيحية على الحقل لا على المعامل؛ يُعاد ضبط الـ mock تلقائيًا قبل كل دالة اختبار؛ وأسلوب given(…).willReturn(…) (BDD) وwhen(…).thenReturn(…) (الكلاسيكي) متبادلان — اختر أحدهما والزمه.

@MockBean داخل @SpringBootTest

يمكنك أيضًا استخدام @MockBean في @SpringBootTest الكامل عندما تريد تحميل السياق بأكمله لكن عزل متعاوِن مكلف واحد — بوابة دفع خارجية أو خدمة إرسال بريد إلكتروني مثلًا.

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) class OrderIntegrationTest { @Autowired private TestRestTemplate restTemplate; @MockBean private PaymentGatewayClient paymentClient; // يمنع الاستدعاءات HTTP الحقيقية @Test void checkout_callsPaymentGateway() { given(paymentClient.charge(any())).willReturn(new ChargeResult("ch_ok", true)); ResponseEntity<String> response = restTemplate.postForEntity("/api/checkout", checkoutRequest(), String.class); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); verify(paymentClient).charge(any()); } }
@MockBean يُسبّب إعادة تحميل السياق. كل مجموعة فريدة من إضافات @MockBean تُجبر Spring على بناء ApplicationContext جديد. إن كانت عشر كلاسات اختبار تُحاكي مجموعات مختلفة من الـ beans فستحصل على عشرة سياقات ويتباطأ البناء بشدة. اجمع الاختبارات التي تحتاج نفس الـ mocks في الكلاس نفسها، أو استخدم كلاس أساسية مشتركة تُعلن فيها حقول @MockBean المشتركة.

تهيئة السلوك والتحقق من التفاعلات

الـ mock الذي يُنتجه @MockBean هو mock Mockito قياسي. بالإعداد الافتراضي تعيد كل دالة "قيمة صفر" (null للكائنات، 0 للأعداد، مجموعة فارغة لأنواع المجموعات). هيّئ فقط ما يختبره الاختبار فعلًا:

// تهيئة استدعاء محدد given(orderService.getById(99L)).willReturn(new OrderDto(99L, "SHIPPED", 120.00)); // تهيئة لرمي استثناء given(orderService.getById(0L)).willThrow(new OrderNotFoundException(0L)); // التحقق من استدعاء الـ mock (اختياري — تحقق فقط من السلوك الظاهر) verify(orderService, times(1)).getById(99L); verifyNoMoreInteractions(orderService);

@SpyBean — عندما تحتاج التنفيذ الحقيقي

أحيانًا تريد الـ bean الحقيقي لكن تحتاج إلى تهيئة أو التحقق من دالة واحدة محددة. يُغلّف @SpyBean الـ bean الحقيقي في Spring bean داخل Mockito spy. تُنفَّذ جميع الدوال بشكل طبيعي ما لم تُهيّئها بـ doReturn(…).when(spy).method(). استخدمه باعتدال — الـ spy الذي يُهيّئ معظم دواله هو في الحقيقة مجرد mock مُقنَّع ويُشير إلى خلل في التصميم.

@SpyBean private AuditService auditService; // التنفيذ الحقيقي يعمل @Test void deleteOrder_auditsTheDeletion() { // تهيئة جزئية: تجاوز الجزء الذي يستدعي شبكة فقط doReturn(true).when(auditService).isEnabled(); restTemplate.delete("/api/orders/5"); verify(auditService).record("DELETE", 5L); }
لا تخلط @MockBean مع Mockito.mock() لنفس النوع في اختبار واحد. الحقل المُعلَّم بـ @MockBean هو ما يُحقنه Spring؛ أما كائن Mockito.mock() العائم في كلاس الاختبار فلن يُربط بأي مكان وستكون تهيئاته بلا أثر بصمت.

تخزين السياق مؤقتًا وأثره على الأداء

يُخزّن Spring Test السياقات بحسب مفتاح التهيئة. تشارك @MockBean في ذلك المفتاح. إرشادات عملية للحفاظ على سرعة البناء:

  • أعلن جميع حقول @MockBean/@SpyBean في كلاس أساسية مجردة مشتركة تمتد منها كلاسات اختبار الشريحة. ستشترك جميع الكلاسات الفرعية في سياق واحد.
  • فضّل شرائح @WebMvcTest على @SpringBootTest الكامل عندما تحتاج طبقة الويب فقط — سياقات الشرائح أرخص بكثير في البدء.
  • استخدم @MockBean فقط للـ beans التي تعتمد عليها الكلاس المُختبَرة فعلًا. محاكاة beans غير مطلوبة "للاحتياط" تُلوّث المفتاح وتُشتّت الذاكرة المؤقتة.

الخلاصة

@MockBean هو الجسر بين قوة محاكاة Mockito وحقن الاعتماديات في Spring. يُسجّل mock بوصفه bean كامل الحقوق في Spring، مما يُتيح لاختبارات الشريحة والتكامل عزل المتعاونين المُكلفين أو الخارجيين دون فقدان فائدة إطار الربط الكامل. تذكّر: استخدم @Mock في اختبارات الوحدة الخالصة، و@MockBean عندما يجب على Spring ربط الـ mock، وخطّط لتجميع كلاسات الاختبار بعناية للحفاظ على تخزين السياق مؤقتًا وسرعة بنائك.