Comment fonctionnent les tests unitaires?


23

J'essaie de rendre mon code plus robuste et j'ai lu sur les tests unitaires, mais je trouve très difficile de trouver une utilisation réellement utile. Par exemple, l'exemple Wikipedia :

public class TestAdder {
    public void testSum() {
        Adder adder = new AdderImpl();
        assert(adder.add(1, 1) == 2);
        assert(adder.add(1, 2) == 3);
        assert(adder.add(2, 2) == 4);
        assert(adder.add(0, 0) == 0);
        assert(adder.add(-1, -2) == -3);
        assert(adder.add(-1, 1) == 0);
        assert(adder.add(1234, 988) == 2222);
    }
}

Je pense que ce test est totalement inutile, car vous devez calculer manuellement le résultat souhaité et le tester, je pense qu'un meilleur test unitaire serait ici

assert(adder.add(a, b) == (a+b));

mais alors c'est juste coder la fonction elle-même dans le test. Quelqu'un peut-il me donner un exemple où les tests unitaires sont réellement utiles? Pour info je suis en train de coder principalement des fonctions "procédurales" qui prennent ~ 10 booléens et quelques ints et me donnent un résultat int basé sur cela, je pense que le seul test unitaire que je pourrais faire serait de simplement recoder l'algorithme dans le tester. edit: j'aurais également dû préciser que c'est lors du portage (peut-être mal conçu) du code ruby ​​(que je n'ai pas fait)


14
How does unit testing work?Personne ne sait vraiment :)
yannis

30
msgstr "vous devez calculer manuellement le résultat souhaité". Comment est-ce "totalement inutile"? Sinon, comment pouvez-vous être sûr que la réponse est la bonne?
S.Lott

9
@ S.Lott: C'est ce qu'on appelle le progrès, dans les temps anciens, les gens utilisaient les ordinateurs pour calculer les nombres et gagner du temps, dans les temps modernes, les gens passent du temps pour s'assurer que les ordinateurs peuvent analyser les nombres: D
Coder

2
@Coder: le but des tests unitaires n'est pas "de croquer les nombres et de gagner du temps";)
Andres F.

7
@lezebulon: l'exemple de Wikipedia n'est pas très bon, mais c'est un problème avec ce cas de test particulier, pas avec les tests unitaires en général. Environ la moitié des données de test de l'exemple n'ajoute rien de nouveau, ce qui le rend redondant (je redoute de penser à ce que l'auteur de ce test ferait avec des scénarios plus complexes). Un test plus significatif partitionnerait les données de test dans au moins les scénarios suivants: "peut-il ajouter des nombres négatifs?", "Est-ce que zéro est neutre?", "Peut-il ajouter un nombre négatif et un nombre positif?".
Andres F.

Réponses:


26

Les tests unitaires, si vous testez des unités suffisamment petites, affirment toujours ce qui est aveuglément évident.

La raison qui fait add(x, y)même mention d'un test unitaire est que, quelque temps plus tard, quelqu'un entrera addet mettra un code de gestion de logique fiscale spécial ne réalisant pas que l'ajout est utilisé partout.

Les tests unitaires sont très bien sur le principe associatif: si A ne B, et B fait C, alors A fait C. « A fait C » est un test de niveau supérieur. Par exemple, considérez le code d'entreprise suivant, tout à fait légitime:

public void LoginUser (string username, string password) {
    var user = db.FetchUser (username);

    if (user.Password != password)
        throw new Exception ("invalid password");

    var roles = db.FetchRoles (user);

    if (! roles.Contains ("member"))
        throw new Exception ("not a member");

    Session["user"] = user;
}

À première vue, cela ressemble à une méthode géniale de test unitaire, car elle a un objectif très clair. Cependant, il fait environ 5 choses différentes. Chaque chose a un cas valide et invalide, et fera une énorme permutation de tests unitaires. Idéalement, cela se décompose davantage:

public void LoginUser (string username, string password) {

    var user = _userRepo.FetchValidUser (username, password);

    _rolesRepo.CheckUserForRole (user, "member");

    _localStorage.StoreValue ("user", user);
}

Nous en sommes maintenant aux unités. Un test unitaire ne se soucie pas de ce qui _userRepoconsidère un comportement valide FetchValidUser, seulement qu'il est appelé. Vous pouvez utiliser un autre test pour garantir exactement ce que constitue un utilisateur valide. De même pour CheckUserForRole... vous avez découplé votre test de savoir à quoi ressemble la structure de rôle. Vous avez également découplé l'intégralité de votre programme Session. J'imagine que toutes les pièces manquantes ici ressembleraient à:

class UserRepository : IUserRepository
{
    public User FetchValidUser (string username, string password)
    {
        var user = db.FetchUser (username);

        if (user.Password != password)
            throw new Exception ("invalid password");

        return user;
    }
}

class RoleRepository : IRoleRepository
{
    public void CheckUserForRole (User user, string role)
    {
        var roles = db.FetchRoles (user);

        if (! roles.Contains (role))
            throw new Exception ("not a member");
    }
}

class SessionStorage : ILocalStorage
{
    public void StoreValue (string key, object value)
    {
        Session[key] = value;
    }
}

En refactorisant, vous avez accompli plusieurs choses à la fois. Le programme est beaucoup plus favorable à la suppression des structures sous-jacentes (vous pouvez abandonner la couche de base de données pour NoSQL), ou à ajouter de façon transparente un verrouillage une fois que vous réalisez que ce Sessionn'est pas thread-safe ou autre. Vous vous êtes également donné des tests très simples à écrire pour ces trois dépendances.

J'espère que cela t'aides :)


13

Je suis en train de coder principalement des fonctions "procédurales" qui prennent ~ 10 booléens et quelques ints et me donnent un résultat entier basé sur cela, j'ai l'impression que le seul test unitaire que je pourrais faire serait de simplement recoder l'algorithme dans le test

Je suis à peu près sûr que chacune de vos fonctions procédurales est déterministe, elle renvoie donc un résultat int spécifique pour chaque ensemble de valeurs d'entrée donné. Idéalement, vous auriez une spécification fonctionnelle à partir de laquelle vous pouvez déterminer le résultat que vous devriez recevoir pour certains ensembles de valeurs d'entrée. En l'absence de cela, vous pouvez exécuter le code ruby ​​(qui est supposé fonctionner correctement) pour certains ensembles de valeurs d'entrée et enregistrer les résultats. Ensuite, vous devez coder les résultats dans votre test. Le test est censé être une preuve que votre code produit effectivement des résultats connus pour être corrects .


+1 pour exécuter le code existant et enregistrer les résultats. Dans cette situation, c'est probablement l'approche pragmatique.
MarkJ

12

Étant donné que personne d'autre ne semble avoir fourni un exemple réel:

    public void testRoman() {
        RomanNumeral numeral = new RomanNumeral();
        assert( numeral.toRoman(1) == "I" )
        assert( numeral.toRoman(4) == "IV" )
        assert( numeral.toRoman(5) == "V" )
        assert( numeral.toRoman(9) == "IX" )
        assert( numeral.toRoman(10) == "X" )
    }
    public void testSqrt() {
        assert( sqrt(4) == 2 )
        assert( sqrt(9) == 3 )
    }

Vous dites:

Je pense que ce test est totalement inutile, car vous devez calculer manuellement le résultat souhaité et le tester

Mais le fait est que vous êtes beaucoup moins susceptible de faire une erreur (ou du moins plus susceptible de remarquer vos erreurs) lors des calculs manuels, puis lors du codage.

Quelle est la probabilité de faire une erreur dans votre code de conversion décimal en romain? Probablement. Quelle est la probabilité de faire une erreur lors de la conversion manuelle des nombres décimaux en chiffres romains? Probablement pas. C'est pourquoi nous testons par rapport aux calculs manuels.

Quelle est la probabilité de faire une erreur lors de l'implémentation d'une fonction de racine carrée? Probablement. Quelle est la probabilité que vous vous trompiez lors du calcul manuel d'une racine carrée? Probablement plus probable. Mais avec sqrt, vous pouvez utiliser une calculatrice pour obtenir les réponses.

Pour info je suis en train de coder principalement des fonctions "procédurales" qui prennent ~ 10 booléens et quelques ints et me donnent un résultat int basé sur cela, je pense que le seul test unitaire que je pourrais faire serait de simplement recoder l'algorithme dans le tester

Je vais donc spéculer sur ce qui se passe ici. Vos fonctions sont un peu compliquées, il est donc difficile de déterminer à partir des entrées ce que devrait être la sortie. Pour ce faire, vous devez exécuter manuellement (dans votre tête) la fonction pour déterminer la sortie. Naturellement, cela semble un peu inutile et sujet aux erreurs.

La clé est que vous voulez trouver les bonnes sorties. Mais vous devez tester ces sorties par rapport à quelque chose de correct. Ce n'est pas bon d'écrire votre propre algorithme pour le calculer car cela peut très bien être incorrect. Dans ce cas, il est trop difficile de calculer manuellement les valeurs.

Je reviendrais au code ruby ​​et exécuterais ces fonctions originales avec divers paramètres. Je prendrais les résultats du code rubis et les mettrais dans le test unitaire. De cette façon, vous n'avez pas à faire le calcul manuel. Mais vous testez par rapport au code d'origine. Cela devrait aider à garder les mêmes résultats, mais s'il y a des bogues dans l'original, cela ne vous aidera pas. Fondamentalement, vous pouvez traiter le code d'origine comme la calculatrice dans l'exemple sqrt.

Si vous montriez le code réel que vous portez, nous pourrions fournir des commentaires plus détaillés sur la façon d'aborder le problème.


Et si le code Ruby contient un bogue dont vous ne savez pas qu'il ne se trouve pas dans votre nouveau code et que votre code échoue à un test unitaire basé sur les sorties Ruby, alors l'enquête sur les raisons de l'échec vous confirmera finalement et entraînera la bogue Ruby latent trouvé. C'est plutôt cool.
Adam Wuerl

11

Je pense que le seul test unitaire que je pourrais faire serait de simplement recoder l'algorithme dans le test

Vous avez presque raison pour une classe aussi simple.

Essayez-le pour une calculatrice plus complexe. Comme une calculatrice de score de bowling.

La valeur des tests unitaires est plus facilement visible lorsque vous avez des règles "métier" plus complexes avec différents scénarios à tester.

Je ne dis pas que vous ne devriez pas tester une course de la calculatrice de l'usine (votre compte de calculatrice émet-il des valeurs comme 1/3 qui ne peuvent pas être représentées? Que fait-il avec la division par zéro?), Mais vous verrez le évaluer plus clairement si vous testez quelque chose avec plus de succursales pour obtenir une couverture.


4
+1 pour le noter devient plus utile pour les fonctions compliquées. Et si vous décidiez d'étendre adder.add () aux valeurs à virgule flottante? Des matrices? Valeurs de compte légères?
joshin4colours

6

Malgré le fanatisme religieux d'environ 100% de couverture de code, je dirai que toutes les méthodes ne doivent pas être testées à l'unité. Seule fonctionnalité qui contient une logique métier significative. Une fonction qui ajoute simplement un nombre est inutile à tester.

Je suis en train de coder principalement des fonctions "procédurales" qui prennent ~ 10 booléens et quelques ints et me donnent un résultat int basé sur ceci

Il y a là votre vrai problème. Si les tests unitaires semblent anormalement durs ou inutiles, cela est probablement dû à un défaut de conception. Si elle était plus orientée objet, vos signatures de méthode ne seraient pas aussi massives et il y aurait moins d'entrées possibles à tester.

Je n'ai pas besoin d'entrer dans mon OO est supérieur à la programmation procédurale ...


dans ce cas, la "signature" de la méthode n'est pas massive, je viens de lire un std :: vector <bool> qui est un membre de la classe. J'aurais également dû préciser que je
portais du

2
@lezebulon Indépendamment s'il y a autant d'entrées possibles pour cette seule méthode à accepter, cette méthode en fait trop .
maple_shaft

3

De mon point de vue, les tests unitaires sont même utiles à votre petite classe d'additionneur: ne pensez pas à "recoder" l'algorithme et considérez-le comme une boîte noire avec la seule connaissance que vous avez sur le comportement fonctionnel (si vous êtes familier avec une multiplication rapide, vous connaissez des tentatives plus rapides mais plus complexes que l'utilisation du "a * b") et de votre interface publique. Alors vous devriez vous demander "Qu'est-ce qui pourrait bien se passer?" ...

Dans la plupart des cas, cela se produit à la frontière (je vois que vous testez déjà l'ajout de ces modèles ++, -, + -, 00 - temps pour les compléter par - +, 0+, 0-, +0, -0). Pensez à ce qui se passe à MAX_INT et MIN_INT lors de l'ajout ou de la soustraction (ajout de négatifs;)). Ou essayez de vous assurer que vos tests ressemblent exactement à ce qui se passe à zéro ou autour de zéro.

Dans l'ensemble, le secret est très simple (peut-être aussi pour les plus complexes;)) pour les classes simples: pensez aux contrats de votre classe (voir conception par contrat) puis testez-les. Mieux vous connaîtrez vos invs, pre's et post's, le "completer" sera vos tests.

Astuce pour vos classes de test: essayez d'écrire une seule assertion dans une méthode. Donnez de bons noms aux méthodes (par exemple, "testAddingToMaxInt", "testAddingTwoNegatives") pour avoir les meilleurs commentaires lorsque votre test échoue après le changement de code.


2

Plutôt que de tester une valeur de retour calculée manuellement ou de dupliquer la logique dans le test pour calculer la valeur de retour attendue, testez la valeur de retour pour une propriété attendue.

Par exemple, si vous voulez tester une méthode qui inverse une matrice, vous ne voulez pas inverser manuellement votre valeur d'entrée, vous devez multiplier la valeur de retour par l'entrée et vérifier que vous obtenez la matrice d'identité.

Pour appliquer cette approche à votre méthode, vous devrez tenir compte de son objectif et de sa sémantique, afin d'identifier les propriétés de la valeur de retour par rapport aux entrées.


2

Les tests unitaires sont un outil de productivité. Vous recevez une demande de modification, implémentez-la, puis exécutez votre code via le gambit de test unitaire. Ces tests automatisés font gagner du temps.

I feel that this test is totally useless, because you are required to manually compute the wanted result and test it, I feel like a better unit test here would be

Un point discutable. Le test de l'exemple montre simplement comment instancier une classe et l'exécuter à travers une série de tests. Se concentrer sur la minutie d'une seule mise en œuvre manque la forêt pour les arbres.

Can someone provide me with an example where unit testing is actually useful?

Vous avez une entité Employé. L'entité contient un nom et une adresse. Le client décide d'ajouter un champ ReportsTo.

void TestBusinessLayer()
{
   int employeeID = 1234
   Employee employee = Employee.GetEmployee(employeeID)
   BusinessLayer bl = new BusinessLayer()
   Assert.isTrue(bl.Add(employee))//assume Add returns true on pass
}

C'est un test de base du BL pour travailler avec un employé. Le code passera / échouera la modification de schéma que vous venez d'apporter. N'oubliez pas que les affirmations ne sont pas la seule chose que fait le test. L'exécution du code garantit également qu'aucune exception ne se propage.

Au fil du temps, la mise en place des tests facilite les changements en général. Le code est automatiquement testé pour les exceptions et les assertions que vous faites. Cela évite une grande partie des frais généraux occasionnés par les tests manuels par un groupe d'assurance qualité. Bien que l'interface utilisateur soit encore assez difficile à automatiser, les autres couches sont généralement très faciles en supposant que vous utilisez correctement les modificateurs d'accès.

I feel like the only unit testing I could do would be to simply re-code the algorithm in the test.

Même la logique procédurale est facilement encapsulée dans une fonction. Encapsuler, instancier et passer l'int / primitive à tester (ou l'objet factice). Ne copiez pas collez le code dans un test unitaire. Cela bat DRY. Il échoue également entièrement au test car vous ne testez pas le code, mais une copie du code. Si le code qui aurait dû être testé change, le test réussit quand même!


<pedantry> "gamut", pas "gambit". </
pedantry

@chao lol apprendre quelque chose de nouveau chaque jour.
P.Brian.Mackey

2

Prenant votre exemple (avec un peu de refactoring),

assert(a + b, math.add(a, b));

n'aide pas à:

  • comprendre comment math.addse comporte en interne,
  • savoir ce qui se passera avec les cas marginaux.

Cela revient à dire:

  • Si vous voulez savoir ce que fait la méthode, allez voir les centaines de lignes de code source vous-même (car, oui, math.add peut contenir des centaines de LOC; voir ci-dessous).
  • Je ne me soucie pas de savoir si la méthode fonctionne correctement. C'est correct si les valeurs attendues et réelles sont différentes de ce que j'attendais vraiment .

Cela signifie également que vous n'avez pas besoin d'ajouter des tests comme:

assert(3, math.add(1, 2));
assert(4, math.add(2, 2));

Ils n'aident pas non plus, ou du moins, une fois que vous avez fait la première affirmation, la seconde n'apporte rien d'utile.

Au lieu de cela, qu'en est-il:

const numeric Pi = 3.1415926535897932384626433832795;
const numeric Expected = 4.1415926535897932384626433832795;
assert(Expected, math.add(Pi, 1),
    "Adding an integer to a long numeric doesn't give a long numeric result.");
assert(Expected, math.add(1, Pi),
    "Adding a long numeric to an integer doesn't give a long numeric result.");

Cela s'explique de lui-même et est extrêmement utile pour vous et pour la personne qui conservera le code source plus tard. Imaginez que cette personne modifie légèrement le math.addpour simplifier le code et optimiser les performances, et voit le résultat du test comme:

Test TestNumeric() failed on assertion 2, line 5: Adding a long numeric to an
integer doesn't give a long numeric result.

Expected value: 4.1415926535897932384626433832795
Actual value: 4

cette personne comprendra immédiatement que la méthode nouvellement modifiée dépend de l'ordre des arguments: si le premier argument est un entier et le second est un long numérique, le résultat serait un entier, alors qu'un long numérique était attendu.

De la même manière, l'obtention de la valeur réelle de 4.141592lors de la première assertion est explicite: vous savez que la méthode est censée traiter avec une grande précision , mais en fait, elle échoue.

Pour la même raison, deux affirmations suivantes peuvent avoir un sens dans certaines langues:

// We don't expect a concatenation. `math` library is not intended for this.
assert(0, math.add("Hello", "World"));

// We expect the method to convert every string as if it was a decimal.
assert(5, math.add("0x2F", 5));

Et qu'en est-il de:

assert(numeric.Infinity, math.add(numeric.Infinity, 1));

Explicite aussi: vous voulez que votre méthode puisse gérer correctement l'infini. Aller au-delà de l'infini ou lever une exception n'est pas un comportement attendu.

Ou peut-être, selon votre langue, cela aura plus de sens?

/**
 * Ensures that when adding numbers which exceed the maximum value, the method
 * fails with OverflowException, instead of restarting at numeric.Minimum + 1.
 */
TestOverflow()
{
    UnitTest.ExpectException(ofType(OverflowException));

    numeric result = math.add(numeric.Maximum, 1));

    UnitTest.Fail("The tested code succeeded, while an OverflowException was
        expected.");
}

1

Pour une fonction très simple comme add, les tests peuvent être considérés comme inutiles, mais à mesure que vos fonctions deviennent plus complexes, il devient de plus en plus évident pourquoi les tests sont nécessaires.

Pensez à ce que vous faites lorsque vous programmez (sans test unitaire). Habituellement, vous écrivez du code, l'exécutez, voyez qu'il fonctionne et passez à la chose suivante, non? Au fur et à mesure que vous écrivez plus de code, en particulier dans un très grand système / interface graphique / site Web, vous constatez que vous devez faire de plus en plus de "courir et voir si cela fonctionne". Vous devez essayer ceci et essayer cela. Ensuite, vous apportez quelques modifications et vous devez recommencer ces mêmes choses. Il devient très évident que vous pourriez gagner du temps en écrivant des tests unitaires qui automatiseraient toute la partie "courir et voir si ça marche".

À mesure que vos projets deviennent de plus en plus grands, le nombre de choses que vous devez "exécuter et voir si cela fonctionne" devient irréaliste. Donc, vous finissez par exécuter et essayer quelques composants majeurs de l'interface graphique / projet et en espérant que tout le reste va bien. Ceci est une recette pour le désastre. Bien sûr, en tant qu'être humain, vous ne pouvez pas tester de manière répétée chaque situation possible que vos clients pourraient utiliser si l'interface graphique est utilisée par des centaines de personnes. Si vous aviez des tests unitaires en place, vous pouvez simplement exécuter le test avant d'expédier la version stable, ou même avant de vous engager dans le référentiel central (si votre lieu de travail en utilise un). Et, s'il y a des bogues trouvés plus tard, vous pouvez simplement ajouter un test unitaire pour le vérifier à l'avenir.


1

L'un des avantages de l'écriture de tests unitaires est qu'il vous aide à écrire du code plus robuste en vous forçant à penser aux cas limites. Que diriez-vous de tester certains cas marginaux, comme le débordement d'entier, la troncature décimale ou la gestion des valeurs nulles pour les paramètres?


0

Vous supposez peut-être que add () a été implémenté avec l'instruction ADD. Si un programmeur junior ou un ingénieur matériel a réimplémenté la fonction add () à l'aide d'ANDS / ORS / XORS, des inversions de bits et des décalages, vous voudrez peut-être le tester unitaire par rapport à l'instruction ADD.

En général, si vous remplacez les tripes d'add () ou de l'unité testée par un nombre aléatoire ou un générateur de sortie, comment sauriez-vous que quelque chose a été cassé? Encodez cette connaissance dans vos tests unitaires. Si personne ne peut dire s'il est cassé, vérifiez simplement le code de rand () et rentrez chez vous, votre travail est terminé.


0

Je l'ai peut-être manqué dans toutes les réponses, mais pour moi, le principal moteur des tests unitaires consiste moins à prouver l'exactitude d'une méthode aujourd'hui, mais à prouver l'exactitude continue de cette méthode lorsque vous la modifiez .

Prenez une fonction simple, comme renvoyer le nombre d'articles dans une collection. Aujourd'hui, lorsque votre liste est basée sur une structure de données interne que vous connaissez bien, vous pourriez penser que cette méthode est si douloureusement évidente que vous n'avez pas besoin de la tester. Ensuite, dans plusieurs mois ou années, vous (ou quelqu'un d'autre ) décidez de remplacer la structure de liste interne. Vous devez toujours savoir que getCount () renvoie la valeur correcte.

C'est là que vos tests unitaires prennent tout leur sens.

Vous pouvez modifier l'implémentation interne de votre code, mais pour tout consommateur de ce code, le résultat reste le même.

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.