انتقل إلى المحتوى الرئيسي

TP9.1 - اختبارات وحدة طبقة الخدمة

أهداف التمرين​

في هذا التمرين، سوف تقوم بـ:

  1. ✅ اختبار ProductService كامل باستخدام Mockito
  2. ✅ تغطية الحالات الاسمية وحالات الخطأ
  3. ✅ استخدام @Mock و@InjectMocks
  4. ✅ التحقق من التفاعلات باستخدام verify()
  5. ✅ تحقيق تغطية كود عالية

المدة المقدرة: 1 ساعة و30 دقيقة


السياق​

لديك ProductService الذي يدير منتجات التجارة الإلكترونية. يجب عليك اختبار جميع أساليبه دون استخدام قاعدة بيانات.


الجزء 1: الكود المراد اختباره​

كيان المنتج​

@Data
@NoArgsConstructor
@AllArgsConstructor
public class Product {
private Long id;
private String name;
private String description;
private BigDecimal price;
private Integer stock;
private boolean active;
}

مستودع المنتج​

public interface ProductRepository extends JpaRepository<Product, Long> {
List<Product> findByActiveTrue();
List<Product> findByPriceLessThan(BigDecimal price);
Optional<Product> findByName(String name);
}

خدمة المنتج​

@Service
@RequiredArgsConstructor
public class ProductService {

private final ProductRepository productRepository;

public List<Product> findAll() {
return productRepository.findAll();
}

public Product findById(Long id) {
return productRepository.findById(id)
.orElseThrow(() -> new ProductNotFoundException("Product not found: " + id));
}

public Product create(Product product) {
if (product.getName() == null || product.getName().isBlank()) {
throw new IllegalArgumentException("Product name is required");
}
if (product.getPrice() == null || product.getPrice().compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalArgumentException("Price must be positive");
}
product.setActive(true);
return productRepository.save(product);
}

public Product update(Long id, Product updated) {
Product existing = findById(id);
existing.setName(updated.getName());
existing.setDescription(updated.getDescription());
existing.setPrice(updated.getPrice());
existing.setStock(updated.getStock());
return productRepository.save(existing);
}

public void delete(Long id) {
if (!productRepository.existsById(id)) {
throw new ProductNotFoundException("Product not found: " + id);
}
productRepository.deleteById(id);
}

public List<Product> findActiveProducts() {
return productRepository.findByActiveTrue();
}

public void deactivate(Long id) {
Product product = findById(id);
product.setActive(false);
productRepository.save(product);
}

public boolean isInStock(Long id) {
Product product = findById(id);
return product.getStock() != null && product.getStock() > 0;
}

public void decreaseStock(Long id, int quantity) {
Product product = findById(id);
if (product.getStock() < quantity) {
throw new InsufficientStockException("Not enough stock");
}
product.setStock(product.getStock() - quantity);
productRepository.save(product);
}
}

الجزء 2: إنشاء فئة الاختبار​

الهيكل الأساسي​

@ExtendWith(MockitoExtension.class)
class ProductServiceTest {

@Mock
private ProductRepository productRepository;

@InjectMocks
private ProductService productService;

private Product sampleProduct;

@BeforeEach
void setUp() {
sampleProduct = new Product(1L, "Laptop", "High-end laptop",
new BigDecimal("999.99"), 10, true);
}
}

الجزء 3: الاختبارات المراد تنفيذها​

اختبار 1: findAll()​

@Test
@DisplayName("findAll يجب أن يعيد جميع المنتجات")
void findAll_ShouldReturnAllProducts() {
// Arrange
List<Product> products = List.of(
sampleProduct,
new Product(2L, "Phone", "Smartphone", new BigDecimal("599.99"), 20, true)
);
when(productRepository.findAll()).thenReturn(products);

// Act
List<Product> result = productService.findAll();

// Assert
assertThat(result).hasSize(2);
verify(productRepository).findAll();
}

اختبار 2: findById() - نجاح​

@Test
@DisplayName("findById يجب أن يعيد المنتج عندما يكون موجودًا")
void findById_WhenExists_ShouldReturnProduct() {
// TODO: تنفيذ
// Hint: when(...).thenReturn(Optional.of(...))
}

اختبار 3: findById() - غير موجود​

@Test
@DisplayName("findById يجب أن يرفع استثناء عندما لا يكون المنتج موجودًا")
void findById_WhenNotExists_ShouldThrowException() {
// TODO: تنفيذ
// Hint: when(...).thenReturn(Optional.empty())
// assertThrows(ProductNotFoundException.class, ...)
}

اختبار 4: create() - نجاح​

@Test
@DisplayName("create يجب أن يحفظ ويعيد المنتج")
void create_WithValidProduct_ShouldSaveAndReturn() {
// TODO: تنفيذ
// التحقق من أن المنتج تم تعيينه كنشط
// التحقق من أن save() تم استدعاؤها
}

اختبار 5: create() - التحقق من الاسم​

@Test
@DisplayName("create يجب أن يرفع استثناء إذا كان الاسم فارغًا")
void create_WithEmptyName_ShouldThrowException() {
// TODO: تنفيذ
// assertThrows(IllegalArgumentException.class, ...)
// verify(productRepository, never()).save(any())
}

اختبار 6: create() - التحقق من السعر​

@Test
@DisplayName("create يجب أن يرفع استثناء إذا كان السعر سالبًا")
void create_WithNegativePrice_ShouldThrowException() {
// TODO: تنفيذ
}

اختبار 7: update()​

@Test
@DisplayName("update يجب أن يعدل ويحفظ المنتج")
void update_ShouldModifyAndSave() {
// TODO: تنفيذ
// التحقق من أن الحقول تم تحديثها
}

اختبار 8: delete() - نجاح​

@Test
@DisplayName("delete يجب أن يحذف المنتج الموجود")
void delete_WhenExists_ShouldDelete() {
// TODO: تنفيذ
// when(existsById).thenReturn(true)
// verify deleteById
}

اختبار 9: delete() - غير موجود​

@Test
@DisplayName("delete يجب أن يرفع استثناء إذا لم يكن المنتج موجودًا")
void delete_WhenNotExists_ShouldThrowException() {
// TODO: تنفيذ
}

اختبار 10: deactivate()​

@Test
@DisplayName("deactivate يجب أن يضع active إلى false")
void deactivate_ShouldSetActiveFalse() {
// TODO: تنفيذ
// استخدام ArgumentCaptor للتحقق من القيمة المحفوظة
}

اختبار 11: isInStock() - صحيح​

@Test
@DisplayName("isInStock يجب أن يعيد true إذا كان المخزون > 0")
void isInStock_WhenStockPositive_ShouldReturnTrue() {
// TODO: تنفيذ
}

اختبار 12: isInStock() - خطأ​

@Test
@DisplayName("isInStock يجب أن يعيد false إذا كان المخزون = 0")
void isInStock_WhenStockZero_ShouldReturnFalse() {
// TODO: تنفيذ
}

اختبار 13: decreaseStock() - نجاح​

@Test
@DisplayName("decreaseStock يجب أن يقلل المخزون")
void decreaseStock_WithSufficientStock_ShouldDecrease() {
// TODO: تنفيذ
// التحقق من أن المخزون الجديد صحيح
}

اختبار 14: decreaseStock() - غير كافٍ​

@Test
@DisplayName("decreaseStock يجب أن يرفع استثناء إذا كان المخزون غير كافٍ")
void decreaseStock_WithInsufficientStock_ShouldThrowException() {
// TODO: تنفيذ
// assertThrows(InsufficientStockException.class, ...)
}

الجزء 4: الحل​

انقر لرؤية الحل الكامل
@ExtendWith(MockitoExtension.class)
class ProductServiceTest {

@Mock
private ProductRepository productRepository;

@InjectMocks
private ProductService productService;

@Captor
private ArgumentCaptor<Product> productCaptor;

private Product sampleProduct;

@BeforeEach
void setUp() {
sampleProduct = new Product(1L, "Laptop", "High-end laptop",
new BigDecimal("999.99"), 10, true);
}

@Test
void findAll_ShouldReturnAllProducts() {
when(productRepository.findAll()).thenReturn(List.of(sampleProduct));

List<Product> result = productService.findAll();

assertThat(result).hasSize(1);
verify(productRepository).findAll();
}

@Test
void findById_WhenExists_ShouldReturnProduct() {
when(productRepository.findById(1L)).thenReturn(Optional.of(sampleProduct));

Product result = productService.findById(1L);

assertThat(result.getName()).isEqualTo("Laptop");
}

@Test
void findById_WhenNotExists_ShouldThrowException() {
when(productRepository.findById(999L)).thenReturn(Optional.empty());

assertThrows(ProductNotFoundException.class,
() -> productService.findById(999L));
}

@Test
void create_WithValidProduct_ShouldSaveAndReturn() {
Product newProduct = new Product(null, "Phone", "Smartphone",
new BigDecimal("599.99"), 5, false);
when(productRepository.save(any())).thenAnswer(inv -> {
Product p = inv.getArgument(0);
p.setId(1L);
return p;
});

Product result = productService.create(newProduct);

assertThat(result.isActive()).isTrue();
verify(productRepository).save(productCaptor.capture());
assertThat(productCaptor.getValue().isActive()).isTrue();
}

@Test
void create_WithEmptyName_ShouldThrowException() {
Product invalid = new Product(null, "", "Desc",
new BigDecimal("100"), 10, true);

assertThrows(IllegalArgumentException.class,
() -> productService.create(invalid));
verify(productRepository, never()).save(any());
}

@Test
void create_WithNegativePrice_ShouldThrowException() {
Product invalid = new Product(null, "Test", "Desc",
new BigDecimal("-10"), 10, true);

assertThrows(IllegalArgumentException.class,
() -> productService.create(invalid));
}

@Test
void delete_WhenExists_ShouldDelete() {
when(productRepository.existsById(1L)).thenReturn(true);

productService.delete(1L);

verify(productRepository).deleteById(1L);
}

@Test
void delete_WhenNotExists_ShouldThrowException() {
when(productRepository.existsById(999L)).thenReturn(false);

assertThrows(ProductNotFoundException.class,
() -> productService.delete(999L));
}

@Test
void deactivate_ShouldSetActiveFalse() {
when(productRepository.findById(1L)).thenReturn(Optional.of(sampleProduct));

productService.deactivate(1L);

verify(productRepository).save(productCaptor.capture());
assertThat(productCaptor.getValue().isActive()).isFalse();
}

@Test
void decreaseStock_WithSufficientStock_ShouldDecrease() {
when(productRepository.findById(1L)).thenReturn(Optional.of(sampleProduct));

productService.decreaseStock(1L, 3);

verify(productRepository).save(productCaptor.capture());
assertThat(productCaptor.getValue().getStock()).isEqualTo(7);
}

@Test
void decreaseStock_WithInsufficientStock_ShouldThrowException() {
sampleProduct.setStock(2);
when(productRepository.findById(1L)).thenReturn(Optional.of(sampleProduct));

assertThrows(InsufficientStockException.class,
() -> productService.decreaseStock(1L, 10));
}
}

التحقق​

تنفيذ الاختبارات​

mvn test -Dtest=ProductServiceTest

تغطية الكود​

mvn test jacoco:report
# افتح target/site/jacoco/index.html

قائمة التحقق​

  • جميع الاختبارات تمر (14+ اختبار)
  • تغطية ProductService > 80%
  • تم اختبار الحالات الاسمية
  • تم اختبار حالات الخطأ
  • لا توجد استدعاءات غير ضرورية للمستودع

🎯 الخطوة التالية​

➡️ TP9.2: اختبارات التكامل


⬅️ العودة إلى الوحدة 9