Logo

المعمل 4: اختبار الوحدة باستخدام جافا وJUnit 5

20 دقيقة قراءة
شرائح الدرس
1 / 15

المعمل 4: اختبار الوحدة باستخدام جافا وJUnit 5

الأطر، التعليقات التوضيحية (Annotations)، دورة الحياة، وأمثلة عملية على JUnit 5

جدول المحتويات

  1. مقدمة في اختبار الوحدة
  2. أدوات اختبار الوحدة
  3. نظرة عامة على JUnit 5
  4. بنية JUnit 5
  5. تعليقات توضيحية JUnit 5 (Annotations)
  6. دورة حياة الاختبار
  7. أمثلة عملية
  8. المزايا والعيوب

مقدمة في اختبار الوحدة

اختبار الوحدة (Unit Testing) هو نوع من اختبار البرمجيات تُختبر فيه وحدات أو مكوّنات منفردة من البرنامج. الغرض منه هو التحقق من أن كل وحدة من كود البرنامج تعمل كما هو متوقع.

الخصائص الأساسية:

  • متى: يتم تنفيذه أثناء مرحلة تطوير (كتابة) التطبيق بواسطة المطورين
  • ماذا: تعزل الاختبارات جزءًا من الكود وتتحقق من صحته
  • النطاق: يمكن أن تكون الوحدة دالة، أو تابعًا (Method)، أو إجراءً (Procedure)، أو وحدة نمطية (Module)، أو كائنًا (Object) منفردًا
graph TD
    A[Software Application] --> B[Module 1]
    A --> C[Module 2]
    A --> D[Module 3]
    B --> E[Function A]
    B --> F[Function B]
    C --> G[Function C]
    C --> H[Function D]
    D --> I[Function E]
    
    style E fill:#90EE90
    style F fill:#90EE90
    style G fill:#90EE90
    style H fill:#90EE90
    style I fill:#90EE90
    
    classDef tested fill:#90EE90,stroke:#333,stroke-width:2px

أدوات اختبار الوحدة

توجد عدة أدوات آلية متاحة للمساعدة في اختبار الوحدة:

1. JUnit

  • أداة اختبار مجانية للغة برمجة جافا
  • توفّر تأكيدات (Assertions) لتحديد دوال الاختبار
  • تختبر البيانات أولاً ثم تُدرجها في قطعة الكود

2. NUnit

  • إطار اختبار وحدة مستخدم على نطاق واسع لجميع لغات .NET
  • أداة مفتوحة المصدر تسمح بكتابة السكربتات يدويًا
  • تدعم اختبارات مدفوعة بالبيانات (Data-driven) يمكن تشغيلها بالتوازي

3. JMockit

  • أداة اختبار وحدة مفتوحة المصدر
  • أداة تغطية كود مع مقاييس على مستوى السطر والمسار
  • تسمح بمحاكاة (Mocking) الواجهات البرمجية (API) بصيغة تسجيل وتحقق
  • توفّر تغطية الأسطر (Line coverage)، وتغطية المسارات (Path Coverage)، وتغطية البيانات (Data Coverage)

4. EMMA

  • مجموعة أدوات مفتوحة المصدر لتحليل كود جافا وإعداد تقارير عنه
  • تدعم أنواع تغطية: الدالة (Method)، السطر (Line)، الكتلة الأساسية (Basic block)
  • مبنية على جافا دون الاعتماد على مكتبات خارجية

5. PHPUnit

  • أداة اختبار وحدة لمبرمجي PHP
  • تختبر أجزاءً صغيرة من الكود (وحدات) كلاً على حدة
  • تسمح للمطورين باستخدام دوال تأكيد (Assertion) مُعرَّفة مسبقًا

نظرة عامة على JUnit 5

JUnit 5 هو إطار الاختبار الأكثر استخدامًا لتطبيقات جافا. يهدف إلى التكيّف مع أسلوب كتابة الكود في جافا 8 ويدمج ميزات مثل تعبيرات لامبدا (Lambda expressions).

المتطلبات:

  • تحتاج جافا 8 أو أحدث لإنشاء وتشغيل الاختبارات في JUnit 5
  • يُحافَظ على التوافقية الخلفية (Backward compatibility) مع اختبارات JUnit 3 وJUnit 4
graph LR
    A[JUnit Evolution] --> B[JUnit 3]
    B --> C[JUnit 4]
    C --> D[JUnit 5]
    D --> E[Java 8+ Features]
    D --> F[Lambda Expressions]
    D --> G[Modular Architecture]

بنية JUnit 5

يتكوّن JUnit 5 من عدة وحدات نمطية (Modules) مختلفة من ثلاثة مشاريع فرعية مختلفة:

graph TB
    A[JUnit 5] --> B[JUnit Platform]
    A --> C[JUnit Jupiter]
    A --> D[JUnit Vintage]
    
    B --> B1[TestEngine API]
    B --> B2[Console Launcher]
    B --> B3[Build Plugins]
    
    C --> C1[New Annotations]
    C --> C2[Extension Model]
    C --> C3[TestEngine Implementation]
    
    D --> D1[JUnit 3 Support]
    D --> D2[JUnit 4 Support]
    D --> D3[Backward Compatibility]
    
    style A fill:#FFD700
    style B fill:#87CEEB
    style C fill:#90EE90
    style D fill:#FFB6C1

المكوّنات:

JUnit Platform

  • يُعرِّف واجهة TestEngine لتطوير أطر اختبار جديدة
  • يوفّر مُشغِّل طرفية (Console Launcher) لتشغيل المنصة من سطر الأوامر
  • يتضمن إضافات بناء (Build plugins) لـGradle وMaven
  • يمكّن بيئات التطوير (IDEs) وأدوات البناء والإضافات من تضمين وتوسيع واجهات المنصة

JUnit Jupiter

  • يحتوي على نماذج برمجة وتوسيع جديدة لكتابة الاختبارات
  • يتضمن جميع التعليقات التوضيحية (Annotations) الجديدة في JUnit
  • يوفّر تنفيذًا لـTestEngine لتشغيل الاختبارات المكتوبة بهذه التعليقات التوضيحية

JUnit Vintage

  • الغرض الأساسي منه هو دعم تشغيل اختبارات JUnit 3 وJUnit 4 على منصة JUnit 5
  • يضمن التوافقية الخلفية

تعليقات توضيحية JUnit 5 (Annotations)

يوفّر JUnit 5 عدة تعليقات توضيحية (Annotations) للتحكم في تنفيذ الاختبار ودورة حياته:

التعليق التوضيحيالوصف
@Testيُعلِّم دالة على أنها دالة اختبار
@BeforeEachيُنفَّذ قبل كل دالة اختبار
@AfterEachيُنفَّذ بعد كل دالة اختبار
@BeforeAllيُنفَّذ مرة واحدة قبل جميع دوال الاختبار (يجب أن تكون ساكنة - static)
@AfterAllيُنفَّذ مرة واحدة بعد جميع دوال الاختبار (يجب أن تكون ساكنة - static)
@DisplayNameيحدّد اسم عرض مخصّصًا لصنف أو دالة الاختبار
@Disabledيعطِّل صنف أو دالة اختبار
@RepeatedTestيكرِّر اختبارًا عددًا محدَّدًا من المرات
@ParameterizedTestيُعلِّم دالة على أنها اختبار معلمي (Parameterized)

دورة حياة الاختبار

sequenceDiagram
    participant TestRunner
    participant TestClass
    participant TestMethod1
    participant TestMethod2
    
    TestRunner->>TestClass: Initialize Test Class
    TestRunner->>TestClass: @BeforeAll (once)
    
    rect rgb(200, 220, 250)
        Note over TestRunner,TestMethod1: Test Method 1 Execution
        TestRunner->>TestClass: @BeforeEach
        TestRunner->>TestMethod1: @Test method1()
        TestRunner->>TestClass: @AfterEach
    end
    
    rect rgb(200, 250, 220)
        Note over TestRunner,TestMethod2: Test Method 2 Execution
        TestRunner->>TestClass: @BeforeEach
        TestRunner->>TestMethod2: @Test method2()
        TestRunner->>TestClass: @AfterEach
    end
    
    TestRunner->>TestClass: @AfterAll (once)
    TestRunner->>TestClass: Cleanup

أمثلة عملية

المثال 1: صنف آلة حاسبة بسيطة

أولاً، لننشئ صنف Calculator بسيطًا لاختباره:

// Calculator.java
public class Calculator {
    
    public int add(int a, int b) {
        return a + b;
    }
    
    public int subtract(int a, int b) {
        return a - b;
    }
    
    public int multiply(int a, int b) {
        return a * b;
    }
    
    public double divide(int a, int b) {
        if (b == 0) {
            throw new IllegalArgumentException("Cannot divide by zero");
        }
        return (double) a / b;
    }
    
    public boolean isEven(int number) {
        return number % 2 == 0;
    }
    
    public int factorial(int n) {
        if (n < 0) {
            throw new IllegalArgumentException("Factorial not defined for negative numbers");
        }
        if (n == 0 || n == 1) {
            return 1;
        }
        return n * factorial(n - 1);
    }
}

المثال 2: اختبارات وحدة أساسية

// CalculatorTest.java
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;
 
@DisplayName("Calculator Test Suite")
public class CalculatorTest {
    
    private Calculator calculator;
    
    @BeforeAll
    static void initAll() {
        System.out.println("Starting Calculator Tests...");
    }
    
    @BeforeEach
    void init() {
        calculator = new Calculator();
        System.out.println("Calculator instance created");
    }
    
    @AfterEach
    void tearDown() {
        calculator = null;
        System.out.println("Test completed\n");
    }
    
    @AfterAll
    static void tearDownAll() {
        System.out.println("All tests completed!");
    }
    
    @Test
    @DisplayName("Test Addition: 5 + 3 = 8")
    void testAddition() {
        int result = calculator.add(5, 3);
        assertEquals(8, result, "5 + 3 should equal 8");
    }
    
    @Test
    @DisplayName("Test Subtraction: 10 - 4 = 6")
    void testSubtraction() {
        int result = calculator.subtract(10, 4);
        assertEquals(6, result, "10 - 4 should equal 6");
    }
    
    @Test
    @DisplayName("Test Multiplication: 6 * 7 = 42")
    void testMultiplication() {
        int result = calculator.multiply(6, 7);
        assertEquals(42, result, "6 * 7 should equal 42");
    }
    
    @Test
    @DisplayName("Test Division: 20 / 4 = 5.0")
    void testDivision() {
        double result = calculator.divide(20, 4);
        assertEquals(5.0, result, 0.001, "20 / 4 should equal 5.0");
    }
    
    @Test
    @DisplayName("Test Division by Zero throws Exception")
    void testDivisionByZero() {
        Exception exception = assertThrows(
            IllegalArgumentException.class,
            () -> calculator.divide(10, 0),
            "Division by zero should throw IllegalArgumentException"
        );
        
        assertEquals("Cannot divide by zero", exception.getMessage());
    }
    
    @Test
    @DisplayName("Test Even Number: 4 is even")
    void testIsEven() {
        assertTrue(calculator.isEven(4), "4 should be even");
        assertFalse(calculator.isEven(5), "5 should not be even");
    }
    
    @Test
    @DisplayName("Test Factorial: 5! = 120")
    void testFactorial() {
        assertEquals(1, calculator.factorial(0), "0! should equal 1");
        assertEquals(1, calculator.factorial(1), "1! should equal 1");
        assertEquals(120, calculator.factorial(5), "5! should equal 120");
    }
    
    @Test
    @DisplayName("Test Negative Factorial throws Exception")
    void testNegativeFactorial() {
        assertThrows(
            IllegalArgumentException.class,
            () -> calculator.factorial(-5),
            "Negative factorial should throw exception"
        );
    }
}

المثال 3: صنف أدوات نصية (String Utility) واختباراته

// StringUtils.java
public class StringUtils {
    
    public String reverse(String input) {
        if (input == null) {
            throw new IllegalArgumentException("Input cannot be null");
        }
        return new StringBuilder(input).reverse().toString();
    }
    
    public boolean isPalindrome(String input) {
        if (input == null || input.isEmpty()) {
            return false;
        }
        String cleaned = input.replaceAll("\\s+", "").toLowerCase();
        return cleaned.equals(new StringBuilder(cleaned).reverse().toString());
    }
    
    public int countVowels(String input) {
        if (input == null) {
            return 0;
        }
        int count = 0;
        String vowels = "aeiouAEIOU";
        for (char c : input.toCharArray()) {
            if (vowels.indexOf(c) != -1) {
                count++;
            }
        }
        return count;
    }
    
    public String capitalize(String input) {
        if (input == null || input.isEmpty()) {
            return input;
        }
        return input.substring(0, 1).toUpperCase() + input.substring(1).toLowerCase();
    }
}
// StringUtilsTest.java
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;
 
@DisplayName("String Utilities Test Suite")
public class StringUtilsTest {
    
    private StringUtils stringUtils;
    
    @BeforeEach
    void setUp() {
        stringUtils = new StringUtils();
    }
    
    @Nested
    @DisplayName("Reverse String Tests")
    class ReverseTests {
        
        @Test
        @DisplayName("Reverse simple string")
        void testReverseSimple() {
            assertEquals("olleH", stringUtils.reverse("Hello"));
        }
        
        @Test
        @DisplayName("Reverse empty string")
        void testReverseEmpty() {
            assertEquals("", stringUtils.reverse(""));
        }
        
        @Test
        @DisplayName("Reverse null string throws exception")
        void testReverseNull() {
            assertThrows(IllegalArgumentException.class, 
                () -> stringUtils.reverse(null));
        }
    }
    
    @Nested
    @DisplayName("Palindrome Tests")
    class PalindromeTests {
        
        @Test
        @DisplayName("Check palindrome: racecar")
        void testPalindromeTrue() {
            assertTrue(stringUtils.isPalindrome("racecar"));
        }
        
        @Test
        @DisplayName("Check palindrome with spaces: A man a plan a canal Panama")
        void testPalindromeWithSpaces() {
            assertTrue(stringUtils.isPalindrome("A man a plan a canal Panama"));
        }
        
        @Test
        @DisplayName("Check non-palindrome: hello")
        void testPalindromeFalse() {
            assertFalse(stringUtils.isPalindrome("hello"));
        }
        
        @Test
        @DisplayName("Check empty string is not palindrome")
        void testPalindromeEmpty() {
            assertFalse(stringUtils.isPalindrome(""));
        }
    }
    
    @Nested
    @DisplayName("Count Vowels Tests")
    class VowelTests {
        
        @Test
        @DisplayName("Count vowels in 'education'")
        void testCountVowels() {
            assertEquals(5, stringUtils.countVowels("education"));
        }
        
        @Test
        @DisplayName("Count vowels in string with no vowels")
        void testCountVowelsNone() {
            assertEquals(0, stringUtils.countVowels("xyz"));
        }
        
        @Test
        @DisplayName("Count vowels in null string")
        void testCountVowelsNull() {
            assertEquals(0, stringUtils.countVowels(null));
        }
    }
    
    @Nested
    @DisplayName("Capitalize Tests")
    class CapitalizeTests {
        
        @Test
        @DisplayName("Capitalize lowercase string")
        void testCapitalizeLowercase() {
            assertEquals("Hello", stringUtils.capitalize("hello"));
        }
        
        @Test
        @DisplayName("Capitalize uppercase string")
        void testCapitalizeUppercase() {
            assertEquals("Hello", stringUtils.capitalize("HELLO"));
        }
        
        @Test
        @DisplayName("Capitalize empty string")
        void testCapitalizeEmpty() {
            assertEquals("", stringUtils.capitalize(""));
        }
        
        @Test
        @DisplayName("Capitalize null string")
        void testCapitalizeNull() {
            assertNull(stringUtils.capitalize(null));
        }
    }
}

المثال 4: الاختبارات المعلمية (Parameterized Tests)

// ParameterizedCalculatorTest.java
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.*;
import static org.junit.jupiter.api.Assertions.*;
 
import java.util.stream.Stream;
 
@DisplayName("Parameterized Calculator Tests")
public class ParameterizedCalculatorTest {
    
    @ParameterizedTest(name = "{0} + {1} = {2}")
    @CsvSource({
        "1, 2, 3",
        "5, 7, 12",
        "10, 20, 30",
        "-5, 5, 0",
        "100, 200, 300"
    })
    @DisplayName("Test Addition with Multiple Inputs")
    void testAdditionWithCsvSource(int a, int b, int expected) {
        Calculator calculator = new Calculator();
        assertEquals(expected, calculator.add(a, b));
    }
    
    @ParameterizedTest(name = "Number {0} is even: {1}")
    @CsvSource({
        "2, true",
        "3, false",
        "4, true",
        "5, false",
        "10, true",
        "15, false"
    })
    @DisplayName("Test Even Numbers")
    void testIsEvenWithCsvSource(int number, boolean expected) {
        Calculator calculator = new Calculator();
        assertEquals(expected, calculator.isEven(number));
    }
    
    @ParameterizedTest(name = "{0}! = {1}")
    @MethodSource("factorialProvider")
    @DisplayName("Test Factorial with Method Source")
    void testFactorialWithMethodSource(int input, int expected) {
        Calculator calculator = new Calculator();
        assertEquals(expected, calculator.factorial(input));
    }
    
    static Stream<Arguments> factorialProvider() {
        return Stream.of(
            Arguments.of(0, 1),
            Arguments.of(1, 1),
            Arguments.of(2, 2),
            Arguments.of(3, 6),
            Arguments.of(4, 24),
            Arguments.of(5, 120)
        );
    }
    
    @ParameterizedTest(name = "Divide {0} by {1} = {2}")
    @CsvSource({
        "10, 2, 5.0",
        "20, 4, 5.0",
        "15, 3, 5.0",
        "7, 2, 3.5",
        "9, 4, 2.25"
    })
    @DisplayName("Test Division with Multiple Inputs")
    void testDivisionWithCsvSource(int dividend, int divisor, double expected) {
        Calculator calculator = new Calculator();
        assertEquals(expected, calculator.divide(dividend, divisor), 0.001);
    }
}

المثال 5: الاختبار باستخدام الاختبارات المكرَّرة (Repeated Tests)

// RepeatedCalculatorTest.java
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;
import java.util.Random;
 
@DisplayName("Repeated Calculator Tests")
public class RepeatedCalculatorTest {
    
    private Calculator calculator;
    private Random random;
    
    @BeforeEach
    void setUp() {
        calculator = new Calculator();
        random = new Random();
    }
    
    @RepeatedTest(value = 5, name = "Repetition {currentRepetition} of {totalRepetitions}")
    @DisplayName("Test Addition Commutative Property")
    void testAdditionCommutative(RepetitionInfo repetitionInfo) {
        int a = random.nextInt(100);
        int b = random.nextInt(100);
        
        System.out.println("Repetition " + repetitionInfo.getCurrentRepetition() + 
                         ": Testing " + a + " + " + b);
        
        assertEquals(calculator.add(a, b), calculator.add(b, a),
            "Addition should be commutative");
    }
    
    @RepeatedTest(3)
    @DisplayName("Test Multiplication Identity")
    void testMultiplicationIdentity() {
        int number = random.nextInt(100);
        assertEquals(number, calculator.multiply(number, 1),
            "Multiplying by 1 should return the same number");
    }
    
    @RepeatedTest(3)
    @DisplayName("Test Addition Identity")
    void testAdditionIdentity() {
        int number = random.nextInt(100);
        assertEquals(number, calculator.add(number, 0),
            "Adding 0 should return the same number");
    }
}

المثال 6: التأكيدات الشائعة (Common Assertions)

// AssertionsExampleTest.java
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.DisplayName;
import static org.junit.jupiter.api.Assertions.*;
import java.time.Duration;
import java.util.Arrays;
import java.util.List;
 
@DisplayName("JUnit 5 Assertions Examples")
public class AssertionsExampleTest {
    
    @Test
    @DisplayName("Equality Assertions")
    void testEquality() {
        // Basic equality
        assertEquals(5, 2 + 3);
        assertEquals("Hello", "Hello");
        
        // With custom message
        assertEquals(10, 5 * 2, "5 * 2 should equal 10");
        
        // Double comparison with delta
        assertEquals(3.14, 3.141, 0.01, "Pi approximation");
    }
    
    @Test
    @DisplayName("Boolean Assertions")
    void testBooleans() {
        assertTrue(5 > 3, "5 should be greater than 3");
        assertFalse(5 < 3, "5 should not be less than 3");
    }
    
    @Test
    @DisplayName("Null Assertions")
    void testNull() {
        String nullString = null;
        String nonNullString = "Hello";
        
        assertNull(nullString, "String should be null");
        assertNotNull(nonNullString, "String should not be null");
    }
    
    @Test
    @DisplayName("Array Assertions")
    void testArrays() {
        int[] expected = {1, 2, 3, 4, 5};
        int[] actual = {1, 2, 3, 4, 5};
        
        assertArrayEquals(expected, actual, "Arrays should be equal");
    }
    
    @Test
    @DisplayName("Collection Assertions")
    void testCollections() {
        List<String> fruits = Arrays.asList("Apple", "Banana", "Orange");
        
        assertEquals(3, fruits.size());
        assertTrue(fruits.contains("Banana"));
        assertFalse(fruits.contains("Grape"));
    }
    
    @Test
    @DisplayName("Same vs Equals")
    void testSameVsEquals() {
        String a = new String("test");
        String b = new String("test");
        String c = a;
        
        assertEquals(a, b, "Strings have same content");
        assertNotSame(a, b, "Strings are different objects");
        assertSame(a, c, "Strings reference same object");
    }
    
    @Test
    @DisplayName("Exception Assertions")
    void testExceptions() {
        Calculator calculator = new Calculator();
        
        // Assert that exception is thrown
        assertThrows(IllegalArgumentException.class, 
            () -> calculator.divide(10, 0));
        
        // Capture and verify exception
        Exception exception = assertThrows(IllegalArgumentException.class,
            () -> calculator.factorial(-5));
        assertEquals("Factorial not defined for negative numbers", 
            exception.getMessage());
    }
    
    @Test
    @DisplayName("Timeout Assertions")
    void testTimeout() {
        // Assert that operation completes within timeout
        assertTimeout(Duration.ofMillis(100), () -> {
            Thread.sleep(50);
        }, "Operation should complete within 100ms");
    }
    
    @Test
    @DisplayName("Grouped Assertions")
    void testGroupedAssertions() {
        Calculator calculator = new Calculator();
        
        assertAll("Calculator Operations",
            () -> assertEquals(8, calculator.add(5, 3)),
            () -> assertEquals(2, calculator.subtract(5, 3)),
            () -> assertEquals(15, calculator.multiply(5, 3)),
            () -> assertEquals(1.666, calculator.divide(5, 3), 0.001)
        );
    }
}

المثال 7: ترتيب تنفيذ الاختبارات

// OrderedCalculatorTest.java
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;
 
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
@DisplayName("Ordered Test Execution Example")
public class OrderedCalculatorTest {
    
    private static Calculator calculator;
    private static int testCounter = 0;
    
    @BeforeAll
    static void initAll() {
        calculator = new Calculator();
        System.out.println("Test suite started\n");
    }
    
    @Test
    @Order(1)
    @DisplayName("First Test: Basic Addition")
    void test1_Addition() {
        testCounter++;
        System.out.println("Executing test " + testCounter);
        assertEquals(5, calculator.add(2, 3));
    }
    
    @Test
    @Order(2)
    @DisplayName("Second Test: Basic Subtraction")
    void test2_Subtraction() {
        testCounter++;
        System.out.println("Executing test " + testCounter);
        assertEquals(2, calculator.subtract(5, 3));
    }
    
    @Test
    @Order(3)
    @DisplayName("Third Test: Basic Multiplication")
    void test3_Multiplication() {
        testCounter++;
        System.out.println("Executing test " + testCounter);
        assertEquals(15, calculator.multiply(3, 5));
    }
    
    @Test
    @Order(4)
    @DisplayName("Fourth Test: Basic Division")
    void test4_Division() {
        testCounter++;
        System.out.println("Executing test " + testCounter);
        assertEquals(2.5, calculator.divide(5, 2), 0.001);
    }
    
    @AfterAll
    static void tearDownAll() {
        System.out.println("\nTotal tests executed: " + testCounter);
    }
}

المزايا والعيوب

المزايا

graph LR
    A[Unit Testing<br/>Advantages] --> B[Learning<br/>Documentation]
    A --> C[Code<br/>Refactoring]
    A --> D[Parallel<br/>Development]
    
    B --> B1[Understand API]
    B --> B2[Usage Examples]
    
    C --> C1[Regression Testing]
    C --> C2[Quick Fault Detection]
    
    D --> D1[Test Independently]
    D --> D2[No Waiting]
    
    style A fill:#FFD700
    style B fill:#90EE90
    style C fill:#87CEEB
    style D fill:#FFB6C1
  1. التعلّم والتوثيق

    • يمكن للمطورين تعلّم الوظائف من خلال فحص اختبارات الوحدة
    • تُستخدم اختبارات الوحدة كتوثيق أساسي لواجهة الوحدة (API)
    • توفّر أمثلة استخدام للمطورين الآخرين
  2. إعادة هيكلة الكود (Refactoring) وصيانته

    • تُتيح إعادة الهيكلة بأمان وثقة
    • يضمن اختبار الانحدار (Regression testing) عمل الوحدات بشكل صحيح بعد التغييرات
    • يحدّد الأخطاء ويصلحها بسرعة عند إجراء تغييرات
  3. التطوير المتوازي

    • بفضل الطبيعة النمطية (Modular)، يمكن اختبار الأجزاء دون انتظار اكتمال الأجزاء الأخرى
    • يتيح تطوير المكوّنات واختبارها بشكل مستقل
    • يقلّل من وقت التطوير والتبعيات (Dependencies)

العيوب

graph LR
    A[Unit Testing<br/>Limitations] --> B[Coverage<br/>Limits]
    A --> C[Scope<br/>Constraints]
    A --> D[Complementary<br/>Approach]
    
    B --> B1[Can't Catch All Errors]
    B --> B2[Path Explosion]
    
    C --> C1[No Integration Testing]
    C --> C2[No System Level Testing]
    
    D --> D1[Use with Other Tests]
    D --> D2[Comprehensive Strategy]
    
    style A fill:#FFB6C1
    style B fill:#FFA07A
    style C fill:#FFD700
    style D fill:#87CEEB
  1. قيود التغطية

    • لا يمكنه اكتشاف كل خطأ في البرنامج
    • من المستحيل تقييم جميع مسارات التنفيذ، حتى في البرامج البسيطة
    • قد تبقى بعض الحالات الحدّية غير مختبَرة
  2. قيود النطاق

    • يركّز بطبيعته على الوحدات المنفردة
    • لا يمكنه اكتشاف أخطاء التكامل بين المكوّنات
    • لا يمكنه اكتشاف أخطاء واسعة على مستوى النظام
  3. الحاجة إلى اختبار مكمِّل

    • ينبغي استخدامه بالتزامن مع أنشطة اختبار أخرى
    • لا يزال اختبار التكامل مطلوبًا
    • يبقى اختبار النظام واختبار القبول (Acceptance testing) ضروريَّين

تشغيل الاختبارات

إعداد Maven

أضف التبعية (Dependency) التالية إلى ملف pom.xml:

<dependencies>
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter</artifactId>
        <version>5.10.0</version>
        <scope>test</scope>
    </dependency>
</dependencies>
 
<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-surefire-plugin</artifactId>
            <version>3.0.0</version>
        </plugin>
    </plugins>
</build>

إعداد Gradle

أضف إلى ملف build.gradle:

dependencies {
    testImplementation 'org.junit.jupiter:junit-jupiter:5.10.0'
}
 
test {
    useJUnitPlatform()
}

تشغيل الاختبارات من سطر الأوامر

Maven:

mvn test

Gradle:

gradle test

تشغيل الاختبارات من بيئة التطوير (IDE)

تدعم معظم بيئات التطوير الحديثة (IntelliJ IDEA، Eclipse، VS Code) JUnit 5 بشكل مدمج:

  • انقر بزر الفأرة الأيمن على صنف الاختبار ← Run
  • استخدم اختصارات لوحة المفاتيح (Ctrl+Shift+F10 في IntelliJ)
  • اضغط زر التشغيل الأخضر بجانب دوال الاختبار

أفضل الممارسات

  1. تسمية الاختبارات: استخدم أسماءً واضحة توضّح ما الذي يُختبَر
  2. تأكيد واحد لكل اختبار: ركّز كل اختبار على سلوك واحد (عند الإمكان)
  3. نمط الترتيب-التنفيذ-التأكيد (Arrange-Act-Assert):
    • الترتيب (Arrange): إعداد بيانات الاختبار
    • التنفيذ (Act): تنفيذ الدالة قيد الاختبار
    • التأكيد (Assert): التحقق من النتائج
  4. اختبارات مستقلة: يجب ألا تعتمد الاختبارات على بعضها البعض
  5. تنفيذ سريع: يجب أن تعمل الاختبارات بسرعة
  6. قابلة للتكرار: يجب أن تُنتج الاختبارات نفس النتائج في كل مرة
  7. استخدم @DisplayName: قدّم أوصاف اختبار واضحة وسهلة القراءة
  8. اختبر الحالات الحدّية: تضمَّن القيم الحدّية وشروط الخطأ
  9. اجعل الاختبارات بسيطة: يجب أن تكون الاختبارات أسهل للفهم من الكود الذي تختبره
  10. التنظيف: استخدم @AfterEach و@AfterAll بشكل مناسب

الملخّص

في هذا المعمل، تعلّمت:

  • أساسيات اختبار الوحدة وأهميته
  • أدوات اختبار الوحدة المختلفة المتاحة للغات برمجة مختلفة
  • بنية JUnit 5 ومكوّناتها الثلاثة الرئيسية
  • التعليقات التوضيحية الأساسية لـJUnit 5 واستخداماتها
  • دورة حياة الاختبار وترتيب التنفيذ
  • كيفية كتابة اختبارات وحدة فعّالة مع أمثلة عملية
  • مزايا اختبار الوحدة وحدوده

اختبار الوحدة ممارسة أساسية في تطوير البرمجيات الحديث، يساعد على ضمان جودة الكود، ويسهّل إعادة الهيكلة، ويُعدّ توثيقًا حيًّا لقاعدة الكود الخاصة بك.


تمرين

أنشئ صنف BankAccount بالميزات التالية:

  • إيداع الأموال
  • سحب الأموال
  • الحصول على الرصيد
  • تحويل الأموال إلى حساب آخر
  • حساب الفائدة

اكتب اختبارات وحدة شاملة تغطي:

  1. العمليات الاعتيادية
  2. الحالات الحدّية (مبالغ صفرية، مبالغ سالبة)
  3. معالجة الاستثناءات (رصيد غير كافٍ، مدخلات غير صالحة)
  4. اختبارات معلمية (Parameterized) لمبالغ إيداع/سحب متنوعة
  5. اختبار وظيفة التحويل بين حسابين

حظًا موفقًا! 🚀

جامعة المنصورة

  • الفصل الدراسي الأول - 2020-2021
  • قسم علوم الحاسب
  • كلية الحاسبات والمعلومات
  • [CS ---] بناء البرمجيات
  • الفرقة: 4
  • المعمل: 4
  • د. عمر الزكي
  • عبد الرحمن جمال

جدول المحاضرة

  • اختبار الوحدة (Unit Test)
  • أدوات اختبار الوحدة
  • تعريف JUnit5
  • بنية JUnit5
  • التعليقات التوضيحية في Junit5
  • أمثلة على استخدام JUnit 5
  • 2

اختبار الوحدة (Unit Test)

  • نوع من اختبار البرمجيات تُختبر فيه وحدات أو مكوّنات منفردة من البرنامج.
  • الغرض منه هو التحقق من أن كل وحدة من كود البرنامج تعمل كما هو متوقع.
  • يتم تنفيذ اختبار الوحدة أثناء مرحلة تطوير (كتابة) التطبيق بواسطة المطورين.
  • تعزل اختبارات الوحدة جزءًا من الكود وتتحقق من صحته. يمكن أن تكون الوحدة دالة، أو تابعًا، أو إجراءً، أو وحدة نمطية، أو كائنًا منفردًا.
  • 3

أدوات اختبار الوحدة

  • توجد عدة أدوات آلية متاحة للمساعدة في اختبار الوحدة. سنقدّم بعض الأمثلة أدناه:
  • Junit: أداة اختبار مجانية الاستخدام للغة برمجة جافا. توفّر تأكيدات لتحديد دالة الاختبار. تختبر هذه الأداة البيانات أولاً ثم تُدرجها في قطعة الكود.
  • NUnit: إطار اختبار وحدة مستخدم على نطاق واسع لجميع لغات .net. أداة مفتوحة المصدر تسمح بكتابة السكربتات يدويًا. تدعم اختبارات مدفوعة بالبيانات يمكن تشغيلها بالتوازي.
  • JMockit: أداة اختبار وحدة مفتوحة المصدر. أداة تغطية كود مع مقاييس على مستوى السطر والمسار. تسمح بمحاكاة الواجهات البرمجية بصيغة تسجيل وتحقق. توفّر هذه الأداة تغطية الأسطر، وتغطية المسارات، وتغطية البيانات.
  • 4

أدوات اختبار الوحدة

  • EMMA: مجموعة أدوات مفتوحة المصدر لتحليل الكود المكتوب بلغة جافا وإعداد تقارير عنه. تدعم EMMA أنواع تغطية مثل الدالة، والسطر، والكتلة الأساسية. وهي مبنية على جافا، لذا فهي دون تبعيات لمكتبات خارجية ويمكنها الوصول إلى الكود المصدري.
  • PHPUnit: أداة اختبار وحدة لمبرمجي PHP. تأخذ أجزاءً صغيرة من الكود تُسمّى وحدات وتختبر كلاً منها على حدة. كما تسمح الأداة للمطورين باستخدام دوال تأكيد مُعرَّفة مسبقًا للتحقق من أن النظام يتصرّف بطريقة معينة.
  • 5

مستويات اختبار الوحدة

  • 6

JUnit 5

  • JUnit 5 هو إطار الاختبار الأكثر استخدامًا لتطبيقات جافا. لفترة طويلة جدًا، كان JUnit يؤدي عمله بشكل ممتاز. في هذه الأثناء، جلبت JDK 8 ميزات مثيرة جدًا في جافا وأبرزها تعبيرات لامبدا. يهدف JUnit 5 إلى التكيّف مع أسلوب الكتابة في جافا 8 وعدة ميزات أخرى أيضًا، ولهذا السبب تُطلَب جافا 8 لإنشاء وتشغيل الاختبارات في JUnit 5 (رغم أنه من الممكن تشغيل الاختبارات المكتوبة بـJUnit 3 أو JUnit 4 من أجل التوافقية الخلفية).
  • 7

بنية JUnit5

  • 8
  • مقارنةً بـJUnit 4، يتكوّن JUnit 5 من عدة وحدات نمطية مختلفة من ثلاثة مشاريع فرعية مختلفة:
  • JUnit 5 = JUnit Platform + JUnit Jupiter + JUnit Vintage
  • JUnit Platform
  • لكي تتمكن بيئات التطوير أو أدوات البناء أو الإضافات من تشغيل اختبارات junit، تحتاج إلى تضمين وتوسيع واجهات المنصة. يُعرِّف واجهة TestEngine لتطوير أطر اختبار جديدة تعمل على المنصة.
  • كما يوفّر مُشغِّل طرفية لتشغيل المنصة من سطر الأوامر وإضافات بناء لـGradle وMaven.
  • JUnit Jupiter
  • يتضمن نماذج برمجة وتوسيع جديدة لكتابة الاختبارات. يحتوي على جميع التعليقات التوضيحية الجديدة في junit وتنفيذ TestEngine لتشغيل الاختبارات المكتوبة بهذه التعليقات التوضيحية.
  • JUnit Vintage
  • الغرض الأساسي منه هو دعم تشغيل الاختبارات المكتوبة بـJUnit 3 وJUnit 4 على منصة JUnit 5. وهو موجود من أجل التوافقية الخلفية.

مزايا اختبار الوحدة

  • يمكن للمطورين الذين يريدون تعلّم الوظائف التي توفّرها وحدة ما وكيفية استخدامها أن ينظروا إلى اختبارات الوحدة للحصول على فهم أساسي لواجهة الوحدة.
  • يسمح اختبار الوحدة للمبرمج بإعادة هيكلة الكود لاحقًا، والتأكد من أن الوحدة لا تزال تعمل بشكل صحيح (أي اختبار الانحدار). الإجراء هو كتابة حالات اختبار لجميع الدوال والتوابع بحيث كلما تسبّب تغيير في خطأ، يمكن تحديده وإصلاحه بسرعة.
  • بفضل الطبيعة النمطية لاختبار الوحدة، يمكننا اختبار أجزاء من المشروع دون انتظار اكتمال أجزاء أخرى.
  • 9

عيوب اختبار الوحدة

  • لا يمكن توقّع أن يكتشف اختبار الوحدة كل خطأ في البرنامج. من غير الممكن تقييم جميع مسارات التنفيذ حتى في أبسط البرامج
  • يركّز اختبار الوحدة بطبيعته على وحدة من الكود. لذا لا يمكنه اكتشاف أخطاء التكامل أو الأخطاء الواسعة على مستوى النظام.
  • يُوصى باستخدام اختبار الوحدة بالتزامن مع أنشطة اختبار أخرى.
  • 10

التعليقات التوضيحية (Annotations)

  • @BeforeEach | تُشغَّل الدالة المُعلَّمَة بهذا التعليق قبل كل دالة اختبار في صنف الاختبار.
  • @AfterEach | تُشغَّل الدالة المُعلَّمَة بهذا التعليق بعد كل دالة اختبار في صنف الاختبار.
  • @BeforeAll | تُشغَّل الدالة المُعلَّمَة بهذا التعليق قبل جميع دوال الاختبار في صنف الاختبار. يجب أن تكون هذه الدالة ساكنة (static).
  • @AfterAll | تُشغَّل الدالة المُعلَّمَة بهذا التعليق بعد جميع دوال الاختبار في صنف الاختبار. يجب أن تكون هذه الدالة ساكنة (static).
  • @Test | يُستخدم لتعليم دالة على أنها اختبار junit
  • @DisplayName | يُستخدم لتوفير اسم عرض مخصّص لصنف أو دالة اختبار
  • @Disable | يُستخدم لتعطيل أو تجاهل صنف أو دالة اختبار من مجموعة الاختبارات
  • @Nested | يُستخدم لإنشاء أصناف اختبار متداخلة
  • @Tag | يُعلِّم دوال أو أصناف الاختبار بوسوم (Tags) لاكتشافها وتصفيتها
  • @TestFactory | يُعلِّم دالة على أنها مصنع اختبارات (Test factory) للاختبارات الديناميكية
  • 11

دورة حياة الاختبار

  • 12