مشروع: اختبار واجهة برمجية ببناء Spring Boot
منحتك الدروس التسعة السابقة كل أداة تحتاجها. يجمع هذا الدرس تلك الأدوات في مجموعة اختبارات متعددة الطبقات ومتماسكة لواجهة برمجية واقعية — خدمة إدارة المهام تحتوي على كيانَي Task وUser. ستُشاهد بدقة كيف تتكامل طبقة الوحدة وشريحة طبقة الويب وشريحة المثابرة واختبار التكامل الشامل، وستفهم القرارات التصميمية التي تُبقي المجموعة الكاملة سريعة وقابلة للصيانة.
نظرة عامة على النطاق
تعرض الواجهة البرمجية ثلاث نقاط نهاية:
POST /api/tasks — إنشاء مهمة (مستخدم مصادق عليه)
GET /api/tasks/{id} — جلب مهمة واحدة بمعرّفها
DELETE /api/tasks/{id} — حذف مهمة (المالك فقط)
الفئات ذات الصلة هي Task (كيان)، وTaskRepository (Spring Data JPA)، وTaskService (منطق الأعمال)، وTaskController (طبقة REST). هذه البنية ثلاثية الطبقات تعني ثلاث طبقات اختبار متمايزة، لكل منها نطاق مختلف وسياق Spring مختلف.
الطبقة الأولى — اختبار الوحدة للخدمة
تطبّق الخدمة قاعدة عمل: لا يجوز للمستخدم حذف إلا مهامه الخاصة. هذه القاعدة تقع بالكامل في Java ولا تستلزم أي سياق Spring أو قاعدة بيانات، مما يجعلها مرشحة مثالية لاختبار JUnit 5 + Mockito بسيط.
@ExtendWith(MockitoExtension.class)
class TaskServiceTest {
@Mock TaskRepository taskRepository;
@InjectMocks TaskService taskService;
@Test
void createTask_persistsAndReturnsDto() {
User owner = new User(1L, "alice@example.com");
TaskCreateDto dto = new TaskCreateDto("Write tests", Priority.HIGH);
Task saved = new Task(42L, dto.title(), dto.priority(), owner, false);
when(taskRepository.save(any(Task.class))).thenReturn(saved);
TaskDto result = taskService.createTask(dto, owner);
assertThat(result.id()).isEqualTo(42L);
assertThat(result.title()).isEqualTo("Write tests");
verify(taskRepository).save(argThat(t ->
t.getOwner().equals(owner) && !t.isCompleted()
));
}
@Test
void deleteTask_throwsForbidden_whenCallerIsNotOwner() {
User alice = new User(1L, "alice@example.com");
User bob = new User(2L, "bob@example.com");
Task task = new Task(10L, "Alice task", Priority.LOW, alice, false);
when(taskRepository.findById(10L)).thenReturn(Optional.of(task));
assertThatThrownBy(() -> taskService.deleteTask(10L, bob))
.isInstanceOf(AccessDeniedException.class);
verify(taskRepository, never()).delete(any());
}
}
لماذا لا يوجد Spring هنا؟ حتى أخف سياق لـ Spring يُضيف مئات المللي ثانية إلى وقت التشغيل. أما الخدمة مع موكّاتها المحقونة فتبدأ في أجزاء من الثانية. أبقِ كل ما لا يمس HTTP أو قاعدة البيانات عند هذا المستوى.
الطبقة الثانية — طبقة الويب مع @WebMvcTest
يُشغّل @WebMvcTest(TaskController.class) طبقة الويب فقط — التسلسل والتحقق من الصحة وفلاتر الأمان ومعالجات الاستثناءات — دون تحميل سياق المثابرة. يُستبدل الـ bean الخاص بـ TaskService بـ @MockBean، مما يُتيح للاختبار التحكم في ما تُعيده الخدمة والتحقق من رموز HTTP وأجسام JSON ورؤوس الاستجابة.
@WebMvcTest(TaskController.class)
@Import(SecurityConfig.class)
class TaskControllerTest {
@Autowired MockMvc mockMvc;
@Autowired ObjectMapper objectMapper;
@MockBean TaskService taskService;
@MockBean UserDetailsService userDetailsService; // مطلوب من Spring Security
@Test
@WithMockUser(username = "alice@example.com")
void getTask_returns200WithTaskDto() throws Exception {
TaskDto dto = new TaskDto(42L, "Write tests", Priority.HIGH, false);
when(taskService.getTask(42L)).thenReturn(dto);
mockMvc.perform(get("/api/tasks/42")
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.title").value("Write tests"))
.andExpect(jsonPath("$.priority").value("HIGH"));
}
@Test
void getTask_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/tasks/42"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(username = "bob@example.com")
void deleteTask_returns403_whenNotOwner() throws Exception {
doThrow(new AccessDeniedException("not owner"))
.when(taskService).deleteTask(eq(42L), any());
mockMvc.perform(delete("/api/tasks/42"))
.andExpect(status().isForbidden());
}
}
اختبر الأمان في طبقة الويب. تعمل فلاتر الأمان كجزء من أنبوب servlet الذي يحمّله @WebMvcTest. تحقّق من استجابات 401/403 هنا، لا في اختبارات الوحدة — يحترم السياق الموكّا الـ bean الخاص بـ SecurityFilterChain عند استخدام @Import.
الطبقة الثالثة — طبقة المثابرة مع @DataJpaTest
يستخدم اختبار المثابرة قاعدة بيانات H2 في الذاكرة للتحقق من استعلامات JPQL المخصصة في TaskRepository دون تحميل طبقة الويب أو سياق التطبيق الكامل.
@DataJpaTest
class TaskRepositoryTest {
@Autowired TestEntityManager em;
@Autowired TaskRepository taskRepository;
@Test
void findByOwner_returnsOnlyOwnerTasks() {
User alice = em.persist(new User(null, "alice@example.com"));
User bob = em.persist(new User(null, "bob@example.com"));
em.persist(new Task(null, "Alice task 1", Priority.LOW, alice, false));
em.persist(new Task(null, "Alice task 2", Priority.HIGH, alice, true));
em.persist(new Task(null, "Bob task", Priority.LOW, bob, false));
em.flush();
List<Task> aliceTasks = taskRepository.findByOwner(alice);
assertThat(aliceTasks).hasSize(2)
.extracting(Task::getTitle)
.containsExactlyInAnyOrder("Alice task 1", "Alice task 2");
}
@Test
void countOpenByOwner_ignoresCompleted() {
User alice = em.persist(new User(null, "alice@example.com"));
em.persist(new Task(null, "Done", Priority.LOW, alice, true));
em.persist(new Task(null, "Pending", Priority.LOW, alice, false));
em.flush();
long open = taskRepository.countByOwnerAndCompletedFalse(alice);
assertThat(open).isEqualTo(1);
}
}
الطبقة الرابعة — اختبار التكامل الشامل
يُشغّل اختبار التكامل سياق التطبيق الكامل مقابل قاعدة بيانات حقيقية (أو مُدارة عبر Testcontainers) ويختبر الرحلة الكاملة من HTTP إلى قاعدة البيانات. استخدم @SpringBootTest(webEnvironment = RANDOM_PORT) مع TestRestTemplate لهذا الغرض.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class TaskApiIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16-alpine");
@DynamicPropertySource
static void configureDataSource(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired TestRestTemplate restTemplate;
@Test
void createAndFetch_roundTrip() {
TaskCreateDto request = new TaskCreateDto("Integration task", Priority.MEDIUM);
ResponseEntity<TaskDto> created =
restTemplate.withBasicAuth("alice@example.com", "password")
.postForEntity("/api/tasks", request, TaskDto.class);
assertThat(created.getStatusCode()).isEqualTo(HttpStatus.CREATED);
Long id = created.getBody().id();
ResponseEntity<TaskDto> fetched =
restTemplate.withBasicAuth("alice@example.com", "password")
.getForEntity("/api/tasks/" + id, TaskDto.class);
assertThat(fetched.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(fetched.getBody().title()).isEqualTo("Integration task");
}
}
اختبارات التكامل بطيئة بطبيعتها. قد يستغرق اختبار مدعوم بـ Testcontainers من 10 إلى 30 ثانية لبدء تشغيل الحاوية. ضعها في مجموعة مصادر أو ملف تعريف Maven/Gradle منفصل (مثل mvn verify -Pfailsafe) حتى تعمل اختبارات الوحدة والشرائح السريعة عند كل حفظ بينما تعمل مجموعة التكامل في بيئة CI.
هرم الاختبار في التطبيق العملي
تُشكّل الطبقات الأربع أعلاه هرمًا متعمَّدًا. اختبارات الوحدة هي الطبقة الأعرض: سريعة وكثيرة ومركّزة على منطق الأعمال. اختبارات طبقة الويب تُتحقق من عقود HTTP وقواعد الأمان. اختبارات المثابرة تحرس استعلامات JPQL. أما اختبارات التكامل فتمنح الثقة الشاملة بأن جميع الطبقات تترابط بصورة صحيحة.
- تُشغَّل عند كل حفظ: اختبارات الوحدة (مللي ثانية لكل منها).
- تُشغَّل عند كل إيداع: اختبارات الوحدة + شرائح طبقة الويب + شرائح المثابرة (ثوانٍ في المجموع).
- تُشغَّل في CI عند كل دفع: جميع الطبقات بما فيها اختبارات التكامل.
الخلاصة
تتكون مجموعة اختبارات Spring Boot الاحترافية من أربع استراتيجيات متكاملة: اختبارات وحدة خالصة مع Mockito لمنطق الأعمال، وشرائح @WebMvcTest لعقود HTTP والأمان، وشرائح @DataJpaTest لاستعلامات المثابرة، واختبارات تكامل شاملة للتأكد من الثقة الكاملة بالنظام. لكل طبقة نطاق واضح وسياق Spring متمايز (أو لا سياق على الإطلاق) وتكلفة تنفيذ يمكن التنبؤ بها. مطابقة نوع الاختبار بالمخاوف التي يُختبرها هي المهارة التصميمية التي تُميّز مجموعة سريعة وموثوقة عن مجموعة بطيئة وهشّة.