اختبار الخدمات بوحدات مع JUnit 5 و Mockito
اختبار الوحدة (Unit Test) يتحقق من فئة واحدة في عزلة تامة. عند اختبار خدمة Spring Boot، يجب ألا تلمس المتعاونون معها — المستودعات، مرسلو البريد الإلكتروني، العملاء الخارجيون — قاعدة بيانات حقيقية أو شبكة حقيقية. يتيح لك Mockito استبدال هؤلاء المتعاونين بنسخ مضبوطة تُسمّى الكائنات الوهمية (mocks)، فيعمل اختبارك في أجزاء من الثانية ولا يفشل إلا لسبب واحد: المنطق داخل الفئة قيد الاختبار.
لماذا تهم العزلة: الاختبار الذي يفشل لأن قاعدة البيانات متوقفة لا يخبرك بأن كودك معطوب — بل يخبرك بأن بنيتك التحتية معطوبة. يجب أن تكون اختبارات الوحدة حتمية وسريعة ومستقلة عن البنية التحتية.
إعداد المشروع
يجلب starter الاختبار في Spring Boot كلاً من JUnit 5 و Mockito تلقائيًا:
<!-- pom.xml -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
يأتي هذا مع junit-jupiter و mockito-core و mockito-junit-jupiter وAssertJ. لا تحتاج لأي شيء إضافي لاختبارات الوحدة الخالصة.
الفئة قيد الاختبار
لنأخذ خدمة OrderService التي تعتمد على OrderRepository و InventoryClient. كلاهما مُحقن عبر المنشئ (constructor) — الأسلوب المفضّل في Spring Boot 3 لأنه يجعل شجرة التبعيات واضحة ويدعم اختبار الوحدة بدون سياق Spring:
// OrderService.java
package com.example.shop.service;
import com.example.shop.client.InventoryClient;
import com.example.shop.model.Order;
import com.example.shop.repository.OrderRepository;
import org.springframework.stereotype.Service;
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final InventoryClient inventoryClient;
public OrderService(OrderRepository orderRepository,
InventoryClient inventoryClient) {
this.orderRepository = orderRepository;
this.inventoryClient = inventoryClient;
}
public Order placeOrder(Long productId, int quantity) {
if (!inventoryClient.isAvailable(productId, quantity)) {
throw new IllegalStateException("Insufficient stock for product " + productId);
}
Order order = new Order(productId, quantity);
return orderRepository.save(order);
}
public Order findById(Long id) {
return orderRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Order not found: " + id));
}
}
كتابة اختبار الوحدة
تربط التوصيف @ExtendWith(MockitoExtension.class) Mockito بنموذج امتداد JUnit 5. فهو يُهيّئ الكائنات الوهمية المعلّمة بـ @Mock قبل كل اختبار ويتحقق من استخدامها بعده. تُنشأ الخدمة يدويًا بتمرير الكائنات الوهمية عبر المنشئ — لا يُشغَّل أي سياق Spring:
// OrderServiceTest.java
package com.example.shop.service;
import com.example.shop.client.InventoryClient;
import com.example.shop.model.Order;
import com.example.shop.repository.OrderRepository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock
private OrderRepository orderRepository;
@Mock
private InventoryClient inventoryClient;
@InjectMocks
private OrderService orderService;
// --- الاختبارات تلي هنا ---
}
تُنشئ @Mock وكيلًا وهميًا من Mockito؛ بينما تُنشئ @InjectMocks كائن OrderService وتُحقن فيه الكائنات الوهمية عبر المنشئ (أو عبر setter أو عبر الحقل — بهذا الترتيب من الأولوية). لأن Mockito يحل الحقن حسب النوع، تأكد من أنه لا يوجد كائنان وهميان من النوع ذاته.
تحديد السلوك مع when…thenReturn
بشكل افتراضي يُعيد الكائن الوهمي قيمًا صفرية (null و false و 0). استخدم when(mock.method(args)).thenReturn(value) لتحديد ما يجب إعادته عند استدعاء معين:
@Test
void placeOrder_savesAndReturnsOrder_whenStockAvailable() {
// ترتيب
long productId = 42L;
int quantity = 3;
Order saved = new Order(productId, quantity);
saved.setId(1L);
when(inventoryClient.isAvailable(productId, quantity)).thenReturn(true);
when(orderRepository.save(any(Order.class))).thenReturn(saved);
// تنفيذ
Order result = orderService.placeOrder(productId, quantity);
// تحقق
assertThat(result.getId()).isEqualTo(1L);
assertThat(result.getProductId()).isEqualTo(productId);
verify(orderRepository, times(1)).save(any(Order.class));
}
any(Order.class) هو مطابق للوسيطة (argument matcher) — يقبل أي Order غير فارغة. يأتي Mockito مع مكتبة واسعة من المطابقات: eq() و anyString() و argThat(predicate) وغيرها.
اختبار مسارات الاستثناء
تستحق كل فرع مهم اختبارًا مستقلًا. عندما تكون المخزون غير متاح، يجب أن تُلقي الخدمة استثناءً دون لمس المستودع:
@Test
void placeOrder_throwsIllegalState_whenStockUnavailable() {
when(inventoryClient.isAvailable(anyLong(), anyInt())).thenReturn(false);
assertThatThrownBy(() -> orderService.placeOrder(42L, 5))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("Insufficient stock");
verifyNoInteractions(orderRepository); // يجب ألا يُستدعى المستودع
}
@Test
void findById_throwsIllegalArgument_whenOrderMissing() {
when(orderRepository.findById(99L)).thenReturn(Optional.empty());
assertThatThrownBy(() -> orderService.findById(99L))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("99");
}
استخدم assertThatThrownBy من AssertJ بدلًا من assertThrows في JUnit. يتيح لك AssertJ سلسلة تأكيدات إضافية على الاستثناء (hasMessage و hasCause و hasMessageContaining) في تعبير واحد مقروء، بينما لا يلتقط assertThrows سوى النوع.
التحقق من التفاعلات
يسجّل Mockito كل استدعاء يتم على الكائن الوهمي. بعد تأكيداتك يمكنك التحقق من أن الخدمة استدعت متعاونًا بالعدد الصحيح من المرات وبالوسيطات الصحيحة:
// تنجح إذا استُدعيت save() مرة واحدة بالضبط مع أي Order
verify(orderRepository, times(1)).save(any(Order.class));
// تنجح إذا لم يُستدعَ العميل أبدًا
verifyNoInteractions(inventoryClient);
// تنجح إذا استُدعيت findById بالمعرّف الدقيق
verify(orderRepository).findById(eq(1L));
لا تُفرط في التحقق. التحقق من كل تفاعل مع الكائنات الوهمية يجعل الاختبارات هشّة — تنكسر عند إعادة هيكلة التنفيذ دون تغيير السلوك. تحقق فقط من التفاعلات التي هي جزء من العقد الذي تريد ضمانه.
محاكاة الدوال التي لا تُعيد قيمة وإلقاء الاستثناءات
للدوال من نوع void أو عندما تحتاج أن يُلقي الكائن الوهمي استثناءً، استخدم أسلوب المحاكاة البديل:
// محاكاة دالة void لتُلقي استثناءً
doThrow(new RuntimeException("DB unavailable"))
.when(orderRepository).deleteById(anyLong());
// محاكاة دالة void لتفعل شيئًا (القيمة الافتراضية، لكن صريحة)
doNothing().when(orderRepository).deleteById(anyLong());
// محاكاة إلقاء استثناء على دالة غير void (بديل لـ thenThrow)
when(inventoryClient.isAvailable(anyLong(), anyInt()))
.thenThrow(new RuntimeException("Inventory service down"));
نمط AAA
كل اختبار في هذا الدرس يتبع نمط الترتيب – التنفيذ – التحقق (Arrange – Act – Assert):
- الترتيب (Arrange) — إعداد المحاكاة وأي كائنات مدخلة.
- التنفيذ (Act) — استدعاء دالة واحدة بالضبط على الفئة قيد الاختبار.
- التحقق (Assert) — فحص القيمة المُعادة و/أو التحقق من التفاعلات.
إبقاء كل قسم صغيرًا ومفصولًا بسطر فارغ يجعل الاختبارات سهلة القراءة دفعةً واحدة.
الأداء: لماذا هذا سريع؟
لا يُحمَّل أي سياق Spring، ولا تُلمَس قاعدة بيانات، ولا يُفتح أي اتصال HTTP. تنتهي فئة OrderServiceTest الكاملة مع عشرات الاختبارات في أقل من 100 ملي ثانية على أي حاسوب حديث. هذه السرعة تشجع على تشغيل الاختبارات عند كل حفظ، مما يمنحك تغذية راجعة فورية أثناء الكتابة.
الخلاصة
اختبار خدمة Spring Boot بوحدات يعني إنشاءها يدويًا مع مُتعاونين وهميين، ومحاكاة الكائنات الوهمية لإعادة قيم مضبوطة، واختبار مسار كود واحد في كل اختبار، والتأكيد على كل من القيمة المُعادة والتفاعلات. يوفر JUnit 5 مشغّل الاختبار عبر @ExtendWith(MockitoExtension.class)؛ بينما يوفر Mockito @Mock و @InjectMocks و when…thenReturn و verify. أبقِ الاختبارات صغيرة وسمِّها بوضوح واتبع نمط AAA — ستشكرك نفسك وزملاؤك لاحقًا.