Un aperçu du développement piloté par les tests.
Le développement piloté par les tests (TDD) désigne un style de programmation où l'on écrit les tests pour guider l'implémentation de la conception du programme dans le code.
On écrit un ou plusieurs tests (en particulier des tests unitaires) avant d'écrire le code. Ces tests visent à couvrir un aspect du comportement du programme, qui peut porter sur une seule fonction ou méthode. L'écriture des tests est un moyen de transformer les exigences du programme et l'architecture générale en une conception propre à l'implémentation. On exécute les tests, et ils doivent échouer, car le code n'a pas encore été implémenté. On implémente le code et on exécute les tests à nouveau. Si les tests passent, alors soit l'implémentation du comportement est terminée, soit il reste peut-être d'autres tests nécessaires à écrire. Si les tests ne passent pas, alors on débogue le code et on exécute les tests à nouveau. Le cycle de test et de codage se répète jusqu'à ce que tous les tests nécessaires passent, moment où l'implémentation de cet aspect du comportement du programme est terminée... pour l'instant.
La réécriture consiste à réécrire du code pour améliorer sa conception. Il ne s'agit pas simplement de réécrire du code pour corriger des bugs. On appelle parfois «\u00A0réécriture\u00A0» le fait de modifier du code pour lui faire passer les tests. Bien que modifier le code puisse inclure une amélioration de la conception comme moyen de faire passer les tests, le simple débogage n'améliore pas nécessairement la conception du code, et n'est donc pas nécessairement de la réécriture.
Voici un exemple de débogage sans réécriture :
# A function intended to return x added to y.
# x and y are bad parameter names, but we ignore that for now.
def add(x, y):
# used multiply operator by mistake. It fails the tests.
return x * y
# Function corrected. It passes the tests. It has been debugged, but not refactored.
def add(x, y):
return x + y
Voici un exemple de réécriture, puis de débogage :
# Function name and parameter names are modified to something more meaningful. This is refactoring.
def lot_inventory(old_cars, new_cars):
# Introduced multiply operator by mistake. It fails the tests. This is why we test.
return old_cars * new_cars
# Function corrected. It passes the tests. This is debugging.
def lot_inventory(old_cars, new_cars):
return old_cars + new_cars
Le parcours Python d'Exercism utilise la méthodologie TDD dans ses exercices. Les tests unitaires sont déjà écrits. L'apprenant peut consulter les tests pour mieux comprendre ce qui est nécessaire pour qu'une solution passe. Un squelette de solution peut être fourni à l'apprenant.
Lorsqu'un ou plusieurs tests échouent pour une solution Python, la ou les tâches correspondantes n'ont pas de fond vert. La première zone de tâche en échec est dépliée et son en-tête ressemble à peu près à ceci
Task 1 Extract coordinates -
Cliquer sur le signe moins réduit la tâche, ce qui permet de regarder les autres tâches, mais pour l'instant on reste sur celle-ci.
En dessous se trouve une zone Test dépliée qui ressemble à peu près à ceci
Test 1 ⌄
FAILED TisburyTreasure > get coordinate
où Tisbury Treasure désigne l'exercice, et get_coordinate la fonction ou la méthode qui échoue.
Test 1 est généralement une sorte de modèle comportant une section de code pour préparer les tests.
Elle ne contient pas d'information sur le ou les tests précis qui ont échoué.
Vers le bas, elle indique que
One or more variations of this test failed. Details can be found under each [variant#].
Cliquer sur le ⌄ réduit le test.
En dessous se trouve un test réduit qui ressemble à peu près à ceci :
Test 2 >
FAILED TisburyTreasure > get coordinate [variation #1] (item=
("Scrimshaw Whale's Tooth", '2A'), result='2A')
Son apparence varie selon la largeur définie pour le panneau de droite.
Cliquer sur le > déplie le test.
Les données d'entrée et les données de résultat attendues sont probablement affichées dans une section de code.
Ces données peuvent concerner tous les tests de cette tâche.
En bas, dans la section Test Failure, se trouve la raison précise de l'échec de ce test.
Cela peut ressembler à ceci :
AssertionError: ['2A'] != '2A'
Dans ce cas précis, cela indique que la valeur renvoyée ['2A'] n'était pas égale à la valeur attendue '2A'.
Si on regarde le code de get_coordinate, on voit qu'il est implémenté ainsi
def get_coordinate(record):
return [record[1]]
Si on retire les crochets de la liste (par ex. return record[1]) et qu'on relance les tests, les tests de la tâche 1 passent.
S'il reste une ou plusieurs tâches en échec, on répète alors le processus ci-dessus pour chacune d'elles jusqu'à ce que tous les tests passent.
Parfois, les données attendues et les données renvoyées sont trop volumineuses pour tenir entièrement dans la section Test Failure.
Cela peut ressembler à ceci :
AssertionError: '("Sc[67 chars]\')\n\n(\'Brass Spyglass\', \'Abandoned Lighth[952 chars]')\n' != '("Sc[67 chars]\')\n(\'Brass Spyglass\', \'Abandoned Lighthou[928 chars]')\n'
Diff is 970 characters long. Set self.maxDiff to None to see it.
Il peut malgré tout y avoir assez de données pour voir quel est le problème.
Dans le cas ci-dessus, deux retours à la ligne sont renvoyés (par ex. \n\n(\'Brass Spyglass) alors qu'un seul est attendu (par ex. \n(\'Brass Spyglass).
Félicitations\u00A0! Tous les tests sont réussis. Et maintenant\u00A0? La solution peut être publiée tout de suite. Ou, maintenant que le code fonctionne, si tu souhaites le réécrire pour une raison quelconque, tu peux modifier le code et soumettre une nouvelle itération. Si tu penses que le code pourrait être meilleur, mais que tu ne sais pas comment t'y prendre, tu peux demander un mentorat pour la solution. Si un mentor est disponible, il peut te contacter avec des idées d'autres approches pour une solution. Lorsque tu publies ta solution, tu peux autoriser les commentaires, et d'autres apprenants pourront en profiter pour poster des commentaires ou poser des questions.
Même si «\u00A0l'optimisation prématurée est la racine de tous les maux\u00A0» (un dicton attribué aussi bien à Tony Hoare qu'à Donald Knuth), il arrive un moment où, même si la solution fonctionne, on souhaite améliorer ses performances.
L'un de ces moments peut être celui où la solution passe certains tests mais dépasse le temps imparti pour d'autres.
Il peut être utile de savoir exactement combien de temps prend un morceau de code.
Le module timeit permet de mesurer le temps d'exécution d'un code jusqu'à de très petites durées.
La fonction timeit peut prendre jusqu'à cinq arguments\u00A0: timeit.timeit(stmt='pass', setup='pass', timer=<default timer>, number=1000000, globals=None).
Le paramètre stmt définit le code réel à exécuter et à mesurer.
Le paramètre number détermine combien de fois le code stmt sera exécuté.
Le paramètre setup définit le code qui n'est exécuté qu'une seule fois pour préparer l'exécution du code stmt.
Le temps d'exécution du code setup est inclus dans le temps total.
Plus on exécute d'itérations du code stmt, moins le temps du setup compte par itération.
Le paramètre timer permet de passer un Timer différent de celui par défaut.
L'argument par défaut du paramètre timer est perf_counter, ce qui devrait suffire dans la plupart des cas.
L'argument par défaut du paramètre number est 1_000_000.
Le paramètre globals spécifie un espace de noms dans lequel exécuter le code.
L'argument par défaut du paramètre globals est None.
Voici un exemple d'utilisation de timeit pour voir combien de temps il faut pour déterminer si une phrase contient toutes les voyelles anglaises :
import timeit
# run one million times
loops = 1_000_000
# first positional argument is for stmt
# second positional argument is for setup
# third (named) argument is for number
print(timeit.timeit("""has_all_vowels('Another piggy digs up the truffles.')""",
"""
VOWELS = "AEIOU"
def has_all_vowels(sentence):
return all(letter in sentence.casefold() for letter in VOWELS)
""", number=loops) / loops)
Exécuter le code un million de fois a pris en moyenne 4.965089999896008e-07 seconde par appel (environ 497 nanosecondes par appel).
L'exemple suivant sert à voir si le fait de sortir l'appel à casefold de la compréhension de liste fait gagner du temps :
import timeit
loops = 1_000_000
print(timeit.timeit("""has_all_vowels('Another piggy digs up the truffles.')""",
"""
VOWELS = "AEIOU"
def has_all_vowels(sentence):
sentence = sentence.casefold()
return all(letter in sentence for letter in VOWELS)
""", number=loops) / loops)
Exécuter le code un million de fois a pris en moyenne 4.923898000270128e-07 seconde par appel (environ 492 nanosecondes par appel.)
Ainsi, sortir casefold de la compréhension de liste a fait gagner environ 5 nanosecondes par appel, soit environ 5 millisecondes au total pour un million d'appels.
cProfile peut aussi servir à profiler du code\u00A0; toutefois, il est moins granulaire, puisqu'il ne descend qu'à des durées de l'ordre de la milliseconde.