
Tests unitaires de composants Spring (services, repositories)
Apprenez à réaliser des tests unitaires efficaces pour vos services et repositories Spring Boot en utilisant Mockito pour isoler vos composants et garantir leur logique.
Principes des tests unitaires pour les composants Spring
Les tests unitaires constituent la base de la pyramide des tests. Leur objectif est de vérifier le comportement d'une petite unité de code isolée, typiquement une méthode ou une classe. Dans le contexte de Spring, cela signifie tester un bean spécifique (comme un service ou un repository) indépendamment du reste de l'application et de ses dépendances externes (base de données, autres services, etc.).
L'isolation est la clé. Pour tester unitairement un service qui dépend d'un repository, par exemple, nous n'allons pas démarrer une base de données ni utiliser une véritable instance du repository. A la place, nous utiliserons un 'mock' (un objet factice) qui simulera le comportement attendu du repository. Cela permet de se concentrer exclusivement sur la logique métier implémentée dans le service.
Spring Boot simplifie grandement la mise en place de ces tests grâce au starter `spring-boot-starter-test`. Celui-ci inclut des bibliothèques essentielles comme JUnit 5 (le framework de test), Mockito (pour la création de mocks) et AssertJ (pour des assertions fluides et lisibles).
Tester les services (`@Service`) avec Mockito
Les services contiennent généralement la logique métier de l'application. Les tester unitairement implique de vérifier que cette logique fonctionne correctement en fonction des entrées fournies et des réponses simulées de ses dépendances.
La bibliothèque Mockito est parfaitement adaptée à cela. Elle permet de créer des mocks pour les dépendances injectées dans le service. On peut ensuite définir le comportement de ces mocks (par exemple, spécifier quelle valeur retourner lorsqu'une méthode du mock est appelée) et vérifier que le service interagit correctement avec eux.
Considérons un exemple simple. Supposons un `ProductService` qui dépend d'un `ProductRepository` pour récupérer des produits :
// Interface du Repository
public interface ProductRepository {
Optional findById(Long id);
Product save(Product product);
}
// Classe du Service
@Service
public class ProductService {
private final ProductRepository productRepository;
@Autowired
public ProductService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
public Optional getProductById(Long id) {
// Logique métier simple : récupérer un produit
return productRepository.findById(id);
}
public Product createProduct(Product product) {
// Logique métier : valider puis sauvegarder
if (product.getName() == null || product.getName().isEmpty()) {
throw new IllegalArgumentException("Product name cannot be empty");
}
return productRepository.save(product);
}
}
Pour tester `ProductService` unitairement, nous allons mocker `ProductRepository` :
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 java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class) // Active l'intégration Mockito avec JUnit 5
class ProductServiceTest {
@Mock // Crée un mock pour cette dépendance
private ProductRepository productRepository;
@InjectMocks // Crée une instance de ProductService et injecte les mocks déclarés avec @Mock
private ProductService productService;
@Test
void getProductById_shouldReturnProduct_whenProductExists() {
// Arrange (Préparation)
Product expectedProduct = new Product(1L, "Test Product", 99.99);
when(productRepository.findById(1L)).thenReturn(Optional.of(expectedProduct));
// Act (Action)
Optional actualProduct = productService.getProductById(1L);
// Assert (Vérification)
assertThat(actualProduct).isPresent();
assertThat(actualProduct.get()).isEqualTo(expectedProduct);
verify(productRepository, times(1)).findById(1L); // Vérifie que findById a été appelé une fois
}
@Test
void getProductById_shouldReturnEmpty_whenProductDoesNotExist() {
// Arrange
when(productRepository.findById(2L)).thenReturn(Optional.empty());
// Act
Optional actualProduct = productService.getProductById(2L);
// Assert
assertThat(actualProduct).isNotPresent();
verify(productRepository, times(1)).findById(2L);
}
@Test
void createProduct_shouldSaveProduct_whenProductIsValid() {
// Arrange
Product productToSave = new Product(null, "New Product", 50.0);
Product savedProduct = new Product(5L, "New Product", 50.0); // Simule le produit après sauvegarde (avec ID)
when(productRepository.save(any(Product.class))).thenReturn(savedProduct);
// Act
Product actualProduct = productService.createProduct(productToSave);
// Assert
assertThat(actualProduct).isEqualTo(savedProduct);
verify(productRepository, times(1)).save(productToSave); // Vérifie l'appel à save
}
@Test
void createProduct_shouldThrowException_whenProductNameIsEmpty() {
// Arrange
Product invalidProduct = new Product(null, "", 10.0);
// Act & Assert
assertThatThrownBy(() -> productService.createProduct(invalidProduct))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Product name cannot be empty");
verify(productRepository, never()).save(any(Product.class)); // Vérifie que save n'a jamais été appelé
}
}
Notez l'utilisation de `@ExtendWith(MockitoExtension.class)` pour intégrer Mockito, `@Mock` pour créer le mock, et `@InjectMocks` pour injecter le mock dans l'instance du service testé. `when(...).thenReturn(...)` définit le comportement du mock, et `verify(...)` vérifie les interactions avec le mock.
Tester les repositories (`@Repository`) : l'approche `@DataJpaTest`
Tester unitairement un repository Spring Data (qui est souvent une interface) est différent. Tenter de mocker l'infrastructure sous-jacente (comme `EntityManager` ou `JdbcTemplate`) est complexe et peu productif. L'objectif principal est de vérifier que les méthodes de requête (dérivées ou personnalisées avec `@Query`) fonctionnent comme attendu contre une base de données.
Spring Boot propose une solution élégante : les 'slice tests'. Pour les repositories JPA, l'annotation `@DataJpaTest` est particulièrement utile. Elle charge une configuration minimale nécessaire pour tester la couche de persistance JPA, incluant :
- La configuration de la source de données (par défaut, une base de données en mémoire comme H2).
- La configuration de l'EntityManagerFactory et du PlatformTransactionManager.
- La détection des entités (`@Entity`) et des repositories Spring Data (`@Repository`).
- L'activation du support transactionnel pour les tests (chaque test s'exécute dans une transaction qui est annulée à la fin).
Cela permet de tester l'interaction réelle entre votre repository et une base de données (en mémoire), mais en isolant cette couche du reste de l'application (pas de chargement des services, contrôleurs, etc.). C'est techniquement un test d'intégration de la couche persistance, mais il est couramment utilisé pour valider les repositories.
Exemple avec un `UserRepository` :
// Entité User
@Entity
public class User {
@Id @GeneratedValue
private Long id;
private String username;
private String email;
// Constructeurs, Getters, Setters, equals/hashCode...
}
// Interface Repository
public interface UserRepository extends JpaRepository {
Optional findByUsername(String username);
List findByEmailContainingIgnoreCase(String emailPart);
}
Et le test correspondant :
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;
import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest // Charge le contexte JPA, configure une base en mémoire (H2 par défaut)
class UserRepositoryTest {
@Autowired
private TestEntityManager entityManager; // Utilitaire pour manipuler les entités dans les tests
@Autowired
private UserRepository userRepository;
@Test
void findById_shouldReturnUser_whenUserExists() {
// Arrange
User user = new User("testuser", "test@example.com");
User savedUser = entityManager.persistFlushFind(user); // Sauvegarde et récupère l'entité managée
// Act
Optional foundUser = userRepository.findById(savedUser.getId());
// Assert
assertThat(foundUser).isPresent();
assertThat(foundUser.get().getUsername()).isEqualTo("testuser");
}
@Test
void findByUsername_shouldReturnUser_whenUsernameExists() {
// Arrange
User user = new User("findme", "findme@example.com");
entityManager.persistAndFlush(user);
// Act
Optional foundUser = userRepository.findByUsername("findme");
// Assert
assertThat(foundUser).isPresent();
assertThat(foundUser.get().getEmail()).isEqualTo("findme@example.com");
}
@Test
void findByUsername_shouldReturnEmpty_whenUsernameDoesNotExist() {
// Act
Optional foundUser = userRepository.findByUsername("nonexistent");
// Assert
assertThat(foundUser).isNotPresent();
}
@Test
void findByEmailContainingIgnoreCase_shouldReturnMatchingUsers() {
// Arrange
entityManager.persistAndFlush(new User("user1", "test1@EXAMPLE.com"));
entityManager.persistAndFlush(new User("user2", "another@example.org"));
entityManager.persistAndFlush(new User("user3", "TEST3@example.com"));
// Act
List foundUsers = userRepository.findByEmailContainingIgnoreCase("example.com");
// Assert
assertThat(foundUsers).hasSize(2)
.extracting(User::getUsername)
.containsExactlyInAnyOrder("user1", "user3");
}
}
`@DataJpaTest` rend ces tests relativement simples à écrire et très efficaces pour valider le comportement de vos requêtes Spring Data JPA. `TestEntityManager` est un utilitaire pratique fourni par Spring Boot Test pour faciliter la manipulation des entités (persister, trouver, vider le contexte) dans vos tests JPA.