← Retourner à la liste des articles
Image blog
Auteur

Par Maxime Jumelle

CTO & Co-Founder

Publié le 31 mai 2021

Catégorie Machine Learning

Tester son code Python

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.

Les tests logiciels

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.

  • Les tests unitaires, où l'on s'assure qu'une portion atomique du code fonctionne correctement (par exemple, une fonction). En règle générale, ce sont des tests rapides et faciles à mettre en place.
  • Les tests de régression, où l'on doit s'assurer que le développement d'une nouvelle fonctionnalité ne va pas faire survenir un bug déjà rencontré par le passé.
  • Les tests d'intégration, où on cherche à voir si la fonctionnalité développée va être correctement intégré dans l'application sans générer des erreurs dues à son interaction avec d'autres composantes. Ces erreurs sont en pratique plus difficiles à prévenir, d'où la difficulté de construire des tests d'intégration efficaces.

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.

  • Ne jamais fusionner de branches si les tests ne sont pas valides.
  • Toujours écrire des tests pour de nouvelles fonctionnalités.
  • Lorsque l'on corrige un bug, toujours écrire le test et l'appliquer sur la correction.

Tests unitaires sous Python

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.

  • Il va d'abord rechercher tous les fichiers dont le nom commence par test* si on lui fournit un dossier.
  • Pour chaque classe/fonction du fichier, si l'objet commence par 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.

Les fixtures

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.

Les fixtures avec pytest

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 ?

Articles similaires

Blog

20 sept. 2022

Machine Learning

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.

Nada Belaidi

Équipe Blent

Data Scientist

Lire l'article

Blog

12 juil. 2022

Machine Learning

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.

Nada Belaidi

Équipe Blent

Data Scientist

Lire l'article

Blog

4 juil. 2022

Machine Learning

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.

Nada Belaidi

Équipe Blent

Data Scientist

Lire l'article