Tester les détails d'implémentation.
Un test vérifie le fonctionnement interne du code — champs privés, appels de méthodes internes, structure du DOM ou classes CSS — au lieu du comportement observable dont dépend un véritable consommateur.
##Signes et symptômes
Vous reconnaissez cette odeur lorsqu'un test va au-delà du contrat public et fige la mécanique qui se trouve derrière. Indices courants :
- Des assertions sur l'état interne/privé ou les champs, ou l'invocation directe de méthodes privées (souvent via des casts, de la réflexion ou
// @ts-expect-error). - Espionner ou vérifier qu'une fonction utilitaire interne a été appelée (
expect(internalCalc).toHaveBeenCalled()) au lieu de contrôler le résultat. - Des tests d'interface qui interrogent par classe CSS, balise, attributs
data-*ou position dans le DOM plutôt que par rôle/libellé/texte — p. ex.container.querySelector('.btn-primary > span:nth-child(2)'),wrapper.state(),wrapper.find('SomeChildComponent').props(). - Des snapshots d'objets internes sérialisés — instances de composants, entrailles du store, structures de données privées — plutôt que de ce que voit l'utilisateur. (Un snapshot géant de toute la sortie rendue est un autre problème : le test sur-spécifié.)
- Des tests qui cassent à chaque refactorisation alors que la fonctionnalité marche toujours (faux négatifs) et, à l'inverse, continuent de passer après un bug de logique parce qu'ils ne revérifient que le câblage (faux positifs).
// ODEUR : couple le test aux rouages internes du composant et à la structure du DOM
test('counter increments', () => {
const wrapper = mount(<Counter />);
wrapper.instance().handleClick(); // appelle directement une méthode privée
expect(wrapper.state('count')).toBe(1); // vérifie l'état interne
expect(wrapper.find('.count-display').text()).toBe('1'); // sélecteur CSS fragile
});
La même forme apparaît côté serveur : expect(service._cache.size).toBe(1) vérifie un cache privé au lieu de ce que reçoit l'appelant.
Smells voisins : vérifier plus que ce que le comportement exige — nombre et ordre exacts des appels, matchers trop précis, snapshots géants — relève du test sur-spécifié ; un test qui remplace chaque collaborateur par un mock relève de l'Excessive Mocking. Ce smell-ci concerne l'endroit où regarde l'assertion : les entrailles, comme la structure du DOM ou l'état privé, au lieu de ce qu'un utilisateur ou un appelant peut observer.
##Causes du problème
Pourquoi cela se produit
- L'état interne est facile à atteindre — un champ public, une fonction utilitaire exportée ou
container.querySelectorest juste là, tandis que solliciter le comportement réel demande davantage de mise en place. - La course aux métriques de couverture : tester chaque méthode privée au cas par cas donne une impression d'exhaustivité.
- L'usage intensif des mocks pousse à vérifier "ce collaborateur a-t-il été appelé" plutôt que "la bonne chose s'est-elle produite."
- Un outillage qui l'encourage : rendu superficiel (shallow rendering) / API
instance()/state(), ou récupération de nœuds par nom de classe.
Pourquoi c'est nuisible
- Maintenabilité / fragilité. C'est le Fragile Test de Meszaros provoqué par un Overspecified Software : le test fige un comportement que le consommateur n'a jamais exigé, si bien que des refactorisations inoffensives (renommer une méthode, restructurer le balisage, modifier un champ privé) cassent des tests verts sans raison réelle. Les tests deviennent une taxe sur la refactorisation plutôt qu'un filet de sécurité.
- Fausse confiance (le danger central, selon Kent C. Dodds). Les tests de détails d'implémentation échouent dans les deux mauvaises directions : faux négatifs (le test passe au rouge alors que la fonctionnalité marche toujours) et faux positifs (le test reste vert alors que la fonctionnalité est cassée — vous avez vérifié le câblage, pas le résultat). Dans les deux cas, la suite cesse de vous dire la vérité.
- Lisibilité. Le test documente comment le code est construit, et non ce qu'il garantit. Un lecteur ne peut pas déterminer quel comportement importe réellement, et le test ne sert plus d'exemple d'utilisation ni de spécification.
- Couplage. Il fige les décisions de conception actuelles, décourageant précisément les refactorisations que les tests sont censés rendre sûres.
##Traitement
Testez à travers le contrat public — la même surface que touche un véritable appelant ou utilisateur — et vérifiez la sortie observable : valeurs de retour, erreurs levées, événements émis, état persisté ou interface rendue/visible.
- Identifiez le consommateur. Pour un module, c'est son API exportée ; pour un composant d'interface, c'est l'utilisateur (clics, saisie) et ce qu'il peut voir.
- Fournissez les entrées comme le ferait un consommateur, et non en appelant des méthodes privées. Déclenchez un vrai clic au lieu d'invoquer le gestionnaire ; appelez la méthode publique au lieu de la fonction utilitaire.
- Vérifiez les résultats, pas les rouages internes. Remplacez les contrôles sur
state()/champ privé/toHaveBeenCalledpar des contrôles sur ce qui ressort. - Interrogez l'interface par l'accessibilité, pas par la structure — rôle, libellé, texte — plutôt que par des classes CSS, des balises ou
nth-child. - Cessez de tester directement les méthodes privées. Couvrez-les via la méthode publique qui les utilise ; si une unité privée est assez complexe pour mériter ses propres tests, c'est le signe qu'il faut l'extraire dans son propre module doté de sa propre API publique.
- Réservez les assertions sur les mocks/espions aux véritables frontières (réseau, temps, passerelle de paiement) où l'appel lui-même est le comportement observable — pas aux collaborateurs internes. Voir aussi Excessive Mocking.
// AVANT : teste les détails d'implémentation
const wrapper = mount(<Counter />);
wrapper.instance().handleClick();
expect(wrapper.state('count')).toBe(1);
expect(wrapper.find('.count-display').text()).toBe('1');
// APRÈS : teste le comportement observable via le contrat public, orienté utilisateur
render(<Counter />);
await userEvent.click(screen.getByRole('button', { name: /increment/i }));
expect(screen.getByText('1')).toBeInTheDocument();
Règle empirique : si une refactorisation préservant le comportement casse le test, c'est que le test vérifiait un détail d'implémentation.
##Détecté par
- eslint-testing-library testing-library/no-node-access — Interdit l’accès direct aux nœuds du DOM (querySelector, parentElement, children…) au lieu des requêtes orientées utilisateur
- eslint-testing-library testing-library/no-container — Interdit d’interroger le DOM via le container renvoyé par render