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

اختبار التكامل باستخدام TestRestTemplate

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

اختبار التكامل باستخدام TestRestTemplate

اختبارات الوحدة واختبارات الشرائح سريعة ومركّزة، غير أنها تترك سؤالًا جوهريًا دون إجابة: هل يعمل المكدّس بأكمله معًا كما ينبغي؟ تسدّ اختبارات التكامل هذه الفجوة. تتيح لك TestRestTemplate في Spring Boot إطلاق طلبات HTTP حقيقية على خادم تطبيق مُشغَّل بالكامل، واجتياز كل طبقة — مرشّحات الأمان، والمتحكمات، والخدمات، وقاعدة البيانات — والتحقق من استجابات HTTP الفعلية التي يتلقاها أي عميل حقيقي.

الفرق بين اختبارات التكامل واختبارات الشرائح

تحمّل اختبارات الشرائح كـ @WebMvcTest و @DataJpaTest شريحةً رفيعة فحسب من سياق Spring. وهي سريعة تحديدًا لأنها تتجاوز معظم أجزاء التطبيق. أما اختبارات التكامل فتعتمد المنهج المعاكس: تُقلع سياق ApplicationContext الكامل، وتُشغّل خادم Tomcat مُضمَّنًا على منفذ حقيقي، وتتيح لك إرسال طلبات HTTP كأي عميل خارجي. المقايضة واضحة — يستغرق الإعداد عدة ثوانٍ، لذا يجب أن تغطّي اختبارات التكامل رحلات المستخدم الكاملة لا كل فرع صغير من منطق التطبيق.

@SpringBootTest مع منفذ حقيقي

لتشغيل خادم HTTP حقيقي في الاختبارات، اضبط webEnvironment على RANDOM_PORT (أو DEFINED_PORT). يربط Spring Boot عندئذٍ منفذًا متاحًا ويُحقن قيمته في فئة الاختبار عبر @LocalServerPort.

import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; import org.springframework.boot.test.web.client.TestRestTemplate; import org.springframework.boot.test.web.server.LocalServerPort; import org.springframework.beans.factory.annotation.Autowired; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) class OrderApiIntegrationTest { @LocalServerPort private int port; @Autowired private TestRestTemplate restTemplate; @Test void getOrder_returnsOk() { var response = restTemplate.getForEntity( "http://localhost:" + port + "/api/orders/1", OrderResponse.class ); assertThat(response.getStatusCode().value()).isEqualTo(200); assertThat(response.getBody()).isNotNull(); } }
لماذا RANDOM_PORT؟ يمنع استخدام منفذ عشوائي تعارض المنافذ عند تشغيل الاختبارات بالتوازي على خوادم CI. يُحقن @LocalServerPort بأي منفذ اختاره Spring، فتبني دائمًا عناوين URL بالقيمة الصحيحة.

TestRestTemplate مقابل RestTemplate

تُغلّف TestRestTemplate الـ RestTemplate القياسية في Spring وتُضيف إليها سلوكيات ملائمة للاختبار. والأهم أنها لا تُلقي استثناءات عند استجابات 4xx/5xx — بل تعيد ResponseEntity يحمل حالة الخطأ كي تتمكن من التحقق من سيناريوهات الخطأ بشكل نظيف. كما تُعطّل متابعة إعادة التوجيه افتراضيًا، وهو ما تحتاجه تحديدًا عند اختبار منطق إعادة التوجيه.

تحصل على هذا الـ Bean مجانًا من الضبط التلقائي لاختبارات Spring Boot عند استخدام RANDOM_PORT أو DEFINED_PORT — لا حاجة لأي إعداد يدوي.

اختبار العمليات الأربع: الإنشاء والقراءة والتحديث والحذف

تستعرض مجموعة اختبارات التكامل الواقعية كامل واجهة CRUD لمورد ما:

import org.springframework.http.*; import java.util.List; @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) class ProductApiIntegrationTest { @Autowired private TestRestTemplate restTemplate; @LocalServerPort private int port; private String base() { return "http://localhost:" + port + "/api/products"; } @Test void createAndRetrieveProduct() { // إنشاء var request = new ProductRequest("Widget", 9.99); ResponseEntity<ProductResponse> created = restTemplate.postForEntity(base(), request, ProductResponse.class); assertThat(created.getStatusCode()).isEqualTo(HttpStatus.CREATED); Long id = created.getBody().id(); // قراءة ResponseEntity<ProductResponse> fetched = restTemplate.getForEntity(base() + "/" + id, ProductResponse.class); assertThat(fetched.getBody().name()).isEqualTo("Widget"); // تحديث var updateRequest = new HttpEntity<>(new ProductRequest("Gadget", 19.99)); restTemplate.exchange(base() + "/" + id, HttpMethod.PUT, updateRequest, Void.class); // حذف restTemplate.delete(base() + "/" + id); // التحقق من الحذف ResponseEntity<String> gone = restTemplate.getForEntity(base() + "/" + id, String.class); assertThat(gone.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); } }

إرسال الترويسات واختبار الأمان

تتطلب التطبيقات الحقيقية ترويسات مصادقة. استخدم TestRestTemplate.withBasicAuth() لمصادقة HTTP Basic، أو أنشئ HttpEntity مخصصًا يحمل الترويسات المطلوبة لمصادقة Bearer Token:

// HTTP Basic — مُغلِّف ملائم ResponseEntity<OrderResponse> response = restTemplate.withBasicAuth("admin", "password") .getForEntity(base() + "/1", OrderResponse.class); // Bearer Token — HttpEntity مخصص HttpHeaders headers = new HttpHeaders(); headers.setBearerAuth(jwtToken); HttpEntity<Void> requestEntity = new HttpEntity<>(headers); ResponseEntity<OrderResponse> secured = restTemplate.exchange(base() + "/1", HttpMethod.GET, requestEntity, OrderResponse.class); // التحقق من رفض الوصول غير المُصادَق ResponseEntity<String> denied = restTemplate.getForEntity(base() + "/1", String.class); assertThat(denied.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
احتفظ بمستخدم اختبار منفصل. ابذر مستخدمًا معروفًا (مثلًا بسكريبت @Sql أو في @BeforeEach يستدعي المستودع مباشرةً) بدلًا من الاعتماد على بيانات الإنتاج. يُبقي هذا اختباراتك محددة النتائج ومستقلة عن أي بيانات في قاعدة البيانات وقت التشغيل.

حالة قاعدة البيانات في اختبارات التكامل

نظرًا لتشغيل سياق التطبيق الكامل، تكون جميع التغييرات على البيانات حقيقية. كل اختبار يُعدّل قاعدة البيانات قد يُلوّث الاختبار التالي ما لم تُعيد ضبط الحالة. ثمة ثلاث استراتيجيات شائعة:

  • @Transactional على فئة الاختبار — تتراجع عن التغييرات بعد كل اختبار. تعمل جيدًا في الاختبارات غير المعتمدة على HTTP، لكن ليس لها أثر هنا لأن TestRestTemplate ترسل الطلبات عبر مقبس HTTP حقيقي؛ ويعمل الخادم في نطاق معاملة مختلف.
  • @Sql للتنظيف — زيّن أسلوب الاختبار أو فئته بـ @Sql(executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD, scripts = "cleanup.sql"). صريح وموثوق.
  • قاعدة بيانات اختبار منفصلة بحالة معروفة — هيّئ application-test.properties لاستخدام قاعدة H2 في الذاكرة واضبط spring.jpa.hibernate.ddl-auto=create-drop. يُعاد إنشاء المخطط من الصفر في كل تشغيل.
# src/test/resources/application-test.properties spring.datasource.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;MODE=MySQL spring.datasource.driver-class-name=org.h2.Driver spring.jpa.hibernate.ddl-auto=create-drop spring.jpa.show-sql=true

فعّل هذا الملف الشخصي في فئة الاختبار بإضافة @ActiveProfiles("test") إلى قائمة التعليقات التوضيحية.

التحقق من أجسام الاستجابة

يُفضَّل التحقق من كائنات استجابة مكتوبة بدقة بدلًا من سلاسل JSON الخام. حين تُمرر فئةً إلى getForEntity، يُفكّك Spring جسمَ الاستجابة تلقائيًا باستخدام Jackson. للتعامل مع المجموعات استخدم ParameterizedTypeReference:

import org.springframework.core.ParameterizedTypeReference; import java.util.List; ResponseEntity<List<ProductResponse>> listResponse = restTemplate.exchange( base(), HttpMethod.GET, null, new ParameterizedTypeReference<List<ProductResponse>>() {} ); assertThat(listResponse.getBody()).hasSize(3); assertThat(listResponse.getBody()) .extracting(ProductResponse::name) .containsExactlyInAnyOrder("Widget", "Gadget", "Doohickey");
تجنب التحقق من سلاسل JSON الخام. مقارنات السلاسل تنكسر عند تغيير ترتيب الحقول أو تنسيقها. فكّك البيانات إلى DTO وتحقق من الحقول — ستصمد اختباراتك أمام ترقيات المُسلسِل JSON.

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

اختبارات التكامل أبطأ بطبيعتها من اختبارات الوحدة. اتبع هذه الممارسات للحفاظ على سرعة مجموعة الاختبارات:

  • إعادة استخدام سياق التطبيق. يُخزّن Spring Boot السياق مؤقتًا بين فئات الاختبار ذات الإعداد المتماثل افتراضيًا. تجنب إضافة @MockBean إلى اختبارات التكامل — فكل مجموعة فريدة من الـ Beans المحاكاة تُنشئ سياقًا منفصلًا وتُضاعف تكلفة الإقلاع.
  • احتفظ باختبارات التكامل في مجموعة مصادر أو حزمة مخصصة. شغّلها بشكل منفصل عن اختبارات الوحدة كي يحصل المطورون على ردود فعل سريعة أثناء التطوير وتغطية كاملة في CI.
  • استخدم Testcontainers لقواعد البيانات المشابهة للإنتاج. في الدرس التالي ستتعلم كيف يستبدل Testcontainers قاعدة H2 بحاوية MySQL أو PostgreSQL حقيقية، مما يُلغي مفاجآت التوافق مع H2 دون فقدان العزل.

الخلاصة

تمنحك TestRestTemplate مع @SpringBootTest(webEnvironment = RANDOM_PORT) اختبارات حقيقية من البداية إلى النهاية: سياق Spring كامل، وخادم HTTP حقيقي، والقدرة على اجتياز كل طبقة من تطبيقك بطلبات HTTP فعلية. هي المحاكاة الأكثر أمانةً لسلوك الإنتاج المتاحة دون نشر فعلي. استخدمها لرحلات المستخدم الحرجة، واضبط حالة قاعدة البيانات لكل اختبار، واعتمد على ResponseEntity في التحقق من سيناريوهات الخطأ، وابق المجموعة مُجدِيّة للحفاظ على سرعة البناء.