Qualité, tests et déploiement
Tester, c’est piloter la qualité : on réduit les risques, on documente le comportement du système et on rend le code fiable à long terme. Les tests servent autant à prévenir les régressions qu’à guider les décisions d’architecture (couplage, frontières, dépendances). Ils forment un filet de sécurité qui autorise le refactoring et accélère les livraisons.
À travers ce module, nous allons nous attarder sur les tests unitaires en Java et voir comment fonctionne le TDD : le cycle rouge → vert → refactor, l’écriture d’assertions claires, et l’usage d’outils comme JUnit et des doubles de test pour isoler les composants.
Unit tests
Un test unitaire correspond à une seule idée (un comportement) et porte un nom explicite ; bien conçus, les tests unitaires guident l’architecture en forçant à réduire le couplage, injecter les dépendances et isoler les effets de bord.
Qu’est-ce qu’un test unitaire ?
- Vérifie une unité de code (fonction/méthode/classe) en isolation.
- Il doit être rapide, déterministe et facile à lire.
- Utile pour ocumenter l’intention : un test bien nommé explique le « contrat ».
- Nommez clairement l’intention :
calculateTotalWDiscount().
La structure d’un test unitaire se fait classiquement selon le schéma AAA (Arrange–Act–Assert), qui rend les tests lisibles, focalisés et faciles à maintenir.
AAA (Arrange–Act–Assert)
- Arrange : préparez les données.
- Act : exécutez la méthode testée.
- Assert : vérifiez le résultat attendu.
Il existe plusieurs frameworks de test (famille xUnit) pour structurer et automatiser les vérifications. En Java, on utilisera simplement JUnit 5 (et, au besoin, des doublures de test) pour écrire des tests unitaires clairs et rapides.
JUnit 5 : mini mémo
- Annotations :
@Test,@DisplayName,@Nested,@BeforeEach,@AfterEach - Assertions :
assertEquals,assertThrows,assertTrue/False,assertAll - Paramétrés :
@ParameterizedTest+@ValueSource/@CsvSource - Désactivation :
@Disabled("raison")
1) Méthode pure : cas nominal + bords
class MathUtils {
static int clamp(int x, int min, int max) {
if (min > max) throw new IllegalArgumentException("min>max");
return Math.max(min, Math.min(max, x));
}
}import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.*;
class MathUtilsTest {
@Test @DisplayName("Clamp borne inférieure")
void clamp_returnsMin_whenBelowMin() {
assertEquals(0, MathUtils.clamp(-5, 0, 10));
}
@Test @DisplayName("Clamp borne supérieure")
void clamp_returnsMax_whenAboveMax() {
assertEquals(10, MathUtils.clamp(99, 0, 10));
}
@Test @DisplayName("Clamp paramètre invalide")
void clamp_throws_whenMinGreaterThanMax() {
assertThrows(IllegalArgumentException.class, () -> MathUtils.clamp(1, 10, 0));
}
}2) test paramétré : couvrir plusieurs cas sans dupliquer
class StringUtils {
// Ignore la casse et la ponctuation
static boolean isPalindrome(String s) {
if (s == null) return false;
var t = s.replaceAll("\\W", "").toLowerCase();
return new StringBuilder(t).reverse().toString().equals(t);
}
}import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
class StringUtilsTest {
@ParameterizedTest(name = "\"{0}\" -> {1}")
@CsvSource({
"kayak, true",
"'A man, a plan, a canal: Panama', true",
"hello, false",
"'', true",
"null, false"
})
void isPalindrome_cases(String input, boolean expected) {
// Petit hack : convertir la chaîne "null" en null réel pour le cas de test
String value = "null".equals(input) ? null : input;
assertEquals(expected, StringUtils.isPalindrome(value));
}
}3) mock d’une dépendance (ex. service + repo)
record Account(String id, int balance) {}
interface AccountRepo {
Account find(String id);
void save(Account a);
}
class TransferService {
private final AccountRepo repo;
TransferService(AccountRepo repo) { this.repo = repo; }
void transfer(String from, String to, int amount) {
if (amount <= 0) throw new IllegalArgumentException("amount<=0");
var a = repo.find(from);
var b = repo.find(to);
if (a == null || b == null) throw new IllegalStateException("account missing");
if (a.balance() < amount) throw new IllegalStateException("insufficient");
repo.save(new Account(a.id(), a.balance() - amount));
repo.save(new Account(b.id(), b.balance() + amount));
}
}import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import org.junit.jupiter.api.Test;
class TransferServiceTest {
@Test
void transfer_moves_funds_and_persists() {
var repo = mock(AccountRepo.class);
when(repo.find("A")).thenReturn(new Account("A", 100));
when(repo.find("B")).thenReturn(new Account("B", 50));
var svc = new TransferService(repo);
svc.transfer("A", "B", 40);
verify(repo).save(new Account("A", 60));
verify(repo).save(new Account("B", 90));
verifyNoMoreInteractions(repo);
}
@Test
void transfer_fails_on_insufficient_funds() {
var repo = mock(AccountRepo.class);
when(repo.find("A")).thenReturn(new Account("A", 10));
when(repo.find("B")).thenReturn(new Account("B", 0));
var svc = new TransferService(repo);
assertThrows(IllegalStateException.class, () -> svc.transfer("A", "B", 50));
verify(repo, never()).save(any());
}
}TDD : Test-Driven Development
Le TDD, ou développement piloté par les tests (Test-Driven Development), est une approche de développement logiciel où les développeurs écrivent des tests automatisés avant d’écrire le code fonctionnel. Le processus itératif se déroule en trois étapes principales : écrire d’abord un test qui échoue, puis le code minimal pour le faire passer, et enfin refactorer sans altérer le comportement.
Au-delà de “vérifier que ça marche”, TDD structure l’architecture : modules cohésifs, dépendances explicites (inversion de dépendances), coutures (seams) pour isoler les E/S et un code naturellement testable, donc maintenable.
Le cycle « rouge → vert → refactor »
- rouge (red) : écrire un test précis qui échoue (comportement souhaité, pas l’implémentation).
- vert (green) : implémenter le strict minimum pour faire passer ce test.
- refactor : améliorer le design (noms, duplication, extractions) sans casser les tests.