Tests¶
todo-api a 20 tests répartis en quatre classes, du test unitaire qui tourne en quelques millisecondes au test qui démarre toute l'appli. Ce chapitre les explique un par un.
Dans ce chapitre
- La pyramide : test unitaire pur, tranches
@WebMvcTestet@DataJpaTest,@SpringBootTest - JUnit 6 et AssertJ, comparés à
cargo testet pytest - Mockito : faux objets,
@Mocket@MockitoBean - Les paquets des annotations de test dans Spring Boot 4 (et la fin de
@MockBean) - Lancer un seul test, lire un rapport d'échec
- Testcontainers pour tester contre un vrai PostgreSQL
La pyramide des tests¶
Plus un test démarre de choses, plus il est lent et plus il couvre. Les quatre classes du projet, avec leurs durées mesurées par ./mvnw verify :
| Classe | Niveau | Ce qui démarre | Durée |
|---|---|---|---|
TodoServiceTest |
unitaire pur | rien : new + Mockito |
0,2 s |
TodoControllerTest |
tranche web | contrôleurs, Jackson, validation | 3,4 s |
TodoRepositoryTest |
tranche JPA | Hibernate, Flyway, H2 | 3,5 s |
TodoApiIntegrationTest |
intégration | toute l'appli + Tomcat | 2,8 s |
L'essentiel du temps des trois derniers, c'est le démarrage d'un contexte Spring (le premier lancé paie aussi le chargement des classes par la JVM, ce qui resserre les écarts). D'où la règle : tester la logique métier en unitaire pur, et réserver les contextes Spring à ce qui en dépend vraiment (routes, JSON, SQL).
Les tests vivent dans src/test/java, dans le même paquet que le code testé : ils accèdent aux membres sans modificateur (visibilité paquet), un peu comme le mod tests d'un fichier Rust accède aux fonctions privées. Les classes de test n'ont donc pas besoin d'être public. Maven les compile à part et ne les met jamais dans le jar.
| Rust | Python (pytest) | Java (JUnit 6) | |
|---|---|---|---|
| Déclarer | #[test] fn ... |
def test_... |
@Test void ...() |
| Vérifier | assert_eq!(a, b) |
assert a == b |
assertThat(a).isEqualTo(b) |
| Erreur attendue | #[should_panic], unwrap_err() |
pytest.raises(...) |
assertThatThrownBy(...) |
| Paramétrer | crate rstest |
@pytest.mark.parametrize |
@ParameterizedTest |
| Un seul test | cargo test nom |
pytest -k nom |
./mvnw test -Dtest=Classe#methode |
JUnit 6 et AssertJ¶
JUnit 6 (le moteur « Jupiter ») garde l'API de JUnit 5 : les tutoriels JUnit 5 restent valables. Les annotations courantes : @Test, @BeforeEach/@AfterEach (avant/après chaque test), @BeforeAll (une fois, sur une méthode static), @DisplayName (un nom lisible) et @Nested (une classe interne pour regrouper des tests) :
@DisplayName("TodoService")
class TodoServiceNestedTest { // + @ExtendWith, @Mock et @BeforeEach comme TodoServiceTest ci-dessous
@Nested
@DisplayName("quand la tâche n'existe pas")
class UnknownTodo {
@BeforeEach // s'exécute après celui de la classe englobante
void noTodo() { when(repository.findById(42L)).thenReturn(Optional.empty()); }
@Test
@DisplayName("get lève TodoNotFoundException")
void get() { assertThatThrownBy(() -> service.get(42L)).isInstanceOf(TodoNotFoundException.class); }
}
}
Maven affiche alors Running quand la tâche n'existe pas puis Tests run: 1, .... Un test paramétré s'exécute une fois par valeur :
@ParameterizedTest
@ValueSource(strings = {"", " "})
void createRejectsBlankTitles(String title) { ... } // 2 exécutions : [1] et [2]
Les valeurs peuvent aussi venir de @CsvSource({"a, 1", "b, 2"}) ou d'une méthode (@MethodSource, exercice 1).
Pour les vérifications, le projet utilise AssertJ plutôt que les assertEquals de JUnit : on part de assertThat(valeur) et l'auto-complétion propose les vérifications adaptées au type.
assertThat(open)
.extracting(Todo::getTitle) // la liste des titres
.containsExactlyInAnyOrder("Première", "Deuxième");
assertThat(open)
.isSortedAccordingTo(Comparator.comparing(Todo::getCreatedAt).reversed());
Tous les imports d'assertion viennent de import static org.assertj.core.api.Assertions.* (assertThat, assertThatThrownBy, tuple...).
Le test unitaire : TodoServiceTest¶
Pas de Spring : le service est créé avec new, et son repository est remplacé par un mock Mockito, un faux objet dont tu programmes les réponses.
@ExtendWith(MockitoExtension.class) // (1)!
class TodoServiceTest {
@Mock
private TodoRepository repository; // (2)!
private TodoService service;
@BeforeEach
void setUp() {
service = new TodoService(repository, new TodoProperties(2)); // (3)!
}
@Test
void createSavesNewTodo() {
when(repository.countByDoneFalse()).thenReturn(0L); // (4)!
when(repository.save(any(Todo.class))).thenAnswer(invocation -> invocation.getArgument(0));
TodoResponse response = service.create(new CreateTodoRequest("Écrire les tests"));
assertThat(response.title()).isEqualTo("Écrire les tests");
verify(repository).save(any(Todo.class)); // (5)!
}
}
- Une extension JUnit : elle crée les mocks des champs
@Mockavant chaque test et vérifie leur usage après. - Mockito fabrique à l'exécution un faux
TodoRepository; chaque méthode renvoie une valeur vide (0,null, liste vide,Optional.empty()) tant qu'on ne l'a pas programmée. - Possible parce que
TodoServicereçoit ses dépendances par son constructeur (voir Injection de dépendances) : pas besoin de Spring pour l'assembler. when(...).thenReturn(...)programme une réponse ;thenAnswerla calcule à partir des arguments (ici,saverenvoie l'entité reçue).verifyvérifie qu'un appel a eu lieu. Le test suivant,createRejectsWhenMaxOpenReached, fait l'inverse avecverify(repository, never()).save(any()).
Piège : les stubs inutiles font échouer le test
MockitoExtension est stricte : un when(...) jamais utilisé par le test est une erreur, pas un détail.
org.mockito.exceptions.misusing.UnnecessaryStubbingException:
Unnecessary stubbings detected.
Clean & maintainable test code requires zero unnecessary code.
Following stubbings are unnecessary (click to navigate to relevant line of code):
1. -> at xyz.dunkan.todo.todo.TodoServiceTest.getThrowsWhenTodoDoesNotExist(TodoServiceTest.java:62)
Supprime le stub, ou marque-le lenient().when(...) s'il ne sert qu'à certains cas.
Sous le capot : l'agent Mockito
Mockito modifie des classes déjà chargées en s'attachant à la JVM comme agent Java. Fait tout seul, cela déclenche un avertissement (Mockito is currently self-attaching to enable the inline-mock-maker. This will no longer work in future releases of the JDK.) ; le pom.xml déclare donc l'agent à surefire, le plugin qui lance les tests (<argLine>-javaagent:...).
La tranche web : TodoControllerTest¶
Une tranche (test slice) démarre un contexte Spring réduit à une couche. @WebMvcTest charge les contrôleurs, les @RestControllerAdvice, Jackson, la validation, mais ni les @Service, ni les repositories, ni la base. Les requêtes HTTP sont simulées : aucun port n'est ouvert.
@WebMvcTest(TodoController.class) // (1)!
class TodoControllerTest {
@Autowired
private MockMvcTester mvc; // (2)!
@MockitoBean
private TodoService service; // (3)!
@Test
void createReturns201WithLocation() {
when(service.create(new CreateTodoRequest("Lire la doc")))
.thenReturn(new TodoResponse(7L, "Lire la doc", false, CREATED_AT));
assertThat(mvc.post().uri("/api/todos")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"title": "Lire la doc"}
"""))
.hasStatus(HttpStatus.CREATED)
.hasHeader("Location", "http://localhost/api/todos/7") // (4)!
.bodyJson().extractingPath("$.id").isEqualTo(7);
}
}
- Seulement ce contrôleur (et l'
ApiExceptionHandler, qui s'applique à tous). - Le client HTTP simulé, version AssertJ de
MockMvc: on écritassertThat(requête)puis les vérifications sur la réponse. - Un mock Mockito enregistré comme bean à la place du vrai
TodoService: c'est lui que reçoit le constructeur du contrôleur. - Pas de port dans l'URL : aucune vraie connexion n'a eu lieu.
On teste ici ce que fait la couche web : routes, JSON, codes HTTP, validation et erreurs Problem Details (voir API REST), avec un service aux réponses programmées. Le test createRejectsBlankTitle envoie {"title": " "}, vérifie le 400 et $.errors.title, puis verifyNoInteractions(service) : la validation a refusé la requête avant même d'appeler le contrôleur. Si tu oublies le @MockitoBean, le contexte ne démarre pas : No qualifying bean of type 'xyz.dunkan.todo.todo.TodoService' available, car la tranche web ne crée pas les services.
La tranche JPA : TodoRepositoryTest¶
@DataJpaTest démarre Hibernate, Flyway et les repositories, sans la couche web. Il remplace la base configurée par une H2 en mémoire au nom unique (Replacing 'dataSource' DataSource bean with embedded version), où Flyway applique V1. Surtout, chaque test tourne dans une transaction annulée à la fin : les tests ne se polluent pas entre eux, sans nettoyage à écrire.
@DataJpaTest
class TodoRepositoryTest {
@Autowired
private TodoRepository repository;
@Test
void countByDoneFalseCountsOnlyOpenTodos() {
Todo finished = new Todo("Terminée");
finished.setDone(true);
repository.saveAll(List.of(new Todo("Ouverte 1"), new Todo("Ouverte 2"), finished));
assertThat(repository.countByDoneFalse()).isEqualTo(2);
}
}
On vérifie ici le SQL généré par les requêtes dérivées (voir Base de données) contre une vraie base. Pour préparer des données sans passer par le repository testé, la tranche fournit aussi un TestEntityManager.
Le test de bout en bout : TodoApiIntegrationTest¶
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, // (1)!
properties = "todo.max-open=3") // (2)!
@AutoConfigureRestTestClient
class TodoApiIntegrationTest {
@Autowired
private RestTestClient client; // (3)!
@Autowired
private TodoRepository repository;
@BeforeEach
void cleanDatabase() {
repository.deleteAll(); // (4)!
}
@Test
void rejectsInvalidTodo() {
client.post().uri("/api/todos")
.contentType(MediaType.APPLICATION_JSON)
.body(new CreateTodoRequest("")) // le record est converti en JSON
.exchange()
.expectStatus().isBadRequest()
.expectHeader().contentType(MediaType.APPLICATION_PROBLEM_JSON)
.expectBody().jsonPath("$.errors.title").exists();
}
}
- Toute l'application démarre, avec un vrai Tomcat sur un port libre tiré au hasard.
- Une propriété surchargée pour ce test seulement (voir Configuration) : 3 tâches suffisent pour provoquer le 409 dans
rejectsNewTodoWhenLimitReached. - Un vrai client HTTP, déjà pointé sur
http://localhost:<port>.RestTestClientest nouveau dans Spring Framework 7. - Les requêtes sont traitées par les threads de Tomcat, dans leurs propres transactions validées : pas de rollback automatique ici, donc on vide la table avant chaque test.
C'est le test le plus réaliste (vrai HTTP, vraie sérialisation JSON, vraie base, Flyway, Actuator) et le plus coûteux : il démarre tout. Spring garde en cache les contextes déjà démarrés : les classes de test qui partagent la même configuration ne le redémarrent pas. Chaque combinaison différente (@MockitoBean, properties...) en crée un nouveau.
Les imports de Spring Boot 4¶
Boot 4 a découpé ses outils de test en modules (un starter de test par module, voir Premier projet) : les paquets ont changé. Un tutoriel pour Boot 3 ne compile plus tel quel.
| Annotation ou classe | Paquet dans Boot 4 | Apportée par |
|---|---|---|
@WebMvcTest |
org.springframework.boot.webmvc.test.autoconfigure |
spring-boot-starter-webmvc-test |
@DataJpaTest |
org.springframework.boot.data.jpa.test.autoconfigure |
spring-boot-starter-data-jpa-test |
@AutoConfigureRestTestClient |
org.springframework.boot.resttestclient.autoconfigure |
spring-boot-starter-webmvc-test |
@SpringBootTest |
org.springframework.boot.test.context (inchangé) |
spring-boot-starter-test |
@MockitoBean |
org.springframework.test.context.bean.override.mockito |
Spring Framework (spring-test) |
MockMvcTester |
org.springframework.test.web.servlet.assertj |
Spring Framework (spring-test) |
RestTestClient |
org.springframework.test.web.servlet.client |
Spring Framework (spring-test) |
Piège : @MockBean n'existe plus
@MockBean (Boot 3) a été supprimé au profit de @MockitoBean, qui vient de Spring Framework lui-même. Avec les imports de Boot 3, la compilation échoue :
TodoControllerTest.java:[5,63] package org.springframework.boot.test.autoconfigure.web.servlet does not exist
TodoControllerTest.java:[8,50] package org.springframework.boot.test.mock.mockito does not exist
Même remède pour @SpyBean, devenu @MockitoSpyBean.
Lancer les tests et lire un échec¶
./mvnw test # tous les tests
./mvnw test -Dtest=TodoServiceTest # une classe (6 tests, ~8 s avec Maven)
./mvnw test -Dtest='TodoServiceTest#createRejectsWhenMaxOpenReached' # une méthode
./mvnw test -Dtest='Todo*Test' # un motif
./mvnw verify # tests + construction du jar
Les guillemets protègent # et * du shell. Pour voir un échec, changeons le message attendu dans getThrowsWhenTodoDoesNotExist en "Todo 42 introuvable" :
[ERROR] Tests run: 6, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 2.466 s <<< FAILURE! -- in xyz.dunkan.todo.todo.TodoServiceTest
[ERROR] xyz.dunkan.todo.todo.TodoServiceTest.getThrowsWhenTodoDoesNotExist -- Time elapsed: 2.200 s <<< FAILURE!
org.opentest4j.AssertionFailedError:
Expecting message to be:
"Todo 42 introuvable"
but was:
"Todo 42 not found"
...
[ERROR] Failures:
[ERROR] TodoServiceTest.getThrowsWhenTodoDoesNotExist:65
[ERROR] BUILD FAILURE
Lis dans l'ordre : la classe et la méthode en échec, le message d'AssertJ (attendu / obtenu), puis la pile d'appels (coupée ici), et :65, la ligne de l'assertion. Une Failure est une assertion fausse ; une Error est une exception inattendue (comme l'UnnecessaryStubbingException plus haut). Le détail de chaque classe est aussi écrit dans target/surefire-reports/ : un fichier xyz.dunkan.todo.todo.TodoServiceTest.txt lisible, et un TEST-*.xml destiné aux outils d'intégration continue.
Contre un vrai PostgreSQL : Testcontainers¶
Les tests tournent sur H2, la prod sur PostgreSQL. L'étape suivante est Testcontainers : la bibliothèque démarre un vrai PostgreSQL dans un conteneur Docker le temps des tests. Quand on coche Testcontainers, start.spring.io ajoute trois dépendances de test : org.springframework.boot:spring-boot-testcontainers, org.testcontainers:testcontainers-junit-jupiter et org.testcontainers:testcontainers-postgresql (versions fixées par le parent).
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) // garder PostgreSQL, pas H2
@Testcontainers
class TodoRepositoryPostgresTest {
@Container
@ServiceConnection // Spring Boot lit l'URL, l'utilisateur et le mot de passe du conteneur
static PostgreSQLContainer postgres = new PostgreSQLContainer("postgres:18");
@Autowired TodoRepository repository;
@Test
void countByDoneFalseOnRealPostgres() {
repository.save(new Todo("Sur un vrai PostgreSQL"));
assertThat(repository.countByDoneFalse()).isEqualTo(1);
}
}
Ce test compile avec Testcontainers 2.0 (org.testcontainers.postgresql.PostgreSQLContainer, AutoConfigureTestDatabase dans org.springframework.boot.jdbc.test.autoconfigure), mais il lui faut Docker : sans lui, il échoue avec Could not find a valid Docker environment.
Exercices¶
1. Un test paramétré. Dans TodoControllerTest, remplace createRejectsBlankTitle par un test paramétré qui vérifie le message d'erreur pour trois titres : vide, que des espaces, et 201 caractères.
Solution
@ParameterizedTest
@MethodSource("invalidTitles")
void createRejectsInvalidTitle(String title, String expectedMessage) {
assertThat(mvc.post().uri("/api/todos")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"title\": \"" + title + "\"}"))
.hasStatus(HttpStatus.BAD_REQUEST)
.bodyJson().extractingPath("$.errors.title").isEqualTo(expectedMessage);
verifyNoInteractions(service);
}
static Stream<Arguments> invalidTitles() { // static, même nom que dans @MethodSource
return Stream.of(
Arguments.of("", "must not be blank"),
Arguments.of(" ", "must not be blank"),
Arguments.of("x".repeat(201), "size must be between 0 and 200"));
}
Imports : org.junit.jupiter.params.ParameterizedTest, org.junit.jupiter.params.provider.Arguments et MethodSource, java.util.stream.Stream. Le rapport compte trois exécutions, createRejectsInvalidTitle(String, String)[1] à [3]. Les messages sont en anglais car MockMvc envoie ses requêtes avec la langue anglaise par défaut.
2. Tester une route existante. L'exercice 1 du chapitre API REST a ajouté GET /api/todos/stats ({"total": 3, "open": 2, "done": 1}). Écris son test @WebMvcTest (service mocké) et un test unitaire du calcul dans le service. Puis casse volontairement le calcul (total + open) et lis l'échec.
Solution
@Test
void statsReturnsCounts() {
when(service.stats()).thenReturn(new TodoStats(3, 2, 1));
assertThat(mvc.get().uri("/api/todos/stats"))
.hasStatusOk()
.bodyJson().isEqualTo("""
{"total": 3, "open": 2, "done": 1}
""");
}
@Test
void statsComputesDoneFromTotalAndOpen() { // dans TodoServiceTest
when(repository.count()).thenReturn(5L);
when(repository.countByDoneFalse()).thenReturn(2L);
assertThat(service.stats()).isEqualTo(new TodoStats(5, 2, 3));
}
Avec total + open dans le service, seul le test unitaire échoue : le test web utilise un service mocké et ne voit pas le calcul.
[ERROR] xyz.dunkan.todo.todo.TodoServiceTest.statsComputesDoneFromTotalAndOpen -- Time elapsed: 0.023 s <<< FAILURE!
org.opentest4j.AssertionFailedError:
expected: TodoStats[total=5, open=2, done=3]
but was: TodoStats[total=5, open=2, done=7]
isEqualTo compare les records avec leur equals généré, et leur toString rend le message lisible. Chaque test vérifie sa couche : le test web, la route et le JSON ; le test unitaire, le calcul.
À retenir¶
- Teste la logique métier en unitaire pur (
new+ Mockito, quelques millisecondes) ; démarre Spring seulement pour ce qui en dépend. @WebMvcTestteste la couche web avec un service@MockitoBean;@DataJpaTestteste les requêtes sur une H2, avec rollback après chaque test ;@SpringBootTest(RANDOM_PORT)teste tout via un vrai serveur.- JUnit 6 a l'API de JUnit 5 ; AssertJ (
assertThat(...)) donne des vérifications lisibles et des messages d'échec clairs. - Mockito est strict : un stub inutile fait échouer le test.
- Boot 4 a déplacé les annotations de test (
org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest...) et supprimé@MockBean, remplacé par@MockitoBean. ./mvnw test -Dtest='Classe#methode'lance un seul test ; le détail des échecs est danstarget/surefire-reports.- Testcontainers permet de tester contre un vrai PostgreSQL, à condition d'avoir Docker.