cours
Comment utiliser Pytest pour les tests unitaires
Les tests sont un sujet très important dans le développement de logiciels. Avant qu'un produit logiciel n'arrive entre les mains d'un utilisateur final, il est probable qu'il ait subi plusieurs tests, tels que des tests d'intégration, des tests de systèmes et des tests d'acceptation. L'idée derrière ces tests vigoureux est de s'assurer que le comportement de l'application fonctionne comme prévu du point de vue de l'utilisateur final. Cette approche des tests est connue sous le nom de développement guidé par le comportement (BDD).
Plus récemment, l'intérêt pour le développement piloté par les tests (TDD) s'est considérablement accru parmi les développeurs. L'idée générale est que le processus traditionnel de développement et de test est inversé : vous écrivez d'abord vos tests unitaires, puis vous modifiez le code jusqu'à ce que les tests soient réussis.
Dans cet article, nous nous concentrerons sur les tests unitaires et, plus précisément, sur la manière de les réaliser à l'aide d'un framework de test Python populaire appelé Pytest.
Que sont les tests unitaires ?
Les tests unitaires sont une forme de tests automatisés - cela signifie simplement que le plan de test est exécuté par un script plutôt que manuellement par un humain. Ils constituent le premier niveau de test des logiciels et sont généralement écrits sous la forme de fonctions qui valident le comportement de diverses fonctionnalités au sein d'un programme logiciel.
Les niveaux de test des logiciels
L'idée derrière ces tests est de permettre aux développeurs d'isoler la plus petite unité de code qui a un sens logique et de tester qu'elle se comporte comme prévu. En d'autres termes, les tests unitaires permettent de valider que chaque composant d'un logiciel fonctionne comme les développeurs l'ont prévu.
Dans l'idéal, ces tests devraient être assez petits - plus ils sont petits, mieux c'est. L'une des raisons de construire des tests plus petits est que le test sera plus efficace, car tester des unités plus petites permettra au code de test de s'exécuter beaucoup plus rapidement. Une autre raison de tester des composants plus petits est que cela vous donne une meilleure idée de la façon dont le code granulaire se comporte lorsqu'il est fusionné.
Pourquoi avons-nous besoin de tests unitaires ?
La justification globale de l'importance des tests unitaires est que les développeurs doivent s'assurer que le code qu'ils écrivent répond aux normes de qualité avant de le laisser entrer dans un environnement de production. Cependant, plusieurs autres facteurs contribuent à la nécessité des tests unitaires. Examinons plus en détail certaines de ces raisons.
Préserver les ressources
Les tests unitaires aident les développeurs à détecter les bogues du code pendant la phase de construction du logiciel, ce qui les empêche de passer à un stade plus avancé du cycle de développement. Cela permet de préserver les ressources puisque les développeurs n'ont pas à supporter le coût de la correction des bogues à un stade ultérieur du développement ; cela signifie également que les utilisateurs finaux sont moins susceptibles d'être confrontés à un code bogué.
Documentation supplémentaire
Une autre justification importante de la réalisation de tests unitaires est qu'ils constituent une couche supplémentaire de documentation vivante pour votre produit logiciel. Les développeurs peuvent simplement se référer aux tests unitaires pour obtenir une compréhension holistique du système global, puisqu'ils détaillent la manière dont les composants les plus mineurs doivent se comporter.
Confiance en soi
Il est extrêmement simple de commettre des erreurs subtiles dans votre code lorsque vous écrivez une fonctionnalité. Cependant, la plupart des développeurs s'accordent à dire qu'il est préférable d'identifier les points de rupture d'un code avant de le mettre en production : les tests unitaires offrent cette possibilité aux développeurs.
Il est juste de dire que "le code couvert par des tests unitaires peut être considéré comme plus fiable que le code qui ne l'est pas". Les ruptures futures dans le code peuvent être découvertes beaucoup plus rapidement qu'un code sans couverture de test, ce qui permet d'économiser du temps et de l'argent. Les développeurs bénéficient également d'une documentation supplémentaire qui leur permet de comprendre plus rapidement la base de code, et ils ont la certitude que s'ils font une erreur dans leur code, celle-ci sera détectée par les tests unitaires plutôt que par l'utilisateur final.
Cadres de test Python
La popularité de Python s'est considérablement accrue au fil des ans. Dans le cadre de la croissance de Python, le nombre de frameworks de test a également augmenté, ce qui se traduit par une multitude d'outils disponibles pour vous aider à tester votre code Python. Entrer dans les détails de chaque outil dépasse le cadre de cet article, mais nous aborderons certains des frameworks de test Python les plus courants.
unittest
Unittest est un framework Python intégré pour les tests unitaires. Il s'inspire d'un cadre de test unitaire appelé JUnit, issu du langage de programmation Java. Comme il est livré avec le langage Python, il n'y a pas de modules supplémentaires à installer, et la plupart des développeurs l'utilisent pour commencer à s'initier aux tests.
Pytest
Python est probablement le framework de test Python le plus utilisé, ce qui signifie qu'il dispose d'une large communauté pour vous aider lorsque vous êtes bloqué. Il s'agit d'un framework open-source qui permet aux développeurs d'écrire des suites de tests simples et compactes tout en prenant en charge les tests unitaires, les tests fonctionnels et les tests d'API.
doctest
Le cadre doctest fusionne deux composantes essentielles de l'ingénierie logicielle : la documentation et les tests. Cette fonctionnalité permet de s'assurer que tous les programmes logiciels sont minutieusement documentés et testés pour garantir qu'ils fonctionnent comme il se doit. doctest est fourni avec la bibliothèque standard de Python et sa prise en main est assez simple.
nose2
Nose2, le successeur de nose regiment, est essentiellement unittest avec des plugins. On parle souvent de nose2 comme de "tests unitaires étendus" ou de "tests unitaires avec un plugin" en raison de ses liens étroits avec le cadre de test unitaire intégré à Python. Puisqu'il s'agit pratiquement d'une extension du framework unittest, nose2 est incroyablement facile à adopter pour ceux qui sont familiers avec unittest.
Témoigner
Testify, un framework Python pour les tests unitaires, d'intégration et de système, est populairement connu comme le framework qui a été conçu pour remplacer unittest et nose. Le framework est doté de nombreux plugins et sa courbe d'apprentissage est assez douce si vous êtes déjà familier avec unittest.
Hypothèse
Hypothesis permet aux développeurs de créer des tests unitaires plus simples à écrire et plus performants à l'exécution. Étant donné que le cadre est conçu pour soutenir les projets de science des données, il aide à trouver des cas limites qui ne sont pas si évidents lorsque vous créez vos tests en générant des exemples d'entrées qui s'alignent sur des propriétés spécifiques que vous définissez.
Pour notre tutoriel, nous utiliserons pytest. Consultez la section suivante pour savoir pourquoi vous pouvez opter pour Pytest plutôt que pour les autres.
Pourquoi utiliser Pytest ?
Au-delà de sa vaste communauté de soutien, pytest possède plusieurs facteurs qui en font l'un des meilleurs outils pour mener votre suite de tests automatisés en Python. La philosophie et les fonctionnalités de Pytest sont conçues pour faire des tests de logiciels une expérience bien meilleure pour les développeurs. Les créateurs de Pytest ont atteint cet objectif en réduisant de manière significative la quantité de code nécessaire à l'exécution des tâches courantes et en permettant d'exécuter des tâches avancées à l'aide de commandes et de modules d'extension étendus.
Voici d'autres raisons d'utiliser Pytest :
Facile à apprendre
Pytest est extrêmement facile à apprendre : si vous comprenez le fonctionnement du mot-clé assert de Python, vous êtes déjà en bonne voie pour maîtriser le framework. Les tests utilisant pytest sont des fonctions Python avec "test_" en préambule ou "_test" en annexe du nom de la fonction - bien que vous puissiez utiliser une classe pour regrouper plusieurs tests. Globalement, la courbe d'apprentissage de pytest est beaucoup moins profonde que celle d'unittest puisque vous n'avez pas à apprendre de nouvelles constructions.
Test de filtrage
Il se peut que vous ne souhaitiez pas exécuter tous vos tests à chaque exécution - ce qui peut être le cas lorsque votre suite de tests s'étoffe. Parfois, vous pouvez isoler quelques tests sur une nouvelle fonctionnalité afin d'obtenir un retour d'information rapide pendant le développement, puis exécuter la suite complète une fois que vous êtes sûr que tout fonctionne comme prévu. Pytest propose trois façons d'isoler les tests : 1) le filtrage par nom, qui indique à pytest de n'exécuter que les tests dont le nom correspond au modèle fourni 2) le filtrage par répertoire, qui est un paramètre par défaut indiquant à pytest de n'exécuter que les tests qui se trouvent dans ou sous le répertoire actuel et 3) la catégorisation des tests, qui vous permet de définir des catégories de tests que pytest doit inclure ou exclure.
Paramétrage
Pytest dispose d'un décorateur intégré appelé parametrize qui permet de paramétrer les arguments d'une fonction de test. Ainsi, si les fonctions que vous testez traitent des données ou effectuent une transformation générique, vous n'êtes pas obligé d'écrire plusieurs tests similaires. Nous reviendrons sur la paramétrisation plus loin dans cet article.
Nous nous arrêterons ici, mais la liste des raisons pour lesquelles pytest est un excellent outil pour votre suite de tests automatisés continue.
Pytest vs unittest
Malgré toutes les raisons que nous venons d'évoquer, certains pourraient encore contester l'idée d'utiliser pytest pour la simple raison qu'il s'agit d'un framework tiers - "quel est l'intérêt d'installer un framework s'il y en a déjà un d'intégré ?" C'est un bon argument, mais pour couvrir nos arrières dans ce conflit, nous allons vous donner quelques éléments à prendre en compte.
Note: Si vous êtes déjà convaincu par pytest, passez directement à la section suivante où nous verrons comment utiliser le framework.
Moins de choucroutes
Unittest demande aux développeurs de créer des classes dérivées du module TestCase, puis de définir les cas de test comme des méthodes dans la classe.
"""
An example test case with unittest.
See: https://docs.python.org/3/library/unittest.html
"""
import unittest
class TestStringMethods(unittest.TestCase):
def test_upper(self):
self.assertEqual('foo'.upper(), 'FOO')
def test_isupper(self):
self.assertTrue('FOO'.isupper())
self.assertFalse('Foo'.isupper())
def test_split(self):
s = 'hello world'
self.assertEqual(s.split(), ['hello', 'world'])
# check that s.split fails when the separator is not a string
with self.assertRaises(TypeError):
s.split(2)
Pytest, quant à lui, ne vous demande que de définir une fonction précédée de "test_" et d'utiliser les conditions d'assert à l'intérieur de celle-ci.
"""
An example test case with pytest.
See: https://docs.pytest.org/en/6.2.x/index.html
"""
# content of test_sample.py
def inc(x):
return x + 1
def test_answer():
assert inc(3) == 5
Remarquez la différence dans la quantité de code requis ; unittest a une quantité significative de code de base requis, qui sert d'exigence minimale pour tout test que vous voudriez effectuer. Cela signifie qu'il est très probable que vous finissiez par écrire le même code plusieurs fois. Pytest, quant à lui, possède de riches fonctionnalités intégrées qui simplifient ce flux de travail en réduisant la quantité de code nécessaire à l'écriture des cas de test.
Sortie
Les résultats fournis par chaque cadre sont extrêmement différents. Voici un exemple d'exécution de pytest :
"""
See: https://docs.pytest.org/en/6.2.x/index.html
"""
$ pytest
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-6.x.y, py-1.x.y, pluggy-1.x.y
cachedir: $PYTHON_PREFIX/.pytest_cache
rootdir: $REGENDOC_TMPDIR
collected 1 item
test_sample.py F [100%]
================================= FAILURES =================================
_______________________________ test_answer ________________________________
def test_answer():
> assert inc(3) == 5
E assert 4 == 5
E + where 4 = inc(3)
test_sample.py:6: AssertionError
========================= short test summary info ==========================
FAILED test_sample.py::test_answer - assert 4 == 5
============================ 1 failed in 0.12s =============================
Le cas de test ci-dessus a échoué, mais remarquez à quel point la décomposition de l'échec est détaillée. Cela permet aux développeurs d'identifier plus facilement où se trouvent les bogues dans leur code, ce qui est très utile lors du débogage. En prime, il y a aussi un rapport d'état général pour la suite de tests, qui nous indique le nombre de tests qui ont échoué et le temps qu'il leur a fallu.
Jetons un coup d'œil à un exemple de cas de test ayant échoué avec unittest.
import unittest
def square(n):
return n*n
def cube(n):
return n*n*n
class TestCase(unittest.TestCase):
def test_square(self):
self.asserEquals(square(4), 16)
def test_cube(self):
self.assertEquals(cube(4), 16)
Lorsque nous exécutons le script, nous obtenons le résultat suivant :
---------------------------------------------------------------
Ran 2 tests in 0.001s
FAILED (errors=1)
La sortie ci-dessus nous indique que deux tests ont été exécutés en 0,001s et qu'un a échoué, mais pas grand-chose d'autre. En fin de compte, pytest fournit des informations beaucoup plus détaillées, ce qui s'avère très utile au moment du débogage.
Dans l'ensemble, pytest et unittest sont tous deux d'excellents outils à utiliser pour les tests automatisés en Python. Plusieurs développeurs Python peuvent se pencher davantage sur pytest que sur ses homologues en raison de sa compacité et de son efficacité. Il est également extrêmement facile à adopter et il existe plusieurs fonctionnalités que vous pouvez utiliser pour créer une suite de tests efficace.
Passons maintenant à la partie principale de cet article. Nous avons discuté de ce que sont les tests unitaires et pourquoi pytest est un excellent outil pour les tests automatisés en Python. Voyons maintenant comment utiliser cet outil.
Tutoriel Pytest
Voyons comment fonctionne ce cadre de test Python dont nous avons parlé.
La première étape consiste à installer le paquet, ce qui peut être fait avec une simple commande pip.
Note : Les créateurs de pytest vous recommandent d'utiliser venv pour le développement et pip pour l'installation de votre application, des dépendances et de pytest lui-même.
pip install -U pytest
Ensuite, vérifiez que le cadre a été installé en utilisant la commande suivante :
>>>> pytest --version
pytest 7.1.2
Tout est installé. Vous êtes maintenant prêt à effectuer des tests.
Création d'un test simple
La création d'un test est simple avec Pytest. Pour démontrer cette fonctionnalité, nous avons créé un script appelé calcualte_age.py. Ce script ne comporte qu'une seule fonction, get_age, qui est chargée de calculer l'âge d'un utilisateur en fonction de sa date de naissance.
import datetime
def get_age(yyyy:int, mm:int, dd:int) -> int:
dob = datetime.date(yyyy, mm, dd)
today = datetime.date.today()
age = round((today - dob).days / 365.25)
return age
Pytest exécutera tous les fichiers Python dont le nom test_ est précédé ou _test est précédé du nom du script. Pour être plus précis, pytest suit les conventions suivantes pour la découverte des tests [source : documentation] :
- Si aucun argument n'est spécifié, la collecte de pytest commencera dans testpaths s'ils sont configurés : testpaths est une liste de répertoires dans lesquels pytest cherchera si aucun répertoire, fichier ou identifiant de test spécifique n'est fourni.
- Pytest va alors rechercher dans les répertoires, sauf si vous lui avez demandé de ne pas le faire en définissant norecursedirs; il recherche les fichiers qui commencent par test_*.py ou qui se terminent par *_test.py.
- Dans ces fichiers, pytest recueille les éléments de test dans l'ordre suivant :
- Fonctions ou méthodes de test préfixées en dehors de la classe
- Fonctions ou méthodes de test préfixées à l'intérieur des classes de test préfixées qui n'ont pas de méthode __init__.
Nous n'avons pas spécifié d'arguments, mais nous avons créé un autre script dans le même répertoire appelé test_calculate_age.py: ainsi, lorsque les répertoires seront récurrents, le test sera découvert. Dans ce script, nous avons un seul test, test_get_age, pour vérifier que notre fonction fonctionne correctement.
Note: Vous pouvez décider de placer vos tests dans un répertoire supplémentaire en dehors de votre application, ce qui est une bonne idée si vous avez plusieurs tests fonctionnels ou si vous voulez séparer le code de test du code d'application pour une autre raison.
from calculate_age import get_age
def test_get_age():
# Given.
yyyy, mm, dd = map(int, "1996/07/11".split(""))
# When.
age = get_age(yyyy, mm, dd)
# Then.
assert age == 26
Pour exécuter le test, exécutez la commande suivante à l'invite de commande :
py -m pytest
La sortie d'une exécution réussie de pytest.
Et c'est tout.
Mais que se passe-t-il si nous devons fournir des données pour que nos tests passent ? Dites bonjour aux fixtures de pytest.
Pytest Fixtures
L'inestimable fonction "fixtures" de Pytest permet aux développeurs d'introduire des données dans les tests. Il s'agit essentiellement de fonctions qui s'exécutent avant chaque fonction de test pour gérer l'état de nos tests. Par exemple, si nous avons plusieurs tests qui utilisent tous les mêmes données, nous pouvons utiliser une fixation pour extraire les données répétées à l'aide d'une seule fonction.
import pytest
from sklearn.model_selection import train_test_split
from fraud_detection_model.config.core import config
from fraud_detection_model.processing.data_manager import (
load_datasets,
load_interim_data
)
@pytest.fixture(scope="session")
def pipeline_inputs():
# import the training dataset
dataset = load_datasets(transaction=config.app_config.train_transaction, identity=config.app_config.train_identity, )
# divide train and test
X_train, X_test, y_train, y_test = train_test_split(
dataset[config.model_config.all_features], dataset[config.model_config.target],
test_size=config.model_config.test_size,
stratify=dataset[config.model_config.target],
random_state=config.model_config.random_state)
return X_train, X_test, y_train, y_test
Dans le code ci-dessus, nous avons créé une fixation en utilisant le décorateur pytest.fixture. Notez que la portée est fixée à "session" pour informer pytest que nous voulons que la fixation soit détruite à la fin de la session de test.
Note: nos fixtures sont stockées dans conftest.py.
Le code utilise une fonction que nous avons importée d'un autre module pour charger deux fichiers csv en mémoire et les fusionner en un seul ensemble de données. Ensuite, l'ensemble de données est divisé en ensembles de formation et de test et renvoyé par la fonction.
Pour utiliser cette fixation dans nos tests, nous devons la passer en paramètre de notre fonction de test. Dans cet exemple, nous utiliserons notre séquence dans notre script de test test_pipeline.py de la manière suivante :
import pandas as pd
from pandas.api.types import is_categorical_dtype, is_object_dtype
from fraud_detection_model import pipeline
from fraud_detection_model.config.core import configfrom fraud_detection_model.processing.validation import validate_inputs
def test_pipeline_most_frequent_imputer(pipeline_inputs):
# Given
X_train, _, _, _ = pipeline_inputs assert all( x in X_train.loc[:, X_train.isnull().any()].columns for x in config.model_config.impute_most_freq_cols )
# When
X_transformed = pipeline.fraud_detection_pipe[:1].fit_transform(X_train[:50])
# Then
assert all( x not in X_transformed.loc[:, X_transformed.isnull().any()].columns for x in config.model_config.impute_most_freq_cols )
def test_pipeline_aggregate_categorical(pipeline_inputs):
# Given
X_train, _, _, _ = pipeline_inputs
assert X_train["R_emaildomain"].nunique() == 60
# When
X_transformed = pipeline.fraud_detection_pipe[:2].fit_transform(X_train[:50])
# Then
assert X_transformed["R_emaildomain"].nunique() == 2
Ne vous préoccupez pas trop de ce que fait le code. La chose la plus importante que nous voulons souligner est la façon dont nous avons considérablement réduit la nécessité d'écrire du code redondant parce que nous avons créé une fixation que nous passons en tant que paramètre pour obtenir des données dans nos tests.
Toutefois, dans certaines situations, les luminaires peuvent s'avérer inutiles. Par exemple, si les données introduites dans vos tests doivent être traitées à nouveau dans chaque cas de test, cela équivaut pratiquement à joncher votre code de plusieurs objets simples. Ceci étant dit, les fixtures joueront probablement un rôle central dans votre suite de tests, mais discerner quand les utiliser ou les éviter nécessitera de la pratique et beaucoup de réflexion.
Paramètres de Pytest
Les montages sont très utiles lorsque vous avez plusieurs tests avec les mêmes entrées. Que se passe-t-il si vous voulez tester une seule fonction avec de légères variations des entrées ? Une solution consiste à écrire plusieurs tests différents avec des cas variés.
def test_eval_addition():
assert eval("2 + 2") == 4
def test_eval_subtraction():
assert eval("2 - 2") == 0
def test_eval_multiplication():
assert eval("2 * 2") == 4
def test_eval_division():
assert eval("2 / 2") == 1.0
Bien que cette solution fonctionne, elle n'est pas la plus efficace : tout d'abord, il y a beaucoup de code standard. Une meilleure solution consiste à utiliser le décorateur pytest.mark.parametrize() pour activer la paramétrisation des arguments d'une fonction de test. Cela nous permettra de définir une seule définition de test, et pytest testera alors les différents paramètres que nous aurons spécifiés.
Voici comment nous réécririons le code ci-dessus si nous utilisions la paramétrisation :
import pytest
@pytest.mark.parametrize("test_input, expected_output", [("2+2", 4), ("2-2", 0), ("2*2", 4), ("2/2", 1.0)])
def test_eval(test_input, expected_output):
assert eval(test_input) == expected_output
Le décorateur @parametrize définit quatre entrées de test différentes et les valeurs attendues pour l'exécution de la fonction test_eval - cela signifie que la fonction s'exécutera quatre fois en utilisant chacune d'entre elles à tour de rôle.
La sortie de pytest pour le test de paramétrage
Dans cet article, nous avons abordé les sujets suivants :
- ce que sont les tests unitaires
- pourquoi nous avons besoin de tests unitaires
- différents cadres de test en Python
- Pourquoi pytest est si utile
- comment utiliser pytest et deux de ses fonctionnalités clés (fixtures et paramétrisation)
Vous en savez maintenant assez pour commencer à écrire votre propre test en utilisant le framework pytest. Nous vous encourageons vivement à le faire, afin que tout ce que vous avez appris dans cet article reste valable. Vous devriez également consulter notre cours sur les tests unitaires pour la science des données en Python pour une explication plus détaillée avec des exemples.
Cours pour Python
cours
Python intermédiaire
cours