Un LLM est non déterministe et coûte de l'argent par appel. Lancer la vraie API dans la CI est lent, cher et flaky. Pourtant le code autour de l'appel (parsing, validation, retry, mapping) doit être testé. Voici comment.
Sépare l'appel du reste
La règle d'or : ton code métier ne doit jamais appeler l'API directement. Il dépend d'une interface.
interface LlmClient {
public function complete(string $prompt): string;
}
final class ExtractInvoice {
public function __construct(private LlmClient $llm) {}
public function run(string $text): array {
$json = $this->llm->complete($this->buildPrompt($text));
return $this->parseAndValidate($json); // <- ce qu'on teste
}
}
La doublure en test
final class FakeLlm implements LlmClient {
public function __construct(private string $canned) {}
public function complete(string $prompt): string { return $this->canned; }
}
public function test_parse_invoice(): void {
$llm = new FakeLlm('{"total": 1240, "currency": "EUR"}');
$result = (new ExtractInvoice($llm))->run('Facture ACME 1240 EUR');
$this->assertSame(1240, $result['total']);
}
Ce qu'on teste, ce qu'on ne teste pas
- On teste : le parsing, la validation, le comportement sur réponse malformée, le retry, le mapping vers le domaine.
- On ne teste pas : la qualité de la réponse du modèle (ce n'est pas un test unitaire, c'est de l'éval).
Les évals, à part
Mesurer si le modèle répond bien est un autre exercice : un jeu de cas réels, un scoring (exact match, LLM-as-judge, métrique métier), lancé hors CI unitaire — en nightly ou à chaque changement de prompt. Ne mélange pas les deux : tests unitaires rapides et déterministes d'un côté, évals lentes et statistiques de l'autre.