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

اختبار طبقة الويب باستخدام @WebMvcTest

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

اختبار طبقة الويب باستخدام @WebMvcTest

حين تكتب متحكّمًا REST تهتم بثلاثة أشياء: هل تستجيب نقطة النهاية للطريقة الصحيحة وعنوان URL الصحيح، وهل تعيّن جسم الطلب ومتغيرات المسار بشكل صحيح، وهل تُعيد رمز الحالة والـ JSON الصحيحَين؟ لا يعنيك على الإطلاق ما إذا كانت قاعدة البيانات متاحة. تحميل سياق التطبيق كاملًا — Hibernate وتجمّعات الاتصال وسماسرة الرسائل — للإجابة على هذه الأسئلة الثلاثة يُعدّ بطيئًا وهشًّا وغير ضروري. وهذه تحديدًا هي المشكلة التي يحلّها @WebMvcTest.

ما هو اختبار الشريحة؟

تحمّل اختبارات الشرائح في Spring Boot مجموعة فرعية محددة جيدًا من سياق التطبيق. @WebMvcTest هي شريحة طبقة الويب: تُشغّل بنية Spring MVC الأساسية (المرشحات، ومحلّلات الوسائط، ومحوّلات الرسائل، وDispatcherServlet) لكنها تستبعد عمدًا @Service و@Repository و@Component وإعداد JPA/قاعدة البيانات التلقائي. النتيجة سياقٌ يبدأ خلال مئات المللي ثانية بدلًا من ثوانٍ عدة، ولا يلمس قاعدة البيانات أبدًا.

الشريحة مقابل اختبار الوحدة مقابل اختبار التكامل: اختبار الوحدة الخالص لا يستخدم سياق Spring أصلًا — مجرد Java عادية وMockito. اختبار التكامل مع @SpringBootTest يحمّل السياق بالكامل. @WebMvcTest يقع في المنتصف: تحصل على إرسال MVC حقيقي (إلغاء التسلسل، والتحقق من الصحة، ومعالجات الاستثناءات) دون تكلفة السياق الكامل.

إعداد @WebMvcTest

لنفترض أن لديك متحكمًا REST بسيطًا لإدارة المنتجات:

// src/main/java/com/example/shop/product/ProductController.java package com.example.shop.product; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import jakarta.validation.Valid; import java.util.List; @RestController @RequestMapping("/api/products") public class ProductController { private final ProductService productService; public ProductController(ProductService productService) { this.productService = productService; } @GetMapping public List<ProductDto> list() { return productService.findAll(); } @GetMapping("/{id}") public ResponseEntity<ProductDto> getById(@PathVariable Long id) { return productService.findById(id) .map(ResponseEntity::ok) .orElse(ResponseEntity.notFound().build()); } @PostMapping public ResponseEntity<ProductDto> create(@Valid @RequestBody CreateProductRequest request) { ProductDto created = productService.create(request); return ResponseEntity.status(201).body(created); } }

لاختبار هذا المتحكّم بمعزل عن غيره، ضع تعليق التوصيف @WebMvcTest على فئة الاختبار وحدّد المتحكّم قيد الاختبار:

// src/test/java/com/example/shop/product/ProductControllerTest.java package com.example.shop.product; import com.fasterxml.jackson.databind.ObjectMapper; 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 java.util.Optional; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @WebMvcTest(ProductController.class) // يُحمَّل هذا المتحكم فقط class ProductControllerTest { @Autowired MockMvc mockMvc; // مُهيَّأ مسبقًا بواسطة الشريحة @Autowired ObjectMapper objectMapper; // نفس محوّل Jackson المستخدم في التطبيق @MockBean ProductService productService; // استبدال الـ bean الحقيقي بنسخة وهمية @Test void list_returnsAllProducts() throws Exception { when(productService.findAll()) .thenReturn(List.of(new ProductDto(1L, "Keyboard", 79.99))); mockMvc.perform(get("/api/products")) .andExpect(status().isOk()) .andExpect(jsonPath("$[0].name").value("Keyboard")) .andExpect(jsonPath("$[0].price").value(79.99)); } @Test void getById_notFound_returns404() throws Exception { when(productService.findById(99L)).thenReturn(Optional.empty()); mockMvc.perform(get("/api/products/99")) .andExpect(status().isNotFound()); } @Test void create_validRequest_returns201() throws Exception { var request = new CreateProductRequest("Mouse", 39.99); var response = new ProductDto(2L, "Mouse", 39.99); when(productService.create(request)).thenReturn(response); mockMvc.perform(post("/api/products") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isCreated()) .andExpect(jsonPath("$.id").value(2)); } }

ما يوفّره @WebMvcTest تلقائيًا

  • كائن MockMvc مُهيَّأ بالكامل — لا حاجة لاستدعاء MockMvcBuilders.standaloneSetup().
  • ObjectMapper الخاص بـ Jackson مُهيَّأ تمامًا كما في الإنتاج (المسلسِلات المخصصة، وتسجيل الوحدات، إلخ).
  • جميع فئات @ControllerAdvice في التطبيق مُحمَّلة — تُختبر معالجة الاستثناءات من البداية إلى النهاية.
  • المرشحات المسجّلة كـ beans في Spring مُطبَّقة (مرشحات الأمان، وCORS، إلخ).
  • التحقق من صحة الـ Bean (‎@Valid) مُربوط ويُطلق أثناء معالجة الطلب.

تحديد نطاق الشريحة: أي متحكمات تُحمَّل؟

حين تكتب @WebMvcTest(ProductController.class)، يُضاف ذلك المتحكم فقط إلى السياق. إن حذفت القيمة — @WebMvcTest بلا وسيطة — يحمّل Spring جميع كيانات @RestController و@Controller الموجودة في حزمك. وهذا يعني أن جميع تبعياتها يجب أن تُحاكى. فضّل الصيغة الصريحة لتُبقي الاختبارات ضيّقة وسريعة.

حدّد دائمًا فئة المتحكم: @WebMvcTest(MyController.class). مع نمو التطبيق، تسحب الصيغة بلا وسيطة متحكمات جديدة وتبعياتها من @MockBean بصمت، مما يُبطئ عملية البناء ويجعل تشخيص الأخطاء أصعب.

اختبار التحقق من صحة الـ Bean

لأن @Valid تُعالَج بواسطة طبقة MVC، فإن @WebMvcTest هو المكان المناسب للتحقق من رفض المدخلات غير الصالحة:

@Test void create_missingName_returns400() throws Exception { // السعر موجود لكن الاسم فارغ — ينتهك @NotBlank var badRequest = new CreateProductRequest("", 39.99); mockMvc.perform(post("/api/products") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(badRequest))) .andExpect(status().isBadRequest()); }

لا يستلزم هذا الاختبار خدمة أو قاعدة بيانات تعمل، ومع ذلك يُثبت أن قيد @NotBlank على CreateProductRequest.name مُوصَّل وسيُعيد 400 عند انتهاكه.

الأمان في @WebMvcTest

إن كان مشروعك يتضمن spring-boot-starter-security، يُطبّق @WebMvcTest إعداد الأمان الخاص بك تلقائيًا. ستُعيد نقاط النهاية التي تتطلب مصادقة 401 أو 403 في الاختبارات. لديك عدة خيارات:

  • استخدم @WithMockUser (من spring-security-test) لتشغيل طلب كمستخدم مصادَق عليه بعينه.
  • وفّر @TestConfiguration مخصصًا يُخفّف الأمان لسياق الاختبار.
  • استدع .with(SecurityMockMvcRequestPostProcessors.csrf()) على طلبات POST/PUT حين يكون CSRF مفعّلًا.
لا تُعطّل الأمان بشكل شامل في الاختبارات. إن استثنيت الإعداد التلقائي للأمان بـ excludeAutoConfiguration، ستتوقف عن اختبار عقد الأمان لواجهة API الخاصة بك. استخدم @WithMockUser أو إعداد أمان مخصصًا للاختبار يحتفظ بقواعد واقعية مع السماح بتنفيذ الاختبارات.

المقايضات في الأداء

شريحة الويب سريعة تحديدًا لأنها ضيّقة. لكن لهذا الضيق عواقبه: طبقتا الخدمة والمستودع غائبتان كليًّا، لذا يجب عليك محاكاة كل تبعية يستخدمها متحكمك بـ @MockBean. إن كان للمتحكم تبعيات متعاونة كثيرة قد يصبح ذلك مطوّلًا. الحد يُشير إلى قلق في التصميم: المتحكمات ذات التبعيات المباشرة الكثيرة أصعب اختبارًا وأصعب استيعابًا.

مقايضة أخرى هي تخزين السياق مؤقتًا. يخزّن Spring Boot السياق عبر الاختبارات التي تشترك في نفس الإعداد. إضافة تعريفات @MockBean مختلفة في فئات اختبار مختلفة يُنشئ إعدادات سياق مختلفة، مما يُعطّل التخزين المؤقت ويُبطئ مجموعة الاختبارات. اجمع المتحكمات ذات متطلبات المحاكاة نفسها في فئة اختبار واحدة كلما أمكن.

الخلاصة

يمنحك @WebMvcTest اختبار شريحة سريعًا ومركّزًا لطبقة الويب: إرسال MVC حقيقي، وتسلسل Jackson حقيقي، وتحقق Bean حقيقي، ومعالجات استثناءات حقيقية — مع صفر تكلفة لقاعدة البيانات. أعلن المتحكم المحدد قيد الاختبار، واستبدل جميع الخدمات بـ @MockBean، واستخدم MockMvc لإجراء تأكيدات على مستوى HTTP. يتناول الدرس التالي MockMvc بعمق أكبر ومجموعة التأكيدات والتخصيصات الغنية التي يوفّرها.