Écrire des tests avec pytest et les fixtures en Python

1. Vue d’ensemble

Lorsque le nombre de tests augmente, la partie préparation des données (créer des clients, des commandes, une connexion à la base de données…) se répète souvent partout. pytest résout ce problème avec les fixtures : une fonction qui fournit des données ou des ressources au test et se nettoie automatiquement après que le test soit terminé.

Cet article passe du premier test pytest aux fixtures avec yield, aux scopes, à conftest.py, aux fixtures intégrées et à la paramétrisation. Une fois que vous aurez fini de lire, vous pourrez organiser votre suite de tests Python de manière concise, lisible et sans beaucoup de répétition de code.

2. Installation de pytest

Vous devez installer pytest dans un environnement virtuel :

python -m venv .venv
source .venv/bin/activate
pip install pytest

La structure du projet utilisée dans cet article est la suivante :

shop/
├── shop.py
└── tests/
    ├── conftest.py
    ├── test_order.py
    └── test_repository.py

pytest trouve automatiquement les fichiers nommés test_*.py ou *_test.py. Dans chaque fichier, il exécute les fonctions commençant par test_.

Pour importer shop depuis le dossier tests, vous ajoutez la configuration dans pyproject.toml :

[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]

3. Votre premier test

Voici le code métier dans shop.py. Les prix sont exprimés en Dong (type int) pour éviter les erreurs de précision avec les float :

from dataclasses import dataclass, field


@dataclass
class Customer:
    name: str
    is_vip: bool = False


@dataclass
class Order:
    customer: Customer
    items: list[tuple[str, int, int]] = field(default_factory=list)

    def add_item(self, sku: str, quantity: int, price: int) -> None:
        if quantity <= 0:
            raise ValueError("quantity must be positive")
        self.items.append((sku, quantity, price))

    def total(self) -> int:
        subtotal = sum(qty * price for _, qty, price in self.items)
        return subtotal * 90 // 100 if self.customer.is_vip else subtotal

Les clients VIP reçoivent une réduction de 10 %. Le test pour cette logique se trouve dans tests/test_order.py :

import pytest

from shop import Customer, Order


def test_total_for_regular_customer():
    order = Order(Customer("An"))
    order.add_item("BOOK-01", 2, 50_000)

    assert order.total() == 100_000


def test_add_item_rejects_zero_quantity():
    order = Order(Customer("An"))

    with pytest.raises(ValueError, match="quantity must be positive"):
        order.add_item("BOOK-01", 0, 50_000)

Vous avez juste besoin de deux choses :

  • assert pur de Python. Vous n’avez pas besoin de assertEqual ni de bibliothèque d’assertion dédiée. pytest réécrit l’instruction assert pour rapporter les erreurs en détail.
  • pytest.raises vérifie qu’un bloc de code lève l’exception du bon type. Le paramètre match est une regex comparée avec le message de l’exception.

Exécutez le test :

pytest -v
tests/test_order.py::test_total_for_regular_customer PASSED              [ 50%]
tests/test_order.py::test_add_item_rejects_zero_quantity PASSED          [100%]

============================== 2 passed in 0.01s ===============================

Si vous changez intentionnellement la valeur attendue en 90_000, pytest affichera les deux côtés :

>       assert order.total() == 90_000
E       assert 100000 == 90000
E        +  where 100000 = total()

4. Les fixtures de base

4.1. Déclarer et utiliser une fixture

Les deux tests ci-dessus créent tous les deux Customer("An") et Order. Lorsqu’il y a des dizaines de tests, ce code se répète beaucoup. Nous le déplaçons dans une fixture :

import pytest

from shop import Customer, Order


@pytest.fixture
def customer():
    return Customer("An")


@pytest.fixture
def order(customer):
    return Order(customer)


def test_total_for_regular_customer(order):
    order.add_item("BOOK-01", 2, 50_000)
    assert order.total() == 100_000


def test_empty_order_has_zero_total(order):
    assert order.total() == 0

Comment cela fonctionne :

  • @pytest.fixture marque une fonction comme une fixture.
  • Le test déclare un paramètre avec le même nom que la fixture. pytest appelle la fixture et transmet la valeur retournée au test. C’est l’injection de dépendance par nom.
  • La fixture order reçoit la fixture customer. Une fixture peut dépendre d’une autre fixture, pytest résout automatiquement l’ordre.

Par défaut, chaque test reçoit une nouvelle instance. Donc l’add_item du premier test ne modifie pas l’order du deuxième test.

4.2. Factory fixture

Parfois, un test a besoin de plusieurs commandes avec des paramètres différents. Au lieu de retourner un objet, une fixture peut retourner une fonction de création d’objets :

@pytest.fixture
def make_order():
    def _make(name="An", is_vip=False, items=()):
        order = Order(Customer(name, is_vip))
        for sku, qty, price in items:
            order.add_item(sku, qty, price)
        return order
    return _make


def test_vip_gets_ten_percent_discount(make_order):
    regular = make_order(items=[("BOOK-01", 1, 200_000)])
    vip = make_order(name="Binh", is_vip=True, items=[("BOOK-01", 1, 200_000)])

    assert regular.total() == 200_000
    assert vip.total() == 180_000

Ce modèle garde la préparation concise tout en permettant au test de décider lui-même des données.

5. Setup et teardown avec yield

Les fixtures sont vraiment utiles lorsqu’on travaille avec des ressources qui doivent être nettoyées, comme les connexions de base de données, les fichiers ou les serveurs fictifs. Ajoutons un repository utilisant SQLite à shop.py :

import sqlite3


class OrderRepository:
    def __init__(self, conn: sqlite3.Connection):
        self.conn = conn

    def save(self, customer_name: str, total: int) -> int:
        cur = self.conn.execute(
            "INSERT INTO orders (customer, total) VALUES (?, ?)",
            (customer_name, total),
        )
        return cur.lastrowid

    def count(self) -> int:
        return self.conn.execute("SELECT COUNT(*) FROM orders").fetchone()[0]

Une fixture utilisant yield à la place de return :

import sqlite3

import pytest

from shop import OrderRepository


@pytest.fixture
def db_connection():
    conn = sqlite3.connect(":memory:")
    conn.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, customer TEXT, total INTEGER)")
    yield conn
    conn.close()


@pytest.fixture
def repository(db_connection):
    return OrderRepository(db_connection)


def test_save_order_returns_id(repository):
    order_id = repository.save("An", 100_000)

    assert order_id == 1
    assert repository.count() == 1

La partie avant yield est le setup. La valeur après yield est transmise au test. La partie après yield est le teardown et s’exécute toujours, même si le test échoue. Vous n’avez pas besoin de try/finally.

Le drapeau --setup-show montre clairement l’ordre :

pytest tests/test_repository.py --setup-show
tests/test_repository.py
        SETUP    F db_connection
        SETUP    F repository (fixtures used: db_connection)
        tests/test_repository.py::test_save_order_returns_id (fixtures used: db_connection, repository).
        TEARDOWN F repository
        TEARDOWN F db_connection

La lettre F signifie le scope function. Le teardown s’exécute dans l’ordre inverse du setup.

Note : Si une exception se produit avant yield, la partie teardown ne s’exécutera pas. C’est parce que la fixture n’a pas fini de se configurer. Vous devez garder la partie setup courte, ou envelopper vous-même dans try/finally si le setup comprend plusieurs étapes qui peuvent échouer.

6. Le scope d’une fixture

Créer des ressources coûteuses (connexions de base de données réelles, lancement de conteneurs…) pour chaque test ralentira votre suite de tests. Le paramètre scope détermine la durée de vie d’une fixture :

Scope Se recrée quand
function (par défaut) chaque test
class chaque classe de test
module chaque fichier de test
package chaque package
session une fois pour toute l’exécution

Exemple d’utilisation d’une seule connexion pour tout un fichier, mais en effaçant les données après chaque test :

@pytest.fixture(scope="module")
def db_connection():
    conn = sqlite3.connect(":memory:")
    conn.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, customer TEXT, total INTEGER)")
    yield conn
    conn.close()


@pytest.fixture
def repository(db_connection):
    yield OrderRepository(db_connection)
    db_connection.execute("DELETE FROM orders")


def test_first_save(repository):
    repository.save("An", 100_000)
    assert repository.count() == 1


def test_second_save_starts_clean(repository):
    repository.save("Binh", 50_000)
    assert repository.count() == 1

db_connection n’est créée qu’une seule fois. repository a un scope function donc la table est nettoyée après chaque test. Grâce à cela, le deuxième test voit toujours une table vide.

Il y a une règle importante : une fixture ne peut utiliser que des fixtures avec un scope plus large ou égal. Si une fixture session utilise une fixture function, pytest rapporte une erreur :

ScopeMismatch: You tried to access the function scoped fixture repository
with a session scoped request object.

7. Partager les fixtures via conftest.py

Les fixtures customer, make_order sont utiles pour de nombreux fichiers de test. Vous les placez dans tests/conftest.py :

import pytest

from shop import Customer, Order


@pytest.fixture
def customer():
    return Customer("An")


@pytest.fixture
def order(customer):
    return Order(customer)

pytest charge automatiquement conftest.py. Tous les tests dans ce dossier et ses sous-dossiers peuvent utiliser ces fixtures sans avoir à les importer. Vous pouvez placer conftest.py à plusieurs niveaux de répertoires. Une fixture à un niveau plus proche du test remplacera une fixture du même nom à un niveau supérieur.

Note : N’importez pas conftest dans un fichier de test. Si vous le faites, la fixture pourrait être enregistrée deux fois et causer un comportement imprévisible.

Pour voir quel test utilise quelle fixture et où cette fixture est définie, vous exécutez :

pytest --fixtures-per-test tests/test_order.py

8. Les fixtures intégrées

pytest fournit de nombreuses fixtures pratiques. Les trois fixtures les plus utilisées sont tmp_path, monkeypatch et capsys.

Supposons que shop.py ait deux fonctions supplémentaires :

import os
from pathlib import Path


def export_orders_csv(rows: list[tuple[str, int]], path: Path) -> None:
    lines = ["customer,total"] + [f"{name},{total}" for name, total in rows]
    path.write_text("\n".join(lines), encoding="utf-8")


class PaymentService:
    def __init__(self):
        self.api_key = os.environ["PAYMENT_API_KEY"]

    def charge(self, order: "Order") -> str:
        print(f"Charging {order.total()} VND")
        return "SUCCESS"

Les tests pour celles-ci :

from shop import PaymentService, export_orders_csv


def test_export_orders_csv(tmp_path):
    output = tmp_path / "orders.csv"

    export_orders_csv([("An", 100_000), ("Binh", 50_000)], output)

    assert output.read_text(encoding="utf-8").splitlines() == [
        "customer,total",
        "An,100000",
        "Binh,50000",
    ]


def test_payment_service_charges_order(monkeypatch, capsys, order):
    monkeypatch.setenv("PAYMENT_API_KEY", "test-key")
    order.add_item("BOOK-01", 1, 75_000)

    result = PaymentService().charge(order)

    assert result == "SUCCESS"
    assert capsys.readouterr().out == "Charging 75000 VND\n"
  • tmp_path est un pathlib.Path pointant vers un répertoire temporaire, privé pour chaque test. Le test ne pollue pas le répertoire du projet.
  • monkeypatch change les variables d’environnement, les attributs ou les fonctions dans la portée d’un test. Tous les changements sont annulés automatiquement lorsque le test se termine. En plus de setenv, vous utiliserez souvent monkeypatch.setattr(obj, "name", value).
  • capsys capture le contenu imprimé sur stdout et stderr. readouterr() retourne ce qui a été imprimé depuis le début du test ou l’appel précédent.

9. Paramétriser les tests

9.1. @pytest.mark.parametrize

Lorsque la même logique doit être vérifiée avec plusieurs ensembles de données, vous ne devriez pas copier le test. Utilisez parametrize :

import pytest


@pytest.mark.parametrize(
    ("is_vip", "price", "expected"),
    [
        (False, 100_000, 100_000),
        (True, 100_000, 90_000),
        (True, 15, 13),
    ],
    ids=["regular", "vip", "vip-rounding-down"],
)
def test_total_by_customer_type(make_order, is_vip, price, expected):
    order = make_order(is_vip=is_vip, items=[("SKU", 1, price)])
    assert order.total() == expected
tests/test_order.py::test_total_by_customer_type[regular] PASSED
tests/test_order.py::test_total_by_customer_type[vip] PASSED
tests/test_order.py::test_total_by_customer_type[vip-rounding-down] PASSED

Chaque ensemble de paramètres est un test distinct, avec un ID lisible grâce à ids. Notez que vous pouvez combiner une fixture (make_order) avec des paramètres. Le cas vip-rounding-down documente clairement le comportement d’arrondi inférieur de l’opération //.

9.2. Fixture paramétrée

Si vous voulez que tous les tests utilisant une fixture s’exécutent avec plusieurs variantes, vous paramétrez la fixture elle-même :

@pytest.fixture(params=[False, True], ids=["regular", "vip"])
def any_customer(request):
    return Customer("An", is_vip=request.param)


def test_total_is_never_negative(any_customer):
    order = Order(any_customer)
    order.add_item("BOOK-01", 3, 10_000)
    assert order.total() >= 0

request est une fixture spéciale. request.param contient la valeur actuelle. Le test ci-dessus s’exécute automatiquement deux fois.

10. Les erreurs courantes et quand ne pas utiliser

  • Mauvais nom de fixture. Un nom de paramètre qui ne correspond à aucune fixture causera l’erreur fixture 'oder' not found. pytest liste les fixtures disponibles juste en dessous, lisez attentivement pour corriger.
  • Appeler une fixture comme une fonction ordinaire. Écrire customer() dans le test signalera l’erreur Fixture "customer" called directly. Déclarez-la plutôt comme paramètre.
  • Scope large avec des données mutables. Un Order avec un scope module auquel ce test ajoute un élément fera échouer l’autre test. Le résultat dépend de l’ordre d’exécution, très difficile à déboguer. Utilisez un scope large uniquement pour les ressources coûteuses et sans changement d’état, ou nettoyez l’état dans une fixture avec un scope function comme dans la section 6.
  • Abuser de autouse=True. Une fixture s’appliquant automatiquement à tous les tests est pratique car elle ne nécessite pas de déclaration, mais le lecteur du test ne la voit pas. Utilisez-la uniquement pour ce qui est vraiment global, par exemple bloquer le réseau.
  • Chaîne de fixtures trop profonde. Lorsque order dépend de customer, customer dépend de address, address dépend de country…, le lecteur doit sauter entre plusieurs fichiers pour comprendre le test. Pour des données simples, créer directement dans le test ou utiliser une factory fixture est généralement plus lisible.

Note : Les fixtures sont des outils de préparation d’environnement, pas un endroit pour mettre la logique de vérification. Gardez les assert dans la fonction de test pour que chaque test puisse décrire ce qu’il vérifie.

11. Conclusion

Vous avez parcouru les parties essentielles de pytest. L’assert pur et pytest.raises sont utilisés pour vérifier les résultats. L’injection de fixtures par nom aide à éliminer le code de préparation répété. yield offre un setup/teardown sûr, tandis que scope aide à équilibrer la vitesse et l’isolation. conftest.py partage les fixtures entre plusieurs fichiers. Les fixtures intégrées comme tmp_path, monkeypatch, capsys gèrent les fichiers, l’environnement et la sortie. Enfin, parametrize et les fixtures avec params vous permettent de couvrir plusieurs cas sans dupliquer les tests.

Lorsque votre suite de tests grandit, vous devriez commencer avec des fixtures au scope function. N’élargissez le scope que lorsque vous mesurez que la vitesse est un vrai problème.

pythonpytesttestingfixtureunit-test