اختبار تطبيقات Spring Boot

اختبار طبقة الاستمرارية باستخدام @DataJpaTest

18 دقيقة الدرس 6 من 13

اختبار طبقة الاستمرارية باستخدام @DataJpaTest

تمثّل مستودعاتك (repositories) الحدَّ الفاصل بين منطق المجال والقاعدة البيانات. اختبارها بمعزل — دون تشغيل سياق التطبيق الكامل — يمنحك تغذية راجعة سريعة وحتمية حول تعيينات JPA الخاصة بك والاستعلامات المخصصة وقيود البيانات. مُرشَّح الشريحة @DataJpaTest من Spring Boot مصمَّم تحديدًا لهذا الغرض.

ما الذي يفعله @DataJpaTest فعليًا

حين تُعلِّق فئة اختبار بـ @DataJpaTest، يبني Spring Boot شريحة من سياق التطبيق تحتوي فقط على المكوّنات اللازمة لطبقة الاستمرارية:

  • جميع مستودعات Spring Data JPA
  • فئات @Entity الخاصة بك وتعييناتها في Hibernate
  • EntityManager مُهيَّأ (وTestEntityManager)
  • دعم المعاملات في Spring

ما يستثنيه عمدًا: حبوب @Service وحبوب @Controller وسياق @SpringBootTest الكامل. هذا يعني أن اختبارك يبدأ في أجزاء من الثانية بدلًا من ثوانٍ، ويختبر فقط الكود الذي يعنيك.

قاعدة بيانات في الذاكرة افتراضيًا: يستبدل @DataJpaTest مصدر البيانات المُهيَّأ لديك بقاعدة بيانات H2 مضمَّنة (في الذاكرة) ويُشغّل schema.sql / data.sql إن وُجدا، أو يعتمد على ddl-auto=create-drop في Hibernate. يعمل كل أسلوب اختبار داخل معاملة يتمّ التراجع عنها في النهاية، فتُعاد القاعدة إلى حالتها تلقائيًا بين الاختبارات.

الإعداد: التبعيات

أضف مُشغّل اختبار Spring Boot وH2 إلى ملف pom.xml (كلاهما بنطاق test):

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <scope>test</scope> </dependency>

تُوفّر إدارة تبعيات Spring Boot إصدارات متوافقة، لذا لا حاجة لتحديد علامات الإصدار.

الكيان والمستودع قيد الاختبار

تأمّل كيانًا بسيطًا Order ومستودع Spring Data يضيف محدّدًا مخصصًا واحدًا:

package com.example.shop.order; import jakarta.persistence.*; import java.math.BigDecimal; import java.time.LocalDateTime; @Entity @Table(name = "orders") public class Order { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(nullable = false) private String customerEmail; @Column(nullable = false) private BigDecimal total; @Column(nullable = false) private String status; // PENDING, PAID, SHIPPED private LocalDateTime placedAt; protected Order() {} public Order(String customerEmail, BigDecimal total, String status) { this.customerEmail = customerEmail; this.total = total; this.status = status; this.placedAt = LocalDateTime.now(); } public Long getId() { return id; } public String getCustomerEmail() { return customerEmail; } public BigDecimal getTotal() { return total; } public String getStatus() { return status; } public LocalDateTime getPlacedAt() { return placedAt; } public void setStatus(String status) { this.status = status; } }
package com.example.shop.order; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import java.util.List; public interface OrderRepository extends JpaRepository<Order, Long> { List<Order> findByCustomerEmail(String email); List<Order> findByStatus(String status); @Query("SELECT o FROM Order o WHERE o.total > :minTotal ORDER BY o.total DESC") List<Order> findHighValueOrders(java.math.BigDecimal minTotal); }

كتابة فئة اختبار @DataJpaTest

فيما يلي فئة اختبار كاملة وواقعية تستخدم TestEntityManager لتهيئة البيانات والمستودع للتنفيذ والتحقق:

package com.example.shop.order; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.orm.jpa.*; import java.math.BigDecimal; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; @DataJpaTest class OrderRepositoryTest { @Autowired private TestEntityManager em; // مساعد JPA خفيف الوزن لمرحلة الترتيب @Autowired private OrderRepository repo; @Test void findByCustomerEmail_returnsOnlyThatCustomersOrders() { // الترتيب em.persistAndFlush(new Order("alice@example.com", new BigDecimal("120.00"), "PENDING")); em.persistAndFlush(new Order("alice@example.com", new BigDecimal("45.00"), "PAID")); em.persistAndFlush(new Order("bob@example.com", new BigDecimal("200.00"), "SHIPPED")); // التنفيذ List<Order> result = repo.findByCustomerEmail("alice@example.com"); // التحقق assertThat(result).hasSize(2) .extracting(Order::getCustomerEmail) .containsOnly("alice@example.com"); } @Test void findHighValueOrders_returnsOrdersAboveThreshold_sortedDescending() { em.persistAndFlush(new Order("alice@example.com", new BigDecimal("50.00"), "PAID")); em.persistAndFlush(new Order("alice@example.com", new BigDecimal("300.00"), "PAID")); em.persistAndFlush(new Order("bob@example.com", new BigDecimal("150.00"), "PAID")); List<Order> result = repo.findHighValueOrders(new BigDecimal("100.00")); assertThat(result).hasSize(2); assertThat(result.get(0).getTotal()).isEqualByComparingTo("300.00"); assertThat(result.get(1).getTotal()).isEqualByComparingTo("150.00"); } @Test void save_withNullCustomerEmail_throwsConstraintViolation() { Order bad = new Order(null, new BigDecimal("10.00"), "PENDING"); assertThrows(Exception.class, () -> em.persistAndFlush(bad)); } }
افلش (flush) دائمًا قبل الاستعلام. تكتب persistAndFlush() الكيانَ في قاعدة البيانات في الذاكرة فورًا. إن اكتفيت بـ persist() فقد يُؤجّل Hibernate عملية INSERT دُفعةً واحدة وقد يعمل استعلام SELECT التالي قبل وجود الصف، مما يُسبّب فشلًا كاذبًا.

TestEntityManager مقابل المستودع

سؤال شائع: إن كان لديك المستودع، فلماذا تستخدم TestEntityManager أيضًا؟ القاعدة بسيطة:

  • استخدم TestEntityManager للترتيب (البذر) والتحقق من الحالة الخام. فهو يتجاوز منطق المستودع، لذا فهو مناسب لإعداد المتطلبات المسبقة والتأكيد على ما يُخزَّن فعليًا على مستوى الكيان.
  • استخدم المستودع باعتباره النظام قيد الاختبار. الأساليب التي تختبرها تنتمي إلى المستودع؛ استدعها في خطوة التنفيذ.

خلط الدورين في خطوة واحدة — كالاستمرار عبر المستودع ثم القراءة بـ em.find() — صحيح أيضًا حين تحتاج إلى التحقق من حالة قاعدة البيانات الخام بعد عملية الحفظ.

التراجع عن المعاملات وعزل الاختبارات

يُغلَّف كل أسلوب اختبار داخل معاملة يتمّ التراجع عنها عند انتهاء الأسلوب. يعني هذا:

  • لا تسرّب للبيانات بين الاختبارات — يبدأ كل اختبار بقاعدة بيانات نظيفة.
  • لا تحتاج أبدًا لكود تنظيف في @BeforeEach لحذف الصفوف.
  • قد يتقدّم عدّاد الزيادة التلقائية (لا يُعيد H2 ضبط التسلسلات عند التراجع)، لذا لا تُقرّ أبدًا بقيم معرّفات محددة.
احذر التحميل الكسول في الاختبارات. بما أن الاختبار يعمل داخل معاملة واحدة، تكون الارتباطات الكسولة لا تزال مفتوحة وتبدو سليمة. حين يعمل نفس الكود في طلب حقيقي (حيث تُغلَق المعاملة قبل الوصول إلى الحقل الكسول)، قد تحصل على LazyInitializationException. اختبر سلوك التحميل الكسول والسريع عن قصد، أو هيّئ @Transactional(propagation = REQUIRES_NEW) في أسلوب مساعد لمحاكاة حدود المعاملة الحقيقية.

الاختبار على قاعدة بيانات حقيقية باستخدام @AutoConfigureTestDatabase

قاعدة بيانات H2 في الذاكرة مريحة لكنها ليست دائمًا أمينة. تختلف اللهجات: نوع jsonb في PostgreSQL وفهارس النص الكامل في MySQL والامتدادات JPQL الخاصة بقاعدة بيانات معينة غير موجودة في H2. حين تعتمد استعلاماتك على سلوك خاص بقاعدة البيانات، تجاوز الاستبدال:

@DataJpaTest @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) class OrderRepositoryIntegrationTest { // يستخدم Spring الآن مصدر البيانات في application.properties (أو مصدر بيانات Testcontainer) }

ادمج Replace.NONE مع Testcontainers (الدرس التاسع) لتشغيل نسخة حقيقية من PostgreSQL أو MySQL في Docker خلال تشغيل الاختبار — أفضل ما في العالمين: العزل مع أمانة اللهجة الكاملة.

اعتبارات الأداء

نظرًا لأن @DataJpaTest يبني سياقًا أدنى حجمًا، فهو سريع — عادةً أقل من 3 ثوانٍ للاختبار الأول وشبه فوري للاختبارات اللاحقة في نفس التشغيل (يُخزّن Spring سياق التطبيق بين الاختبارات ذات الإعداد نفسه). أبق الشريحة خفيفة:

  • لا تضف @Import لحبوب الخدمات أو وحدات التحكم في @DataJpaTest — هذا يُهزم الغرض. اختبر تلك الطبقات في شرائحها الخاصة.
  • جمّع الاختبارات التي تشترك في إعداد السياق نفسه في فئة واحدة كي يُعاد استخدام السياق.
  • استخدم @Sql لمجموعات البيانات الكبيرة بدلًا من استدعاءات em.persist() المتعددة — فهو أسرع ويجعل مرحلة الترتيب أكثر وضوحًا.

الخلاصة

يمنحك @DataJpaTest شريحة مُركَّزة وسريعة لاختبار المستودعات: قاعدة بيانات H2 في الذاكرة وTestEntityManager للبيانات التجريبية والتراجع التلقائي عن المعاملات بين الاختبارات وتحميل مكوّنات JPA فقط. استخدمه للتحقق من محدّداتك المخصصة واستعلامات JPQL وقيود قاعدة البيانات دون عبء سياق التطبيق الكامل. حين لا تكون H2 أمينة بما يكفي، انتقل إلى Replace.NONE وأشر الشريحة إلى قاعدة بيانات حقيقية أو Testcontainer. في الدرس القادم ستتعلم كيف يتيح لك @MockBean استبدال أي حبة في السياق بمحاكٍ Mockito.