pytest (+ async, mocking)
TL;DR —
pytestest le standard de fait du test en Python : assertions natives (assert x == y, pas deassertEqual), une mécanique de fixtures par injection de dépendances qui remplace lesetUp/tearDownde 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'asyncproprement avecpytest-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 fixturesL'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'abordLe 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.
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) :
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 == 404La mauvaise façon, classique chez les ex-développeurs xUnit :
# ❌ 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.
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() == 1Les 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.
@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 viergeRè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
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) == expectedChaque 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 :
# pyproject.toml
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]# 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.
# ❌ 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étierTester 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.
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.
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).
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. Simyapp.servicefaitfrom myapp.http import http_post, tu patches"myapp.service.http_post"— la référence importée dansservice— 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.
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 conclusionCe 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.
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.
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 ».
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 NoneNote : 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
sessionmutable,dependency_overridesnon nettoyé, ordre des tests qui devient signifiant. Diagnostic :pytest -p no:randomlyvs avecpytest-randomly— si l'ordre change le résultat, tu as une fuite d'état. Fix : isole l'état mutable en scopefunction, nettoie dans leyield. asyncjamais awaité. Un warningcoroutine was never awaitedest un test cassé déguisé en test vert. Activefilterwarnings = ["error"]danspyproject.tomlpour le transformer en échec dur.- Mock au mauvais endroit. Patch
module_qui_utilise.symbole, pasmodule_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
sleepcachés.
Sécurité.
- Jamais de vraie clé API dans les tests ni les fixtures.
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test-fake")ou une fixtureautousequi 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-missingte 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 (lesexcept, lesif 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 champtotalest 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.