MockMvc بعمق
قدّم الدرس السابق @WebMvcTest وأوضح كيفية توصيل MockMvc بفئة الاختبار. يتعمّق هذا الدرس فيما يمكنك فعله بها فعلًا: كيفية صياغة كل أنواع طلبات HTTP، وكيفية التحقق من رموز الحالة وترويسات الاستجابة ومحتوى جسم JSON بدقة وسهولة قراءة. بنهاية الدرس ستتمكن من التحقق من العقد الكامل لنقطة نهاية REST — لا مجرد "أعادت 200".
نمط بناء الطلبات في MockMvc
يبدأ كل اختبار بدالة مصنع ساكنة من MockMvcRequestBuilders (تُستورد عادةً بشكل ساكن مثل get وpost وput وdelete وغيرها). يجمع البنّاء الترويسات والمعاملات وجسم الطلب ونوع المحتوى؛ ثم ترسل mockMvc.perform() الطلب عبر خط أنابيب Spring MVC كاملًا — تعيين المعالج ومُحلّلات الوسيطات والمعترضات ومحوّلات الرسائل — دون الحاجة إلى تشغيل خادم HTTP فعلي.
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.*;
// GET مع معامل استعلام وترويسة مخصصة
mockMvc.perform(
get("/api/orders")
.param("status", "PENDING")
.header("X-Tenant-Id", "acme")
.accept(MediaType.APPLICATION_JSON)
)
.andExpect(status().isOk());
تضبط دالة accept() ترويسة Accept، التي تتحكم في تفاوض المحتوى في Spring. احرص دائمًا على ضبطها في اختبارات API لضمان تسلسل بيانات حتمي.
إرسال جسم الطلب
لنقاط النهاية من نوع POST / PUT التي تستهلك JSON، أمدّ الجسم المُسلسَل وأعلن Content-Type: application/json.
import com.fasterxml.jackson.databind.ObjectMapper;
@Autowired
private ObjectMapper objectMapper; // يُهيَّأ تلقائيًا بواسطة @WebMvcTest
@Test
void createOrder_returnsCreated() throws Exception {
OrderRequest req = new OrderRequest("widget-9", 3, "STANDARD");
mockMvc.perform(
post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req))
)
.andExpect(status().isCreated())
.andExpect(header().string("Location", org.hamcrest.Matchers.containsString("/api/orders/")));
}
استخدم ObjectMapper بدلًا من سلاسل JSON المكتوبة يدويًا. إنّ التهيئة التلقائية لـObjectMapper تعني أن اختبارك يستخدم إعدادات التسلسل ذاتها (تنسيقات التواريخ ومعالجة القيم الخالية والمسلسلات المخصصة) كما يفعل التطبيق الحي. تتباعد السلاسل المكتوبة يدويًا بصمت عند تغيير DTO.
التحقق من حالة HTTP
يُعدّ MockMvcResultMatchers.status() مصنعَ ResultMatcher. لكل رمز حالة قياسي اختصار مُسمَّى، وهناك أيضًا مسار ترقيمي للحالات الأخرى:
.andExpect(status().isOk()) // 200
.andExpect(status().isCreated()) // 201
.andExpect(status().isNoContent()) // 204
.andExpect(status().isBadRequest()) // 400
.andExpect(status().isUnauthorized()) // 401
.andExpect(status().isForbidden()) // 403
.andExpect(status().isNotFound()) // 404
.andExpect(status().is(422)) // أي رمز عبر int
اختر الشكل المُسمَّى كلما أمكن — فهو يوثّق النية بوضوح أكبر في مخرجات الاختبار مقارنةً برقم مجرد.
التحقق من ترويسات الاستجابة
تمنحك header() تحققات المساواة الدقيقة ومطابقات Hamcrest:
// مساواة دقيقة
.andExpect(header().string("Content-Type", "application/json"))
// مطابق Hamcrest — الترويسة موجودة وتحتوي سلسلة فرعية
.andExpect(header().string("Location",
org.hamcrest.Matchers.startsWith("/api/orders/")))
// التحقق من وجود ترويسة أصلًا
.andExpect(header().exists("X-Request-Id"))
// التحقق من غياب ترويسة
.andExpect(header().doesNotExist("X-Internal-Debug"))
اختبر ترويسة Location في كل استجابة 201. تشترطها RFC 9110، وكثير من SDKs للعملاء تعتمد عليها لمتابعة إعادة التوجيه. الـLocation المفقودة انتهاك للعقد تكتشفه MockMvc مجانًا.
التحقق من جسم JSON باستخدام jsonPath()
الطريقة الأكثر تعبيرًا للتحقق من استجابات JSON هي jsonPath()، المدعومة بمكتبة JsonPath (مُضمَّنة بشكل انتقالي في spring-boot-starter-test). وتستخدم لغة مسار بترميز النقطة تشبه XPath.
mockMvc.perform(get("/api/orders/42").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(42))
.andExpect(jsonPath("$.status").value("PENDING"))
.andExpect(jsonPath("$.items").isArray())
.andExpect(jsonPath("$.items.length()").value(3))
.andExpect(jsonPath("$.items[0].sku").value("widget-9"))
.andExpect(jsonPath("$.customer.email").exists())
.andExpect(jsonPath("$.internalAuditLog").doesNotExist()); // يجب ألا يُكشف
يُغطّي الرمز الجذري $ وعامل العناصر الفرعية . وترميز المصفوفة [] ودالة length() الغالبية العظمى من التحققات. للمنطق الأغنى، يمكنك تمرير أي مطابق Hamcrest كوسيطة ثانية لـjsonPath():
import static org.hamcrest.Matchers.*;
// إجمالي السعر بين 50 و500
.andExpect(jsonPath("$.totalPrice", allOf(greaterThan(50.0), lessThan(500.0))))
// الحالة إحدى قيم مجموعة صالحة
.andExpect(jsonPath("$.status", oneOf("PENDING", "PROCESSING")))
// المصفوفة تحتوي على عنصر واحد على الأقل بـ sku تساوي "widget-9"
.andExpect(jsonPath("$.items[*].sku", hasItem("widget-9")))
التحقق من جسم JSON الكامل باستخدام content()
للحمولات الصغيرة والثابتة التي تريد التحقق من المستند بأكمله، تكون content().json() أنظف من قائمة أسطر jsonPath. تتجاهل الحقول الإضافية افتراضيًا (الوضع غير الصارم)، أو يمكنك فرض مساواة دقيقة:
// غير صارم: يتحقق من تطابق كل حقل مُعلَن؛ يتجاهل الحقول الإضافية
.andExpect(content().json("""
{
"id": 42,
"status": "PENDING"
}
"""))
// صارم: يجب أن تحتوي الاستجابة على هذه الحقول فقط دون غيرها
.andExpect(content().json("""
{ "id": 42, "status": "PENDING", "items": [] }
""", true))
فضّل jsonPath() للاستجابات الكبيرة أو الديناميكية. ينكسر content().json() الصارم في كل مرة تُضاف فيها حقل جديد إلى DTO، حتى لو لم يكن الحقل ذا صلة بالاختبار. احجزه للعقود الصغيرة والمتعمَّد ثباتها (مثل شكل مغلف الخطأ).
طباعة الاستجابة لأغراض التصحيح
حين يفشل اختبار ولا ترى السبب، سلسل andDo(print()) قبل التحققات. تُفرغ الطلب/الاستجابة الكاملة — الحالة والترويسات والجسم — على وحدة التحكم:
mockMvc.perform(get("/api/orders/99"))
.andDo(print()) // احذفها قبل الإيداع
.andExpect(status().isNotFound());
هناك أيضًا andReturn() إن احتجت إلى كائن MvcResult الخام (على سبيل المثال، لاستخراج معرّف مُولَّد من جسم الاستجابة واستخدامه في طلب لاحق):
MvcResult result = mockMvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isCreated())
.andReturn();
String location = result.getResponse().getHeader("Location");
long newId = Long.parseLong(location.substring(location.lastIndexOf('/') + 1));
الجمع معًا — اختبار متحكم كامل
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired MockMvc mockMvc;
@Autowired ObjectMapper objectMapper;
@MockBean OrderService orderService;
@Test
void getOrder_found_returns200WithBody() throws Exception {
OrderResponse resp = new OrderResponse(42L, "PENDING", List.of());
given(orderService.findById(42L)).willReturn(resp);
mockMvc.perform(get("/api/orders/42").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(42))
.andExpect(jsonPath("$.status").value("PENDING"))
.andExpect(jsonPath("$.items").isArray());
}
@Test
void getOrder_notFound_returns404() throws Exception {
given(orderService.findById(99L))
.willThrow(new OrderNotFoundException(99L));
mockMvc.perform(get("/api/orders/99").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.message").exists());
}
@Test
void createOrder_validBody_returns201WithLocation() throws Exception {
OrderRequest req = new OrderRequest("widget-9", 3, "STANDARD");
OrderResponse resp = new OrderResponse(7L, "PENDING", List.of());
given(orderService.create(any())).willReturn(resp);
mockMvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isCreated())
.andExpect(header().string("Location", containsString("/api/orders/7")))
.andExpect(jsonPath("$.id").value(7));
}
}
الخلاصة
تمنحك واجهة MockMvc الطلاقة تحكمًا دقيقًا في كل بُعد من أبعاد تفاعل HTTP. استخدم param() وheader() وcontentType() وcontent() لبناء الطلبات؛ واستخدم status() وheader() وjsonPath() (المدعومة بمطابقات Hamcrest) لتحقق عقد الاستجابة. تحقق فقط مما يهتم به الاختبار فعلًا — أبقِ كل اختبار محدود الهدف، واستخدم andDo(print()) لتصحيح الأعطال، وارجع إلى andReturn() حين تحتاج إلى تسلسل الطلبات. يتحول الدرس التالي بالتركيز من طبقة الخدمة إلى طبقة الثبات مع @DataJpaTest.