Comment éviter les tests unitaires fragiles?


24

Nous avons écrit près de 3 000 tests - les données ont été codées en dur, très peu de réutilisation du code. Cette méthodologie a commencé à nous mordre dans le cul. À mesure que le système change, nous passons plus de temps à réparer les tests brisés. Nous avons des tests unitaires, d'intégration et fonctionnels.

Ce que je recherche, c'est un moyen définitif d'écrire des tests gérables et maintenables.

Cadres


C'est beaucoup mieux adapté aux programmeurs.StackExchange, IMO ...
IAbstract

Réponses:


21

Ne les considérez pas comme des "tests unitaires brisés", car ils ne le sont pas.

Ce sont des spécifications que votre programme ne prend plus en charge.

Ne le considérez pas comme «fixant les tests», mais comme «définissant de nouvelles exigences».

Les tests doivent d'abord spécifier votre application, et non l'inverse.

Vous ne pouvez pas dire que votre implémentation fonctionne tant que vous ne savez pas que cela fonctionne. Vous ne pouvez pas dire que cela fonctionne tant que vous ne l'avez pas testé.

Quelques autres notes qui pourraient vous guider:

  1. Les tests et les classes sous test doivent être courts et simples . Chaque test ne doit vérifier qu'une fonctionnalité cohérente. Autrement dit, il ne se soucie pas des choses que d'autres tests vérifient déjà.
  2. Les tests et vos objets doivent être couplés de manière lâche, de sorte que si vous modifiez un objet, vous ne modifiez que son graphique de dépendance vers le bas, et les autres objets qui utilisent cet objet ne sont pas affectés par celui-ci.
  3. Vous créez peut-être et testez des éléments incorrects . Vos objets sont-ils conçus pour une interface facile ou une mise en œuvre facile? Si c'est le dernier cas, vous allez vous retrouver à changer beaucoup de code qui utilise l'interface de l'ancienne implémentation.
  4. Dans le meilleur des cas, respectez strictement le principe de responsabilité unique. Dans le pire des cas, respectez le principe de séparation d'interface. Voir les principes SOLID .

5
+1 pourDon't think of it as "fixing the tests", but as "defining new requirements".
StuperUser

2
+1 Les tests doivent d'abord spécifier votre application, et non l'inverse
treecoder

11

Ce que vous décrivez n'est peut-être pas une si mauvaise chose, mais un indicateur de problèmes plus profonds découverts par vos tests

À mesure que le système change, nous passons plus de temps à réparer les tests brisés. Nous avons des tests unitaires, d'intégration et fonctionnels.

Si vous pouviez changer votre code et que vos tests ne se cassaient pas , ce serait suspect pour moi. La différence entre un changement légitime et un bogue n'est que le fait qu'il est demandé, ce qui est demandé est (supposé TDD) défini par vos tests.

les données ont été codées en dur.

Les données codées en dur dans les tests sont à mon humble avis une bonne chose. Les tests fonctionnent comme des falsifications, pas comme des preuves. S'il y a trop de calcul, vos tests peuvent être des tautologies. Par exemple:

assert sum([1,2,3]) == 6
assert sum([1,2,3]) == 1 + 2 + 3
assert sum([1,2,3]) == reduce(operator.add, [1,2,3])

Plus l'abstraction est élevée, plus vous vous rapprochez de l'algorithme, et par là, plus proche de la comparaison de l'implémentation acutale à elle-même.

très peu de réutilisation du code

La meilleure réutilisation du code dans les tests est à mon humble avis, comme dans jUnits assertThat, car ils gardent les tests simples. En plus de cela, si les tests peuvent être refactorisés pour partager du code, le code réel testé peut également l'être , réduisant ainsi les tests à ceux testant la base refactorisée.


Je voudrais savoir où le downvoter n'est pas d'accord.
keppla

keppla - Je ne suis pas le downvoter, mais en général, selon où j'en suis dans le modèle, je préfère tester l'interaction des objets plutôt que tester les données au niveau de l'unité. Les tests de données fonctionnent mieux au niveau de l'intégration.
Ritch Melton

@keppla J'ai une classe qui achemine une commande vers un canal différent si le nombre total d'articles contient certains articles restreints. Je crée une fausse commande, remplissez-la avec 4 articles dont deux sont restreints. Dans la mesure où les éléments restreints sont ajoutés, ce test est unique. Mais les étapes de création d'une fausse commande et d'ajout de deux articles normaux sont la même configuration qu'un autre test utilise pour tester le flux de travail des articles non restreint. Dans ce cas, ainsi que les articles si la commande doit avoir la configuration des données client et la configuration des adresses, etc. n'est pas ce bon cas de réutilisation des assistants de configuration. Pourquoi affirmer uniquement la réutilisation?
Asif Shiraz du

6

J'ai aussi eu ce problème. Mon approche améliorée a été la suivante:

  1. N'écrivez pas de tests unitaires à moins qu'ils ne soient le seul bon moyen de tester quelque chose.

    Je suis tout à fait prêt à admettre que les tests unitaires ont le coût de diagnostic et le délai de réparation les plus bas. Cela en fait un outil précieux. Le problème est, avec l'évidence que votre kilométrage peut varier, que les tests unitaires sont souvent trop mesquins pour mériter le coût du maintien de la masse du code. J'ai écrit un exemple en bas, regardez.

  2. Utilisez des assertions partout où elles sont équivalentes au test unitaire pour ce composant. Les assertions ont la belle propriété qu'elles sont toujours vérifiées tout au long de toute construction de débogage. Ainsi, au lieu de tester les contraintes de classe «Employé» dans une unité de test distincte, vous testez efficacement la classe Employé dans tous les cas de test du système. Les assertions ont également la propriété agréable de ne pas augmenter la masse du code autant que les tests unitaires (qui nécessitent finalement un échafaudage / moquerie / autre).

    Avant que quelqu'un ne me tue: les versions de production ne doivent pas planter sur les assertions. Au lieu de cela, ils doivent se connecter au niveau "Erreur".

    À titre de mise en garde pour quelqu'un qui n'y a pas encore pensé, n'affirmez rien sur l'entrée utilisateur ou réseau. C'est une énorme erreur ™.

    Dans mes dernières bases de code, j'ai judicieusement supprimé les tests unitaires partout où je vois une opportunité évidente pour les assertions. Cela a considérablement réduit le coût de la maintenance dans l'ensemble et a fait de moi une personne beaucoup plus heureuse.

  3. Préférez les tests système / d'intégration, en les implémentant pour tous vos flux principaux et expériences utilisateur. Les valises de coin n'ont probablement pas besoin d'être ici. Un test système vérifie le comportement côté utilisateur en exécutant tous les composants. Pour cette raison, un test système est nécessairement plus lent, alors écrivez ceux qui comptent (ni plus, ni moins) et vous attraperez les problèmes les plus importants. Les tests du système ont une surcharge de maintenance très faible.

    Il est essentiel de se rappeler que, puisque vous utilisez des assertions, chaque test système exécutera simultanément quelques centaines de "tests unitaires". Vous êtes également plutôt assuré que les plus importants sont exécutés plusieurs fois.

  4. Écrivez des API solides qui peuvent être testées fonctionnellement. Les tests fonctionnels sont maladroits et (avouons-le) un peu dénués de sens si votre API rend trop difficile la vérification des composants fonctionnels par eux-mêmes. Une bonne conception de l'API a) rend les étapes de test simples et b) engendre des affirmations claires et précieuses.

    Le test fonctionnel est la chose la plus difficile à bien faire, surtout lorsque vous avez des composants communiquant un à plusieurs ou (pire encore, oh mon dieu) plusieurs à plusieurs à travers les barrières de processus. Plus il y a d'entrées et de sorties attachées à un seul composant, plus les tests fonctionnels sont difficiles, car vous devez isoler l'un d'entre eux pour vraiment tester sa fonctionnalité.


Sur la question de «ne pas écrire de tests unitaires», je présenterai un exemple:

TEST(exception_thrown_on_null)
{
    InternalDataStructureType sink;
    ASSERT_THROWS(sink.consumeFrom(NULL), std::logic_error);
    try {
        sink.consumeFrom(NULL);
    } catch (const std::logic_error& e) {
        ASSERT(e.what() == "You must not pass NULL as a parameter!");
    }
}

L'auteur de ce test a ajouté sept lignes qui ne contribuent pas du tout à la vérification du produit final. L'utilisateur ne devrait jamais voir cela se produire, soit parce que a) personne ne devrait jamais y passer NULL (alors écrivez une assertion, alors) ou b) le cas NULL devrait provoquer un comportement différent. Si le cas est (b), écrivez un test qui vérifie réellement ce comportement.

Ma philosophie est devenue que nous ne devrions pas tester les artefacts d'implémentation. Nous ne devons tester que tout ce qui peut être considéré comme une sortie réelle. Sinon, il n'y a aucun moyen d'éviter d'écrire deux fois la masse de code de base entre les tests unitaires (qui forcent une implémentation particulière) et l'implémentation elle-même.

Il est important de noter ici qu'il existe de bons candidats pour les tests unitaires. En fait, il existe même plusieurs situations où un test unitaire est le seul moyen adéquat pour vérifier quelque chose et dans lequel il est très utile d'écrire et de maintenir ces tests. Du haut de ma tête, cette liste comprend des algorithmes non triviaux, des conteneurs de données exposés dans une API et du code hautement optimisé qui semble "compliqué" (alias "le prochain gars va probablement tout gâcher").

Mon conseil spécifique pour vous, alors: commencez à supprimer judicieusement les tests unitaires lorsqu'ils se cassent, en vous posant la question "est-ce une sortie ou est-ce que je gaspille du code?" Vous réussirez probablement à réduire le nombre de choses qui vous font perdre votre temps.


3
Préférez les tests de système / d'intégration - C'est terriblement mauvais. Votre système arrive au point où il utilise ces tests (au ralenti!) Pour tester les choses qui pourraient être détectées rapidement au niveau de l'unité et cela prend des heures à s'exécuter car vous avez tellement de tests similaires et lents.
Ritch Melton

1
@RitchMelton Entièrement distinct de la discussion, il semble que vous ayez besoin d'un nouveau serveur CI. CI ne devrait pas se comporter comme ça.
Andres Jaan Tack

1
Un programme qui plante (c'est ce que font les assertions) ne devrait pas tuer votre testeur (CI). C'est pourquoi vous avez un testeur; afin que quelque chose puisse détecter et signaler de tels échecs.
Andres Jaan Tack

1
Les assertions de style «Assert» de débogage uniquement que je connais (pas les assertions de test) font apparaître une boîte de dialogue qui bloque le CI car il attend l'interaction du développeur.
Ritch Melton

1
Ah, eh bien, ça expliquerait beaucoup notre désaccord. :) Je fais référence aux assertions de style C. Je viens juste de remarquer que c'est une question .NET. cplusplus.com/reference/clibrary/cassert/assert
Andres Jaan Tack

5

Il me semble que vos tests unitaires fonctionnent comme un charme. C'est une bonne chose qu'il soit si fragile aux changements, car c'est en quelque sorte le point. De petits changements dans les tests de rupture de code afin que vous puissiez éliminer la possibilité d'erreur dans tout votre programme.

Cependant, gardez à l'esprit que vous ne devez vraiment tester que les conditions qui pourraient faire échouer votre méthode ou donner des résultats inattendus. Cela permettrait à votre unité de tester plus encline à "casser" s'il y a un problème réel plutôt que des choses insignifiantes.

Bien qu'il me semble que vous repensez fortement le programme. Dans de tels cas, faites tout ce dont vous avez besoin et supprimez les anciens tests et remplacez-les par de nouveaux par la suite. La réparation des tests unitaires ne vaut que si vous ne corrigez pas en raison de changements radicaux dans votre programme. Sinon, vous constaterez peut-être que vous consacrez trop de temps à la réécriture des tests pour être applicable dans votre nouvelle section de code de programme.


3

Je suis sûr que d'autres auront beaucoup plus à dire, mais d'après mon expérience, ce sont des choses importantes qui vous aideront:

  1. Utilisez une fabrique d'objets de test pour créer des structures de données d'entrée, vous n'avez donc pas besoin de dupliquer cette logique. Peut-être cherchez dans une bibliothèque d'aide comme AutoFixture pour réduire le code nécessaire à la configuration du test.
  2. Pour chaque classe de test, centralisez la création du SUT, il sera donc facile de changer lorsque les choses seront refactorisées.
  3. N'oubliez pas que ce code de test est tout aussi important que le code de production. Il doit également être refactorisé, si vous constatez que vous vous répétez, si le code vous semble irréalisable, etc., etc.

Plus vous réutilisez le code à travers les tests, plus ils deviennent fragiles, car maintenant changer un test peut en casser un autre. Cela peut être un coût raisonnable, en échange de la maintenabilité - je n'entre pas dans cet argument ici - mais argumenter que les points 1 et 2 rendent les tests moins fragiles (ce qui était la question) est tout simplement faux.
pdr

@driis - À droite, le code de test a des idiomes différents de ceux du code en cours d'exécution. Cacher des choses en refactorisant le code «commun» et en utilisant des choses comme les conteneurs IoC masque simplement les problèmes de conception exposés par vos tests.
Ritch Melton

Bien que l'argument de @pdr soit probablement valable pour les tests unitaires, je dirais que pour les tests d'intégration / système, il pourrait être utile de penser en termes de "préparation de l'application pour la tâche X". Cela peut impliquer de naviguer au bon endroit, de définir certains paramètres d'exécution, d'ouvrir un fichier de données, etc. Si plusieurs tests d'intégration commencent au même endroit, la refactorisation de ce code pour le réutiliser sur plusieurs tests peut ne pas être une mauvaise chose si vous comprenez les risques et les limites d'une telle approche.
un CVn le

2

Gérez les tests comme vous le faites avec du code source.

Contrôle de version, versions des points de contrôle, suivi des problèmes, "propriété des fonctionnalités", planification et estimation des efforts, etc.


1

Vous devriez certainement jeter un œil aux modèles de test XUnit de Gerard Meszaros . Il a une grande section avec de nombreuses recettes pour réutiliser votre code de test et éviter la duplication.

Si vos tests sont fragiles, il se peut aussi que vous n'ayez pas assez recours pour tester les doubles. Surtout, si vous recréez des graphiques entiers d'objets au début de chaque test unitaire, les sections Arrangement de vos tests peuvent devenir surdimensionnées et vous pouvez souvent vous retrouver dans des situations où vous devez réécrire les sections Arrange dans un nombre considérable de tests simplement parce que l'une de vos classes les plus utilisées a changé. Les maquettes et les talons peuvent vous aider ici en réduisant le nombre d'objets que vous devez réhydrater pour avoir un contexte de test pertinent.

Supprimer les détails sans importance de vos configurations de test via des simulations et des talons et appliquer des modèles de test pour réutiliser le code devrait réduire considérablement leur fragilité.

En utilisant notre site, vous reconnaissez avoir lu et compris notre politique liée aux cookies et notre politique de confidentialité.
Licensed under cc by-sa 3.0 with attribution required.