انتقل إلى المحتوى الرئيسي

TP9.3 - اختبارات REST API مع MockMvc

أهداف التمرين​

في هذا التمرين، سوف تقوم بـ:

  1. ✅ اختبار وحدة تحكم REST كاملة باستخدام MockMvc
  2. ✅ التحقق من طلبات GET، POST، PUT، DELETE
  3. ✅ اختبار الردود JSON باستخدام JSONPath
  4. ✅ اختبار الأخطاء (404، 400، 500)
  5. ✅ اختبار مع المصادقة

المدة المقدرة: 1 ساعة و30 دقيقة


السياق​

لديك ProductController الذي يعرض CRUD REST API. يجب عليك اختبار جميع النقاط النهائية باستخدام MockMvc.


الجزء 1: وحدة التحكم للاختبار​

@RestController
@RequestMapping("/api/products")
@RequiredArgsConstructor
public class ProductController {

private final ProductService productService;

@GetMapping
public List<Product> getAll() {
return productService.findAll();
}

@GetMapping("/{id}")
public ResponseEntity<Product> getById(@PathVariable Long id) {
return productService.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}

@PostMapping
public ResponseEntity<Product> create(@Valid @RequestBody ProductRequest request) {
Product product = productService.create(request);
URI location = URI.create("/api/products/" + product.getId());
return ResponseEntity.created(location).body(product);
}

@PutMapping("/{id}")
public ResponseEntity<Product> update(@PathVariable Long id,
@Valid @RequestBody ProductRequest request) {
return productService.update(id, request)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}

@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
if (productService.delete(id)) {
return ResponseEntity.noContent().build();
}
return ResponseEntity.notFound().build();
}

@GetMapping("/search")
public List<Product> search(
@RequestParam(required = false) String name,
@RequestParam(required = false) BigDecimal minPrice,
@RequestParam(required = false) BigDecimal maxPrice) {
return productService.search(name, minPrice, maxPrice);
}
}

ProductRequest DTO​

@Data
public class ProductRequest {
@NotBlank(message = "Name is required")
private String name;

private String description;

@NotNull(message = "Price is required")
@Positive(message = "Price must be positive")
private BigDecimal price;

@Min(value = 0, message = "Stock cannot be negative")
private Integer stock;
}

الجزء 2: إعداد الاختبارات​

@WebMvcTest(ProductController.class)
class ProductControllerTest {

@Autowired
private MockMvc mockMvc;

@Autowired
private ObjectMapper objectMapper;

@MockBean
private ProductService productService;

private Product sampleProduct;

@BeforeEach
void setUp() {
sampleProduct = new Product(1L, "Laptop", "High-end laptop",
new BigDecimal("999.99"), 10, true, null, LocalDateTime.now());
}
}

الجزء 3: اختبارات GET​

اختبار 1: GET /api/products​

@Test
@DisplayName("GET /api/products - يجب أن يعيد قائمة المنتجات")
void getAll_ShouldReturnProducts() throws Exception {
when(productService.findAll()).thenReturn(List.of(sampleProduct));

mockMvc.perform(get("/api/products"))
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$").isArray())
.andExpect(jsonPath("$.length()").value(1))
.andExpect(jsonPath("$[0].id").value(1))
.andExpect(jsonPath("$[0].name").value("Laptop"));
}

اختبار 2: GET /api/products/{id} - نجاح​

@Test
@DisplayName("GET /api/products/{id} - يجب أن يعيد المنتج")
void getById_WhenExists_ShouldReturnProduct() throws Exception {
when(productService.findById(1L)).thenReturn(Optional.of(sampleProduct));

mockMvc.perform(get("/api/products/{id}", 1))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(1))
.andExpect(jsonPath("$.name").value("Laptop"))
.andExpect(jsonPath("$.price").value(999.99));
}

اختبار 3: GET /api/products/{id} - غير موجود​

@Test
@DisplayName("GET /api/products/{id} - يجب أن يعيد 404 إذا لم يتم العثور عليه")
void getById_WhenNotExists_ShouldReturn404() throws Exception {
// TODO: تنفيذ
}

اختبار 4: GET /api/products/search​

@Test
@DisplayName("GET /api/products/search - يجب أن يقوم بتصفية المنتجات")
void search_ShouldFilterProducts() throws Exception {
// TODO: تنفيذ مع params
}

الجزء 4: اختبارات POST​

اختبار 5: POST /api/products - نجاح​

@Test
@DisplayName("POST /api/products - يجب أن ينشئ منتجًا")
void create_WithValidData_ShouldReturnCreated() throws Exception {
ProductRequest request = new ProductRequest();
request.setName("New Product");
request.setPrice(new BigDecimal("49.99"));
request.setStock(10);

Product created = new Product(1L, "New Product", null,
new BigDecimal("49.99"), 10, true, null, LocalDateTime.now());
when(productService.create(any())).thenReturn(created);

mockMvc.perform(post("/api/products")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(header().exists("Location"))
.andExpect(jsonPath("$.id").value(1))
.andExpect(jsonPath("$.name").value("New Product"));
}

اختبار 6: POST /api/products - خطأ في التحقق​

@Test
@DisplayName("POST /api/products - يجب أن يعيد 400 إذا كانت البيانات غير صالحة")
void create_WithInvalidData_ShouldReturn400() throws Exception {
ProductRequest invalid = new ProductRequest();
invalid.setName(""); // غير صالح: فارغ
invalid.setPrice(new BigDecimal("-10")); // غير صالح: سلبي

mockMvc.perform(post("/api/products")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(invalid)))
.andExpect(status().isBadRequest());
}

اختبار 7: POST بدون Content-Type​

@Test
@DisplayName("POST /api/products - يجب أن يعيد 415 بدون Content-Type")
void create_WithoutContentType_ShouldReturn415() throws Exception {
mockMvc.perform(post("/api/products")
.content("{\"name\":\"Test\"}"))
.andExpect(status().isUnsupportedMediaType());
}

الجزء 5: اختبارات PUT​

اختبار 8: PUT /api/products/{id} - نجاح​

@Test
@DisplayName("PUT /api/products/{id} - يجب أن يقوم بتحديث المنتج")
void update_WhenExists_ShouldReturnUpdated() throws Exception {
// TODO: تنفيذ
}

اختبار 9: PUT /api/products/{id} - غير موجود​

@Test
@DisplayName("PUT /api/products/{id} - يجب أن يعيد 404 إذا لم يتم العثور عليه")
void update_WhenNotExists_ShouldReturn404() throws Exception {
// TODO: تنفيذ
}

الجزء 6: اختبارات DELETE​

اختبار 10: DELETE /api/products/{id} - نجاح​

@Test
@DisplayName("DELETE /api/products/{id} - يجب أن يقوم بحذف المنتج")
void delete_WhenExists_ShouldReturn204() throws Exception {
when(productService.delete(1L)).thenReturn(true);

mockMvc.perform(delete("/api/products/{id}", 1))
.andExpect(status().isNoContent());

verify(productService).delete(1L);
}

اختبار 11: DELETE /api/products/{id} - غير موجود​

@Test
@DisplayName("DELETE /api/products/{id} - يجب أن يعيد 404 إذا لم يتم العثور عليه")
void delete_WhenNotExists_ShouldReturn404() throws Exception {
// TODO: تنفيذ
}

الجزء 7: اختبارات مع المصادقة​

إعداد الأمان​

@WebMvcTest(ProductController.class)
@Import(SecurityConfig.class) // استيراد إعدادات الأمان
class SecuredProductControllerTest {

@Autowired
private MockMvc mockMvc;

@MockBean
private ProductService productService;

@Test
@WithMockUser(roles = "USER")
void getAll_AsUser_ShouldReturnProducts() throws Exception {
when(productService.findAll()).thenReturn(List.of());

mockMvc.perform(get("/api/products"))
.andExpect(status().isOk());
}

@Test
void getAll_AsAnonymous_ShouldReturn401() throws Exception {
mockMvc.perform(get("/api/products"))
.andExpect(status().isUnauthorized());
}

@Test
@WithMockUser(roles = "ADMIN")
void delete_AsAdmin_ShouldSucceed() throws Exception {
when(productService.delete(1L)).thenReturn(true);

mockMvc.perform(delete("/api/products/{id}", 1))
.andExpect(status().isNoContent());
}

@Test
@WithMockUser(roles = "USER")
void delete_AsUser_ShouldReturn403() throws Exception {
mockMvc.perform(delete("/api/products/{id}", 1))
.andExpect(status().isForbidden());
}
}

الجزء 8: الحل​

انقر لرؤية الحل
@WebMvcTest(ProductController.class)
class ProductControllerTest {

@Autowired
private MockMvc mockMvc;

@Autowired
private ObjectMapper objectMapper;

@MockBean
private ProductService productService;

private Product sampleProduct;

@BeforeEach
void setUp() {
sampleProduct = new Product(1L, "Laptop", "High-end",
new BigDecimal("999.99"), 10, true, null, LocalDateTime.now());
}

@Test
void getById_WhenNotExists_ShouldReturn404() throws Exception {
when(productService.findById(999L)).thenReturn(Optional.empty());

mockMvc.perform(get("/api/products/{id}", 999))
.andExpect(status().isNotFound());
}

@Test
void search_ShouldFilterProducts() throws Exception {
when(productService.search(eq("Laptop"), any(), any()))
.thenReturn(List.of(sampleProduct));

mockMvc.perform(get("/api/products/search")
.param("name", "Laptop")
.param("minPrice", "500")
.param("maxPrice", "1500"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.length()").value(1));
}

@Test
void update_WhenExists_ShouldReturnUpdated() throws Exception {
ProductRequest request = new ProductRequest();
request.setName("Updated");
request.setPrice(new BigDecimal("1099.99"));
request.setStock(5);

Product updated = new Product(1L, "Updated", null,
new BigDecimal("1099.99"), 5, true, null, LocalDateTime.now());
when(productService.update(eq(1L), any())).thenReturn(Optional.of(updated));

mockMvc.perform(put("/api/products/{id}", 1)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("Updated"));
}

@Test
void update_WhenNotExists_ShouldReturn404() throws Exception {
ProductRequest request = new ProductRequest();
request.setName("Test");
request.setPrice(new BigDecimal("100"));

when(productService.update(eq(999L), any())).thenReturn(Optional.empty());

mockMvc.perform(put("/api/products/{id}", 999)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isNotFound());
}

@Test
void delete_WhenNotExists_ShouldReturn404() throws Exception {
when(productService.delete(999L)).thenReturn(false);

mockMvc.perform(delete("/api/products/{id}", 999))
.andExpect(status().isNotFound());
}
}

التحقق​

mvn test -Dtest=ProductControllerTest

قائمة التحقق​

  • تم اختبار جميع نقاط النهاية CRUD
  • تم التحقق من رموز الحالة (200، 201، 204، 400، 404)
  • تم التحقق من ردود JSON
  • تم التحقق من الرؤوس (الموقع لـ POST)
  • اختبارات التحقق
  • اختبارات مع المصادقة (اختياري)

🎯 الوحدة مكتملة!​

تهانينا! لقد أكملت الوحدة 9 - اختبارات مع Spring Boot.

ملخص:

  • الفصل 1: مقدمة في الاختبارات
  • الفصل 2: JUnit 5
  • الفصل 3: Mockito
  • الفصل 4: اختبارات التكامل
  • الفصل 5: اختبارات REST API
  • TP9.1: اختبارات وحدات الخدمة
  • TP9.2: اختبارات تكامل المستودع
  • TP9.3: اختبارات REST API Controller

➡️ العودة إلى الوحدة 9


⬅️ العودة إلى خطة الدورة