استراتيجية الاختبار
الاختبارات هي ضمان الجودة—كتابة كود بدون اختبارات كالقيادة بدون حزام أمان؛ عاجلاً أم آجلاً سيقع حادث.
1. ما ستتعلمه
- اختبار التكامل
@SpringBootTestواختبار الشرائح@WebMvcTest - محاكاة الاعتماديات باستخدام Mockito
@MockBean/@SpyBean - اختبار طبقة البيانات
@DataJpaTestوقاعدة بيانات H2 المدمجة - TestContainers: اختبار تكوين حاوي لـ MySQL وRedis
- Alice رفعت تغطية الاختبار لعملية تقديم الطلبات الأساسية في OrderFlow إلى أكثر من 80%
2. قصة حقيقية لمهندس جودة
(1) نقطة الألم: إصلاح خلل يؤدي إلى ثلاثة أخرى
عدّلت Alice منطق إلغاء الطلبات، معتقدة أنه سيؤثر فقط على ميزة الإلغاء، لكنه تسبب في مشكلات في استعادة المخزون أيضًا، مما أثر بدوره على إحصائيات التقارير. في كل مرة تغير الكود، تكون في توتر لأنه لا توجد اختبارات آلية للتحقق من الانحدار. Bob يُستيقظ كثيرًا في منتصف الليل بمكالمات للإصلاحات الطارئة، والفريق يقضي 30% من وقته أسبوعيًا في إصلاح الأخطاء.
(2) حلول الاختبار الآلي
اكتب اختبارات جيدة لتوفير "شبكة أمان" عند تعديل الكود:
@SpringBootTest
class OrderServiceTest {
@Test
void shouldCancelOrderAndRestoreStock() {
// Given: منتج بمخزون 10، طلب بكمية 3
// When: إلغاء الطلب
// Then: المخزون يعود إلى 13، حالة الطلب CANCELLED
}
}
(3) العائد
كتبت Alice 50 حالة اختبار لعملية OrderFlow الأساسية، محققةً 85% تغطية كود. في كل مرة يُعدل الكود، تعمل الاختبارات تلقائيًا، وتُكتشف مشكلات الانحدار خلال خمس دقائق—لذا لم يعد Bob يضطر لإصلاح الأخطاء في منتصف الليل.
3. هرم الاختبار
(1) استراتيجية الاختبار ثلاثية المستويات
graph LR
A["اختبارات الوحدة<br/>70% - سريعة، معزولة<br/>JUnit + Mockito"] --> B["اختبارات التكامل<br/>20% - سرعة متوسطة<br/>@SpringBootTest + H2"]
B --> C["اختبارات E2E<br/>10% - بطيئة، مكدس كامل<br/>TestContainers + REST Assured"]
| المستوى | النسبة | السرعة | الاعتماديات | محتوى الاختبار |
|---|---|---|---|---|
| اختبار الوحدة | 70% | مستوى المللي ثانية | Mock | المنطق التجاري |
| اختبار التكامل | 20% | ثوانٍ | H2/TestContainers | تفاعل المكونات |
| اختبار E2E | 10% | دقائق | البيئة الكاملة | العملية التجارية |
(1) ▶ مثال:اختبار الوحدة
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock
private OrderRepository orderRepository;
@Mock
private ProductRepository productRepository;
@InjectMocks
private OrderServiceImpl orderService;
@Test
void shouldCreateOrderSuccessfully() {
// Given
Product product = new Product("Laptop", BigDecimal.valueOf(999), 10);
when(productRepository.findById(1L)).thenReturn(Optional.of(product));
// When
Order order = orderService.createOrder(1L, 3);
// Then
assertThat(order).isNotNull();
assertThat(product.getStock()).isEqualTo(7);
verify(orderRepository).save(any(Order.class));
}
@Test
void shouldThrowWhenInsufficientStock() {
Product product = new Product("Laptop", BigDecimal.valueOf(999), 2);
when(productRepository.findById(1L)).thenReturn(Optional.of(product));
assertThatThrownBy(() -> orderService.createOrder(1L, 5))
.isInstanceOf(InsufficientStockException.class);
verify(orderRepository, never()).save(any());
}
}
الناتج:
// تم التنفيذ بنجاح
4. اختبارات شرائح @WebMvcTest
(1) اختبار مستوى المتحكم
(1) ▶ مثال:اختبار شرائح المتحكم
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private OrderService orderService;
@Test
void shouldReturnOrderById() throws Exception {
OrderResponse order = new OrderResponse(
1L, "bob", "PENDING", BigDecimal.valueOf(999),
List.of(), Instant.now());
when(orderService.getOrder(1L)).thenReturn(order);
mockMvc.perform(get("/api/v1/orders/1")
.with(httpBasic("bob", "pass123")))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(1))
.andExpect(jsonPath("$.status").value("PENDING"));
}
@Test
void shouldReturn404WhenOrderNotFound() throws Exception {
when(orderService.getOrder(999L))
.thenThrow(new ResourceNotFoundException("Order", 999L));
mockMvc.perform(get("/api/v1/orders/999")
.with(httpBasic("bob", "pass123")))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("RESOURCE_NOT_FOUND"));
}
@Test
void shouldReturn400WhenValidationFails() throws Exception {
String body = """
{"productId": null, "quantity": 0}
""";
mockMvc.perform(post("/api/v1/orders")
.with(httpBasic("bob", "pass123"))
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
}
}
الناتج:
// تم التنفيذ بنجاح
| تعليق الاختبار | نطاق التحميل | المستوى المطبق |
|---|---|---|
@WebMvcTest |
المتحكم + طبقة الويب | المتحكم |
@DataJpaTest |
المستودع + طبقة JPA | المستودع |
@SpringBootTest |
سياق التطبيق الكامل | اختبار التكامل |
5. اختبار طبقة البيانات @DataJpaTest
(1) ▶ مثال:اختبار المستودع
@DataJpaTest
class ProductRepositoryTest {
@Autowired
private ProductRepository productRepository;
@Autowired
private TestEntityManager entityManager;
@Test
void shouldFindByNameContaining() {
entityManager.persist(new Product("Laptop Pro", BigDecimal.valueOf(1299), 50));
entityManager.persist(new Product("Laptop Air", BigDecimal.valueOf(899), 30));
entityManager.persist(new Product("Wireless Mouse", BigDecimal.valueOf(29), 200));
List<Product> results = productRepository.findByNameContaining("Laptop");
assertThat(results).hasSize(2);
}
@Test
void shouldFindOutOfStockProducts() {
Product outOfStock = new Product("Old Model", BigDecimal.valueOf(99), 0);
entityManager.persist(outOfStock);
entityManager.persist(new Product("New Model", BigDecimal.valueOf(199), 10));
List<Product> results = productRepository.findOutOfStockProducts();
assertThat(results).hasSize(1);
assertThat(results.get(0).getName()).isEqualTo("Old Model");
}
@Test
void shouldCheckStockBeforeDeduction() {
Product product = entityManager.persist(
new Product("Laptop", BigDecimal.valueOf(999), 5));
Product found = productRepository.findById(product.getId()).orElseThrow();
found.deductStock(3);
Product updated = productRepository.findById(product.getId()).orElseThrow();
assertThat(updated.getStock()).isEqualTo(2);
}
}
الناتج:
// تم التنفيذ بنجاح
6. TestContainers: اختبار تكوين حاوي
(1) اختبار قاعدة بيانات حية
(1) ▶ مثال:MySQL TestContainer
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>mysql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
الناتج:
// تم التنفيذ بنجاح
@SpringBootTest
@Testcontainers
class OrderServiceIntegrationTest {
@Container
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
.withDatabaseName("orderflow_test")
.withUsername("test")
.withPassword("test");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", mysql::getJdbcUrl);
registry.add("spring.datasource.username", mysql::getUsername);
registry.add("spring.datasource.password", mysql::getPassword);
}
@Autowired
private OrderService orderService;
@Autowired
private ProductRepository productRepository;
@Test
void shouldCreateOrderAndDeductStock() {
Product product = productRepository.save(
new Product("Laptop", BigDecimal.valueOf(999), 10));
Order order = orderService.createOrder(product.getId(), 3);
assertThat(order.getStatus()).isEqualTo("PENDING");
Product updated = productRepository.findById(product.getId()).orElseThrow();
assertThat(updated.getStock()).isEqualTo(7);
}
}
(2) ▶ مثال:Redis TestContainer
@SpringBootTest
@Testcontainers
class RedisCacheIntegrationTest {
@Container
static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine")
.withExposedPorts(6379);
@DynamicPropertySource
static void configureRedis(DynamicPropertyRegistry registry) {
registry.add("spring.data.redis.host", redis::getHost);
registry.add("spring.data.redis.port", () -> redis.getMappedPort(6379));
}
@Autowired
private ProductService productService;
@Autowired
private ProductRepository productRepository;
@Test
void shouldCacheProductInRedis() {
Product product = productRepository.save(
new Product("Laptop", BigDecimal.valueOf(999), 10));
// الاستدعاء الأول: استعلام قاعدة البيانات
productService.getProduct(product.getId());
// الاستدعاء الثاني: إصابة تخزين Redis المؤقت
Product cached = productService.getProduct(product.getId());
assertThat(cached).isNotNull();
}
}
الناتج:
// تم التنفيذ بنجاح
7. تغطية الاختبار
(1) ▶ مثال:تكوين JaCoCo
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.11</version>
<executions>
<execution>
<goals><goal>prepare-agent</goal></goals>
</execution>
<execution>
<id>report</id>
<phase>test</phase>
<goals><goal>report</goal></goals>
</execution>
</executions>
</plugin>
الناتج:
// تم التنفيذ بنجاح
| بُعد التغطية | الهدف | الوصف |
|---|---|---|
| تغطية السطور | ≥ 80% | كود الأعمال الأساسي |
| تغطية الفروع | ≥ 70% | منطق if/else |
| تغطية الطرق | ≥ 80% | الطرق العامة |
8. مثال شامل: اختبار عملية تقديم الطلبات الأساسية في OrderFlow
// OrderServiceTest.java (اختبار وحدة)
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock private OrderRepository orderRepository;
@Mock private ProductRepository productRepository;
@InjectMocks private OrderServiceImpl orderService;
@Test
void createOrder_success() {
Product product = new Product("Laptop", BigDecimal.valueOf(999), 10);
when(productRepository.findById(1L)).thenReturn(Optional.of(product));
when(orderRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
Order order = orderService.createOrder(1L, 3);
assertThat(order.getStatus()).isEqualTo("PENDING");
assertThat(product.getStock()).isEqualTo(7);
}
@Test
void createOrder_insufficientStock_throws() {
Product product = new Product("Laptop", BigDecimal.valueOf(999), 2);
when(productRepository.findById(1L)).thenReturn(Optional.of(product));
assertThatThrownBy(() -> orderService.createOrder(1L, 5))
.isInstanceOf(InsufficientStockException.class);
}
@Test
void cancelOrder_success_restoresStock() {
Product product = new Product("Laptop", BigDecimal.valueOf(999), 7);
Order order = new Order();
order.setStatus("PENDING");
order.addItem(product, 3);
when(orderRepository.findByIdWithItems(1L)).thenReturn(Optional.of(order));
orderService.cancelOrder(1L);
assertThat(order.getStatus()).isEqualTo("CANCELLED");
assertThat(product.getStock()).isEqualTo(10);
}
@Test
void cancelOrder_nonPending_throws() {
Order order = new Order();
order.setStatus("SHIPPED");
when(orderRepository.findByIdWithItems(1L)).thenReturn(Optional.of(order));
assertThatThrownBy(() -> orderService.cancelOrder(1L))
.isInstanceOf(OrderStateException.class);
}
}
// OrderControllerTest.java (اختبار شرائح)
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired MockMvc mockMvc;
@MockBean OrderService orderService;
@Test
@WithMockUser(roles = "CUSTOMER")
void createOrder_validRequest_returns201() throws Exception {
when(orderService.createOrder(any(), eq("bob"))).thenReturn(
new OrderResponse(1L, "bob", "PENDING", BigDecimal.valueOf(999), List.of(), Instant.now()));
mockMvc.perform(post("/api/v1/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"productId\":1,\"quantity\":3}"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.status").value("PENDING"));
}
@Test
void createOrder_unauthenticated_returns401() throws Exception {
mockMvc.perform(post("/api/v1/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"productId\":1,\"quantity\":3}"))
.andExpect(status().isUnauthorized());
}
}
❓ أسئلة شائعة
static)، لذا الاختبارات اللاحقة تعيد استخدامها مباشرة. العبء النموذجي: 30 ثانية لأول تشغيل، وأقل من 5 ثوانٍ للتشغيلات اللاحقة. نوصي بتسخين الصور مسبقًا في بيئة CI.SyncTaskExecutor) لتبسيط الاختبار.📖 ملخص
- هرم الاختبار: 70% اختبارات وحدة + 20% اختبارات تكامل + 10% اختبارات E2E
- استخدم
@ExtendWith(MockitoExtension.class)+@Mockلاختبار الوحدة - اختبار المتحكم:
@WebMvcTest+MockMvc - اختبار المستودع:
@DataJpaTest+ H2 - TestContainers: اختبار باستخدام حاويات قاعدة بيانات حقيقية للقضاء على اختلافات H2
- JaCoCo يراقب تغطية الاختبار؛ كود الأعمال الأساسي ≥ 80%
📝 تمارين
-
تمرين أساسي (صعوبة: ⭐): اكتب خمسة اختبارات وحدة لـ OrderService تغطي سيناريوهات مثل إنشاء طلب، مخزون غير كافٍ، إلغاء طلب، استثناءات الحالة، وموارد غير موجودة.
-
تمرين متقدم (صعوبة ⭐⭐): استخدم
@WebMvcTestلكتابة اختبارات شرائح لـ OrderController، تغطي سيناريوهات مثل الطلبات العادية، فشل التحقق، أخطاء 404، وأخطاء 401 (غير مصرح). استخدم@DataJpaTestلكتابة اختبارات استعلامات لـ ProductRepository. -
تحدي (صعوبة: ⭐⭐⭐): ادمج TestContainers (MySQL + Redis) واكتب اختبار تكامل شامل—يغطي العملية الكاملة من إنشاء منتج، تقديم طلب وخصم مخزون، إصابة تخزين Redis المؤقت، إلى إلغاء طلب واستعادة مخزون، بمعدل تغطية ≥ 80%.



