Test vs ne vous répétez pas (SEC)

11

Pourquoi vous répétez-vous en écrivant des tests si fortement encouragés?

Il semble que les tests expriment essentiellement la même chose que le code, et sont donc un doublon (dans le concept, pas l'implémentation) du code. La cible ultime de DRY ne comprendrait-elle pas l'élimination de tout le code de test?

John Tseng
la source

Réponses:

25

Je crois que c'est une idée fausse de toute façon à laquelle je peux penser.

Le code de test qui teste le code de production n'est pas du tout similaire. Je vais démontrer en python:

def multiply(a, b):
    """Multiply ``a`` by ``b``"""
    return a*b

Un test simple serait alors:

def test_multiply():
    assert multiply(4, 5) == 20

Les deux fonctions ont une définition similaire mais les deux font des choses très différentes. Pas de code en double ici. ;-)

Il arrive également que des personnes écrivent des tests en double ayant essentiellement une assertion par fonction de test. C'est de la folie et j'ai vu des gens faire ça. C'est une mauvaise pratique.

def test_multiply_1_and_3():
    """Assert that a multiplication of 1 and 3 is 3."""
    assert multiply(1, 3) == 3

def test_multiply_1_and_7():
    """Assert that a multiplication of 1 and 7 is 7."""
    assert multiply(1, 7) == 7

def test_multiply_3_and_4():
    """Assert that a multiplication of 3 and 4 is 12."""
    assert multiply(3, 4) == 12

Imaginez faire cela pour plus de 1000 lignes de code efficaces. Au lieu de cela, vous testez par «fonctionnalité»:

def test_multiply_positive():
    """Assert that positive numbers can be multiplied."""
    assert multiply(1, 3) == 3
    assert multiply(1, 7) == 7
    assert multiply(3, 4) == 12

def test_multiply_negative():
    """Assert that negative numbers can be multiplied."""
    assert multiply(1, -3) == -3
    assert multiply(-1, -7) == 7
    assert multiply(-3, 4) == -12

Maintenant, lorsque des fonctionnalités sont ajoutées / supprimées, je n'ai plus qu'à envisager d'ajouter / supprimer une fonction de test.

Vous avez peut-être remarqué que je n'ai pas appliqué de forboucles. C'est parce que répéter certaines choses est bon. Quand j'aurais appliqué des boucles, le code serait beaucoup plus court. Mais lorsqu'une assertion échoue, elle peut masquer la sortie affichant un message ambigu. Si cela se produit, vos tests seront moins utiles et vous aurez besoin d'un débogueur pour inspecter les problèmes.

siebz0r
la source
8
Une assertion par test est techniquement recommandée car elle signifie que plusieurs problèmes ne s'afficheront pas comme un seul échec. Cependant, dans la pratique, je pense qu'une agrégation prudente des assertions réduit la quantité de code répété et je ne m'en tiens presque jamais à une assertion par directive de test.
Rob Church
@ pink-diamond-square Je vois que NUnit n'arrête pas de tester après l'échec d'une assertion (ce qui je pense est bizarre). Dans ce cas précis, il est en effet préférable d'avoir une assertion par test. Si une infrastructure de tests unitaires arrête les tests après une assertion ayant échoué, plusieurs assertions sont meilleures.
siebz0r
3
NUnit n'arrête pas toute la suite de tests, mais ce test s'arrête à moins que vous ne preniez des mesures pour l'empêcher (vous pouvez intercepter l'exception qu'il lève, ce qui est parfois utile). Le point que je pense qu'ils font est que si vous écrivez des tests qui incluent plus d'une affirmation, vous n'obtiendrez pas toutes les informations dont vous avez besoin pour corriger le problème. Pour travailler sur votre exemple, imaginez que cette fonction de multiplication n'aime pas le nombre 3. Dans ce cas, assert multiply(1,3)échouerait mais vous n'obtiendrez pas non plus le rapport de test ayant échoué assert multiply(3,4).
Rob Church
Je pensais juste que je le soulèverais car une seule assertion par test est, d'après ce que j'ai lu dans le monde .net, la "bonne pratique" et plusieurs assertions sont "une utilisation pragmatique". Il semble un peu différent dans la documentation Python où l'exemple def test_shuffleeffectue deux assertions.
Rob Church
Je suis d'accord et en désaccord: D Il y a clairement répétition ici: assert multiply(*, *) == *vous pouvez donc définir une assert_multiplyfonction. Dans le scénario actuel, peu importe le nombre de lignes et la lisibilité, mais par des tests plus longs, vous pouvez réutiliser des assertions compliquées, des montages, du code générant des montages, etc. Je ne sais pas si c'est une meilleure pratique, mais je le fais habituellement ce.
inf3rno
10

Il semble que les tests expriment essentiellement la même chose que le code, et est donc un doublon

Non, ce n'est pas vrai.

Les tests ont un objectif différent de celui de votre implémentation:

  • Les tests s'assurent que votre implémentation fonctionne.
  • Ils servent de documentation: En regardant les tests, vous voyez les contrats que votre code doit remplir, c'est-à-dire quelle entrée renvoie quelle sortie, quels sont les cas particuliers, etc.
  • De plus, vos tests garantissent que lorsque vous ajoutez de nouvelles fonctionnalités, vos fonctionnalités existantes ne se cassent pas.
Uooo
la source
4

Non. DRY consiste à écrire du code une seule fois pour effectuer une tâche particulière, les tests valident que la tâche est effectuée correctement. Cela ressemble un peu à un algorithme de vote, où, évidemment, utiliser le même code serait inutile.

jmoreno
la source
2

La cible ultime de DRY ne comprendrait-elle pas l'élimination de tout le code de test?

Non, l'objectif ultime de DRY signifierait en fait l'élimination de tout le code de production .

Si nos tests pouvaient être des spécifications parfaites de ce que nous voulons que le système fasse, nous n'aurions qu'à générer automatiquement le code de production (ou les binaires) correspondant, supprimant ainsi efficacement la base du code de production en soi.

C'est en fait ce que des approches telles que l'architecture pilotée par les modèles prétendent atteindre - une source de vérité unique conçue par l'homme, dont tout est dérivé par le calcul.

Je ne pense pas que l'inverse (se débarrasser de tous les tests) soit souhaitable car:

  • Vous devez résoudre le décalage d'impédance entre l'implémentation et la spécification. Le code de production peut transmettre l'intention dans une certaine mesure, mais il ne sera jamais aussi facile de raisonner sur des tests aussi bien exprimés. Nous, les êtres humains, avons besoin de cette vision plus élevée des raisons pour lesquelles nous construisons des choses. Même si vous ne faites pas de tests à cause de DRY, les spécifications devront probablement être écrites dans les documents de toute façon, ce qui est une bête nettement plus dangereuse en termes de décalage d'impédance et de désynchronisation de code si vous me le demandez.
  • Alors que le code de production peut être facilement dérivé des spécifications exécutables correctes (en supposant suffisamment de temps), une suite de tests est beaucoup plus difficile à reconstituer à partir du code final d'un programme. Les spécifications n'apparaissent pas clairement uniquement en regardant le code, car les interactions entre les unités de code lors de l'exécution sont difficiles à distinguer. C'est pourquoi nous avons tant de mal à gérer les applications héritées sans test. En d'autres termes: si vous voulez que votre application survive pendant plus de quelques mois, vous feriez probablement mieux de perdre le disque dur qui héberge votre base de code de production que celui où se trouve votre suite de tests.
  • Il est beaucoup plus facile d'introduire un bogue par accident dans le code de production que dans le code de test. Et puisque le code de production n'est pas auto-vérifiable (bien que cela puisse être abordé avec Design by Contract ou des systèmes de type plus riches), nous avons toujours besoin d'un programme externe pour le tester et nous avertir si une régression se produit.
guillaume31
la source
1

Parce que parfois, se répéter c'est bien. Aucun de ces principes n'est censé être appliqué en toutes circonstances sans question ni contexte. J'ai parfois écrit des tests contre une version naïve (et lente) d'un algorithme, ce qui est une violation assez nette de DRY, mais certainement bénéfique.

U2EF1
la source
1

Étant donné que les tests unitaires consistent à rendre les changements involontaires plus difficiles, ils peuvent parfois aussi rendre les changements intentionnels plus difficiles. Ce fait est en effet lié au principe DRY.

Par exemple, si vous avez une fonction MyFunctionqui est appelée dans le code de production à un seul endroit et que vous écrivez 20 tests unitaires pour elle, vous pouvez facilement finir par avoir 21 places dans votre code où cette fonction est appelée. Maintenant, lorsque vous devez changer la signature de MyFunction, ou la sémantique, ou les deux (car certaines exigences changent), vous avez 21 emplacements à modifier au lieu d'un seul. Et la raison est en effet une violation du principe DRY: vous avez répété (au moins) le même appel de fonction à MyFunction21 fois.

L'approche correcte pour un tel cas consiste également à appliquer le principe DRY à votre code de test: lors de l'écriture de 20 tests unitaires, encapsulez les appels à MyFunctiondans vos tests unitaires en quelques fonctions d'assistance (idéalement une seule), qui sont utilisées par le 20 tests unitaires. Idéalement, vous vous retrouvez avec seulement deux endroits dans votre appel de code MyFunction: un de votre code de production et un de vos tests unitaires. Ainsi, lorsque vous devrez modifier la signature de MyFunctionplus tard, vous n'aurez que quelques emplacements à modifier dans vos tests.

"Quelques endroits" sont toujours plus qu'un "endroit" (ce que vous obtenez sans aucun test unitaire), mais les avantages d'avoir des tests unitaires devraient largement l'emporter sur l'avantage d'avoir moins de code à changer (sinon vous faites des tests unitaires complètement faux).

Doc Brown
la source
0

L'un des plus grands défis à la création de logiciels est de capturer les exigences; c'est pour répondre à la question "que doit faire ce logiciel?" Les logiciels ont besoin d'exigences exactes pour définir avec précision ce que le système doit faire, mais ceux qui définissent les besoins en systèmes logiciels et en projets incluent souvent des personnes qui n'ont pas de connaissances en logiciel ou formelles (mathématiques). Le manque de rigueur dans la définition des exigences a forcé le développement de logiciels à trouver un moyen de valider les logiciels selon les exigences.

L'équipe de développement s'est retrouvée à traduire la description familière d'un projet en exigences plus rigoureuses. La discipline des tests a fusionné en tant que point de contrôle pour le développement de logiciels, pour combler l'écart entre ce qu'un client dit vouloir et ce que le logiciel comprend. Tant les développeurs de logiciels que l'équipe de qualité / tests forment une compréhension de la spécification (informelle), et chacun (indépendamment) écrit des logiciels ou des tests pour s'assurer que leur compréhension est conforme. L'ajout d'une autre personne pour comprendre les exigences (imprécises) a ajouté des questions et une perspective différente pour affiner davantage la précision des exigences.

Comme il y a toujours eu des tests d'acceptation, il était naturel d'élargir le rôle de test pour écrire des tests automatisés et unitaires. Le problème était que cela signifiait embaucher des programmeurs pour faire des tests, et donc vous avez restreint la perspective de l'assurance qualité aux programmeurs faisant des tests.

Cela dit, vous faites probablement de mauvais tests si vos tests diffèrent peu des programmes réels. La suggestion de Msdy serait de se concentrer davantage sur quoi dans les tests, et moins sur comment.

L'ironie est que plutôt que de capturer une spécification formelle des exigences à partir de la description familière, l'industrie a choisi de mettre en œuvre des tests ponctuels comme code pour automatiser les tests. Plutôt que de produire des exigences formelles auxquelles un logiciel pourrait être conçu pour répondre, l'approche adoptée a consisté à tester quelques points, plutôt que de concevoir un logiciel en utilisant une logique formelle. Il s'agit d'un compromis, mais il a été assez efficace et relativement réussi.

ChuckCottrill
la source
0

Si vous pensez que votre code de test est trop similaire à votre code d'implémentation, cela peut être une indication que vous utilisez trop un framework de simulation. Les tests factices à un niveau trop bas peuvent aboutir à une configuration de test ressemblant beaucoup à la méthode testée. Essayez d'écrire des tests de niveau supérieur qui sont moins susceptibles de se casser si vous modifiez votre implémentation (je sais que cela peut être difficile, mais si vous pouvez le gérer, vous aurez une suite de tests plus utile en conséquence).

Jules
la source
0

Les tests unitaires ne doivent pas inclure une duplication du code testé, comme cela a déjà été noté.

J'ajouterais cependant que les tests unitaires ne sont généralement pas aussi SECS que le code de "production", car la configuration a tendance à être similaire (mais pas identique) entre les tests ... surtout si vous avez un nombre important de dépendances dont vous vous moquez / truquer.
Il est bien sûr possible de refactoriser ce genre de chose dans une méthode de configuration commune (ou un ensemble de méthodes de configuration) ... mais j'ai trouvé que ces méthodes de configuration ont tendance à avoir de longues listes de paramètres et à être plutôt cassantes.

Soyez donc pragmatique. Si vous pouvez consolider le code de configuration sans compromettre la maintenabilité, faites-le par tous les moyens. Mais si l'alternative est un ensemble complexe et fragile de méthodes de configuration, un peu de répétition dans vos méthodes de test est OK.

Un évangéliste local TDD / BDD le dit ainsi:
"Votre code de production doit être SEC. Mais il est OK que vos tests soient" humides "."

David
la source
0

Il semble que les tests expriment essentiellement la même chose que le code, et sont donc un doublon (dans le concept, pas l'implémentation) du code.

Ce n'est pas vrai, les tests décrivent les cas d'utilisation, tandis que le code décrit un algorithme qui passe les cas d'utilisation, ce qui est plus général. Par TDD, vous commencez par écrire des cas d'utilisation (probablement basés sur la user story) et ensuite vous implémentez le code nécessaire pour passer ces cas d'utilisation. Vous écrivez donc un petit test, un petit morceau de code, et ensuite vous refactorisez si nécessaire pour vous débarrasser des répétitions. Voilà comment ça fonctionne.

Par tests, il peut aussi y avoir des répétitions. Par exemple, vous pouvez réutiliser des appareils, du code générant des appareils, des assertions compliquées, etc. , lorsque vous recherchez le bogue dans le code pendant une demi-heure et que le test est incorrect ... xD

inf3rno
la source