الفصل 2: اختبارات الوحدة مع JUnit 5
مقدمة
JUnit 5 هو إطار العمل القياسي للاختبار في Java. إنه أساس جميع اختبارات Spring Boot الخاصة بك. هذا الفصل يعلمك كيفية استخدامه بفعالية.
🎯 أهداف الفصل
في نهاية هذا الفصل، ستعرف:
- ✅ استخدام التعليقات التوضيحية لـ JUnit 5
- ✅ كتابة تأكيدات واضحة
- ✅ اختبار الاستثناءات
- ✅ إنشاء اختبارات مفصلة
- ✅ إدارة دورة حياة الاختبارات
1. هيكل الاختبار
التشريح الأساسي
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class CalculatorTest {
@Test // ← يشير إلى طريقة اختبار
void shouldAddTwoNumbers() {
// الترتيب (الإعداد)
Calculator calc = new Calculator();
// التنفيذ (التنفيذ)
int result = calc.add(2, 3);
// التحقق (التحقق)
assertEquals(5, result);
}
}
الاتفاقيات
class ProductServiceTest { // ← الاسم: ClassTested + Test
@Test
void shouldReturnProductById() { // ← طريقة وصفية
// ...
}
@Test
void shouldThrowExceptionWhenProductNotFound() {
// ...
}
}
2. التعليقات التوضيحية الرئيسية
@Test
import org.junit.jupiter.api.Test;
@Test
void simpleTest() {
assertTrue(1 + 1 == 2);
}
@DisplayName
@Test
@DisplayName("يجب حساب السعر مع ضريبة القيمة المضافة بنسبة 20%")
void shouldCalculatePriceWithVAT() {
double result = priceService.calculateWithVAT(100.0, 0.20);
assertEquals(120.0, result);
}
@Disabled
@Test
@Disabled("في انتظار إصلاح الخطأ #123")
void brokenTest() {
// سيتم تجاهل هذا الاختبار
}
@BeforeEach و @AfterEach
class ProductServiceTest {
private ProductService productService;
@BeforeEach
void setUp() {
// يتم تنفيذه قبل كل اختبار
productService = new ProductService();
}
@AfterEach
void tearDown() {
// يتم تنفيذه بعد كل اختبار (تنظيف)
productService = null;
}
@Test
void test1() { /* productService جديد */ }
@Test
void test2() { /* productService جديد */ }
}
@BeforeAll و @AfterAll
class DatabaseTest {
@BeforeAll
static void setUpAll() {
// يتم تنفيذه مرة واحدة قبل جميع الاختبارات
// يجب أن يكون static!
System.out.println("تهيئة عامة...");
}
@AfterAll
static void tearDownAll() {
// يتم تنفيذه مرة واحدة بعد جميع الاختبارات
System.out.println("تنظيف عام...");
}
}
ترتيب التنفيذ
@BeforeAll (مرة واحدة)
│
├── @BeforeEach
│ └── @Test test1()
│ └── @AfterEach
│
├── @BeforeEach
│ └── @Test test2()
│ └── @AfterEach
│
└── @AfterAll (مرة واحدة)
3. التأكيدات
التأكيدات الأساسية
import static org.junit.jupiter.api.Assertions.*;
@Test
void demonstrateAssertions() {
// المساواة
assertEquals(5, calculator.add(2, 3));
assertEquals("Hello", greeting.getMessage());
assertEquals(3.14, Math.PI, 0.01); // مع دلتا للـ double
// القيم المنطقية
assertTrue(user.isActive());
assertFalse(list.isEmpty());
// القيم الفارغة
assertNull(optionalValue.orElse(null));
assertNotNull(service.findById(1L));
// المراجع
assertSame(expected, actual); // نفس الكائن
assertNotSame(obj1, obj2); // كائنات مختلفة
}
التأكيدات على المجموعات
@Test
void testCollections() {
List<String> fruits = List.of("Apple", "Banana", "Cherry");
// الحجم
assertEquals(3, fruits.size());
// المحتوى
assertTrue(fruits.contains("Apple"));
assertFalse(fruits.contains("Grape"));
// مع AssertJ (أكثر قابلية للقراءة)
assertThat(fruits)
.hasSize(3)
.contains("Apple", "Banana")
.doesNotContain("Grape")
.startsWith("Apple")
.endsWith("Cherry");
}
الرسائل المخصصة
@Test
void testWithMessage() {
int age = user.getAge();
assertTrue(age >= 18, "يجب أن يكون المستخدم بالغًا");
assertEquals(25, age, () -> "العمر المتوقع كان 25 ولكن كان " + age);
}
assertAll (التأكيدات المجمعة)
@Test
void testUserProperties() {
User user = new User("John", "john@email.com", 25);
// يتم تنفيذ جميع التأكيدات، حتى إذا فشل أحدها
assertAll("خصائص المستخدم",
() -> assertEquals("John", user.getName()),
() -> assertEquals("john@email.com", user.getEmail()),
() -> assertEquals(25, user.getAge()),
() -> assertTrue(user.isActive())
);
}
4. اختبار الاستثناءات
assertThrows
@Test
void shouldThrowExceptionWhenDividingByZero() {
Calculator calc = new Calculator();
// تحقق من أن الاستثناء يتم إطلاقه
assertThrows(ArithmeticException.class, () -> {
calc.divide(10, 0);
});
}
@Test
void shouldThrowExceptionWithMessage() {
// التقاط الاستثناء للتحقق من الرسالة
IllegalArgumentException exception = assertThrows(
IllegalArgumentException.class,
() -> userService.createUser(null)
);
assertEquals("Username cannot be null", exception.getMessage());
}
assertDoesNotThrow
@Test
void shouldNotThrowException() {
assertDoesNotThrow(() -> {
userService.createUser("validUser");
});
}
5. الاختبارات المفصلة
@ParameterizedTest مع @ValueSource
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
@ParameterizedTest
@ValueSource(strings = {"John", "Jane", "Bob"})
void shouldAcceptValidNames(String name) {
assertTrue(validator.isValidName(name));
}
@ParameterizedTest
@ValueSource(ints = {1, 2, 3, 4, 5})
void shouldBePositive(int number) {
assertTrue(number > 0);
}
@CsvSource
@ParameterizedTest
@CsvSource({
"1, 1, 2",
"2, 3, 5",
"10, 20, 30",
"-1, 1, 0"
})
void shouldAddNumbers(int a, int b, int expected) {
assertEquals(expected, calculator.add(a, b));
}
@ParameterizedTest
@CsvSource({
"john@email.com, true",
"invalid-email, false",
"test@domain.org, true",
"'', false"
})
void shouldValidateEmail(String email, boolean expected) {
assertEquals(expected, validator.isValidEmail(email));
}
@MethodSource
@ParameterizedTest
@MethodSource("provideUsers")
void shouldValidateUser(User user, boolean expectedValid) {
assertEquals(expectedValid, validator.isValid(user));
}
static Stream<Arguments> provideUsers() {
return Stream.of(
Arguments.of(new User("John", 25), true),
Arguments.of(new User("", 25), false),
Arguments.of(new User("Jane", -1), false),
Arguments.of(new User("Bob", 18), true)
);
}
@EnumSource
@ParameterizedTest
@EnumSource(Status.class)
void shouldHandleAllStatuses(Status status) {
assertDoesNotThrow(() -> processor.process(status));
}
@ParameterizedTest
@EnumSource(value = Status.class, names = {"ACTIVE", "PENDING"})
void shouldHandleActiveStatuses(Status status) {
assertTrue(status == Status.ACTIVE || status == Status.PENDING);
}
6. الاختبارات الشرطية
@EnabledOnOs / @DisabledOnOs
@Test
@EnabledOnOs(OS.WINDOWS)
void windowsOnlyTest() {
// لا يتم تنفيذه إلا على Windows
}
@Test
@DisabledOnOs(OS.MAC)
void notOnMacTest() {
// لا يتم تنفيذه على Mac
}
@EnabledIfEnvironmentVariable
@Test
@EnabledIfEnvironmentVariable(named = "ENV", matches = "dev")
void onlyInDevEnvironment() {
// يتم تنفيذه فقط إذا كان ENV=dev
}
@EnabledIf
@Test
@EnabledIf("customCondition")
void conditionalTest() {
// ...
}
boolean customCondition() {
return LocalTime.now().getHour() < 12; // صباحًا فقط
}
7. الاختبارات المتداخلة
@Nested
class UserServiceTest {
@Nested
@DisplayName("عند إنشاء مستخدم")
class CreateUser {
@Test
@DisplayName("يجب أن ينجح مع بيانات صالحة")
void shouldSucceedWithValidData() {
User user = userService.create("John", "john@email.com");
assertNotNull(user.getId());
}
@Test
@DisplayName("يجب أن يفشل مع اسم فارغ")
void shouldFailWithEmptyName() {
assertThrows(ValidationException.class,
() -> userService.create("", "john@email.com"));
}
}
@Nested
@DisplayName("عند حذف مستخدم")
class DeleteUser {
@Test
@DisplayName("يجب أن يتم إزالته من قاعدة البيانات")
void shouldRemoveFromDatabase() {
userService.delete(1L);
assertFalse(userService.exists(1L));
}
}
}
العرض في IDE
UserServiceTest
├── عند إنشاء مستخدم
│ ├── ✓ يجب أن ينجح مع بيانات صالحة
│ └── ✓ يجب أن يفشل مع اسم فارغ
└── عند حذف مستخدم
└── ✓ يجب أن يتم إزالته من قاعدة البيانات
8. المهلات الزمنية
@Timeout
@Test
@Timeout(5) // يفشل إذا استغرق أكثر من 5 ثوانٍ
void shouldCompleteQuickly() {
// ...
}
@Test
@Timeout(value = 500, unit = TimeUnit.MILLISECONDS)
void shouldCompleteVeryQuickly() {
// ...
}
assertTimeout
@Test
void shouldCompleteInTime() {
assertTimeout(Duration.ofSeconds(2), () -> {
// كود يجب أن يتم تنفيذه في أقل من 2 ثانية
Thread.sleep(1000);
});
}
9. AssertJ (موصى به)
يوفر AssertJ صياغة أكثر سلاسة وقابلية للقراءة:
import static org.assertj.core.api.Assertions.*;
@Test
void demonstrateAssertJ() {
// السلاسل
assertThat("Hello World")
.startsWith("Hello")
.endsWith("World")
.contains("lo Wo")
.hasSize(11);
// الأرقام
assertThat(42)
.isPositive()
.isGreaterThan(40)
.isBetween(40, 50);
// المجموعات
assertThat(List.of("a", "b", "c"))
.hasSize(3)
.contains("a", "b")
.doesNotContain("d")
.containsExactly("a", "b", "c");
// الكائنات
assertThat(user)
.isNotNull()
.hasFieldOrPropertyWithValue("name", "John")
.extracting(User::getEmail)
.isEqualTo("john@email.com");
// الاستثناءات
assertThatThrownBy(() -> service.riskyMethod())
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("error");
}
10. ملخص
التعليقات التوضيحية الرئيسية
| التعليق التوضيحي | الوصف |
|---|---|
@Test | يحدد طريقة اختبار |
@BeforeEach | قبل كل اختبار |
@AfterEach | بعد كل اختبار |
@BeforeAll | قبل جميع الاختبارات (static) |
@AfterAll | بعد جميع الاختبارات (static) |
@DisplayName | اسم قابل للقراءة للاختبار |
@Disabled | يعطل الاختبار |
@ParameterizedTest | اختبار مع قيم متعددة |
@Nested | مجموعة اختبارات متداخلة |
التأكيدات الأساسية
assertEquals(expected, actual);
assertTrue(condition);
assertFalse(condition);
assertNull(value);
assertNotNull(value);
assertThrows(Exception.class, () -> ...);
assertAll("group", () -> ..., () -> ...);
نمط الاختبار
@Test
void should_ExpectedBehavior_When_Condition() {
// الترتيب
// ...
// التنفيذ
// ...
// التحقق
// ...
}
🎯 الخطوة التالية
JUnit 5 يسمح بكتابة الاختبارات، ولكن كيف يمكن اختبار فئة تعتمد على فئات أخرى؟ هنا يأتي دور Mockito.
➡️ الفصل 3: المحاكاة مع Mockito