[JOUR 24] II. Python, the right way : Tester son code et le TDD
Découvrez comment les tests et le TDD évitent les catastrophes
Sommaire :
I.Pourquoi tester son code est important ?
II.Les différents types de tests.
III.Le TDD
IV. Pytest et unitest
Article précédent :
I. Pourquoi tester son code est important ?
S’il y a bien une pratique à retenir dans le génie logiciel, ce sont les tests.
C’est impératif, vous devez tester votre code.
Il y a une foule d'avantages à cela :
Détecter des bugs : En testant différents cas limites pour une fonction, par exemple, nous nous assurons que le code est robuste et n'entraîne pas de bug.
Prévenir des régressions : Nous codons rarement l'intégralité du code utilisé dans notre application. Par là, j'entends que nous utilisons très fréquemment des packages extérieurs. Ces packages sont versionnés et évoluent. Régulièrement, des mises à jour sont faites et potentiellement, du code va être déprécié, c’est-à-dire supprimé. Une fonction pourrait par exemple changer de nom, entre autres. Si nous voulons avoir les dernières versions de nos packages, nous allons donc les mettre à jour petit à petit.
Comment vérifier que ces mises à jour n'entraînent pas des comportements différents dans notre code ou des bugs ?
La réponse est simple : une série de tests. Les tests nous permettent de vérifier les fonctionnalités développées au fur et à mesure du développement de notre application. En modifiant les versions de nos packages, par exemple, grâce à eux, nous nous assurons que toutes les fonctionnalités sont toujours opérationnelles et, dans le cas contraire, nous pouvons très rapidement savoir lesquelles sont impactées.
Confiance dans le code : Comme vous l’aurez compris, les tests permettent d’avoir confiance en notre code. Nous savons ensuite avec certitude quelles fonctions, parties du code, modules, etc., fonctionnent et ont le comportement désiré.
Pouvoir faire du refactoring : En ayant confiance dans notre code, nous pouvons aussi facilement faire du refactoring, c’est-à-dire changer du code pour le rendre plus lisible, maintenable ou bien plus performant. En lançant les tests, nous nous assurons encore une fois que nos changements n'ont pas affecté les comportements de notre code.
Argent économisé : Un bug trouvé par un client, c’est un client mécontent qui va moins acheter, voire se tourner vers la concurrence. Donc il faut à tout prix éviter les bugs en production.
Limiter les bugs qui vont en production permet aussi de réduire les aller-retours côté développeurs, et ainsi optimiser le temps des développeurs pour développer des fonctionnalités et non gérer des bugs. En fin de compte, un développeur sera plus rentable.
Donc en prenant l’habitude d’écrire des tests, on économise de l’argent et on réduit les possibles futures frustrations.
Ils servent de documentation : Les tests permettent aussi souvent d'expliciter le fonctionnement de plusieurs parties du code, simplifiant leur compréhension.
Vous l’aurez compris, ne pas tester son code, c’est comme prendre la voiture sans ceinture, faire de la moto sans casque ou manger des pâtes au ketchup : ce sont des choses qui ne devraient pas exister !
II. Les différents types de tests
On différencie plusieurs types de tests en fonction de la surface de code qu’ils vérifient :
En tout premier, nous avons les tests unitaires, qui permettent de tester une fonction spécifique.
Ensuite, il y a les tests d'intégration qui testent une partie entière du code, donc un enchaînement de plusieurs fonctions, mais également le fonctionnement du code avec des parties externes de l’application, comme une base de données ou une web API externe.
Ces deux premiers types de tests sont les plus courants.
Vous allez rencontrer d’autres types de tests plus spécifiques, tels que :
des tests end-to-end, qui, comme leur nom l'indique, vérifient le fonctionnement de bout en bout de l’application. Pour des applications avec un front-end et un back-end, cela peut être, par exemple, appuyer sur un bouton sur le front et vérifier que la donnée est bien enregistrée en base de données à la fin du processus en passant par le code du back-end.
Pour une pipeline de données en data engineering, cela consisterait à tester que la sortie en bout de pipeline correspond à ce que nous attendons au vu de la donnée fournie en entrée.
Le chaos testing, plus orienté vers le SRE (site-reliability engineering), ce type de test sert à déterminer si notre système ou application est résilient si un incident imprévu fait tomber un composant externe ou interne nécessaire au fonctionnement de l’application.
Le load testing, pour observer le comportement de notre application selon différents scénarios de charge, la charge étant généralement le nombre de requêtes utilisateurs sur un temps donné.
Nous nous concentrerons dans cette formation uniquement sur les tests unitaires et d'intégration.
Les tests sont inégaux dans leurs impacts et leur temps de développement :
Un test end-to-end est excellent car il est très proche de la manière dont l'application sera utilisée en situation réelle, mais il est long à développer.
À l’inverse, un test unitaire se limite au périmètre de la fonction mais est très rapide à développer.
Les tests d'intégration se situent entre les deux en termes de réalisme et de temps de développement.
Ainsi, il est préférable d'avoir un petit nombre de tests end-to-end, une part significative de tests d'intégration et beaucoup de tests unitaires.
Nous allons voir comment simplement intégrer l’écriture de tests à notre développement avec le TDD.
III. Le TDD
Pour écrire des tests, il ne suffit pas de le vouloir ; il faut surtout avoir du code testable !
Pour obtenir du code testable, il faut la plupart du temps avoir un code faiblement couplé ! (principe de cohésion vu dans les précédents articles). Grâce à cela, nous pouvons tester simplement les différentes parties du code sans se compliquer la vie. D’où l’intérêt de bien découper son code, faire des petites fonctions ou petites classes qui ne font qu’une chose, du clean code et des principes SOLID. Toutes ces bonnes pratiques feront que votre code sera un vrai plaisir à tester.
Anecdote
Petite anecdote personnelle, j’étais dans une entreprise (dont je ne citerai pas le nom), sur le développement d’une application. Les clients observaient des bugs et les remontaient à notre équipe. La décision fut prise de se poser une après-midi pour investiguer ces problèmes.
Le problème que nous avons constaté était que le code n'avait pas été bien découpé, ce qui rendait difficile pour nous de :
Reproduire programmatiquement les bugs constatés en production.
Ajouter des tests sur des parties essentielles du code.
Il a donc fallu refactoriser le code, ce qui a pris du temps, donc de l’argent, et qui nous a en outre ralenti dans l’ajout de nouvelles fonctionnalités.
Moralité : Appliquer les bonnes pratiques essentielles dès le début, créer du code testable et ajouter des tests permet de sauver de l’argent, du temps et évite beaucoup de frustration à vous et vos équipes encore une fois.
TDD, la méthodologie
Le TDD, pour Test-Driven Development, est une pratique très valorisée dans la communauté des développeurs.
Tout d’abord, le principe du TDD est d’écrire les tests avant d’écrire le code. Cet ordre peut sembler contre-intuitif lorsque l’on commence à développer, mais il permet de prendre un peu de recul lorsqu'on conçoit son code au lieu de foncer tête baissée.
1ère étape : vous écrivez juste l'entête de votre fonction.
2e étape : vous écrivez le test associé à votre fonction, en incluant les cas limites et ce que votre fonction est censée retourner.
3e étape : Vous écrivez le code de votre fonction pour faire passer le test.
4e étape : Une fois le test réussi, vous pouvez refactoriser votre fonction pour l'améliorer.
L'énorme avantage du TDD est qu'il permet par défaut d’écrire du code testable.
Croyez-moi, cela ne prend pas tellement plus de temps d’écrire du code de cette manière, et vous y gagnez grandement en maintenabilité et qualité de code.
IV. Unittest
Dans cette partie, nous allons explorer unittest permettant d’écrire des tests et de les lancer.
Unittest est fourni d’emblée avec Python et fait largement le travail pour des tests unitaires.
Sans plus attendre, codons un petit exemple avec unittest en mode TDD.
Création de l’en-tête d’une fonction :
# fichier fonction.py
def count_stop_msg(messages: list[str]) -> int:
return 0 #astuce pour éviter que Mypy nous crie dessus
Ajout d’un test :
# fichier test_fonction.py
import unittest
class TestCountStopMsg(unittest.TestCase):
def test_count_one_msg(self):
self.assertEqual(count_stop_msg(["hello", "stop"]), 1)
def test_count_zero_stop_msg(self):
self.assertEqual(count_stop_msg(["hello", "world"]), 0)
def test_count_multiple_msg(self):
self.assertEqual(count_stop_msg(["stop", "stop", "stop"]), 3)
if __name__ == '__main__':
unittest.main()
Notez que les trois tests sont définis avec des noms commençant par "test". Cette convention de nommage permet au test runner de Pytest de savoir quelles méthodes représentent des tests.
Si on lance les tests aucun n’est censé passer. En réalité un seul passe ici à cause de notre astuce concernant Mypy :
#Commandes pour lancer pytest
python -m unittest test_fonction.py #sur le fichier spécifique
python -m unittest discover #sur tous les fichiers de test
uv run test_fonction.py #besoin d'un if __name__ == "__main__" dans le fichier
Écrivons la fonction :
def count_stop_msg(messages: list[str]) -> int:
return messages.count("stop")
On relance les tests, et hop, le tour est joué.
Il existe d’autres moyens d’évaluer le résultat ressorti par nos fonctions. Vous pouvez lire la documentation pour plus de détails : Unittest Python
Dans le prochaine article nous regarderons plus en détails Pytest. Pytest est une librairie à part mais plus fournie en fonctionnalités.
Merci de votre lecture !