Par Maxime Jumelle
CTO & Co-Founder
Publié le 31 mai 2021
Catégorie Machine Learning
Dans l'univers du développement logiciel, les tests sont omniprésents. Ils permettent de vérifier que le logiciel ou l'application développée adopte correctement le comportement attendu, ne produit pas de bugs ou s'intègre efficacement dans un environnement existant. Mais de quoi s'agit-il exactement ? Comment peut-on écrire du code pour tester du code ? Et surtout, comment peut-on le faire avec du Python ?
Ça tombe bien, cet article est là pour t'aider ! Sous Python, il existe une librairie très utilisée qui s'appelle pytest
et qui va nous simplifier la vie pour écrire des tests en Python, car il y a plusieurs choses à prendre en compte.
Tout d'abord, définissons correctement ce que l'on appelle un test logiciel. Lorsque l'on développe des programmes ou des applications, il y a plusieurs composantes qui peuvent être développées indépendemment mais qui constituent l'ensemble de l'application. Or, ce n'est pas parce que le code est syntaxiquement correct que l'application elle-même fonctionnera.
L'objectif c'est d'évaluer le comportement de notre application pour prévenir au maximum des bugs, des incohérences ou encore pire, des crashs. Pour cela, nous créons un maximum de situations qui cherchent à couvrir toutes les possibilités, et l'on évalue ensuite la réponse de l'application face à cette situation : ce sont les tests. Et bien entendu, c'est rarement un ou deux tests, mais bien des dizaines ou centaines de tests qui sont crées pour tester une application.
À lire aussi : découvrez notre formation MLOps
Dans ce contexte, chaque test est de nature différente et ne va pas forcément avoir les mêmes attentes. Habituellement, on regroupe les tests sous forme de trois familles.
Dans les faits, les bonnes pratiques nécessitent de suivre plusieurs conventions. En travail collaboratif, notamment avec git
, les règles de base suivantes sont appliquées.
Commençons par introduire les tests unitaires avec pytest
. Il s'agit d'une librairie qui permet de faciliter la mise en place et l'exécution des tests de code sous Python. Bien que les tests unitaires puissent être réalisés from scratch, pytest
améliore la productivité et apporte des fonctionnalités très utiles.
Testons la librairie sur le premier fichier suivant. Nous avons codé la fonction argmax
qui cherche à obtenir la position du plus grand élément d'une liste. Nous codons également la fonction test_argmax
qui va tester unitairement la fonction argmax
sur plusieurs exemples : cela reflète du comportement attendu de la fonction.
# code.py def argmax(liste): if len(liste) == 0: return None idx_max = 0 value_max = liste[0] for i, x in enumerate(liste): if x > value_max: value_max = x idx_max = i return idx_max def test_argmax(): assert argmax([5, 8, 2, 9, 6, 3]) == 3 assert argmax([7]) == 0 assert argmax([]) == None
Exécutons le code avec pytest en spécifiant le chemin d'accès au fichier avec pytest code.py
dans un terminal.
============================= test session starts ============================== platform linux -- Python 3.8.0, pytest-5.4.1, py-1.10.0, pluggy-0.13.1 rootdir: /tmp collected 1 item code.py . [100%] ============================== 1 passed in 0.01s ===============================
En exécutant cette commande, pytest
effectue une découverte automatique des tests.
test*
si on lui fournit un dossier.test*
, alors ce dernier sera instancié (dans le cas d'une fonction) et les fonctions seront exécutées (pour les deux).Cette découverte des tests permet de simplifier la mise en place des tests : plus besoin de spécifier tous les tests dans un fichier, qui lui-même effectue des importations. Nous pouvons imaginer que pour chaque module, il y ait un fichier test.py
qui regroupe tous les tests unitaires liés à ce module. De manière générale, il est plus approprié de créer un fichier spécifique pour les tests unitaires plutôt que de les insérer dans le code qui fournit la logique à l'application.
C'est de cette manière que pytest
exécute naturellement la fonction test_argmax
sans avoir eu besoin de la spécifier comme argument. Dans certains cas, nous pouvons être amené à éviter volontairement l'exécution d'une fonction. Dans ce cas, il suffit d'ajouter le décorateur pytest.mark.skip
.
import pytest def argmax(liste): if len(liste) == 0: return None idx_max = 0 value_max = liste[0] for i, x in enumerate(liste): if x > value_max: value_max = x idx_max = i return idx_max @pytest.mark.skip def test_argmax(): assert argmax([5, 8, 2, 9, 6, 3]) == 3 assert argmax([7]) == 0 assert argmax([]) == None
============================= test session starts ============================== platform linux -- Python 3.8.0, pytest-5.4.1, py-1.10.0, pluggy-0.13.1 rootdir: /tmp collected 1 item code.py s [100%] ============================== 1 skipped in 0.01s ==============================
Comme nous pouvons le voir, 100% des tests ont réussi car le seul test présent a été ignoré (skipped). Voyons maintenant un autre fichier Python dont le test unitaire va volontairement générer une erreur.
def argmin(liste): if len(liste) == 0: return None idx_min = 0 value_min = liste[0] for i, x in enumerate(liste): if x < value_min: value_min = x idx_min = i + 1 return idx_min def test_argmin(): assert argmin([5, 8, 2, 9, 6, 3]) == 2 assert argmin([7]) == 0 assert argmin([]) == None
============================= test session starts ============================== platform linux -- Python 3.8.0, pytest-5.4.1, py-1.10.0, pluggy-0.13.1 rootdir: /tmp collected 1 item code.py F [100%] =================================== FAILURES =================================== _________________________________ test_argmin __________________________________ def test_argmin(): > assert argmin([5, 8, 2, 9, 6, 3]) == 2 E assert 3 == 2 E + where 3 = argmin([5, 8, 2, 9, 6, 3]) /tmp/pytest_2.py:14: AssertionError =========================== short test summary info ============================ FAILED code.py::test_argmin - assert 3 == 2 ============================== 1 failed in 0.10s ===============================
D'après la sortie générée par pytest
, les tests du fichier code.py
ont échoué. Si l'on regarde en détaille l'exécution de test_argmin
, nous avons un assert 3 == 2
, ce qui signifie que notre test unitaire a échoué. Corrigeons la fonction argmin
et ajoutons la fonction argmax
avec son test unitaire associé.
def argmin(liste): if len(liste) == 0: return None idx_min = 0 value_min = liste[0] for i, x in enumerate(liste): if x < value_min: value_min = x idx_min = i return idx_min def argmax(liste): if len(liste) == 0: return None idx_max = 0 value_max = liste[0] for i, x in enumerate(liste): if x > value_max: value_max = x idx_max = i return idx_max def test_argmin(): assert argmin([5, 8, 2, 9, 6, 3]) == 2 assert argmin([7]) == 0 assert argmin([]) == None def test_argmax(): assert argmax([5, 8, 2, 9, 6, 3]) == 3 assert argmax([7]) == 0 assert argmax([]) == None
============================= test session starts ============================== platform linux -- Python 3.8.0, pytest-5.4.1, py-1.10.0, pluggy-0.13.1 -- /usr/bin/python3.8 cachedir: .pytest_cache rootdir: /tmp collected 2 items code.py::test_argmin PASSED [ 50%] code.py::test_argmax PASSED [100%] ============================== 2 passed in 0.01s ===============================
Le paramètre -v
permet d'afficher plus de détails concernant les tests. Puisque deux fonctions sont nommées test*
, il y a deux tests effectués par pytest
. Cette option permet d'obtenir un détail pour chaque test codé, simplifiant ensuite le déboggage de l'application.
En pratique, les tests unitaires doivent être exécutés une fois les données envoyées vers le dépôt Git. En revanche, il est déconseillé de les exécuter lors du pre-commit, car ce dernier doit être rapide. Les tests unitaires, notamment ceux incluant des tests pour les modèles, peuvent prendre du temps ce qui n'est pas conseillé pour les pre-commits.
Imaginons que l'on souhaite utiliser des données/paramètres uniquement pour les tests unitaires. Si l'on regarde bien, les deux fonctions test_argmin
et test_argmax
utilisent les mêmes listes pour tester les deux fonctions. Nous pourrions tout à fait définir des catalogues de référence pour les tests unitaires qui seront utilisés à chaque fois. C'est à cela que servent les fixtures.
À lire aussi : découvrez notre formation MLOps
Regardons le code suivant qui n'utilise pas de fixture. Nous allons simplement créer une liste test_data
qui sera utilisée par les deux fonctions de test.
# Pas bien ! test_data = [5, 8, 2, 9, 6, 3] def argmin(liste): if len(liste) == 0: return None idx_min = 0 value_min = liste[0] for i, x in enumerate(liste): if x < value_min: value_min = x idx_min = i return idx_min def argmax(liste): if len(liste) == 0: return None idx_max = 0 value_max = liste[0] for i, x in enumerate(liste): if x > value_max: value_max = x idx_max = i return idx_max def test_argmin(): assert argmin(test_data) == 2 def test_argmax(): assert argmax(test_data) == 3
============================= test session starts ============================== platform linux -- Python 3.8.0, pytest-5.4.1, py-1.10.0, pluggy-0.13.1 -- /usr/bin/python3.8 cachedir: .pytest_cache rootdir: /tmp collected 2 items code::test_argmin PASSED [ 50%] code::test_argmax PASSED [100%] ============================== 2 passed in 0.01s ===============================
Bien que le test ait fonctionné, cela n'est pas une bonne pratique, car nous allons obligatoirement définir cette variable globale en mémoire à chaque exécution du code, alors qu'elle n'est utilisée que pour les tests unitaires. Dans ce cas de figure, il est préférable de créer des fixtures.
Les fixtures définissent un environnement dans lequel nous allons pouvoir tester notre code. Dans beaucoup de situations, il nous faut initialiser certaines variables avant de lancer les tests unitaires. Les fixtures sous pytest
sont des fonctions qui sont utilisés comme paramètres des fonctions de tests unitaires.
Regardons le code suivant.
import pytest @pytest.fixture def test_data(): return [5, 8, 2, 9, 6, 3] def argmin(liste): if len(liste) == 0: return None idx_min = 0 value_min = liste[0] for i, x in enumerate(liste): if x < value_min: value_min = x idx_min = i return idx_min def argmax(liste): if len(liste) == 0: return None idx_max = 0 value_max = liste[0] for i, x in enumerate(liste): if x > value_max: value_max = x idx_max = i return idx_max def test_argmin(test_data): assert argmin(test_data) == 2 def test_argmax(test_data): assert argmax(test_data) == 3
Tout d'abord, nous définissons la fonction test_data
comme fixture à l'aide du décorateur de fonctions @pytest.fixture
. Cette fonction va renvoyer une liste qui correspond à la liste de référence pour tester les deux fonctions. Ensuite, dans les fonctions de tests unitaires, nous allons récupérer comme paramètre cette même fonction test_data
. Mais attention : lorsque l'on exécutera pytest
, ce dernier va automatiquement remplacer le paramètre test_data
(qui est supposé être une fonction car fixture) par le résultat de cette fonction.
Ainsi, à chaque exécution de pytest
, ce sera en réalité test_data()
qui sera passé comme paramètre pour les fonctions test_argmin
et test_argmax
(et non la fonction test_data
elle-même). Cette méthode permet d'instancier plus efficacement les initialisations pour les tests, sans compromettre le reste du code qui lui n'aura pas besoin des tests dans un environnement de production.
Exécutons maintenant pytest
.
============================= test session starts ============================== platform linux -- Python 3.8.0, pytest-5.4.1, py-1.10.0, pluggy-0.13.1 -- /usr/bin/python3.8 cachedir: .pytest_cache rootdir: /tmp collected 2 items code::test_argmin PASSED [ 50%] code::test_argmax PASSED [100%] ============================== 2 passed in 0.01s ===============================
Tout a correctement fonctionné. L'intérêt de ce système est de pouvoir ensuite centraliser l'initialisation des variables et des données pour les tests, évitant ainsi les duplicata de codes que l'on connaît déjà bien hors des tests.
À lire aussi : découvrez notre formation MLOps
Nous avons pu voir qu'il est plutôt facile de créer des tests unitaires sous Python avec pytest
. Plus aucune raison de ne pas en faire maitenant ! 🙂
Vous souhaitez vous former au MLOps ?
Dans cet article
Articles similaires
20 sept. 2022
Hugging Face est une startup française qui s'est fait connaître grâce à l'infrastructure NLP qu'ils ont développée. Aujourd'hui, elle est sur le point de révolutionner le domaine du Machine Learning et traitement automatique du langage naturel. Dans cet article, nous allons présenter Hugging Face et détailler les taches de base que cette librairie permet de réaliser. Nous allons également énumérer ses avantages et ses alternatifs.
Équipe Blent
Data Scientist
Lire l'article
12 juil. 2022
spaCy est une bibliothèque open-source pour le traitement avancé du langage naturel. Elle est conçue spécifiquement pour une utilisation en production et permet de construire des applications qui traitent et comprennent de grands volumes de texte.
Équipe Blent
Data Scientist
Lire l'article
4 juil. 2022
Un auto-encodeur est une structure de réseaux neuronaux profonds qui s'entraîne pour réduire la quantité de données nécessaires pour représenter une donnée d'entrée. Ils sont couramment utilisés en apprentissage automatique pour effectuer des tâches de compression de données, d'apprentissage de représentations et de détection de motifs.
Équipe Blent
Data Scientist
Lire l'article
60 rue François 1er
75008 Paris
Blent est une plateforme 100% en ligne pour se former aux métiers Tech & Data.
Organisme de formation n°11755985075.
Data Engineering
IA Générative
MLOps
Cloud & DevOps
À propos
Gestion des cookies
© 2025 Blent.ai | Tous droits réservés