Ecrire le code minimum pour réussir un test unitaire - sans tricher!


36

Lors de l'exécution de TDD et de l'écriture d'un test unitaire, comment résister à l'envie de "tricher" lors de l'écriture de la première itération du code "d'implémentation" que vous testez?

Par exemple:
il faut que je calcule la factorielle d'un nombre. Je commence par un test unitaire (avec MSTest), par exemple:

[TestClass]
public class CalculateFactorialTests
{
    [TestMethod]
    public void CalculateFactorial_5_input_returns_120()
    {
        // Arrange
        var myMath = new MyMath();
        // Act
        long output = myMath.CalculateFactorial(5);
        // Assert
        Assert.AreEqual(120, output);
    }
}

J'exécute ce code, et il échoue car la CalculateFactorialméthode n'existe même pas. J'écris donc maintenant la première itération du code pour implémenter la méthode sous test en écrivant le code minimum requis pour réussir le test.

Le problème, c’est que je suis toujours tenté d’écrire ce qui suit:

public class MyMath
{
    public long CalculateFactorial(long input)
    {
        return 120;
    }
}

Ceci est, sur le plan technique, correct en ce qu'elle vraiment est le code minimum requis pour faire ce test de passage spécifique (passer au vert), bien qu'il soit clairement un « tricher » , car il ne même pas vraiment tenter d'exécuter la fonction de calcul d' un factoriel. Bien entendu, la partie refactorisation devient désormais un exercice consistant à "écrire la fonctionnalité correcte" plutôt qu’une véritable refactorisation de la mise en oeuvre. Évidemment, l'ajout de tests supplémentaires avec différents paramètres échouera et forcera une refactorisation, mais vous devez commencer par ce test.

Ma question est donc la suivante: comment obtenez-vous cet équilibre entre "écrire le code minimum pour réussir le test" tout en le maintenant fonctionnel et dans l’esprit de ce que vous essayez réellement de réaliser?


4
C'est une chose humaine: vous devez résister à l'envie de tricher. Il n'y a rien de plus. Vous pouvez ajouter plus de tests et écrire plus de code de test que de code à tester, mais si vous ne disposez pas de ce luxe, il vous faudra simplement résister. Il existe de nombreux endroits dans le codage où nous devons résister à l'envie de pirater ou de tricher, car nous savons que, même si cela peut fonctionner aujourd'hui, cela ne fonctionnera pas plus tard.
Dan Rosenstark

7
En TDD, il est certain que tricher est le contraire, c’est-à-dire que le retour 120 est le bon moyen. Je trouve assez difficile de me faire faire cela, de ne pas courir à l’avant et de commencer à écrire le calcul factoriel.
Paul Butcher

2
Je considérerais cela comme une triche, uniquement parce qu’il réussit peut-être le test, mais qu’il n’ajoute aucune fonctionnalité réelle ni ne vous rapproche d’une solution finale au problème à résoudre.
GrumpyMonkey

3
S'il s'avère que le code client ne passe jamais que dans un 5, renvoyer 120 n'est pas simplement une astuce, mais une solution légitime.
Kramii Réintègre Monica

Je suis d’accord avec @PaulButcher. En fait, de nombreux exemples de tests unitaires dans des textes et des articles suivraient cette approche.
HorusKol

Réponses:


45

C'est parfaitement légitime. Rouge, vert, refactor.

Le premier test est réussi.

Ajoutez le deuxième test, avec une nouvelle entrée.

Maintenant passons rapidement au vert, vous pouvez ajouter un if-else, qui fonctionne bien. Cela passe, mais vous n'êtes pas encore fini.

La troisième partie de Red, Green, Refactor est la plus importante. Refactor pour supprimer la duplication . Vous aurez la duplication dans votre code maintenant. Deux instructions renvoyant des nombres entiers. Et le seul moyen de supprimer cette duplication consiste à coder correctement la fonction.

Je ne dis pas que vous ne l'écrivez pas correctement la première fois. Je dis juste que ce n'est pas tricher si vous ne le faites pas.


12
Cela soulève simplement la question, pourquoi ne pas simplement écrire la fonction correctement en premier lieu?
Robert Harvey

8
@ Robert, les nombres factoriels sont trivialement simples. Le véritable avantage de TDD est que vous écrivez des bibliothèques non triviales et que l'écriture du test vous oblige d'abord à concevoir l'API avant la mise en œuvre, ce qui - selon mon expérience - conduit à un meilleur code.

1
@ Robert, c'est vous qui êtes soucieux de résoudre le problème au lieu de passer le test. Je vous dis que pour des problèmes non triviaux, il est simplement préférable de différer la conception complexe jusqu'à ce que des tests soient en place.

1
@ Thorbjørn Ravn Andersen, non, je ne dis pas que vous ne pouvez avoir qu'un seul retour. Il existe des raisons valables pour plusieurs (c.-à-d. Déclarations de garde). Le problème est que les deux déclarations de retour étaient "égales". Ils ont fait la même chose. Ils venaient juste d’avoir des valeurs différentes. Le TDD n’est pas une question de rigidité et d’adhérence à un ratio spécifique de test / code. Il s'agit de créer un niveau de confort dans votre base de code. Si vous pouvez écrire un test qui échoue, alors une fonction qui fonctionnera pour les tests futurs de cette fonction, c'est génial. Faites-le, puis écrivez vos tests de cas extrêmes en vous assurant que votre fonction fonctionne toujours.
CaffGeek

3
le point de ne pas écrire la mise en œuvre complète (bien que simple) à la fois est que vous n'avez alors aucune garantie que vos tests peuvent même échouer. le point de voir un test échouer avant de le faire passer est que vous avez alors la preuve réelle que votre modification du code correspond à l'affirmation que vous avez faite à son sujet. C'est la seule raison pour laquelle TDD est si efficace pour la construction d'une suite de tests de régression et efface complètement le sol avec l'approche "test après" dans ce sens.
Sara

25

Clairement, une compréhension de l'objectif ultime et la réalisation d'un algorithme répondant à cet objectif sont nécessaires.

Le TDD n’est pas une solution miracle au design; vous devez toujours savoir comment résoudre les problèmes en utilisant du code et savoir comment le faire à un niveau supérieur à quelques lignes de code pour réussir un test.

J'aime l'idée de TDD car elle encourage une bonne conception. cela vous fait penser à la façon dont vous pouvez écrire votre code pour qu'il soit testable, et généralement cette philosophie le poussera vers une meilleure conception globale. Mais vous devez encore savoir comment concevoir une solution.

Je ne suis pas en faveur des philosophies réductionnistes de TDD selon lesquelles il est possible de développer une application en écrivant simplement la plus petite quantité de code permettant de réussir un test. Sans penser à l'architecture, cela ne fonctionnera pas, et votre exemple le prouve.

Oncle Bob Martin dit ceci:

Si vous ne faites pas de développement piloté par les tests, il est très difficile de vous qualifier de professionnel. Jim Coplin m'a appelé sur le tapis pour celui-ci. Il n'a pas aimé que j'ai dit ça. En fait, sa position actuelle est que Test Driven Development détruit les architectures parce que les gens écrivent des tests pour qu'ils abandonnent toute autre forme de pensée et déchirent leurs architectures dans la précipitation folle voulue pour faire passer les tests et il a un point intéressant, c'est un moyen intéressant d'abuser du rituel et de perdre l'intention de la discipline.

si vous ne réfléchissez pas à l'architecture, si ce que vous faites, c'est ignorer l'architecture, lancer des tests ensemble et les faire passer, vous détruisez ce qui permettra au bâtiment de rester en place parce que c'est la concentration sur le la structure du système et des décisions de conception solides qui aident le système à maintenir son intégrité structurelle.

Vous ne pouvez pas simplement lancer tout un tas de tests ensemble et les faire passer, décennie après décennie, en supposant que votre système va survivre. Nous ne voulons pas nous transformer en enfer. Ainsi, un bon développeur axé sur les tests est toujours conscient de prendre des décisions architecturales, en pensant toujours à la situation dans son ensemble.


Pas vraiment une réponse à la question, mais 1+
Personne Personne

2
@rmx: La question est de savoir comment trouver cet équilibre entre "écrire le code minimal pour réussir le test" tout en le maintenant fonctionnel et dans l'esprit de ce que vous essayez réellement d'atteindre. Lisons-nous la même question?
Robert Harvey

La solution idéale est un algorithme et n'a rien à voir avec l'architecture. Faire TDD ne vous fera pas inventer des algorithmes. À un moment donné, vous devez définir un algorithme / une solution.
Joppe

Je suis d'accord avec @rmx. Cela ne répond pas vraiment à ma question, mais donne à réfléchir sur la manière dont le TDD s’intègre dans l’ensemble du processus de développement logiciel. Donc, pour cette raison, +1.
CraigTP

Je pense que vous pouvez remplacer "architecture" par "algorithmes" - et d'autres termes - et l'argument est toujours valable; il s'agit avant tout de ne pas voir le bois des arbres. À moins que vous n'écriviez un test séparé pour chaque entrée entière, TDD ne sera pas en mesure de faire la distinction entre une implémentation factorielle appropriée et un codage codé en dur pervers qui fonctionne pour tous les cas testés, mais pas pour les autres. Le problème avec TDD est la facilité avec laquelle "tous les tests réussissent" et "le code est bon" sont confondus. À un moment donné, il faut appliquer une forte mesure de bon sens.
Julia Hayward

16

Une très bonne question ... et je suis en désaccord avec presque tout le monde sauf @Robert.

L'écriture

return 120;

pour une fonction factorielle, réussir un test est une perte de temps . Ce n'est pas "tricher", ni suivre le refactor rouge-vert littéralement. C'est faux .

Voici pourquoi:

  • Calculer factoriel est la fonction, pas "renvoyer une constante". "return 120" n'est pas un calcul.
  • les arguments du "refactor" sont erronés; si vous avez deux scénarios de test pour 5 et 6, ce code est toujours faux, car vous ne calculez pas de factorielle du tout :

    if (input == 5) { return 120; } //input=5 case
    else { return 720; }   //input=6 case
    
  • si nous suivons à la lettre l' argument 'refactor' , alors, lorsque nous avons 5 cas de test, nous invoquons YAGNI et implémentons la fonction à l'aide d'une table de recherche:

    if (factorialDictionary.Contains(input)) {
        return factorialDictionary[input]; 
    }
    throw new Exception("Input failure");
    

Aucun d'entre eux ne calcule réellement quoi que ce soit, vous l'êtes . Et ce n'est pas la tâche!


1
@ rmx: non, ça ne m'a pas manqué; "refactor pour supprimer la duplication" peut être satisfait avec une table de recherche. BTW le principe que les tests unitaires codent les exigences n’est pas spécifique à BDD, c’est un principe général de Agile / XP. Si l'exigence était "Répondez à la question" quelle est la factorielle de 5 "" alors "retournez 120;" serait légitime ;-)
Steven A. Lowe

2
@Chad tout ce qui est un travail inutile - il suffit d'écrire la fonction la première fois ;-)
Steven A. Lowe

2
@Steven A.Lowe, par cette logique, pourquoi écrire des tests?! "Il suffit d'écrire l'application la première fois!" Le point de TDD, est petit, sûr, changements progressifs.
CaffGeek

1
@Chad: Strawman.
Steven A. Lowe

2
le point de ne pas écrire la mise en œuvre complète (bien que simple) à la fois est que vous n'avez alors aucune garantie que vos tests peuvent même échouer. le point de voir un test échouer avant de le faire passer est que vous avez alors la preuve réelle que votre modification du code correspond à l'affirmation que vous avez faite à son sujet. C'est la seule raison pour laquelle TDD est si efficace pour la construction d'une suite de tests de régression et efface complètement le sol avec l'approche "test après" dans ce sens. vous n'écrivez jamais accidentellement un test qui ne peut échouer. aussi, jetez un oeil à kata facteur premier oncle bobs.
Sara

10

Lorsque vous avez écrit un seul test unitaire, l'implémentation sur une ligne ( return 120;) est légitime. Écrire une boucle calculant la valeur de 120 - ce serait tricher!

De tels tests initiaux simples sont un bon moyen de détecter les cas extrêmes et d'éviter les erreurs ponctuelles. Cinq n'est pas la valeur d'entrée avec laquelle je commencerais.

Une règle de base qui pourrait être utile ici est la suivante: zéro, un, plusieurs, beaucoup . Zéro et un sont des cas extrêmes pour la factorielle. Ils peuvent être mis en œuvre avec une seule ligne. Le "nombreux" cas de test (par exemple 5!) Vous obligerait alors à écrire une boucle. Le cas de test "lots" (1000!?) Peut vous obliger à mettre en œuvre un algorithme alternatif permettant de gérer des nombres très importants.


2
Le cas "-1" serait intéressant. Parce que ce n'est pas bien défini, le gars qui écrit le test et celui qui écrit le code doivent d'abord se mettre d'accord sur ce qui devrait se passer.
gnasher729

2
+1 pour signaler que factorial(5)c'est un mauvais premier test. nous partons des cas les plus simples possibles et, à chaque itération, nous donnons aux tests un peu plus spécifiques, en incitant le code à devenir un peu plus générique. c'est ce que oncle bob appelle le principe de priorité de la transformation ( blog.8thlight.com/uncle-bob/2013/05/27/… )
sara

5

Tant que vous n'avez qu'un seul test, le code minimal requis pour réussir le test est vraiment return 120;, et vous pouvez le conserver facilement tant que vous n'avez plus de tests.

Cela vous permet de différer la conception jusqu'à ce que vous écriviez réellement les tests qui exercent AUTRE valeurs renvoyées par cette méthode.

S'il vous plaît rappelez-vous que le test est la version exécutable de votre spécification, et si tout ce que cette spécification dit est que f (6) = 120, alors cela convient parfaitement.


Sérieusement? Selon cette logique, vous devrez réécrire le code chaque fois que quelqu'un propose une nouvelle entrée.
Robert Harvey

6
@ Robert, à un moment donné, l'ajout d'un nouveau cas ne donnera plus le code le plus simple possible, et vous écrivez alors une nouvelle implémentation. Comme vous avez déjà les tests en place, vous savez exactement quand votre nouvelle implémentation sera identique à l’ancienne.

1
@ Thorbjørn Ravn Andersen, précisément, la partie la plus importante de Red-Green-Refactor, est le refactoring.
CaffGeek

+1: C’est aussi l’idée générale à ma connaissance, mais il ya quelque chose à dire sur la façon de remplir le contrat implicite (c’est-à-dire le nom de la méthode factorial ). Si vous spécifiez (testez) uniquement f (6) = 120, il vous suffit alors de «renvoyer 120». Une fois que vous avez commencé à ajouter des tests pour vous assurer que f (x) == x * x-1 ... * xx-1: upperBound> = x> = 0, vous arriverez alors à une fonction qui satisfait l'équation factorielle.
Steven Evers

1
@SnOrfus, la place des "contrats implicites" est dans les cas de test. Si votre contrat concerne des factorielles, vous devez TESTER si les factorielles connues sont et si les non-factorielles connues ne le sont pas. Beaucoup d'entre eux. Il ne faut pas longtemps pour convertir la liste des dix premières factorielles en une boucle for testant chaque nombre jusqu'au dixième factoriel.

4

Si vous êtes capable de "tricher" de cette manière, cela suggère que vos tests unitaires sont imparfaits.

Plutôt que de tester la méthode factorielle avec une seule valeur, testez qu'il s'agit d'une plage de valeurs. Les tests basés sur les données peuvent aider ici.

Visualisez vos tests unitaires comme une manifestation des exigences. Ils doivent définir collectivement le comportement de la méthode testée. (Ceci est connu sous le nom de développement basé sur le comportement - c'est l'avenir;-) )

Alors posez-vous la question suivante: si quelqu'un modifiait la mise en œuvre en une solution incorrecte, vos tests seraient-ils toujours satisfaisants ou diraient-ils "attendez une minute!"?

En gardant cela à l'esprit, si votre seul test était celui de votre question, techniquement, l'implémentation correspondante est correcte. Le problème est alors considéré comme une exigence mal définie.


Comme Nanda l'a souligné, vous pouvez toujours ajouter une série infinie d' caseinstructions à un switch, et vous ne pouvez pas écrire un test pour toutes les entrées et sorties possibles pour l'exemple du PO.
Robert Harvey

Vous pouvez techniquement tester les valeurs de Int64.MinValueà Int64.MaxValue. Cela prendrait beaucoup de temps, mais cela définirait explicitement l'exigence sans risque d'erreur. Avec la technologie actuelle, cela est impossible (je suppose que cela pourrait devenir plus commun à l'avenir) et je suis d'accord, vous pouvez tricher mais je pense que la question des PO n'était pas une question pratique (personne ne tricherait de cette manière en pratique), mais théorique.
Personne

@rmx: Si vous pouviez le faire, les tests seraient l'algorithme et vous n'auriez plus besoin d'écrire l'algorithme.
Robert Harvey

C'est vrai. Ma thèse universitaire implique en fait la génération automatique de la mise en œuvre en utilisant les tests unitaires comme guide avec un algorithme génétique comme aide au TDD - et cela n'est possible qu'avec des tests solides. La différence est qu’il est généralement plus difficile de lire et de comprendre la liaison de vos exigences avec votre code qu’une méthode unique qui incorpore les tests unitaires. Vient ensuite la question: si votre implémentation est une manifestation de vos tests unitaires et que vos tests unitaires sont une manifestation de vos exigences, pourquoi ne pas simplement ignorer les tests? Je n'ai pas de réponse.
Personne

Aussi, ne sommes-nous pas, en tant qu'êtres humains, aussi susceptibles de commettre une erreur dans les tests unitaires que dans le code d'implémentation? Alors pourquoi un test unitaire?
Personne

3

Il suffit d'écrire plus de tests. Finalement, il serait plus court d'écrire

public long CalculateFactorial(long input)
{
    return input <= 1 ? 1 : CalculateFactorial(input-1)*input;
}

que

public long CalculateFactorial(long input)
{
    switch (input) {
       case 0: return 1;
       case 1: return 1;
       case 2: return 2;
       case 3: return 6;
       case 4: return 24;
       case 5: return 120;
    }
}

:-)


3
Pourquoi ne pas écrire l'algorithme correctement en premier lieu?
Robert Harvey

3
@ Robert, c'est l' algorithme correct pour calculer la factorielle d'un nombre compris entre 0 et 5. En outre, que signifie "correctement"? Ceci est un exemple très simple, mais quand cela devient plus complexe, il y a beaucoup de gradations de ce que "correct" signifie. Un programme nécessitant un accès root est-il "correct"? L'utilisation de XML est-elle "correcte" au lieu d'utiliser CSV? Vous ne pouvez pas répondre à cela. Tout algorithme est correct s'il répond à certaines exigences de l’entreprise, qui sont formulées sous forme de tests dans TDD.
P expédié

3
Il convient de noter que, comme le type de sortie est long, il n’existe qu’un petit nombre de valeurs d’entrée (environ 20) que la fonction peut éventuellement gérer correctement. Par conséquent, une instruction switch importante n’est pas nécessairement la pire implémentation - si la vitesse est plus grande plus importante que la taille du code, l’instruction switch peut être la solution, en fonction de vos priorités.
user281377

3

Écrire des tests de "triche" est correct, pour des valeurs suffisamment faibles de "OK". Mais rappelez-vous - les tests unitaires ne sont terminés que lorsque tous les tests sont réussis et qu'aucun nouveau test ne peut être écrit qui échouera . Si vous voulez vraiment avoir une méthode CalculateFactorial qui contient un tas d' instructions if (ou mieux encore, une grosse instruction switch / case :-) vous pouvez le faire, et puisque vous avez affaire à un nombre à précision fixe, le code requis sa mise en œuvre est finie (bien que probablement assez grande et laide, et peut-être limitée par les limitations du compilateur ou du système sur la taille maximale du code d'une procédure). À ce stade si vous vraimentinsistez sur le fait que tout développement doit être piloté par un test unitaire; vous pouvez écrire un test qui nécessite que le code calcule le résultat dans un délai inférieur à celui qui peut être obtenu en suivant toutes les branches de l' instruction if .

TDD peut vous aider à écrire du code qui implémente correctement les exigences , mais il ne peut pas vous forcer à écrire. bon code. C'est à toi de voir.

Partager et profiter.


+1 pour "les tests unitaires ne sont terminés que lorsque tous les tests sont réussis et qu'aucun nouveau test ne peut échouer" Plusieurs personnes disent qu'il est légitime de renvoyer la constante, mais ne suivent pas avec "à court terme", ou " si les exigences globales ne nécessitent que ces cas spécifiques "
Thymine

1

Je suis entièrement d'accord avec la suggestion de Robert Harveys: il ne s'agit pas uniquement de faire passer les tests, vous devez aussi garder à l'esprit l'objectif général.

En guise de solution à votre problème: "il est seulement vérifié de travailler avec un ensemble d'entrées donné", je vous proposerais d'utiliser des tests basés sur les données, tels que la théorie xunit. La puissance derrière ce concept est qu’il vous permet de créer facilement des spécifications d’entrées à sorties.

Pour Factorials, un test ressemblerait à ceci:

    [Theory]
    [InlineData(0, 1)]
    [InlineData( 1, 1 )]
    [InlineData( 2, 2 )]
    [InlineData( 3, 6 )]
    [InlineData( 4, 24 )]
    public void Test_Factorial(int input, int expected)
    {
        int result = Factorial( input );
        Assert.Equal( result, expected);
    }

Vous pouvez même implémenter une fourniture de données de test (qui renvoie IEnumerable<Tuple<xxx>> ) et coder un invariant mathématique, tel qu'une division répétée par n donnera n-1).

Je trouve que c’est un moyen de test très puissant.


1

Si vous êtes toujours capable de tricher, les tests ne suffisent pas. Ecrire plus de tests! Pour votre exemple, je vais essayer d’ajouter des tests avec les entrées 1, -1, -1000, 0, 10, 200.

Néanmoins, si vous vous engagez vraiment à tricher, vous pouvez écrire une infinité si-alors. Dans ce cas, rien ne pourrait aider sauf l'examen du code. Vous seriez bientôt pris sur le test d'acceptation ( écrit par une autre personne! )

Le problème avec les tests unitaires est parfois que les programmeurs les considèrent comme un travail inutile. La bonne façon de les voir est d’utiliser votre outil pour corriger le résultat de votre travail. Donc, si vous créez un si-alors, vous savez inconsciemment qu'il y a d'autres cas à considérer. Cela signifie que vous devez écrire un autre test. Et ainsi de suite jusqu'à ce que vous réalisiez que la tricherie ne fonctionne pas et qu'il vaut mieux coder de la bonne manière. Si vous sentez toujours que vous n'êtes pas fini, vous n'êtes pas fini.


1
Il semble donc que vous disiez qu'écrire juste assez de code pour que le test réussisse (comme le préconise TDD) n'est pas suffisant. Vous devez également garder à l’esprit les principes de conception logicielle. Je suis d'accord avec toi BTW.
Robert Harvey

0

Je suggérerais que votre choix de test n'est pas le meilleur.

Je commencerais par:

factoriel (1) comme premier test,

factorielle (0) comme seconde

factoriel (-ve) comme troisième

puis continuez avec les cas non triviaux

et terminer avec un cas de débordement.


C'est quoi -ve??
Robert Harvey

une valeur négative.
Chris Cudmore
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.