مشروع التخرّج: تطبيق جافا حقيقي

اختبار التطبيق

15 دقيقة الدرس 8 من 13

اختبار التطبيق

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

مراجعة هرم الاختبار

يتكوّن الهرم الكلاسيكي من ثلاث طبقات:

  • اختبارات الوحدة — سريعة، معزولة، بلا إدخال/إخراج. تختبر صفًا واحدًا مع استبدال التبعيات بنسخ وهمية. تُشكّل القاعدة العريضة.
  • اختبارات التكامل — متوسطة السرعة، تختبر مكوّنَين حقيقيَّين أو أكثر (مثل: خدمة + مستودع + قاعدة بيانات). الطبقة الوسطى.
  • اختبارات شاملة / نظام — الأبطأ، تختبر التطبيق بأكمله من الخارج. أبقِها قليلة ومحدّدة.

النسبة المستهدفة في مشروع التخرّج هي تقريبًا 70 % اختبارات وحدة، 25 % تكامل، 5 % شاملة. كتابة اختبارات تكامل كثيرة جدًا ينتج عنها مجموعة بطيئة وهشّة؛ وعدد قليل جدًا يترك التوصيلات غير مختبرة.

تبعيات المشروع

أضِف JUnit 5 وMockito وAssertJ إلى pom.xml (أو build.gradle). تمنح AssertJ واجهة تأكيد طليقة تُنتج رسائل فشل أفضل بكثير من تأكيدات JUnit الخام.

<!-- مقتطف pom.xml --> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter</artifactId> <version>5.10.2</version> <scope>test</scope> </dependency> <dependency> <groupId>org.mockito</groupId> <artifactId>mockito-junit-jupiter</artifactId> <version>5.11.0</version> <scope>test</scope> </dependency> <dependency> <groupId>org.assertj</groupId> <artifactId>assertj-core</artifactId> <version>3.25.3</version> <scope>test</scope> </dependency>

اختبار وحدة منطق المجال

صفوف المجال — الكيانات، وكائنات القيمة، وخدمات المجال — يجب أن تكون نقية وسهلة الإنشاء بـ new. المجال المصمّم جيدًا لا يملك تبعيات خارجية، لذا اختبارات الوحدة سهلة الكتابة.

لنفترض أن المشروع يُنمذج كيان Order بطريقة totalPrice():

// src/test/java/.../domain/OrderTest.java import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.*; class OrderTest { @Test void totalPrice_sumsLineItemsWithQuantity() { Order order = new Order(); order.addItem(new Product("Widget", new Money(10_00)), 3); order.addItem(new Product("Gadget", new Money(5_00)), 2); assertThat(order.totalPrice()).isEqualTo(new Money(40_00)); } @Test void addItem_withZeroQuantity_throwsDomainException() { Order order = new Order(); assertThatThrownBy(() -> order.addItem(new Product("X", new Money(1_00)), 0)) .isInstanceOf(DomainException.class) .hasMessageContaining("quantity"); } }
خزّن المبالغ المالية كأعداد صحيحة بالسنتات، وليس كـ double أبدًا. الحساب بالفاصلة العائمة على العملة يُنتج أخطاء تقريب خفية يكاد يستحيل إعادة إنتاجها في الاختبارات. استخدم كائن قيمة Money مدعومًا بـ long سنتات، أو استخدم java.math.BigDecimal.

اختبار وحدة طبقة الخدمة مع Mockito

صفوف الخدمة تنسّق كائنات المجال والمستودعات. المستودع تبعية — استبدله بنسخة وهمية ليبقى اختبار الخدمة سريعًا وفي الذاكرة. استخدم تكامل JUnit 5 عبر @ExtendWith(MockitoExtension.class) لتوصيل النسخ الوهمية تلقائيًا.

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 static org.mockito.Mockito.*; import static org.assertj.core.api.Assertions.*; @ExtendWith(MockitoExtension.class) class OrderServiceTest { @Mock OrderRepository orderRepository; @Mock InventoryService inventoryService; @InjectMocks OrderService orderService; @Test void placeOrder_savesAndReturnsOrder() { PlaceOrderCommand cmd = new PlaceOrderCommand(42L, List.of( new LineItemDto(10L, 2) )); when(inventoryService.hasStock(10L, 2)).thenReturn(true); Order saved = new Order(1L, 42L); when(orderRepository.save(any(Order.class))).thenReturn(saved); Order result = orderService.placeOrder(cmd); assertThat(result.getId()).isEqualTo(1L); verify(orderRepository).save(any(Order.class)); verify(inventoryService).reserveStock(10L, 2); } @Test void placeOrder_whenOutOfStock_throwsException() { PlaceOrderCommand cmd = new PlaceOrderCommand(42L, List.of(new LineItemDto(10L, 99))); when(inventoryService.hasStock(10L, 99)).thenReturn(false); assertThatThrownBy(() -> orderService.placeOrder(cmd)) .isInstanceOf(OutOfStockException.class); verifyNoInteractions(orderRepository); } }
تحقّق من التفاعلات بشكل مقصود. استخدم verify() فقط على الآثار الجانبية المهمة — الحفظ، إرسال البريد الإلكتروني، نشر الأحداث. التحقّق من كل استدعاء يجعل الاختبارات هشّة ويربطها بتفاصيل التنفيذ بدلًا من السلوك القابل للملاحظة.

الاختبارات البارامترية

عندما يجب أن يصمد نفس المنطق لمدخلات متعددة، يُزيل @ParameterizedTest التكرار في طرق الاختبار:

import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; class DiscountCalculatorTest { DiscountCalculator calculator = new DiscountCalculator(); @ParameterizedTest @CsvSource({ "0, 0.00", "99, 0.00", "100, 5.00", "500, 15.00", "1000,25.00" }) void discount_returnsCorrectPercentage(int orderCents, double expectedPercent) { double actual = calculator.discountPercent(new Money(orderCents)); assertThat(actual).isEqualTo(expectedPercent); } }

اختبار التكامل لطبقة البيانات

اختبارات التكامل تُثبت أن مستودعك يُترجم فعلًا بشكل صحيح بين نموذج كائن Java وقاعدة البيانات. استخدم قاعدة بيانات H2 مضمّنة (مهيّأة لوضع التوافق مع MySQL) لكي تعمل المجموعة دون اتصال بالإنترنت وبسرعة، دون لمس بيانات الإنتاج.

// src/test/resources/application-test.properties spring.datasource.url=jdbc:h2:mem:testdb;MODE=MySQL;DB_CLOSE_DELAY=-1 spring.datasource.driver-class-name=org.h2.Driver spring.jpa.hibernate.ddl-auto=create-drop // أو إذا كنت تستخدم JDBC خالصًا مع مجموعة اتصالات: // JdbcOrderRepositoryIntegrationTest.java class JdbcOrderRepositoryIntegrationTest { private static DataSource dataSource; private JdbcOrderRepository repository; @BeforeAll static void initDataSource() { dataSource = new EmbeddedDatabaseBuilder() .setType(EmbeddedDatabaseType.H2) .addScript("classpath:schema.sql") .build(); } @BeforeEach void setUp() { repository = new JdbcOrderRepository(dataSource); } @AfterEach void tearDown() throws Exception { try (var conn = dataSource.getConnection(); var stmt = conn.createStatement()) { stmt.execute("DELETE FROM order_items"); stmt.execute("DELETE FROM orders"); } } @Test void saveAndFindById_roundTripsSuccessfully() { Order order = new Order(null, 7L); order.addItem(new Product("Widget", new Money(10_00)), 2); Order saved = repository.save(order); Order loaded = repository.findById(saved.getId()).orElseThrow(); assertThat(loaded.getCustomerId()).isEqualTo(7L); assertThat(loaded.getItems()).hasSize(1); assertThat(loaded.totalPrice()).isEqualTo(new Money(20_00)); } }
لا تشارك حالة قاعدة البيانات أبدًا بين اختبارات التكامل. يجب أن يُنظّف كل اختبار بعد نفسه في @AfterEach أو يعمل داخل معاملة يتم التراجع عنها. الحالة المشتركة تُسبّب إخفاقات متقطّعة يصعب تشخيصها — تظهر فقط عند تشغيل الاختبارات بترتيب معيّن.

الاختبار بـ @Nested لبنية واضحة

تتيح لك صفّة @Nested في JUnit 5 تجميع السيناريوهات المتعلّقة داخل صف اختبار واحد، ممّا يُعطي مخرجات هرمية وواضحة في IDE وسجلّات CI:

class OrderServiceTest { @Nested class WhenPlacingAnOrder { @Test void succeeds_withValidItemsInStock() { /* ... */ } @Test void fails_whenProductNotFound() { /* ... */ } @Test void fails_whenQuantityExceedsStock() { /* ... */ } } @Nested class WhenCancellingAnOrder { @Test void succeeds_forPendingOrder() { /* ... */ } @Test void fails_forAlreadyShippedOrder() { /* ... */ } } }

قياس التغطية

شغّل JaCoCo لقياس تغطية الأسطر والفروع. الهدف الشائع للمشروع هو 80 % تغطية أسطر على حزمتَي domain وservice؛ طبقة البيانات تُغطّيها اختبارات التكامل. التغطية أداة وليست هدفًا — 100 % تغطية أسطر مع تأكيدات ضعيفة أسوأ من 70 % تغطية مع تأكيدات قوية. ركّز على تغطية كل فرع لقاعدة أعمال، وخاصة مسارات الخطأ.

# Maven — يُنشئ تقريرًا HTML تحت target/site/jacoco/ mvn test jacoco:report

الخلاصة

الاختبار الفعّال لمشروع التخرّج يتطلّب ثلاث طبقات تكاملية: اختبارات وحدة سريعة لمنطق المجال (بلا نسخ وهمية) ومنطق الخدمة (مستودع وهمي)، واختبارات تكامل تُمارس كود JDBC/JPA الحقيقي مع قاعدة بيانات في الذاكرة، واستخدام مقصود لميزات JUnit 5 — @ParameterizedTest، و@Nested، وتعليقات دورة الحياة — للحفاظ على المجموعة قابلة للقراءة والصيانة. نظّف دائمًا حالة قاعدة البيانات بين اختبارات التكامل، وتحقّق فقط من الآثار الجانبية المهمة، واستخدم التغطية دليلًا للثغرات وليس مقياسًا للتحسين.