Skip to content

pytest (+ async, mocking)

TL;DRpytest est le standard de fait du test en Python : assertions natives (assert x == y, pas de assertEqual), une mécanique de fixtures par injection de dépendances qui remplace le setUp/tearDown de xUnit, et une paramétrisation qui transforme un test en table de cas. Pour ton stack (FastAPI + agents IA), trois choses comptent : tester de l'async proprement avec pytest-asyncio, mocker le SDK Anthropic au bon niveau (jamais le réseau réel dans un test unitaire), et écrire des tests déterministes sur du code intrinsèquement non-déterministe (un LLM). On finit sur les pièges de prod : tests lents, flaky, fixtures qui fuient de l'état, et l'illusion de couverture.

🧠 Mental model

Si tu viens de PHPUnit ou de Jest, ton réflexe est : une classe de test, un setUp() qui prépare l'état, des méthodes testFoo(). Oublie la classe. En pytest, un test est une fonction libre dont les paramètres sont des dépendances que le framework te fournit. C'est de l'injection de dépendances, exactement comme les providers de NestJS — sauf que le conteneur d'injection, c'est pytest, et les providers s'appellent des fixtures.

NestJS                         pytest
──────                         ──────
@Injectable() provider    ≈    @pytest.fixture
constructor(dep: Dep)     ≈    def test_x(dep):          # injection par nom de paramètre
useValue / useFactory     ≈    fixture qui return / yield
scope: REQUEST/SINGLETON  ≈    scope="function"/"session"
TestingModule.compile()   ≈    résolution automatique du graphe de fixtures

L'analogie tient jusqu'au bout : une fixture peut dépendre d'une autre fixture (elle la prend en paramètre), pytest résout le graphe, et le scope contrôle la durée de vie (recréée à chaque test, ou partagée sur toute la session). Le yield d'une fixture est ton tearDown : ce qui est avant yield c'est le setup, ce qui est après c'est le nettoyage, garanti même si le test plante.

   test_create_user(db, client)

        ┌───────┴────────┐
        ▼                ▼
   fixture db        fixture client
        │                │
        └──── dépend ────┘   client(db)  → pytest construit db d'abord

Le test ne cherche jamais ses dépendances ; il les déclare. C'est ce qui rend les tests lisibles : la signature dit tout ce dont le test a besoin.

Les fondamentaux

Assertions : assert nu, et c'est tout

Pas de self.assertEqual. Pytest réécrit le bytecode de assert pour produire un diff détaillé en cas d'échec (l'assertion rewriting) — tu écris l'expression naturelle, tu obtiens un message riche.

python
def test_slugify() -> None:
    assert slugify("Hello World") == "hello-world"
    # En cas d'échec, pytest affiche:
    #   assert 'hello world' == 'hello-world'
    #     - hello-world
    #     + hello world
    #     ?      ^

Pour les exceptions, pytest.raises en context manager. Vérifie le type et, idéalement, le message via match (une regex) :

python
import pytest

def test_divide_by_zero_raises() -> None:
    with pytest.raises(ValueError, match="cannot divide by zero"):
        divide(10, 0)

# La bonne pratique: capturer l'exception pour inspecter ses attributs
def test_http_error_carries_status() -> None:
    with pytest.raises(HTTPException) as exc_info:
        get_user(user_id=-1)
    assert exc_info.value.status_code == 404

La mauvaise façon, classique chez les ex-développeurs xUnit :

python
# ❌ ANTI-PATTERN: try/except manuel — si aucune exception n'est levée,
# le test passe quand même (faux positif silencieux).
def test_bad() -> None:
    try:
        divide(10, 0)
    except ValueError:
        assert True   # inutile
    # ... et si divide ne lève rien ? Le test est VERT. Catastrophe.

Fixtures : setup/teardown par injection

Une fixture est une fonction décorée. Le return fournit la valeur ; le yield permet le nettoyage post-test.

python
import pytest
from collections.abc import Iterator
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker

@pytest.fixture
def db_session() -> Iterator[Session]:
    engine = create_engine("sqlite:///:memory:")
    Base.metadata.create_all(engine)
    SessionLocal = sessionmaker(bind=engine)
    session = SessionLocal()
    yield session          # ← le test s'exécute ici
    session.close()        # ← teardown, garanti même si le test échoue
    engine.dispose()

def test_user_persistence(db_session: Session) -> None:
    db_session.add(User(email="[email protected]"))
    db_session.commit()
    assert db_session.query(User).count() == 1

Les fixtures partagées vivent dans conftest.py — pytest le découvre automatiquement, sans import, pour tous les tests du répertoire et de ses sous-répertoires. C'est le mécanisme de réutilisation.

Scopes — la durée de vie. function (défaut) recrée la fixture à chaque test : isolation maximale, coût maximal. session la crée une fois pour toute la suite : rapide, mais l'état fuit entre les tests si tu n'y prends pas garde.

python
@pytest.fixture(scope="session")
def anthropic_client() -> AsyncAnthropic:
    # Un client coûteux à construire, mais stateless → partageable.
    return AsyncAnthropic()

@pytest.fixture(scope="function")  # défaut, explicite ici pour la clarté
def fresh_db() -> Iterator[Session]:
    ...   # chaque test repart d'une base vierge

Règle senior : tout ce qui porte de l'état mutable de test → scope function. Tout ce qui est immuable et coûteux (un client HTTP, un moteur de template) → session. Mélanger les deux est la première source de tests flaky.

Paramétrisation : une table de cas, pas dix tests copiés-collés

python
import pytest

@pytest.mark.parametrize(
    ("raw", "expected"),
    [
        ("Hello World", "hello-world"),
        ("  trim me  ", "trim-me"),
        ("Accentué É", "accentue-e"),
        ("", ""),                          # cas limite
        ("a" * 300, ("a" * 300)[:255]),    # troncature
    ],
)
def test_slugify_cases(raw: str, expected: str) -> None:
    assert slugify(raw) == expected

Chaque ligne devient un test indépendant avec son propre rapport. Un échec sur "" n'empêche pas les autres de s'exécuter, et le nom du test affiche le cas (test_slugify_cases[-]). C'est la différence entre « le test slugify échoue » et « le cas chaîne vide échoue ».

Tester de l'async

C'est ici que ton stack devient concret. FastAPI est async, le SDK Anthropic moderne s'utilise via AsyncAnthropic, donc tes tests doivent savoir await.

Installe et configure pytest-asyncio. Le mode auto évite de décorer chaque test :

toml
# pyproject.toml
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
python
# Avec asyncio_mode = "auto", un test async est détecté automatiquement.
async def test_fetch_returns_payload() -> None:
    result = await fetch_user(user_id=42)
    assert result.email == "[email protected]"

Le piège classique : oublier await sur une coroutine. Le test passe — parce que tu assertes sur un objet coroutine truthy, pas sur son résultat.

python
# ❌ ANTI-PATTERN: pas de await. `fetch_user(...)` est une coroutine non
# exécutée. `assert <coroutine>` est toujours vrai. Le test est un faux vert
# (et tu auras un warning "coroutine was never awaited" — ne l'ignore jamais).
async def test_bad() -> None:
    result = fetch_user(user_id=42)
    assert result   # ← teste la vérité d'un objet, pas la logique métier

Tester un endpoint FastAPI

FastAPI fournit TestClient (synchrone, basé sur httpx), mais pour du vrai async de bout en bout, utilise httpx.AsyncClient avec ASGITransport. Et surtout : override les dépendances via app.dependency_overrides — l'équivalent exact du overrideProvider des tests NestJS.

python
import pytest
from collections.abc import AsyncIterator
from httpx import ASGITransport, AsyncClient
from myapp.main import app
from myapp.deps import get_db

@pytest.fixture
async def client(db_session: Session) -> AsyncIterator[AsyncClient]:
    # Injecte la session de test à la place de la vraie dépendance DB.
    app.dependency_overrides[get_db] = lambda: db_session
    transport = ASGITransport(app=app)
    async with AsyncClient(transport=transport, base_url="http://test") as ac:
        yield ac
    app.dependency_overrides.clear()   # teardown: ne JAMAIS laisser fuiter l'override

async def test_create_user_endpoint(client: AsyncClient) -> None:
    resp = await client.post("/users", json={"email": "[email protected]"})
    assert resp.status_code == 201
    assert resp.json()["email"] == "[email protected]"

Le app.dependency_overrides.clear() dans le teardown n'est pas optionnel : app est un singleton de module, l'override survit au test et empoisonne les suivants. C'est exactement le genre de fuite d'état évoquée plus haut.

Mocking : remplacer ce qui est lent, coûteux ou non-déterministe

La règle d'or du mock : mocke aux frontières, pas au cœur. Un appel réseau, l'horloge, le système de fichiers, un LLM — oui. Ta propre logique métier — non, sinon tu testes ton mock.

monkeypatch vs unittest.mock

monkeypatch (fixture native pytest) remplace un attribut puis le restaure automatiquement à la fin du test. Idéal pour les variables d'environnement et les substitutions simples.

python
def test_reads_api_key_from_env(monkeypatch: pytest.MonkeyPatch) -> None:
    monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test-fake")
    assert load_config().api_key == "sk-test-fake"
    # Restauration automatique : l'env réel n'est pas pollué.

Pour mocker des appels et inspecter comment ils ont été faits, unittest.mock (avec AsyncMock pour l'async).

python
from unittest.mock import AsyncMock, patch

async def test_calls_downstream_once() -> None:
    with patch("myapp.service.http_post", new=AsyncMock(return_value={"ok": True})) as m:
        await sync_user(user_id=42)
    m.assert_awaited_once_with("/sync", json={"user_id": 42})

Le piège de patch : mocke là où c'est utilisé, pas où c'est défini. Si myapp.service fait from myapp.http import http_post, tu patches "myapp.service.http_post" — la référence importée dans service — et pas "myapp.http.http_post". Erreur n°1 des débutants en mocking Python.

🤖 Tester du code qui appelle un agent LLM (Anthropic)

C'est le cœur de ton métier. Un test qui appelle vraiment claude-opus-4-8 est lent (plusieurs secondes), coûteux (5 / 25 USD par Mtok), et non-déterministe — trois propriétés interdites en test unitaire. La discipline : mocker le SDK Anthropic, et réserver les vrais appels à une poignée de tests d'intégration explicitement marqués et exclus du CI rapide.

Tester la boucle d'outils (tool-use loop)

Ta logique à toi, c'est l'orchestration : tu envoies un message, le modèle répond stop_reason="tool_use", tu exécutes l'outil, tu renvoies le résultat, le modèle conclut avec end_turn. Cette boucle est testable à 100 % sans réseau en faisant retourner au mock une séquence de réponses scriptées.

python
from dataclasses import dataclass
from unittest.mock import AsyncMock
import pytest

# --- Le code sous test : une boucle d'agent minimale ---
async def run_agent(client, user_msg: str, tools: list[dict], execute) -> str:
    messages: list[dict] = [{"role": "user", "content": user_msg}]
    while True:
        resp = await client.messages.create(
            model="claude-opus-4-8",
            max_tokens=1024,
            tools=tools,
            messages=messages,
        )
        if resp.stop_reason != "tool_use":
            return "".join(b.text for b in resp.content if b.type == "text")
        messages.append({"role": "assistant", "content": resp.content})
        results = []
        for block in resp.content:
            if block.type == "tool_use":
                output = await execute(block.name, block.input)
                results.append({
                    "type": "tool_result",
                    "tool_use_id": block.id,
                    "content": output,
                })
        messages.append({"role": "user", "content": results})

# --- Des doubles légers qui imitent la forme des objets du SDK ---
@dataclass
class TextBlock:
    text: str
    type: str = "text"

@dataclass
class ToolUseBlock:
    id: str
    name: str
    input: dict
    type: str = "tool_use"

@dataclass
class FakeResponse:
    content: list
    stop_reason: str

# --- Le test : deux tours scriptés, zéro réseau ---
async def test_agent_executes_tool_then_concludes() -> None:
    client = AsyncMock()
    client.messages.create.side_effect = [
        FakeResponse(
            content=[ToolUseBlock(id="toolu_1", name="get_weather",
                                  input={"city": "Paris"})],
            stop_reason="tool_use",
        ),
        FakeResponse(
            content=[TextBlock(text="Il fait 18°C à Paris.")],
            stop_reason="end_turn",
        ),
    ]
    execute = AsyncMock(return_value="18°C")

    out = await run_agent(client, "Météo à Paris ?",
                          tools=[{"name": "get_weather"}], execute=execute)

    assert out == "Il fait 18°C à Paris."
    execute.assert_awaited_once_with("get_weather", {"city": "Paris"})
    assert client.messages.create.await_count == 2   # un tour outil + une conclusion

Ce test vérifie ce qui t'appartient — l'enchaînement, le passage du tool_use_id, l'appel d'outil — sans jamais dépendre de ce que le modèle « décide ». C'est ça, tester un agent de façon déterministe.

Tester le streaming de tokens

Le SDK Anthropic stream via messages.stream() (un context manager async qui yield des événements). Pour le tester, ton mock doit reproduire la forme du context manager + l'itération async.

python
from contextlib import asynccontextmanager
from unittest.mock import MagicMock

async def collect_stream(client) -> str:
    chunks: list[str] = []
    async with client.messages.stream(
        model="claude-opus-4-8",
        max_tokens=1024,
        messages=[{"role": "user", "content": "Salut"}],
    ) as stream:
        async for event in stream:
            if event.type == "content_block_delta" and event.delta.type == "text_delta":
                chunks.append(event.delta.text)
    return "".join(chunks)

async def test_streams_text_deltas() -> None:
    @dataclass
    class Delta:
        text: str
        type: str = "text_delta"

    @dataclass
    class Event:
        delta: Delta
        type: str = "content_block_delta"

    async def fake_events():
        for piece in ["Bon", "jour", " !"]:
            yield Event(delta=Delta(text=piece))

    @asynccontextmanager
    async def fake_stream(**kwargs):
        # L'objet yieldé doit être async-itérable : un async generator l'est
        # nativement (il implémente __aiter__/__anext__). Pas besoin de MagicMock.
        yield fake_events()

    client = MagicMock()
    client.messages.stream = fake_stream

    assert await collect_stream(client) == "Bonjour !"

Tester les sorties structurées et les retries

Les sorties structurées natives passent par client.messages.parse(...), qui valide la réponse contre un modèle Pydantic v2 et expose .parsed_output. Côté test, tu mockes parse pour qu'il retourne un objet validé — tu testes ta gestion du résultat, pas la validation du SDK.

python
from pydantic import BaseModel

class Invoice(BaseModel):
    total: float
    currency: str

async def extract_invoice(client, text: str) -> Invoice:
    resp = await client.messages.parse(
        model="claude-opus-4-8",
        max_tokens=512,
        messages=[{"role": "user", "content": text}],
        output_config={"format": {"type": "json_schema", "schema": Invoice.model_json_schema()}},
    )
    return resp.parsed_output

async def test_extract_invoice_returns_typed_model() -> None:
    client = AsyncMock()
    client.messages.parse.return_value = MagicMock(
        parsed_output=Invoice(total=42.0, currency="EUR")
    )
    inv = await extract_invoice(client, "Total: 42 EUR")
    assert inv.total == 42.0 and inv.currency == "EUR"

Pour les retries : le SDK lève des exceptions typées (anthropic.RateLimitError, anthropic.APIError, etc.). Teste ta gestion d'erreur en faisant lever ces exceptions par le mock — side_effect accepte une liste mêlant exceptions et valeurs pour simuler « échoue deux fois puis réussit ».

python
import anthropic

async def test_retries_on_rate_limit_then_succeeds() -> None:
    client = AsyncMock()
    err = anthropic.RateLimitError(
        message="429", response=MagicMock(status_code=429), body=None
    )
    client.messages.create.side_effect = [err, err, FakeResponse([], "end_turn")]

    out = await call_with_retry(client, max_attempts=3)

    assert client.messages.create.await_count == 3   # 2 échecs + 1 succès
    assert out is not None

Note : le SDK Anthropic retry déjà automatiquement les 429 et 5xx (avec backoff). Si tu ré-implémentes un retry par-dessus, teste explicitement qu'il s'enclenche bien — et qu'il ne double pas le retry du SDK.

⚙️ En production

Modes d'échec.

  • Tests flaky. La cause n°1 n'est pas le hasard, c'est l'état partagé : fixture session mutable, dependency_overrides non nettoyé, ordre des tests qui devient signifiant. Diagnostic : pytest -p no:randomly vs avec pytest-randomly — si l'ordre change le résultat, tu as une fuite d'état. Fix : isole l'état mutable en scope function, nettoie dans le yield.
  • async jamais awaité. Un warning coroutine was never awaited est un test cassé déguisé en test vert. Active filterwarnings = ["error"] dans pyproject.toml pour le transformer en échec dur.
  • Mock au mauvais endroit. Patch module_qui_utilise.symbole, pas module_qui_definit.symbole.

Performance.

  • Parallélise avec pytest-xdist (pytest -n auto). Prérequis : tests indépendants (encore l'isolation d'état). C'est le test de vérité de ta discipline de fixtures.
  • N'appelle jamais le vrai modèle dans la suite rapide. Marque les tests d'intégration (@pytest.mark.integration) et exclus-les par défaut : addopts = "-m 'not integration'". Le CI rapide tourne en secondes ; les intégrations dans un job nightly séparé.
  • Une suite de 2 000 tests unitaires bien mockés doit tourner en moins de 30 s. Si c'est plus, tu as du réseau, de la vraie I/O ou des sleep cachés.

Sécurité.

  • Jamais de vraie clé API dans les tests ni les fixtures. monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test-fake") ou une fixture autouse qui pose une clé bidon pour toute la session. Un test qui plante avec « pas de clé » est mieux qu'un test qui facture.
  • Pour les tests d'intégration réels, clé dédiée à budget plafonné, jamais celle de prod.

Observabilité.

  • pytest --cov=myapp --cov-report=term-missing te donne la couverture ligne et branche. Mais la couverture est une métrique de présence, pas de qualité : 100 % de lignes couvertes avec des assertions faibles ne prouve rien. Vise les branches d'erreur (les except, les if stop_reason == ...), c'est là que sont les vrais bugs.
  • Pour aller plus loin : le mutation testing (mutmut) modifie ton code et vérifie qu'un test casse. Si aucun test ne casse, ta couverture est cosmétique. C'est l'outil senior pour auditer une suite héritée.

Les arbitrages senior.

  • Mocker beaucoup → tests rapides et déterministes, mais tu peux tester un monde qui n'existe pas (le mock diverge de l'API réelle). Mocker peu → fidélité, mais lenteur et flakiness. La résolution n'est pas un curseur : c'est la pyramide. Beaucoup d'unitaires mockés (la boucle d'agent, le parsing), quelques tests de contrat qui vérifient que ton mock correspond encore à la forme du SDK, une poignée d'intégrations bout-en-bout avec le vrai modèle.
  • Tester la sortie exacte d'un LLM est une erreur de conception. Le modèle est non-déterministe ; asserter output == "Il fait 18°C" sur un vrai appel produit un test flaky par construction. Sur les rares intégrations réelles, teste des invariants (le JSON est valide, le champ total est un float positif, l'outil a été appelé) — pas la prose. Pour juger la qualité sémantique, c'est de l'éval, pas du test unitaire — un pipeline distinct.

🏋️ Exercices

Exercice 1 — Fixtures et teardown (implémenter)

Objectif : écris une fixture temp_workspace qui crée un répertoire temporaire, y dépose un fichier config.json, le yield au test, puis supprime tout — même si le test échoue. Ajoute un test qui modifie le fichier et un second qui vérifie qu'il est revenu à l'état initial (preuve de l'isolation).

Indice/Solution : utilise tmp_path (fixture native pytest, déjà un répertoire isolé par test) plutôt que tempfile manuel. Le yield suffit pour le teardown ; tmp_path étant scope function, le second test obtient un répertoire neuf — démontre que tu n'as pas besoin de nettoyer toi-même quand tu pars de tmp_path. Le piège pédagogique : si tu utilises scope="session", le second test voit le fichier modifié → l'exercice révèle pourquoi.

Exercice 2 — Endpoint FastAPI avec dépendance overridée (implémenter)

Objectif : un endpoint POST /agents/{id}/run qui dépend de get_anthropic_client. Écris le test qui override cette dépendance par un AsyncMock, poste une requête, et vérifie le code 200 + le corps. Le vrai client ne doit jamais être instancié.

Indice/Solution : app.dependency_overrides[get_anthropic_client] = lambda: mock_client dans une fixture, clear() au teardown. Vérifie l'isolation : ajoute un second test sans override et assure-toi qu'il ne voit pas le mock du premier (si c'est le cas, ton clear() manque).

Exercice 3 — Boucle d'agent multi-tours (production-grade)

Objectif : étends run_agent pour gérer deux appels d'outils dans un même tour (le modèle renvoie deux tool_use blocks). Script le mock pour reproduire ça, et vérifie que les deux outils sont exécutés et que les deux tool_result reviennent avec les bons tool_use_id.

Indice/Solution : une seule FakeResponse avec content=[ToolUseBlock(...), ToolUseBlock(...)], stop_reason="tool_use", suivie d'une réponse end_turn. Itère sur tous les blocks tool_use. L'assertion qui compte : execute.await_count == 2 et que chaque tool_result mappe le bon tool_use_id — c'est exactement l'invariant que casse une mauvaise implémentation parallèle.

Exercice 4 — Retry sur erreur typée (casser-puis-réparer)

Objectif : on te donne une fonction call_with_retry qui boucle bêtement sans cap. Écris d'abord un test qui échoue en prouvant qu'elle boucle à l'infini sur une RateLimitError permanente (utilise un timeout). Puis corrige la fonction (cap d'essais + ré-lève après épuisement) et écris le test qui prouve qu'elle s'arrête à max_attempts et propage l'exception.

Indice/Solution : side_effect=err (une exception unique, ré-utilisée à l'infini par AsyncMock) pour le cas permanent ; pytest.raises(anthropic.RateLimitError) après le fix, plus assert client.messages.create.await_count == max_attempts. Le piège : side_effect=[err, err] (liste finie) lèverait StopIteration au 3ᵉ appel et masquerait le bug de boucle infinie — d'où l'exception unique.

Exercice 5 — Test de contrat sur le mock (anti-divergence)

Objectif : tes mocks de FakeResponse imitent la forme du SDK. Écris un test qui vérifie que les vrais objets du SDK Anthropic exposent bien les attributs que tes mocks supposent (.stop_reason, .content, blocks avec .type/.text/.tool_use_id). Marque-le @pytest.mark.integration.

Indice/Solution : un seul vrai appel minimal (max_tokens=16, prompt trivial) ou, mieux et gratuit, instancie les types du SDK directement si exposés. Assert sur la présence des attributs (hasattr / accès), pas sur les valeurs. Ce test attrape le jour où une montée de version du SDK change la forme et rend tous tes unitaires verts mais faux.

Exercice 6 — Paramétrisation + cas limites (consolidation)

Objectif : une fonction estimate_cost(input_tokens, output_tokens, model) calcule le coût (claude-opus-4-8 : 5 USD/Mtok en entrée, 25 en sortie ; claude-haiku-4-5 : 1 / 5). Paramétrise au moins 6 cas dont : zéro token, gros volumes, et un modèle inconnu qui doit lever ValueError.

Indice/Solution : @pytest.mark.parametrize avec une colonne expected ; pour le cas d'erreur, mets pytest.raises(...) dans le corps conditionné par le modèle, ou utilise pytest.param(..., marks=...). Vérifie l'arithmétique : 1M tokens d'entrée sur opus = exactement 5.0. Les flottants : assert avec pytest.approx pour éviter les surprises d'arrondi.

🎤 En entretien

Q : Différence entre une fixture function-scoped et session-scoped, et quand chaque scope devient dangereux ? R : function recrée la fixture par test (isolation, coût) ; session la crée une fois (rapidité, mais l'état mutable fuit entre tests et casse l'indépendance — donc le parallélisme et la reproductibilité). Règle : état mutable → function, immuable et coûteux → session.

Q : Comment testes-tu du code qui appelle un LLM de façon déterministe ? R : Je mocke le SDK aux frontières et je script les réponses (side_effect = séquence de FakeResponse). Je teste mon orchestration — la boucle d'outils, le parsing, la gestion d'erreur — pas la décision du modèle. Les vrais appels sont réservés à quelques tests d'intégration marqués, exclus du CI rapide, qui asservent sur des invariants (JSON valide, type correct), jamais sur la prose exacte.

Q : monkeypatch ou unittest.mock.patch — lequel et pourquoi ? R : monkeypatch pour les substitutions simples et l'environnement (restauration automatique, pas de gestion de context manager). mock.patch / AsyncMock quand j'ai besoin d'inspecter les appels (assert_awaited_once_with) ou de scripter des side_effect. Et toujours : patcher là où le symbole est utilisé, pas où il est défini.

Q : Pourquoi 100 % de couverture ne veut pas dire « bien testé » ? R : La couverture mesure les lignes exécutées, pas les comportements vérifiés — on peut couvrir une ligne avec une assertion vide. Elle ignore aussi les cas d'entrée non testés et la qualité des assertions. Pour auditer une suite, je regarde la couverture de branches (les except, les embranchements de stop_reason) et, sur du legacy critique, je lance du mutation testing : si je mute le code et qu'aucun test ne casse, la couverture était cosmétique.

Bibliothèque tech perso — Achref