TDD et couverture de test complète où des cas de test exponentiels sont nécessaires

18

Je travaille sur un comparateur de liste pour aider à trier une liste non ordonnée de résultats de recherche selon les exigences très spécifiques de notre client. Les exigences nécessitent un algorithme de pertinence classé avec les règles suivantes par ordre d'importance:

  1. Correspondance exacte sur le nom
  2. Tous les mots de la requête de recherche dans le nom ou un synonyme du résultat
  3. Quelques mots de la requête de recherche dans le nom ou le synonyme du résultat (% décroissant)
  4. Tous les mots de la requête de recherche dans la description
  5. Quelques mots de la requête de recherche dans la description (% décroissant)
  6. Dernière date de modification décroissante

Le choix de conception naturel pour ce comparateur semblait être un classement noté basé sur des puissances de 2. La somme des règles moins importantes ne peut jamais être plus qu'une correspondance positive sur une règle de plus grande importance. Ceci est réalisé par le score suivant:

  1. 32
  2. 16
  3. 8 (score bris d'égalité secondaire basé sur le% décroissant)
  4. 4
  5. 2 (score bris d'égalité secondaire basé sur le% décroissant)
  6. 1

Dans l'esprit TDD, j'ai décidé de commencer par mes tests unitaires en premier. Avoir un scénario de test pour chaque scénario unique serait au minimum de 63 scénarios de test uniques sans tenir compte des scénarios de test supplémentaires pour la logique de bris d'égalité secondaire sur les règles 3 et 5. Cela semble dominateur.

Les tests réels seront en fait moins importants. Sur la base des règles réelles elles-mêmes, certaines règles garantissent que les règles inférieures seront toujours vraies (par exemple, lorsque «Tous les mots de la requête de recherche apparaissent dans la description», la règle «Certains mots de la requête de recherche apparaissent dans la description» sera toujours vraie). Le niveau d'effort pour écrire chacun de ces cas de test en vaut-il la peine? S'agit-il du niveau de test généralement requis lorsque l'on parle de couverture de test à 100% en TDD? Sinon, quelle serait une stratégie de test alternative acceptable?

maple_shaft
la source
1
Ce scénario et d'autres similaires sont la raison pour laquelle j'ai développé un "TMatrixTestCase" et un énumérateur pour lesquels vous pouvez écrire le code de test une fois et le nourrir deux ou plusieurs tableaux contenant les entrées et le résultat attendu.
Marjan Venema

Réponses:

17

Votre question implique que TDD a quelque chose à voir avec "l'écriture de tous les cas de test en premier". À mon humble avis, ce n'est pas «dans l'esprit du TDD», c'est en fait contre . N'oubliez pas que TDD est synonyme de « développement piloté par les tests », vous n'avez donc besoin que des cas de test qui «pilotent» réellement votre implémentation, pas plus. Et tant que votre implémentation n'est pas conçue de manière à ce que le nombre de blocs de code augmente de façon exponentielle à chaque nouvelle exigence, vous n'aurez pas besoin non plus d'un nombre exponentiel de cas de test. Dans votre exemple, le cycle TDD ressemblera probablement à ceci:

  • commencez par la première exigence de votre liste: les mots avec "Correspondance exacte sur le nom" doivent obtenir un score plus élevé que tout le reste
  • Maintenant, vous écrivez un premier cas de test pour cela (par exemple: un mot correspondant à une requête donnée) et implémentez la quantité minimale de code de travail qui fait que le test réussit
  • ajoutez un deuxième scénario de test pour la première exigence (par exemple: un mot ne correspondant pas à la requête), et avant d'ajouter un nouveau scénario de test , modifiez votre code existant jusqu'à ce que le 2e test réussisse
  • en fonction des détails de votre implémentation, n'hésitez pas à ajouter d'autres cas de test, par exemple, une requête vide, un mot vide, etc. (rappelez-vous: TDD est une approche de boîte blanche , vous pouvez utiliser le fait que vous connaissez concevoir vos cas de test).

Ensuite, commencez par la 2e exigence:

  • "Tous les mots de la requête de recherche dans le nom ou un synonyme du résultat" doivent obtenir un score inférieur à "Correspondance exacte sur le nom", mais un score plus élevé que tout le reste.
  • maintenant, créez des cas de test pour cette nouvelle exigence, comme ci-dessus, l'un après l'autre, et implémentez la partie suivante de votre code après chaque nouveau test. N'oubliez pas de refactoriser entre les deux, votre code ainsi que vos cas de test.

Voici le problème : lorsque vous ajoutez des cas de test pour l'exigence / le numéro de catégorie "n", vous n'aurez qu'à ajouter des tests pour vous assurer que le score de la catégorie "n-1" est supérieur à celui de la catégorie "n" . Vous n'aurez pas à ajouter de cas de test pour chaque autre combinaison des catégories 1, ..., n-1, car les tests que vous avez écrits auparavant vous assureront que les scores de ces catégories seront toujours dans le bon ordre.

Cela vous donnera donc un certain nombre de cas de test qui croît approximativement de façon linéaire avec le nombre d'exigences, et non de façon exponentielle.

Doc Brown
la source
1
J'aime vraiment cette réponse. Il donne une stratégie de test unitaire claire et concise pour aborder ce problème en gardant TDD à l'esprit. Vous le décomposez assez bien.
maple_shaft
@maple_shaft: merci, et j'aime vraiment votre question. J'aime ajouter que je suppose que même avec votre approche de conception de tous les cas de test en premier, la technique classique de création de classes d'équivalence pour les tests pourrait être suffisante pour réduire la croissance exponentielle (mais je n'ai pas travaillé cela jusqu'à présent).
Doc Brown
13

Envisagez d'écrire une classe qui passe par une liste prédéfinie de conditions et multiplie un score actuel par 2 pour chaque vérification réussie.

Cela peut être testé très facilement, en utilisant seulement quelques tests simulés.

Ensuite, vous pouvez écrire une classe pour chaque condition et il n'y a que 2 tests pour chaque cas.

Je ne comprends pas vraiment votre cas d'utilisation, mais j'espère que cet exemple vous aidera.

public class ScoreBuilder
{
    private ISingleScorableCondition[] _conditions;
    public ScoreBuilder (ISingleScorableCondition[] conditions)
    {
        _conditions = conditions;
    }

    public int GetScore(string toBeScored)
    {
        foreach (var condition in _conditions)
        {
            if (_conditions.Test(toBeScored))
            {
                // score this somehow
            }
        }
    }
}

public class ExactMatchOnNameCondition : ISingleScorableCondition
{
    private IDataSource _dataSource;
    public ExactMatchOnNameCondition(IDataSource dataSource)
    {
        _dataSource = dataSource;
    }

    public bool Test(string toBeTested)
    {
        return _dataSource.Contains(toBeTested);
    }
}

// etc

Vous remarquerez que vos tests de 2 ^ conditions se résument rapidement à 4+ (2 * conditions). 20 est beaucoup moins autoritaire que 64. Et si vous en ajoutez une autre plus tard, vous n'avez pas à changer TOUTES les classes existantes (principe ouvert-fermé), donc vous n'avez pas à écrire 64 nouveaux tests, vous avez juste pour ajouter une autre classe avec 2 nouveaux tests et l'injecter dans votre classe ScoreBuilder.

pdr
la source
Approche intéressante. Pendant tout ce temps, mon esprit n'a jamais envisagé une approche POO, car j'étais coincé dans l'esprit d'un seul composant de comparaison. Je ne cherchais vraiment pas de conseils sur les algorithmes, mais cela est très utile malgré tout.
maple_shaft
4
@maple_shaft: Non, mais vous cherchiez des conseils TDD et ce type d'algorithmes est parfait pour éliminer la question de savoir si cela en vaut la peine, en réduisant considérablement l'effort. La réduction de la complexité est la clé du TDD.
pdr
+1, excellente réponse. Bien que je pense que même sans une solution aussi sophistiquée, le nombre de cas de test ne doit pas augmenter de façon exponentielle (voir ma réponse ci-dessous).
Doc Brown
Je n'ai pas accepté votre réponse parce que je sentais qu'une autre réponse répondait mieux à la question réelle, mais j'ai tellement aimé votre approche de conception que je la mets en œuvre comme vous l'avez suggérée. Cela réduit la complexité et la rend plus extensible à long terme.
maple_shaft
4

Le niveau d'effort pour écrire chacun de ces cas de test en vaut-il la peine?

Vous devrez définir "ça vaut le coup". Le problème avec ce type de scénario est que les tests auront un retour sur l'utilité décroissant. Le premier test que vous écrivez en vaudra certainement la peine. Il peut trouver des erreurs évidentes dans la priorité et même des choses comme l'analyse des erreurs lors de la tentative de décomposition des mots.

Le deuxième test en vaudra la peine car il couvre un chemin différent à travers le code, vérifiant probablement une autre relation de priorité.

Le 63e test n'en vaudra probablement pas la peine car c'est quelque chose dont vous êtes sûr à 99,99% qu'il est couvert par la logique de votre code ou d'un autre test.

S'agit-il du niveau de test généralement requis lorsque l'on parle de couverture de test à 100% en TDD?

Ma compréhension est que la couverture à 100% signifie que tous les chemins de code sont exercés. Cela ne signifie pas que vous effectuez toutes les combinaisons de vos règles, mais tous les différents chemins que votre code pourrait emprunter (comme vous le faites remarquer, certaines combinaisons ne peuvent pas exister dans le code). Mais puisque vous faites du TDD, il n'y a pas encore de "code" pour vérifier les chemins. La lettre du processus dirait faire tous les 63+.

Personnellement, je trouve que la couverture à 100% est un rêve de pipe. Au-delà, c'est peu pragmatique. Les tests unitaires existent pour vous servir, et non l'inverse. À mesure que vous effectuez plus de tests, vous obtenez des rendements décroissants sur l'avantage (la probabilité que le test empêche un bogue + la confiance que le code est correct). Selon ce que fait votre code, vous définissez où sur cette échelle mobile vous arrêtez de faire des tests. Si votre code exécute un réacteur nucléaire, alors peut-être que tous les tests 63+ en valent la peine. Si votre code organise vos archives musicales, vous pourriez probablement vous en sortir avec beaucoup moins.

Telastyn
la source
"couverture" désigne généralement la couverture de code (chaque ligne de code est exécutée) ou la couverture de branche (chaque branche est exécutée au moins une fois dans n'importe quelle direction). Pour les deux types de couverture, 64 cas de test différents ne sont pas nécessaires. Du moins, pas avec une implémentation sérieuse qui ne contient pas de parties de code individuelles pour chacun des 64 cas. Une couverture à 100% est donc entièrement possible.
Doc Brown
@DocBrown - bien sûr, dans ce cas - d'autres choses sont plus difficiles / impossibles à tester; considérez les chemins d'exception de mémoire insuffisante. Les 64 ne seraient-ils pas tous nécessaires dans le TDD «par la lettre» pour faire appliquer le comportement est ignoré de la mise en œuvre?
Telastyn
eh bien, mon commentaire était lié à la question, et votre réponse donne l'impression qu'il peut être difficile d'obtenir une couverture à 100% dans le cas de l'OP . Je doute que. Et je suis d'accord avec vous que l'on peut construire des cas où une couverture à 100% est plus difficile à réaliser, mais cela n'a pas été demandé.
Doc Brown
4

Je dirais que c'est un cas parfait pour TDD.

Vous disposez d'un ensemble connu de critères à tester, avec une répartition logique de ces cas. En supposant que vous allez les tester unitairement maintenant ou plus tard, il semble logique de prendre le résultat connu et de le construire en vous assurant en fait de couvrir chacune des règles de manière indépendante.

De plus, vous pouvez découvrir au fur et à mesure si l'ajout d'une nouvelle règle de recherche rompt une règle existante. Si vous faites tout cela à la fin du codage, vous courez probablement un plus grand risque de devoir en changer un pour en corriger un, qui en casse un autre, qui en casse un autre ... Et, vous apprenez en mettant en œuvre les règles si votre conception est valide ou a besoin de peaufiner.

Wonko the Sane
la source
1

Je ne suis pas un fan de l'interprétation stricte d'une couverture de test à 100% comme de la rédaction de spécifications par rapport à chaque méthode ou du test de chaque permutation du code. Faire cela de manière fanatique a tendance à conduire à une conception pilotée par les tests de vos classes qui n'encapsule pas correctement la logique métier et génère des tests / spécifications qui n'ont généralement aucun sens en termes de description de la logique métier prise en charge. Au lieu de cela, je me concentre sur la structuration des tests un peu comme les règles métier elles-mêmes et je m'efforce d'exercer chaque branche conditionnelle du code avec des tests dans l'espoir explicite que ces tests soient facilement compris par le testeur comme des cas d'utilisation généraux et décrivent en fait le règles commerciales qui ont été mises en œuvre.

Avec cette idée en tête, je testerais de manière exhaustive les 6 facteurs de classement que vous avez énumérés isolément, suivi de 2 ou 3 tests de style d'intégration qui garantissent que vous obtenez vos résultats aux valeurs de classement globales attendues. Par exemple, le cas # 1, Exact Match on Name, j'aurais au moins deux tests unitaires pour tester quand c'est exact et quand ce n'est pas le cas et que les deux scénarios renvoient le score attendu. S'il est sensible à la casse, un cas pour tester "Exact Match" par rapport à "exact match" et éventuellement d'autres variations d'entrée telles que la ponctuation, des espaces supplémentaires, etc. renvoie également les scores attendus.

Une fois que j'ai étudié tous les facteurs individuels contribuant aux scores de classement, je suppose essentiellement que ceux-ci fonctionnent correctement au niveau de l'intégration et je me concentre sur la garantie que leurs facteurs combinés contribuent correctement au score de classement final attendu.

En supposant que les cas # 2 / # 3 et # 4 / # 5 sont généralisés aux mêmes méthodes sous-jacentes, mais en passant des champs différents, vous n'avez qu'à écrire un ensemble de tests unitaires pour les méthodes sous-jacentes et écrire de simples tests unitaires supplémentaires pour tester le spécifique (titre, nom, description, etc.) et la notation dans l'affacturage désigné, ce qui réduit encore la redondance de votre effort de test global.

Avec cette approche, l'approche décrite ci-dessus produirait probablement 3 ou 4 tests unitaires sur le cas # 1, peut-être 10 spécifications sur certains / tous avec des synonymes pris en compte - plus 4 spécifications sur la notation correcte des cas # 2 - # 5 et 2 à 3 spécifications sur le classement de la date finale ordonnée, puis 3 à 4 tests de niveau d'intégration qui mesurent les 6 cas combinés de manière probable (oubliez les cas de bord obscurs pour l'instant, sauf si vous voyez clairement un problème dans votre code qui doit être exercé pour garantir cette condition est gérée) ou assurez - vous de ne pas être violé / cassé par des révisions ultérieures. Cela donne environ 25 spécifications pour exercer 100% du code écrit (même si vous n'avez pas appelé directement 100% des méthodes écrites).

Michael Lang
la source
1

Je n'ai jamais été fan d'une couverture de test à 100%. D'après mon expérience, si quelque chose est assez simple à tester avec seulement un ou deux cas de test, alors c'est assez simple pour échouer rarement. Quand il échoue, c'est généralement en raison de modifications architecturales qui nécessiteraient de toute façon des modifications de test.

Cela étant dit, pour des exigences comme la vôtre, je fais toujours des tests unitaires minutieux, même sur des projets personnels où personne ne me fait, car ce sont les cas où les tests unitaires vous font gagner du temps et vous aggravent. Plus il y aura de tests unitaires requis pour tester quelque chose, plus les tests unitaires de temps permettront d'économiser.

C'est parce que vous ne pouvez tenir que tant de choses dans votre tête à la fois. Si vous essayez d'écrire du code qui fonctionne pour 63 combinaisons différentes, il est souvent difficile de corriger une combinaison sans en casser une autre. Vous finissez par tester manuellement d'autres combinaisons encore et encore. Les tests manuels sont beaucoup plus lents, ce qui vous empêche de réexécuter toutes les combinaisons possibles chaque fois que vous effectuez un changement. Cela vous rend plus susceptible de manquer quelque chose et plus de chances de perdre du temps à suivre des chemins qui ne fonctionnent pas dans tous les cas.

Outre le gain de temps par rapport aux tests manuels, il y a beaucoup moins de tension mentale, ce qui facilite la concentration sur le problème à résoudre sans se soucier d'introduire accidentellement des régressions. Cela vous permet de travailler plus rapidement et plus longtemps sans épuisement. À mon avis, les avantages pour la santé mentale valent à eux seuls le coût des tests unitaires de code complexe, même si cela ne vous a pas fait gagner de temps.

Karl Bielefeldt
la source