Pourquoi Pytest ?

Python propose plusieurs frameworks de tests (unittest, nose, doctest), mais Pytest s’est imposĂ© comme la rĂ©fĂ©rence pour sa simplicitĂ©, sa puissance et son Ă©cosystĂšme riche. Contrairement Ă  unittest, Pytest vous permet d’Ă©crire des tests sans boilerplate — pas de classes obligatoires, pas de mĂ©thodes spĂ©ciales, juste des fonctions assert natives.

Dans ce tutoriel, vous apprendrez Ă  :

  • Installer et configurer Pytest
  • Écrire vos premiers tests
  • MaĂźtriser les fixtures et la paramĂ©trisation
  • Utiliser le mocking avec monkeypatch et pytest-mock
  • Mesurer la couverture de code
  • IntĂ©grer Pytest dans une CI/CD

Installation et premiers pas

Installez Pytest et ses extensions courantes :

pip install pytest pytest-cov pytest-mock

VĂ©rifiez l’installation :

pytest --version

Créez un fichier test_demo.py :

def test_addition():
    assert 1 + 1 == 2

def test_string():
    assert "hello".upper() == "HELLO"

Lancez les tests :

pytest

Pytest découvre automatiquement les fichiers nommés test_*.py ou *_test.py et exécute les fonctions préfixées par test_. En mode verbeux :

pytest -v

Assertions avancées

Pytest fonctionne avec l’assert natif de Python, mais enrichit les messages d’erreur grĂące Ă  l’introspection :

def test_liste():
    resultat = [1, 2, 3]
    attendu = [1, 2, 4]
    assert resultat == attendu

ExĂ©cutez ce test : Pytest vous montrera exactement quels Ă©lĂ©ments diffĂšrent, contrairement Ă  unittest qui se contente d’un boolĂ©en.

Pour tester les exceptions :

import pytest

def test_division_par_zero():
    with pytest.raises(ZeroDivisionError):
        1 / 0

def test_erreur_avec_message():
    with pytest.raises(ValueError, match="doit ĂȘtre positif"):
        raise ValueError("le nombre doit ĂȘtre positif")

Les fixtures : le cƓur de Pytest

Les fixtures permettent de préparer un contexte (base de données, fichiers, API) réutilisable entre les tests :

import pytest

@pytest.fixture
def utilisateur_test():
    """Crée un dictionnaire utilisateur factice."""
    return {"nom": "Alice", "age": 30, "actif": True}

def test_utilisateur_actif(utilisateur_test):
    assert utilisateur_test["actif"] is True

def test_age_utilisateur(utilisateur_test):
    assert utilisateur_test["age"] >= 18

Portées (scope) des fixtures

Les fixtures peuvent ĂȘtre partagĂ©es Ă  diffĂ©rentes Ă©chelles :

  • function (dĂ©faut) — recréée Ă  chaque test
  • class — une instance par classe de test
  • module — une fois par module
  • session — une fois pour toute la session de test
@pytest.fixture(scope="session")
def connexion_bdd():
    """Connexion partagée entre tous les tests de la session."""
    conn = creer_connexion()
    yield conn
    conn.fermer()

Notez l’utilisation de yield pour le nettoyage : le code aprĂšs yield est exĂ©cutĂ© Ă  la fin du test, quel que soit le rĂ©sultat.

Paramétrisation

Évitez la duplication de tests avec @pytest.mark.parametrize :

import pytest

def multiplier(a, b):
    return a * b

@pytest.mark.parametrize("a,b,attendu", [
    (2, 3, 6),
    (0, 5, 0),
    (-1, 10, -10),
    (100, 0, 0),
])
def test_multiplier(a, b, attendu):
    assert multiplier(a, b) == attendu

Vous pouvez aussi combiner plusieurs niveaux de paramétrisation :

@pytest.mark.parametrize("a", [1, 2, 3])
@pytest.mark.parametrize("b", [10, 20])
def test_produit_cartesien(a, b):
    assert isinstance(a * b, int)

Mocking avec monkeypatch

monkeypatch est une fixture intĂ©grĂ©e Ă  Pytest qui permet de modifier temporairement des objets, variables d’environnement ou comportements :

import os
import pytest

def get_api_key():
    return os.environ.get("API_KEY", "default")

def test_api_key_personnalisee(monkeypatch):
    monkeypatch.setenv("API_KEY", "secret-123")
    assert get_api_key() == "secret-123"

def test_api_key_defaut(monkeypatch):
    monkeypatch.delenv("API_KEY", raising=False)
    assert get_api_key() == "default"

Pour mocker des fonctions ou classes :

import requests

def obtenir_utilisateur(url, user_id):
    reponse = requests.get(f"{url}/users/{user_id}")
    return reponse.json()

def test_obtenir_utilisateur(monkeypatch):
    class FakeReponse:
        @staticmethod
        def json():
            return {"id": 1, "nom": "Alice"}

    def fake_get(url):
        return FakeReponse()

    monkeypatch.setattr(requests, "get", fake_get)

    resultat = obtenir_utilisateur("https://api.exemple.com", 1)
    assert resultat["nom"] == "Alice"

Mocking avancé avec pytest-mock

L’extension pytest-mock apporte une API plus expressive basĂ©e sur unittest.mock :

def test_mock_requests(mocker):
    mock_get = mocker.patch("requests.get")
    mock_get.return_value.json.return_value = {"id": 1, "nom": "Bob"}

    resultat = obtenir_utilisateur("https://api.exemple.com", 1)
    assert resultat["nom"] == "Bob"
    mock_get.assert_called_once_with("https://api.exemple.com/users/1")

Vérifications avancées

def test_spy_sur_methode(mocker):
    from datetime import datetime

    spy = mocker.spy(datetime, "now")
    maintenant = datetime.now()

    spy.assert_called_once()
    assert isinstance(maintenant, datetime)

Couverture de code avec pytest-cov

Mesurez quel pourcentage de votre code est couvert par les tests :

pytest --cov=mon_package --cov-report=html --cov-report=term

Options utiles :

  • --cov=mon_package — mesure la couverture du package cible
  • --cov-report=term — affiche le rĂ©sumĂ© dans le terminal
  • --cov-report=html — gĂ©nĂšre un rapport HTML dans htmlcov/
  • --cov-fail-under=80 — Ă©choue si la couverture est infĂ©rieure Ă  80 %

Organisation des tests

Structure recommandée pour un projet Python :

mon_projet/
├── src/
│   └── mon_package/
│       ├── __init__.py
│       ├── calculs.py
│       └── services.py
├── tests/
│   ├── __init__.py
│   ├── conftest.py          # fixtures partagĂ©es
│   ├── test_calculs.py
│   └── test_services.py
├── pytest.ini
└── pyproject.toml

Fichier pytest.ini minimal :

[pytest]
testpaths = tests
python_files = test_*.py
python_functions = test_*
markers =
    slow: tests lents (exécution optionnelle)
    integration: tests d'intégration

Exécution sélective :

pytest -m "not slow"        # ignore les tests lents
pytest -m "integration"     # seulement les tests d'intégration
pytest tests/test_calculs.py::test_multiplier  # un test spécifique
pytest -k "multiplier"      # filtre par expression

Intégration CI/CD (GitHub Actions)

Exemple de workflow GitHub Actions :

.github/workflows/tests.yml

name: Tests
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: pip install -r requirements.txt
      - run: pip install pytest pytest-cov
      - run: pytest --cov=mon_package --cov-fail-under=80

Bonnes pratiques

  • Un test = une assertion logique : testez un comportement Ă  la fois
  • Nommage explicite : test_xx_quand_y_alors_z plutĂŽt que test_01
  • Fixtures scope session : pour les ressources coĂ»teuses (connexions BDD, clients API)
  • conftest.py : regroupez les fixtures partagĂ©es entre plusieurs fichiers de test
  • –strict-markers : activez cette option pour Ă©viter les typos dans les marqueurs
  • TDD (Test-Driven Development) : Ă©crivez le test avant l’implĂ©mentation

Aller plus loin

Pytest dispose d’un Ă©cosystĂšme riche d’extensions :

  • pytest-xdist — exĂ©cution parallĂšle des tests
  • pytest-asyncio — tests pour code asynchrone
  • pytest-django / pytest-flask — tests pour frameworks web
  • pytest-benchmark — benchmarks de performance
  • pytest-timeout — limite de temps par test
  • pytest-sugar — affichage amĂ©liorĂ© dans le terminal

Documentation officielle : https://docs.pytest.org/