web-dev-qa-db-fra.com

Comment éviter les erreurs logiques dans le code, quand TDD n'a pas aidé?

J'écrivais récemment un petit morceau de code qui indiquerait de manière conviviale l'âge d'un événement. Par exemple, cela pourrait indiquer que l'événement s'est produit "il y a trois semaines" ou "il y a un mois" ou "hier".

Les exigences étaient relativement claires et c'était un cas parfait pour un développement piloté par les tests. J'ai écrit les tests un par un, implémentant le code pour passer chaque test, et tout semblait fonctionner parfaitement. Jusqu'à ce qu'un bug apparaisse en production.

Voici le morceau de code pertinent:

now = datetime.datetime.utcnow()
today = now.date()
if event_date.date() == today:
    return "Today"

yesterday = today - datetime.timedelta(1)
if event_date.date() == yesterday:
    return "Yesterday"

delta = (now - event_date).days

if delta < 7:
    return _number_to_text(delta) + " days ago"

if delta < 30:
    weeks = math.floor(delta / 7)
    if weeks == 1:
        return "A week ago"

    return _number_to_text(weeks) + " weeks ago"

if delta < 365:
    ... # Handle months and years in similar manner.

Les tests vérifiaient le cas d'un événement se produisant aujourd'hui, hier, il y a quatre jours, il y a deux semaines, il y a une semaine, etc., et le code a été construit en conséquence.

Ce qui m'a manqué, c'est qu'un événement peut se produire avant-hier, tout en étant il y a un jour: par exemple, un événement se produisant il y a vingt-six heures serait il y a un jour, alors que ce n'est pas exactement hier s'il est maintenant 1 heure du matin. Plus exactement, c'est un point quelque chose, mais puisque le delta est un entier, il n'en sera qu'un. Dans ce cas, l'application affiche "Il y a un jour", ce qui est évidemment inattendu et non géré dans le code. Il peut être corrigé en ajoutant:

if delta == 1:
    return "A day ago"

juste après avoir calculé le delta.

Bien que la seule conséquence négative du bug soit que j'ai perdu une demi-heure à me demander comment ce cas pourrait se produire (et à croire qu'il a à voir avec les fuseaux horaires, malgré l'utilisation uniforme de l'UTC dans le code), sa présence me dérange. Il indique que:

  • Il est très facile de commettre une erreur logique même dans un code source aussi simple.
  • Le développement piloté par les tests n'a pas aidé.

Ce qui est également inquiétant, c'est que je ne vois pas comment éviter de tels bugs. En plus de réfléchir avant d'écrire du code, la seule façon dont je peux penser est d'ajouter beaucoup d'assertions pour les cas qui, je pense, ne se produiraient jamais (comme je pensais qu'il y a un jour, c'est nécessairement hier), puis de parcourir chaque seconde pour au cours des dix dernières années, en vérifiant toute violation d'affirmation, qui semble trop complexe.

Comment pourrais-je éviter de créer ce bug en premier lieu?

67
Arseni Mourzenko

Ce sont les types d'erreurs que vous trouvez généralement à l'étape refactor de red/green/refactor. N'oubliez pas cette étape! Considérez un refactor comme le suivant (non testé):

def pluralize(num, unit):
    if num == 1:
        return unit
    else:
        return unit + "s"

def convert_to_unit(delta, unit):
    factor = 1
    if unit == "week":
        factor = 7 
    Elif unit == "month":
        factor = 30
    Elif unit == "year":
        factor = 365
    return delta // factor

def best_unit(delta):
    if delta < 7:
        return "day"
    Elif delta < 30:
        return "week"
    Elif delta < 365:
        return "month"
    else:
        return "year"

def human_friendly(event_date):
    date = event_date.date()
    today = now.date()
    yesterday = today - datetime.timedelta(1)
    if date == today:
        return "Today"
    Elif date == yesterday:
        return "Yesterday"
    else:
        delta = (now - event_date).days
        unit = best_unit(delta)
        converted = convert_to_unit(delta, unit)
        pluralized = pluralize(converted, unit)
        return "{} {} ago".format(converted, pluralized)

Ici, vous avez créé 3 fonctions à un niveau d'abstraction inférieur qui sont beaucoup plus cohérentes et plus faciles à tester isolément. Si vous omettiez un intervalle de temps que vous vouliez, cela ressortirait comme un pouce endolori dans les fonctions d'assistance plus simples. De plus, en supprimant la duplication, vous réduisez le risque d'erreur. Vous devrez en fait ajouter du code pour implémenter votre cas cassé.

D'autres cas de test plus subtils viennent aussi plus facilement à l'esprit lorsque l'on regarde une forme refactorisée comme celle-ci. Par exemple, que doit best_unit faire si delta est négatif?

En d'autres termes, le refactoring n'est pas seulement pour le rendre joli. Cela permet aux humains de repérer plus facilement les erreurs que le compilateur ne peut pas détecter.

57
Karl Bielefeldt

Le développement piloté par les tests n'a pas aidé.

Il semble que cela ait aidé, c'est juste que vous n'avez pas eu de test pour le scénario "il y a un jour". Vraisemblablement, vous avez ajouté un test après la découverte de ce cas; il s'agit toujours de TDD, en ce sens que lorsque des bogues sont trouvés, vous écrivez un test unitaire pour détecter le bogue, puis le corrigez.

Si vous oubliez d'écrire un test de comportement, TDD n'a rien pour vous aider; vous oubliez d'écrire le test et n'écrivez donc pas l'implémentation.

149
esoterik

un événement qui se passe il y a vingt-six heures serait il y a un jour

Les tests n'aideront pas beaucoup si un problème est mal défini. Vous mélangez évidemment les jours civils avec les jours calculés en heures. Si vous vous en tenez aux jours civils, alors à 1 h du matin, il y a 26 heures est pas hier. Et si vous vous en tenez aux heures, il y a 26 heures, il y a 1 jour, quelle que soit l'heure.

114
Kevin Krumwiede

Tu ne peux pas. TDD est formidable pour vous protéger contre d'éventuels problèmes que vous connaissez. Cela n'aide pas si vous rencontrez des problèmes que vous n'avez jamais pris en compte. Votre meilleur pari est d'avoir quelqu'un d'autre qui teste le système, ils peuvent trouver les cas Edge que vous n'avez jamais envisagés.

Lecture connexe: Est-il possible d'atteindre l'état de bogue zéro absolu pour les logiciels à grande échelle?

38
Ian Jacobs

Il y a deux approches que je prends normalement et qui peuvent aider.

Tout d'abord, je cherche les cas Edge. Ce sont des endroits où le comportement change. Dans votre cas, le comportement change à plusieurs moments de la séquence de jours entiers positifs. Il y a un cas Edge à zéro, à un, à sept, etc. J'écrirais alors des cas de test dans et autour des cas Edge. J'aurais des cas de test à -1 jours, 0 jours, 1 heures, 23 heures, 24 heures, 25 heures, 6 jours, 7 jours, 8 jours, etc.

La deuxième chose que je rechercherais, ce sont les modèles de comportement. Dans votre logique pendant des semaines, vous avez un traitement spécial pendant une semaine. Vous avez probablement une logique similaire dans chacun de vos autres intervalles non représentés. Cette logique est pas présente pendant des jours, cependant. Je regarderais cela avec suspicion jusqu'à ce que je puisse soit expliquer de façon vérifiable pourquoi ce cas est différent, soit ajouter la logique.

35
cbojar

Vous ne pouvez pas intercepter les erreurs logiques présentes dans vos besoins avec TDD. Mais quand même, TDD aide. Après tout, vous avez trouvé l'erreur et ajouté un cas de test. Mais fondamentalement, TDD niquement garantit que le code est conforme à votre modèle mental. Si votre modèle mental est défectueux, les cas de test ne les rattraperont pas.

Mais gardez à l'esprit que, tout en corrigeant le bogue, les cas de test que vous aviez déjà vérifiés ne comportaient aucun comportement fonctionnel existant. C'est assez important, il est facile de corriger un bogue mais d'en introduire un autre.

Afin de trouver ces erreurs à l'avance, vous essayez généralement d'utiliser des cas de test basés sur la classe d'équivalence. en utilisant ce principe, vous choisiriez un cas de chaque classe d'équivalence, puis tous les cas Edge.

Vous choisiriez une date d'aujourd'hui, d'hier, d'il y a quelques jours, il y a exactement une semaine et plusieurs semaines comme exemples de chaque classe d'équivalence. Lorsque vous testez des dates, vous devez également vous assurer que vos tests ont pas utilisé la date système, mais utilisé une date prédéterminée pour la comparaison. Cela mettrait également en évidence certains cas Edge: vous vous assureriez d'exécuter vos tests à une heure arbitraire de la journée, vous l'exécuteriez directement après minuit, directement avant minuit et même directement at minuit. Cela signifie que pour chaque test, il y aurait quatre fois de base contre lesquelles il est testé.

Ensuite, vous ajouteriez systématiquement des cas Edge à toutes les autres classes. Vous avez le test pour aujourd'hui. Ajoutez donc une heure juste avant et après le changement de comportement. La même chose pour hier. La même chose pour une semaine, etc.

Il est probable qu'en énumérant tous les cas Edge de manière systématique et en les écrivant, vous découvrez que votre spécification manque de détails et l'ajoutez. Notez que la gestion des dates est quelque chose que les gens se trompent souvent, car les gens oublient souvent d'écrire leurs tests afin de pouvoir les exécuter à des heures différentes.

Notez, cependant, que la plupart de ce que j'ai écrit a peu à voir avec TDD. Il s'agit d'écrire des classes d'équivalence et de s'assurer que vos propres spécifications sont suffisamment détaillées à leur sujet. That est le processus avec lequel vous minimisez les erreurs logiques. TDD s'assure simplement que votre code est conforme à votre modèle mental.

Venir avec des cas de test est dur. Les tests basés sur la classe d'équivalence ne sont pas la fin de tout, et dans certains cas, ils peuvent augmenter considérablement le nombre de cas de test. Dans le monde réel, ajouter tous ces tests n'est souvent pas économiquement viable (même si en théorie, cela devrait être fait).

14
Polygnome

La seule façon dont je peux penser est d'ajouter beaucoup d'affirmations pour les cas qui, je crois, ne se produiraient jamais (comme je pensais qu'il y a un jour, c'est nécessairement hier), puis de parcourir chaque seconde au cours des dix dernières années, en vérifiant toute violation d'assertion, qui semble trop complexe.

Pourquoi pas? Cela ressemble à une assez bonne idée!

L'ajout de contrats (assertions) au code est un moyen assez solide d'améliorer sa justesse. Généralement, nous les ajoutons en tant que conditions préalables à l'entrée de la fonction et conditions postérieures au retour de la fonction . Par exemple, nous pourrions ajouter une postcondition selon laquelle toutes les valeurs retournées sont soit de la forme "A [unité] il y a" ou "[nombre] [unité] il y a". Lorsque cela est fait de manière disciplinée, cela conduit à conception par contrat, et est l'un des moyens les plus courants d'écrire du code à haute assurance.

Surtout, les contrats ne sont pas destinés à être testés; ce sont autant de spécifications de votre code que vos tests. Cependant, vous pouvez tester via les contrats: appelez le code dans votre test et, si aucun des contrats ne soulève d'erreurs, le test réussit. Parcourir en boucle tous les seconde des dix dernières années, c'est un peu trop. Mais nous pouvons tirer parti d'un autre style de test appelé test basé sur les propriétés.

Dans PBT au lieu de tester des sorties spécifiques du code, vous testez que la sortie obéit à une propriété. Par exemple, une propriété d'une fonction reverse() est celle de toute liste l, reverse(reverse(l)) = l. L'avantage d'écrire des tests comme celui-ci est que le moteur PBT peut générer quelques centaines de listes arbitraires (et quelques listes pathologiques) et vérifier qu'elles ont toutes cette propriété. Si aucun ne le faites pas, le moteur "rétrécit" le cas défaillant pour trouver une liste minimale qui casse votre code. Il semble que vous écriviez Python, qui a Hypothesis comme framework PBT principal.

Donc, si vous voulez un bon moyen de trouver des cas Edge plus délicats auxquels vous pourriez ne pas penser, l'utilisation conjointe de contrats et de tests basés sur les propriétés vous aidera beaucoup. Bien sûr, cela ne remplace pas l'écriture de tests unitaires, mais cela l'augmente, ce qui est vraiment le meilleur que nous puissions faire en tant qu'ingénieurs.

12
Hovercouch

Il s'agit d'un exemple où l'ajout d'un peu de modularité aurait été utile. Si un segment de code sujet aux erreurs est utilisé plusieurs fois, il est recommandé de l'envelopper dans une fonction si possible.

def time_ago(delta, unit):
    delta_str = _number_to_text(delta) + " " + unit;
    if delta == 1:
        return delta_str + " ago"
    else:
        return delta_str = "s ago"

now = datetime.datetime.utcnow()
today = now.date()
if event_date.date() == today:
    return "Today"

yesterday = today - datetime.timedelta(1)
if event_date.date() == yesterday:
    return "Yesterday"

delta = (now - event_date).days

if delta < 7:
    return time_ago(delta, "day")

if delta < 30:
    weeks = math.floor(delta / 7)
    return time_ago(weeks, "week")

if delta < 365:
    months = math.floor(delta / 31)
    return time_ago(months, "month")
5
Antonio Perez

Le développement piloté par les tests n'a pas aidé.

TDD fonctionne mieux comme technique si la personne qui passe les tests est accusatoire. C'est difficile si vous n'êtes pas en programmation par paires, donc une autre façon d'y penser est:

  • N'écrivez pas de tests pour confirmer que la fonction testée fonctionne comme vous l'avez faite. Écrivez des tests qui le cassent délibérément.

Il s'agit d'un art différent, qui s'applique à l'écriture de code correct avec ou sans TDD, et peut-être aussi complexe (sinon plus) que d'écrire réellement du code. C'est quelque chose que vous devez pratiquer, et c'est quelque chose pour lequel il n'y a pas de réponse simple, facile et simple.

La technique de base pour écrire un logiciel robuste est également la technique de base pour comprendre comment écrire des tests efficaces:

Comprendre les conditions préalables d'une fonction - les états valides (c.-à-d. Quelles hypothèses faites-vous sur l'état de la classe dont la fonction est une méthode) et les plages de paramètres d'entrée valides - chaque type de données a une plage de valeurs possibles - un sous-ensemble dont sera géré par votre fonction.

Si vous ne faites rien de plus que de tester explicitement ces hypothèses sur l'entrée de fonction et de vous assurer qu'une violation est enregistrée ou levée et/ou que les erreurs de fonction sont éliminées sans autre manipulation, vous pouvez rapidement savoir si votre logiciel échoue en production, rendez-le robuste et tolérant aux erreurs, et développer vos compétences en rédaction de tests contradictoires.


NB. Il existe toute une littérature sur les conditions préalables et postérieures, les invariants, etc., ainsi que des bibliothèques qui peuvent les appliquer à l'aide d'attributs. Personnellement, je ne suis pas fan de devenir aussi formel, mais cela vaut la peine d'être étudié.

5
Chris Becke

C'est l'un des faits les plus importants concernant le développement de logiciels: il est absolument, absolument impossible d'écrire du code sans bogue.

TDD ne vous évitera pas d'introduire des bogues correspondant à des cas de test auxquels vous n'aviez pas pensé. Cela ne vous évitera pas non plus d'écrire un test incorrect sans vous en rendre compte, puis d'écrire du code incorrect qui passe le test de buggy. Et toutes les autres techniques de développement logiciel jamais créées ont des trous similaires. En tant que développeurs, nous sommes des humains imparfaits. À la fin de la journée, il n'y a aucun moyen d'écrire du code 100% sans bug. Cela ne s'est jamais produit et ne se produira jamais.

Cela ne veut pas dire que vous devez abandonner tout espoir. Bien qu'il soit impossible d'écrire du code complètement parfait, il est très possible d'écrire du code qui a si peu de bogues qui apparaissent dans des cas Edge si rares que le logiciel est extrêmement pratique à utiliser. Un logiciel qui ne présente pas de comportement bogué en pratique est très possible à écrire.

Mais l'écrire nous oblige à accepter le fait que nous produirons un logiciel buggy. Presque toutes les pratiques de développement de logiciels modernes sont à un certain niveau construites pour empêcher les bogues d'apparaître en premier lieu ou pour nous protéger contre les conséquences des bogues que nous produisons inévitablement:

  • La collecte d'exigences approfondies nous permet de savoir à quoi ressemble un comportement incorrect dans notre code.
  • L'écriture d'un code propre et soigneusement conçu permet d'éviter plus facilement l'introduction de bogues et de les corriger plus facilement lorsque nous les identifions.
  • L'écriture de tests nous permet de produire un enregistrement de ce que nous pensons être parmi les pires bogues possibles de notre logiciel et de prouver que nous évitons au moins ces bogues. TDD produit ces tests avant le code, BDD dérive ces tests des exigences et les tests unitaires à l'ancienne produisent des tests après l'écriture du code, mais ils empêchent tous les pires régressions à l'avenir.
  • Les évaluations par les pairs signifient que chaque fois que le code est modifié, au moins deux paires d'yeux ont vu le code, ce qui diminue la fréquence à laquelle les bogues se glissent dans le maître.
  • L'utilisation d'un outil de suivi des bogues ou d'un outil de suivi des histoires d'utilisateurs qui traite les bogues comme des histoires d'utilisateurs signifie que lorsque des bogues apparaissent, ils sont conservés et finalement traités, pas oubliés et laissés de manière cohérente pour les utilisateurs.
  • L'utilisation d'un serveur de transfert signifie qu'avant une version majeure, tout bogue show-stopper a une chance d'apparaître et d'être traité.
  • L'utilisation du contrôle de version signifie que dans le pire des cas, où du code avec des bogues majeurs est expédié aux clients, vous pouvez effectuer une restauration d'urgence et remettre un produit fiable entre les mains de vos clients pendant que vous triez les choses.

La solution ultime au problème que vous avez identifié n'est pas de lutter contre le fait que vous ne pouvez pas garantir que vous écrivez du code sans bogue, mais plutôt de l'adopter. Adoptez les meilleures pratiques de l'industrie dans tous les domaines de votre processus de développement, et vous fournirez systématiquement à vos utilisateurs du code qui, bien que pas tout à fait parfait, est plus que suffisamment robuste pour le travail.

1
Kevin

Vous n'aviez tout simplement pas pensé à ce cas auparavant et vous n'aviez donc pas de test pour cela.

Cela arrive tout le temps et c'est tout à fait normal. C'est toujours un compromis sur l'effort que vous consacrez à la création de tous les cas de test possibles. Vous pouvez passer un temps infini à considérer tous les cas de test.

Pour un pilote automatique d'avion, vous passeriez beaucoup plus de temps que pour un simple outil.

Il est souvent utile de réfléchir aux plages valides de vos variables d'entrée et de tester ces limites.

En outre, si le testeur est une personne différente de celle du développeur, des cas souvent plus importants sont trouvés.

1
Simon

(et croyant que cela a à voir avec les fuseaux horaires, malgré l'utilisation uniforme de l'UTC dans le code)

C'est une autre erreur logique dans votre code pour laquelle vous n'avez pas encore de test unitaire :) - votre méthode retournera des résultats incorrects pour les utilisateurs dans les fuseaux horaires non UTC. Vous devez convertir à la fois "maintenant" et la date de l'événement au fuseau horaire local de l'utilisateur avant de calculer.

Exemple: en Australie, un événement se produit à 9 h, heure locale. À 11h, il sera affiché comme "hier" car la date UTC a changé.

1
Sergey
  • Laissez quelqu'un d'autre écrire les tests. De cette façon, quelqu'un qui ne connaît pas votre implémentation peut rechercher des situations rares auxquelles vous n'avez pas pensé.

  • Si possible, injectez des cas de test en tant que collections. Cela rend l'ajout d'un autre test aussi simple que l'ajout d'une autre ligne comme yield return new TestCase(...). Cela peut aller dans le sens de tests exploratoires , automatisant la création de cas de test: "Voyons ce que le code retourne pendant toutes les secondes de la semaine précédente".

0
null

Vous semblez croire à tort que si tous vos tests réussissent, vous n'avez aucun bogue. En réalité, si tous vos tests réussissent, tout le comportement conn est correct. Vous ne savez toujours pas si le comportement inconnu est correct ou non.

J'espère que vous utilisez la couverture de code avec votre TDD. Ajoutez un nouveau test pour le comportement inattendu. Ensuite, vous pouvez exécuter uniquement le test du comportement inattendu pour voir quel chemin il prend réellement dans le code. Une fois que vous connaissez le comportement actuel, vous pouvez apporter une modification pour le corriger, et lorsque tous les tests réussiront à nouveau, vous saurez que vous l'avez fait correctement.

Cela ne signifie toujours pas que votre code est exempt de bogues, mais qu'il est meilleur qu'avant, et encore une fois, le comportement conn est correct!

Utiliser TDD correctement ne signifie pas que vous écrirez du code sans bogue, cela signifie que vous écrirez moins de bogues. Vous dites:

Les exigences étaient relativement claires

Est-ce à dire que le comportement de plus d'un jour mais pas d'hier a été spécifié dans les exigences? Si vous avez manqué une exigence écrite, c'est de votre faute. Si vous avez réalisé que les exigences étaient incomplètes pendant que vous les codiez, tant mieux pour vous! Si tous ceux qui ont travaillé sur les exigences ont raté ce cas, vous n'êtes pas pire que les autres. Tout le monde fait des erreurs, et plus ils sont subtils, plus ils sont faciles à manquer. Le gros point à retenir ici est que TDD ne pas empêche tous les erreurs!

0
CJ Dennis

Il est très facile de commettre une erreur logique même dans un code source aussi simple.

Oui. Le développement piloté par les tests ne change rien à cela. Vous pouvez toujours créer des bogues dans le code réel, ainsi que dans le code de test.

Le développement piloté par les tests n'a pas aidé.

Oh, mais ça l'a fait! Tout d'abord, lorsque vous avez remarqué le bogue, vous aviez déjà le cadre de test complet en place, et il vous suffisait de corriger le bogue dans le test (et le code réel). Deuxièmement, vous ne savez pas combien de bugs de plus vous auriez eu si vous n'aviez pas fait TDD au début.

Ce qui est également inquiétant, c'est que je ne vois pas comment éviter de tels bugs.

Tu ne peux pas. Même la NASA n'a pas trouvé un moyen d'éviter les bugs; nous, les humains moindres, non plus.

En plus de réfléchir avant d'écrire du code,

C'est une erreur. L'un des plus grands avantages de TDD est que vous pouvez coder avec less en pensant, car tous ces tests captent au moins assez bien les régressions. De plus, même, ou surtout avec TDD, il est pas prévu de fournir du code sans bogue en premier lieu (ou votre vitesse de développement s'arrêtera simplement).

la seule façon dont je peux penser est d'ajouter beaucoup d'affirmations pour les cas qui, je pense, ne se produiraient jamais (comme je pensais qu'il y a un jour, c'est nécessairement hier), puis de parcourir chaque seconde au cours des dix dernières années, en vérifiant toute violation d'assertion, qui semble trop complexe.

Cela entrerait clairement en conflit avec le principe de ne coder que ce dont vous avez réellement besoin actuellement. Vous pensiez avoir besoin de ces cas, et il en était ainsi. C'était un morceau de code non critique; comme vous l'avez dit, il n'y a pas eu de dégâts, sauf si vous vous posez la question pendant 30 minutes.

Pour le code critique, vous pouvez réellement faire ce que vous avez dit, mais pas pour votre code standard de tous les jours.

Comment pourrais-je éviter de créer ce bug en premier lieu?

Non. Vous faites confiance à vos tests pour trouver la plupart des régressions; vous vous en tenez au cycle de refactorisation rouge-vert, en écrivant des tests avant/pendant le codage réel, et (important!) vous implémentez la quantité minimale nécessaire pour faire le changement rouge-vert (ni plus, ni moins). Cela se terminera par une excellente couverture de test, au moins positive.

Lorsque, et non si, vous trouvez un bogue, vous écrivez un test pour reproduire ce bogue et corrigez le bogue avec le moins de travail pour faire passer ce test du rouge au vert.

0
AnoE