data-testid n'est pas un code smell - réponse nuancée à Testing Library

Kent C. Dodds et la communauté Testing Library affirment que data-testid est un code smell d'accessibilité. Ils ont raison. Ils ont aussi tort sur un point. Voici pourquoi les deux camps ont leurs arguments, et comment les combiner en pratique.

data-testid n'est pas un code smell : réponse nuancée à Testing Library

Si vous avez passé un peu de temps dans la communauté testing ces dernières années, vous avez vu l'argument : data-testid est un code smell. Ne l'utilisez pas. Utilisez getByRole, getByLabel, des requêtes sémantiques. N'ajoutez un data-testid qu'en dernier recours, et sentez-vous légèrement coupable.

La version la plus articulée de cet argument vient de Kent C. Dodds et de l'équipe Testing Library. Une version plus tranchée vient de TkDodo, qui qualifie spécifiquement les test IDs de smell d'accessibilité.

Je veux défendre deux thèses dans cet article :

  1. Ils ont raison. Leur argument central est solide et la plupart des équipes devraient l'écouter.
  2. Il leur manque un point. Il existe une catégorie d'éléments où les requêtes par rôle ne fonctionnent réellement pas, et la réponse n'est pas "corriger l'UI" mais "avoir un point d'accroche stable pour les tests."

Le résultat est un compromis pragmatique que je pense que la plupart des équipes devraient adopter.

L'argument Testing Library, version forte

Je vais formuler leur thèse aussi solidement que possible, parce que je veux argumenter contre la version forte.

L'argument va comme ceci. Quand vous écrivez un test, vous encodez ce qui compte pour vos utilisateurs. Les utilisateurs ne se soucient pas de data-testid. Ils se soucient de cliquer sur un bouton étiqueté "Enregistrer", de remplir un champ étiqueté "Email", de lire un titre qui dit "Bienvenue".

Si votre test interroge par data-testid, vous testez l'implémentation, pas l'expérience. Pire : si votre test peut trouver un élément via data-testid mais qu'un lecteur d'écran ne peut pas trouver ce même élément via son nom accessible, vous avez livré un bug d'accessibilité et le test ne l'a pas attrapé. Le data-testid permet au test de passer pendant que les vrais utilisateurs (ceux qui utilisent une techno d'assistance, ceux qui lisent l'UI sans contexte préalable) sont bloqués.

Donc : un data-testid est souvent le symptôme d'une UI inaccessible. Ajouter le data-testid permet de livrer l'inaccessibilité sans qu'elle soit challengée. Donc : c'est un smell. Le bon fix n'est pas d'ajouter le data-testid, c'est de rendre l'élément accessible (un <label> correct, un aria-label, un role, etc.), ce qui le rend à la fois interrogeable par Testing Library et utilisable par tout le monde.

C'est un argument solide. Je suis sincèrement d'accord avec la majeure partie.

Là où l'argument se brise

L'argument suppose que pour chaque élément d'UI avec lequel votre équipe QA doit interagir, il existe un nom accessible sensé que l'équipe dev peut lui donner.

C'est vrai pour les boutons avec du texte visible. C'est vrai pour les inputs avec des labels. C'est vrai pour les titres, les liens, et tout ce qui a déjà un markup sémantique.

Ce n'est pas vrai pour une part non négligeable des UIs réelles. Exemples :

Plusieurs boutons identiques

Votre dashboard liste des lignes utilisateur, chacune avec un bouton "Supprimer". Ils sont tous <button>Supprimer</button>. Ils sont tous accessibles. Un utilisateur de lecteur d'écran navigue par contexte de ligne : "ligne contenant Marie Dupont, bouton Supprimer". Il a tout ce qu'il faut.

Mais votre test ? getByRole('button', { name: 'Supprimer' }) retourne une liste de 50 éléments. Vous devez désambiguer. La sortie de secours recommandée par Testing Library est within(row).getByRole('button', { name: 'Supprimer' }) où vous scopez d'abord à un élément parent. Ce qui veut dire que vous avez maintenant besoin d'une façon stable d'identifier la ligne.

Si la ligne a un ID comme user-row-marie-dupont, vous pouvez l'utiliser. Si elle ne l'a pas, vous revenez à nth-child. Ou à ajouter un data-testid sur la ligne. Ce que la philosophie Testing Library considère comme un smell.

Le truc, c'est qu'il n'y a pas de fix d'accessibilité ici. L'UI est accessible. Plusieurs boutons identiques dans des contextes distinguables est un pattern parfaitement valide. La seule désambiguïsation côté test demande un point d'accroche contractuel stable sur la ligne.

Boutons icône avec aria-label

Pattern courant : une rangée de boutons icône (fermer, agrandir, paramètres). Chacun a aria-label="Fermer", aria-label="Agrandir la ligne", aria-label="Ouvrir les paramètres". Côté accessibilité, parfait.

Maintenant votre équipe QA utilise getByRole('button', { name: 'Fermer' }) de Playwright. Ça marche.

Six mois plus tard, l'équipe design system renomme aria-label="Fermer" en aria-label="Annuler" parce que la recherche UX montre que c'est plus clair. Tous les tests cassent. Le dev qui a fait le changement n'avait aucune idée que cet aria-label était porteur pour la suite de tests.

Remarquez ce qui s'est passé : l'équipe a tout fait correctement du point de vue de l'accessibilité. L'équipe a aussi tout fait correctement du point de vue de Testing Library. Et pourtant le contrat entre dev et QA était implicite, fragile, et a cassé silencieusement.

Un data-testid="close-button" aurait survécu à ce renommage. Pas parce que data-testid est moralement supérieur, mais parce qu'il est conçu spécifiquement pour le contrat de test. Le renommer demande d'ouvrir le fichier de test. Renommer un aria-label ne le demande pas.

Inputs sans label visible

Une barre de recherche avec un placeholder "Rechercher..." et pas de <label>. Correct côté accessibilité ? Probablement pas, mais c'est un pattern réel qui part en production tous les jours. Testing Library dirait : ajoutez un vrai label d'abord.

D'accord. Mais parfois le design contre-attaque. Le placeholder est le label visuel par choix design. Ajouter un <label> visible changerait l'UX. Ajouter un <label> masqué (visuellement caché mais lisible par les lecteurs d'écran) est le bon fix a11y, mais l'équipe QA doit attendre que les équipes design et front livrent ça.

Pendant ce temps, le test doit tourner aujourd'hui. data-testid="search-input" débloque le test, et l'amélioration d'accessibilité peut être livrée plus tard comme un changement séparé.

La synthèse pragmatique

Voici la position que je pense que la plupart des équipes devraient adopter :

  1. Par défaut, requêtes par rôle. Essayez getByRole, getByLabel, getByPlaceholderText, getByText en premier. Elles sont plus alignées avec l'expérience utilisateur et elles poussent l'équipe vers l'accessibilité. C'est la philosophie Testing Library et elle est juste.

  2. Quand les requêtes par rôle sont ambigües ou fragiles, préférez corriger le markup. Si vous ne pouvez pas désambiguer deux boutons, donnez à la ligne parente un id stable ou utilisez un scope sur sous-arbre. Si un bouton n'a pas de nom accessible, ajoutez-en un. L'équipe Testing Library a raison que ça devrait être le premier réflexe.

  3. Utilisez data-testid comme contrat de test explicite pour les cas où (1) et (2) ne s'appliquent pas. Plusieurs boutons identiques dans des contextes similaires. Contenu très dynamique où l'aria-label est détenu par l'équipe design system et change pour des raisons UX. Éléments où le texte visible est la copy CTA contrôlée par la marque ("S'inscrire maintenant" aujourd'hui, "Démarrer" le trimestre prochain).

Ce n'est pas une trahison de la philosophie Testing Library. C'est une reconnaissance que la philosophie marche parfaitement pour ~80% des éléments et a besoin d'un fallback pour les 20% restants.

Comment un outil peut aider

C'est ici que je mentionne le produit, brièvement. TestID Hunter implémente exactement cette hiérarchie dans son système de scoring :

Quand l'extension génère un ticket demandant au dev de "stabiliser" un élément, elle suggère data-testid par défaut et mentionne qu'un aria-label aurait le même rang Solid et améliorerait l'accessibilité. Le dev choisit selon le contexte.

De cette façon l'équipe n'est pas forcée dans un choix religieux entre deux camps valides. Les deux options améliorent la testabilité. Une des deux améliore aussi l'a11y.

Là où je pousse contre l'argument fort

L'argument Testing Library a un souci subtil : il confond deux choses. "Tester ce qui compte pour les utilisateurs" et "utiliser des requêtes qui matchent les sélecteurs des technos d'assistance". Ces deux choses se recoupent beaucoup mais ne sont pas identiques.

Ce qui compte pour les utilisateurs, c'est que le bouton fonctionne quand ils cliquent dessus. Ils se soucient du résultat, pas du locator. Un test qui trouve fiablement le bon bouton via data-testid et exerce fiablement le workflow teste ce qui compte pour les utilisateurs, même si le locator ne matche pas comment un lecteur d'écran navigue.

L'argument accessibilité est réel mais c'est une préoccupation séparée. Elle mérite ses propres outils dédiés : axe-core, audits d'accessibilité automatisés, tests de lecteurs d'écran en CI. Essayer de plier la validation d'accessibilité dans la suite de tests e2e via la stratégie de locator est une façon bancale de mal faire les deux jobs.

Utilisez les requêtes par rôle parce qu'elles sont robustes aux changements de copy et qu'elles poussent les équipes vers l'accessibilité. Ne les utilisez pas parce qu'elles seraient "la seule façon éthique" d'écrire des tests. Elles ne le sont pas, et les traiter comme ça tend à casser dans le monde réel.

La conclusion

Les deux camps ont raison. La philosophie Testing Library est juste de dire que les requêtes par rôle devraient être votre défaut et que data-testid ne devrait pas être le premier outil que vous attrapez. Le camp data-testid-first est juste de dire que certains éléments ont vraiment besoin d'un contrat de test dédié et que c'est très bien.

Choisissez le bon outil pour chaque élément. Rendez le contrat explicite dans les deux cas. Ne menez pas une guerre quand vous pouvez avoir les deux.