Comment gardez-vous efficacement vos tests en cours de refonte?

14

Une base de code bien testée présente un certain nombre d'avantages, mais le test de certains aspects du système se traduit par une base de code résistante à certains types de changement.

Un exemple teste une sortie spécifique - par exemple, du texte ou du HTML. Les tests sont souvent (naïvement?) Écrits pour attendre un bloc de texte particulier en sortie pour certains paramètres d'entrée, ou pour rechercher des sections spécifiques dans un bloc.

Changer le comportement du code, pour répondre à de nouvelles exigences ou parce que les tests d'utilisabilité ont entraîné une modification de l'interface, nécessite également de changer les tests - peut-être même des tests qui ne sont pas spécifiquement des tests unitaires pour le code en cours de modification.

  • Comment gérez-vous le travail de recherche et de réécriture de ces tests? Et si vous ne pouvez pas simplement "les exécuter tous et laisser le framework les trier"?

  • Quels autres types de code sous test donnent des tests habituellement fragiles?

Alex Feinman
la source
En quoi est-ce significativement différent de programmers.stackexchange.com/questions/5898/… ?
AShelly
4
Cette question a été posée par erreur au sujet du refactoring - les tests unitaires devraient être invariants lors du refactoring.
Alex Feinman,

Réponses:

9

Je sais que les gens de TDD détesteront cette réponse, mais une grande partie pour moi est de choisir soigneusement où tester quelque chose.

Si je deviens trop fou avec les tests unitaires dans les niveaux inférieurs, aucun changement significatif ne peut être fait sans modifier les tests unitaires. Si l'interface n'est jamais exposée et n'est pas destinée à être réutilisée en dehors de l'application, il s'agit simplement d'une surcharge inutile par rapport à ce qui aurait pu être un changement rapide autrement.

Inversement, si ce que vous essayez de changer est exposé ou réutilisé, chacun de ces tests que vous allez devoir changer est la preuve de quelque chose que vous pourriez casser ailleurs.

Dans certains projets, cela peut consister à concevoir vos tests à partir du niveau d'acceptation vers le bas plutôt qu'à partir des tests unitaires. et avoir moins de tests unitaires et plus de tests de style d'intégration.

Cela ne signifie pas que vous ne pouvez toujours pas identifier une seule fonctionnalité et un seul code tant que cette fonctionnalité ne répond pas à ses critères d'acceptation. Cela signifie simplement que dans certains cas, vous ne finissez pas par mesurer les critères d'acceptation avec des tests unitaires.

Facture
la source
Je pense que vous vouliez écrire "en dehors du module", pas "en dehors de l'application".
SamB
SamB, ça dépend. Si l'interface est interne à quelques endroits avec une seule application, mais pas publique, j'envisagerais de tester à un niveau supérieur si je pensais que l'interface était susceptible d'être volatile.
Bill
J'ai trouvé cette approche très compatible avec TDD. J'aime commencer dans les couches supérieures de l'application plus près de l'utilisateur final afin de pouvoir concevoir les couches inférieures en sachant comment les couches supérieures doivent utiliser les couches inférieures. La construction descendante vous permet essentiellement de concevoir plus précisément l'interface entre une couche et une autre.
Greg Burghardt
4

Je viens de terminer une refonte majeure de ma pile SIP, en réécrivant tout le transport TCP. (Il s'agissait d'un quasi-refactor, à une échelle plutôt grande, par rapport à la plupart des refactorings.)

En bref, il existe une sous-classe TIdSipTcpTransport, de TIdSipTransport. Tous les TIdSip Transports partagent une suite de tests commune. En interne à TIdSipTcpTransport se trouvaient un certain nombre de classes - une carte contenant des paires connexion / message initiateur, des clients TCP filetés, un serveur TCP fileté, etc.

Voici ce que j'ai fait:

  • Supprimé les classes que j'allais remplacer.
  • Suppression des suites de tests pour ces classes.
  • Laissé la suite de tests spécifique à TIdSipTcpTransport (et il y avait toujours la suite de tests commune à tous les TIdSip Transports).
  • Exécutez les tests TIdSipTransport / TIdSipTcpTransport pour vous assurer qu'ils ont tous échoué.
  • A commenté tous les tests TIdSipTransport / TIdSipTcpTransport sauf un.
  • Si j'avais besoin d'ajouter une classe, je lui ajouterais des tests d'écriture pour créer suffisamment de fonctionnalités pour que le seul test non commenté réussisse.
  • Faire mousser, rincer, répéter.

J'ai donc su ce que je devais encore faire, sous forme de tests commentés (*), et je savais que le nouveau code fonctionnait comme prévu, grâce aux nouveaux tests que j'ai écrits.

(*) Vraiment, vous n'avez pas besoin de les commenter. Ne les lancez pas; 100 tests échouant n'est pas très encourageant. De plus, dans ma configuration particulière, la compilation de moins de tests signifie une boucle de test-écriture-refactorisation plus rapide.

Frank Shearar
la source
Je l'ai fait aussi il y a quelques mois et cela a très bien fonctionné pour moi. Cependant, je ne pouvais absolument pas appliquer cette méthode lors du jumelage avec un collègue lors de la refonte de notre module de modèle de domaine (qui à son tour a déclenché la refonte de tous les autres modules du projet).
Marco Ciambrone
3

Lorsque les tests sont fragiles, je trouve que c'est généralement parce que je teste la mauvaise chose. Prenons par exemple la sortie HTML. Si vous vérifiez la sortie HTML réelle, votre test sera fragile. Mais vous n'êtes pas intéressé par la sortie réelle, vous voulez savoir si elle transmet les informations qu'elle devrait. Malheureusement, cela nécessite de faire des affirmations sur le contenu du cerveau de l'utilisateur et ne peut donc pas être fait automatiquement.

Vous pouvez:

  • Générez le code HTML comme test de fumée pour vous assurer qu'il fonctionne réellement
  • Utilisez un système de modèles pour pouvoir tester le processeur de modèle et les données envoyées au modèle sans réellement tester le modèle lui-même.

Le même genre de choses se produit avec SQL. Si vous affirmez le SQL réel, vos classes tentent de vous faire avoir des ennuis. Vous voulez vraiment affirmer les résultats. Par conséquent, j'utilise une base de données de mémoire SQLITE lors de mes tests unitaires pour m'assurer que mon SQL fait réellement ce qu'il est censé faire.

Winston Ewert
la source
Cela pourrait également aider à utiliser le HTML structurel.
SamB
@SamB certainement, cela aiderait, mais je ne pense pas que cela résoudra complètement le problème
Winston Ewert
bien sûr que non, rien ne peut :-)
SamB
-1

Créez d'abord une NOUVELLE API, qui fait ce que vous voulez que votre comportement de NOUVELLE API soit. S'il arrive que cette nouvelle API porte le même nom qu'une ancienne API, j'ajoute le nom _NEW au nouveau nom de l'API.

int DoSomethingInterestingAPI ();

devient:

int DoSomethingInterestingAPI_NEW (int prend_plus_arguments); int DoSomethingInterestingAPI_OLD (); int DoSomethingInterestingAPI () {DoSomethingInterestingAPI_NEW (quel que soit_de_fault_mimics_the_old_API); OK - à ce stade - tous vos tests de régression réussissent - en utilisant le nom DoSomethingInterestingAPI ().

SUIVANT, parcourez votre code et remplacez tous les appels par DoSomethingInterestingAPI () par la variante appropriée de DoSomethingInterestingAPI_NEW (). Cela inclut la mise à jour / réécriture de toutes les parties de vos tests de régression qui doivent être modifiées pour utiliser la nouvelle API.

SUIVANT, marquez DoSomethingInterestingAPI_OLD () comme [[obsolète ()]]. Restez dans l'API obsolète aussi longtemps que vous le souhaitez (jusqu'à ce que vous ayez mis à jour en toute sécurité tout le code qui pourrait en dépendre).

Avec cette approche, les échecs dans vos tests de régression sont simplement des bogues dans ce test de régression ou identifient les bogues dans votre code - exactement comme vous le souhaitez. Ce processus par étapes de révision d'une API en créant explicitement les versions _NEW et _OLD de l'API vous permet de faire coexister des bits du nouveau et de l'ancien code pendant un certain temps.

Voici un bon (dur) exemple de cette approche dans la pratique. J'avais la fonction BitSubstring () - où j'avais utilisé l'approche selon laquelle le troisième paramètre serait le COUNT de bits dans la sous-chaîne. Pour être cohérent avec d'autres API et modèles en C ++, je voulais passer en début / fin en tant qu'arguments de la fonction.

https://github.com/SophistSolutions/Stroika/commit/003dd8707405c43e735ca71116c773b108c217c0

J'ai créé une fonction BitSubstring_NEW avec la nouvelle API et mis à jour tout mon code pour l'utiliser (en ne laissant PLUS D'APPELS à BitSubString). Mais je suis parti dans l'implémentation pour plusieurs versions (mois) - et je l'ai marqué comme obsolète - afin que tout le monde puisse passer à BitSubString_NEW (et à ce moment-là changer l'argument d'un décompte en style de début / fin).

ALORS - lorsque cette transition a été terminée, j'ai fait une autre validation en supprimant BitSubString () et en renommant BitSubString_NEW-> BitSubString () (et j'ai déconseillé le nom BitSubString_NEW).

Lewis Pringle
la source
N'ajoutez jamais de suffixes qui n'ont aucune signification ou qui se déprécient d'eux-mêmes. Efforcez-vous toujours de donner des noms significatifs.
Basilevs
Vous avez complètement raté le point. Premièrement, ce ne sont pas des suffixes qui "n'ont aucun sens". Ils signifient que l'API est en train de passer d'une ancienne à une plus récente. En fait, c'est tout l'intérêt de la QUESTION à laquelle je répondais, et tout l'intérêt de la réponse. Les noms communiquent CLAIREMENT qui est la VIEILLE API, qui est la NOUVELLE API, et qui est le nom cible final de l'API une fois la transition terminée. ET - les suffixes _OLD / _NEW sont temporaires - UNIQUEMENT pendant la transition de changement d'API.
Lewis Pringle
Bonne chance avec la version NEW_NEW_3 de l'API trois ans plus tard.
Basilevs