Explorer les différentes couches de la pyramide de tests

La couche unitaire

Les tests unitaires valident le comportement des plus petits éléments testables d'une application, comme une fonction ou une classe. Ils forment la base de la pyramide, visant à garantir la qualité du code source de manière isolée. Le concept d'"unité" n'est pas fixe et dépend du paradigme de programmation ; il peut s'agir d'une seule fonction ou d'une classe entière.

L'un des avantages majeurs des tests unitaires est leur portabilité. On peut appliquer les mêmes principes de test à n'importe quelle classe de l'application, qu'il s'agisse d'un contrôleur, d'un dépôt de données ou d'un service métier. Un bon point de départ consiste à créer une classe de test dédiée pour chaque classe de production.

Il est recommandé de tester principalement l'interface publique d'une classe. Les méthodes privées sont généralement des détails d'implémentation. Si la nécessité de tester une méthode privée se fait sentir, cela indique souvent un problème de conception. La solution consiste alors à refactoriser le code, par exemple en extrayant la logique complexe dans une nouvelle classe, la rendant ainsi publique et facileemnt testable. Cette démarche améliore la conception en suivant le principe de responsabilité unique.

Une bonne pratique pour structurer un test unitaire est le schéma AAA (Arrange, Act, Assert) ou son équivalent BDD (Given, When, Then). Ce modèle rend les tests lisibles, cohérents et concentrés sur le comportement observable.

Voici un exemple de contrôleur simplifié :


@RestController
public class GreetingsController {

    private final ContactRepository contactRepository;

    public GreetingsController(ContactRepository contactRepository) {
        this.contactRepository = contactRepository;
    }

    @GetMapping("/greet/{familyName}")
    public String greet(@PathVariable String familyName) {
        return contactRepository.findByFamilyName(familyName)
            .map(contact -> "Bonjour " + contact.getFirstName() + " " + contact.getFamilyName() + " !")
            .orElse("Je ne connais pas de '" + familyName + "'.");
    }
}

Un test pour ce contrôleur pourrait ressembler à :


@ExtendWith(MockitoExtension.class)
class GreetingsControllerTest {

    @Mock
    private ContactRepository mockRepository;
    @InjectMocks
    private GreetingsController controller;

    @Test
    void shouldReturnFullGreetingForKnownContact() {
        Contact knownContact = new Contact("Alice", "Dupont");
        when(mockRepository.findByFamilyName("Dupont")).thenReturn(Optional.of(knownContact));

        String result = controller.greet("Dupont");

        assertEquals("Bonjour Alice Dupont !", result);
    }

    @Test
    void shouldHandleUnknownContactGracefully() {
        when(mockRepository.findByFamilyName(anyString())).thenReturn(Optional.empty());

        String result = controller.greet("Martin");

        assertEquals("Je ne connais pas de 'Martin'.", result);
    }
}

La couche d'intégration

Les applications interagissent avec des systèmes externes comme des bases de données, des fichiers ou des services distants. Les tests d'intégration vérifient que ces interactions fonctionnent correctement. Ils se situent au-dessus des tests unitaires dans la pyramide.

Dans une approche étroite, chaque test d'intégration cible un point d'intégration spécifique. Pour isoler ce point, on utilise des doublures de test (stubs, mocks) pour les autres dépendances. Cela permet des tests plus rapides et plus fiables. Par exemple, pour tester l'intégration avec une base de données, le test pourrait :

  1. Préparer un environnement de base de données local (comme H2 en mémoire).
  2. Exécuter le code qui lit ou écrit des données.
  3. Vérifier que les données sont correctement persistées ou lues.

Lorsqu'on travaille avec des frameworks comme Spring, les tests d'intégration exploitent souvent le contexte de l'application. Le repository suivant utilise les conventions de Spring Data :


public interface ContactRepository extends CrudRepository<contact long=""> {
    Optional<contact> findByFamilyName(String familyName);
}
</contact></contact>

Un test d'intégration pour ce repository, utilisant une base H2 en mémoire, validerait son fonctionnement réel :


@DataJpaTest
class ContactRepositoryIntegrationTest {

    @Autowired
    private ContactRepository repository;

    @Test
    void shouldPersistAndRetrieveContact() {
        Contact contactToSave = new Contact("Bob", "L'éponge");
        repository.save(contactToSave);

        Optional<contact> retrievedContact = repository.findByFamilyName("L'éponge");

        assertTrue(retrievedContact.isPresent());
        assertEquals("Bob", retrievedContact.get().getFirstName());
    }
}
</contact>

Pour les intégrations avec des services tiers, il est crucial de ne pas dépendre des systèmes de production lors des tests automatisés. On privilégie l'utilisation d'instances locales ou de services simulés pour éviter les impacts non désirés.

La couche de l'interface utilisateur

Cette couche concerne la validation de l'interface utilisateur, qu'il s'agisse d'une application web, d'une API REST ou d'une interface en ligne de commande. L'objectif est de s'assurer que l'interaction de l'utilisateur avec l'application produit les résultats attendus (navigation, soumission de formulaires, affichage des données).

Il est important de distinguer les tests d'UI des tests de bout en bout (end-to-end ou E2E). Un test E2E couvre un scénario utilisateur complet à travers plusieurs couches du système. Bien qu'un test E2E inclue souvent une composante UI, un test d'UI peut être effectué de manière plus ciblée, par exemple en testant le JavaScript côté client avec des mocks pour le backend.

Les tests de bout en bout, bien qu'ils fournissent une grande confiance dans le système global, présentent des défis : ils sont lents, fragiles (sensibles aux variations de timing ou de navigateur), et coûteux en maintenance. Dans une architecture microservices, la responsabilité de leur mise en œuvre et de leur maintenance est souvent floue.

Étant donné leur coût, il est judicieux de limiter les tests E2E aux parcours utilisateurs les plus critiques qui définissent la valeur fondamentale de l'application (par exemple, le processus de commande sur un site e-commerce). Il ne s'agit pas de reproduire tous les cas testés dans les couches inférieures, mais de valider que l'ensemble cohérent fonctionne.

En résumé, une stratégie de test efficace distribue l'effort à travers la pyramide. Des tests unitaires rapides et nombreux fournissent un filet de sécurité pour le code. Des tests d'intégrasion spécifiques valident les interactions avec les systèmes périphériques. Enfin, un nombre restreint mais stratégique de tests de bout en bout couvre les scénarios essentiels du parcours utilisateur.

Étiquettes: tests unitaires Tests d'Intégration Tests de l'Interface Utilisateur Pyramide de Tests JUnit

Publié le 16 juin à 21h31