Seu fake determinístico passa. A API real mudou. Como detectar drift de contrato antes do incidente
Numa POC que fiz recentemente, o cliente do provedor enviava uma pergunta declarada como "type": "boolean". A documentação de primitivos do fornecedor dizia que o tipo correto era noul. O catálogo do gateway exibia "Boolean" como capacidade.
A suíte de testes estava verde. Vinte e oito testes, todos passando, sem rede e em 0,34 segundo.
Eles passavam porque o fake devolvia exatamente aquilo que eu tinha escrito nele. O fake não sabia de contrato nenhum. Ele sabia do meu palpite sobre o contrato — e testava meu palpite contra ele mesmo.
Esse é o ponto cego. Testes com mock provam que seu código lida corretamente com a resposta que você imaginou. Eles não provam nada sobre a resposta que o provedor de fato envia.
Este artigo mostra como fechar essa lacuna sem transformar o CI em refém da API de terceiros.
[0,1], soma de probabilidades, campos obrigatórios presentes.| Camada | Roda quando | Usa rede | Prova |
|---|---|---|---|
| unitário com fake | cada commit | não | seu código lida com a forma esperada |
| contract test | agendado, e no deploy | sim | a forma esperada ainda é a forma real |
| validação em runtime | toda requisição | sim | esta resposta específica é utilizável |
A maioria dos times tem a primeira e a terceira. A segunda é a que falta, e é a única que detecta drift antes do usuário.
Não jogue fora os mocks. Eles são rápidos, determinísticos, não custam tokens e cobrem o caminho de degradação — coisa que a API real dificilmente reproduz sob demanda.
A única regra é: o fake deve ser construído a partir de uma resposta gravada, nunca digitado à mão a partir da documentação.
import json
from pathlib import Path
FIXTURES = Path(__file__).parent / "fixtures"
def load_fixture(name: str) -> dict:
return json.loads((FIXTURES / f"{name}.json").read_text())
class RecordedClient:
"""Fake alimentado por resposta real gravada. Nunca escrito à mão."""
def __init__(self, fixture: str = "evaluate_ok"):
self.payload = load_fixture(fixture)
self.calls: list[dict] = []
def evaluate(self, state, questions):
self.calls.append({"state": state, "questions": questions})
return self.payload
class FailingClient:
def __init__(self, error: Exception):
self.error = error
def evaluate(self, state, questions):
raise self.error
A lista calls importa mais do que parece: ela permite asserções sobre o que você enviou, não só sobre o que recebeu. Metade dos bugs de contrato está no request.
def test_envia_tipo_de_pergunta_correto(client: RecordedClient):
service.triage("Checkout fora do ar", "Pagamentos falhando há 20 min")
sent = client.calls[0]["questions"]
assert sent["is_urgent"]["type"] == "noul", (
"o provedor documenta 'noul'; 'boolean' é rótulo de catálogo"
)
assert set(sent["department"]["criteria"]) == {
"billing", "technical", "shipping", "general",
}
Esse teste teria pegado o drift da minha POC — porque ele afirma o contrato, em vez de repetir o palpite.
Aqui você chama a API real. Uma vez, com um payload mínimo, e afirma apenas a forma — nunca o conteúdo.
import os
import pytest
pytestmark = pytest.mark.contract
MINIMAL_QUESTIONS = {
"is_urgent": {"type": "noul", "instructions": "Is this urgent?"},
"department": {
"type": "choice",
"instructions": "Which team?",
"criteria": {"billing": "payments", "technical": "bugs"},
},
"urgency": {
"type": "score",
"instructions": "How urgent?",
"criteria": ["low", "medium", "high"],
},
}
@pytest.fixture(scope="session")
def live_client():
key = os.environ.get("PROVIDER_API_KEY")
if not key:
pytest.skip("PROVIDER_API_KEY ausente — contract test pulado")
return ProviderClient(api_key=key, timeout=30.0)
def test_contrato_de_resposta(live_client, record_fixture):
body = live_client.evaluate(
state={"subject": "Checkout down", "message": "Payments failing"},
questions=MINIMAL_QUESTIONS,
)
record_fixture("evaluate_ok", body) # atualiza a fixture versionada
answers = body["answers"]
assert set(answers) == set(MINIMAL_QUESTIONS), "conjunto de respostas mudou"
# noul: probabilidade, não booleano
assert "noul" in answers["is_urgent"] or "probability" in answers["is_urgent"]
p = answers["is_urgent"].get("noul", answers["is_urgent"].get("probability"))
assert isinstance(p, float) and 0.0 <= p <= 1.0
# choice: opção dentro do enum enviado, probabilidades somando ~1
dep = answers["department"]
assert dep["choice"] in MINIMAL_QUESTIONS["department"]["criteria"]
assert abs(sum(dep["probabilities"].values()) - 1.0) < 0.02
# score: dentro da faixa dos critérios enviados
assert 0.0 <= answers["urgency"]["score"] <= 2.0
Três decisões deliberadas neste teste:
Nenhuma asserção sobre qual departamento foi escolhido. O modelo pode mudar de opinião entre versões sem quebrar contrato. Testar conteúdo aqui produz flakiness e ensina a equipe a ignorar o teste.
record_fixture grava a resposta. O contract test alimenta os mocks da camada 1. As duas camadas ficam sincronizadas por construção.
pytest.skip quando não há credencial. Nenhum desenvolvedor sem chave fica bloqueado; o teste some em vez de falhar.
O gravador:
@pytest.fixture
def record_fixture():
def _record(name: str, body: dict) -> None:
if os.environ.get("RECORD_FIXTURES") != "1":
return
path = FIXTURES / f"{name}.json"
path.write_text(json.dumps(redact(body), indent=2, sort_keys=True) + "\n")
return _record
SENSITIVE = {"authorization", "api_key", "request_id", "trace_id"}
def redact(obj):
"""Remove identificadores e segredos antes de versionar a fixture."""
if isinstance(obj, dict):
return {
k: ("<redacted>" if k.lower() in SENSITIVE else redact(v))
for k, v in obj.items()
}
if isinstance(obj, list):
return [redact(i) for i in obj]
return obj
Nunca versione fixture sem redigir. IDs de requisição, tokens e eventuais trechos do input real não pertencem ao repositório.
Contract test falha por motivos que não são culpa do PR: rate limit, indisponibilidade do provedor, rotação de chave. Colocá-lo no caminho do merge treina o time a ignorar CI vermelho — o pior resultado possível.
name: contract-tests
on:
schedule:
- cron: "0 7 * * *" # diário, antes do expediente
workflow_dispatch: # sob demanda
push:
branches: [main] # após merge, não antes
jobs:
contract:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.12" }
- run: pip install -e ".[dev]"
- name: contract tests
env:
PROVIDER_API_KEY: ${{ secrets.PROVIDER_API_KEY }}
run: pytest -m contract -v
- name: abrir issue em caso de drift
if: failure()
uses: actions/github-script@v7
with:
script: |
github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: `Drift de contrato detectado em ${new Date().toISOString().slice(0,10)}`,
labels: ['contract-drift', 'priority'],
body: `O contract test agendado falhou.\n\nRun: ${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`
})
No pyproject.toml, registre o marcador e mantenha-o fora do default:
[tool.pytest.ini_options]
markers = ["contract: chama a API real do provedor (lento, precisa de credencial)"]
addopts = "-m 'not contract'"
Schema válido não significa resposta utilizável. Um department fora do enum é sintaticamente perfeito e semanticamente inválido.
from pydantic import BaseModel, Field, field_validator, model_validator
DEPARTMENTS = {"billing", "technical", "shipping", "general"}
class Evaluation(BaseModel):
is_urgent: float = Field(ge=0.0, le=1.0)
department: str
department_probabilities: dict[str, float]
urgency_score: float = Field(ge=0.0, le=2.0)
confidence: float | None = Field(default=None, ge=0.0, le=1.0)
@field_validator("department")
@classmethod
def department_no_enum(cls, v: str) -> str:
if v not in DEPARTMENTS:
raise ValueError(f"departamento fora do enum: {v!r}")
return v
@model_validator(mode="after")
def probabilidades_coerentes(self) -> "Evaluation":
probs = self.department_probabilities
if set(probs) != DEPARTMENTS:
raise ValueError(f"chaves de probabilidade divergem: {sorted(probs)}")
total = sum(probs.values())
if abs(total - 1.0) > 0.02:
raise ValueError(f"probabilidades somam {total:.3f}")
if max(probs, key=probs.get) != self.department:
raise ValueError("escolha não corresponde ao argmax das probabilidades")
return self
O último validador é o mais valioso e o menos comum. Ele detecta uma classe inteira de inconsistências internas: a resposta diz technical, mas a maior probabilidade está em billing. Nenhum teste de tipo pega isso.
E a falha de validação precisa ter um destino claro, não um except: pass:
def build_evaluation(raw: dict) -> Evaluation | None:
try:
return Evaluation.model_validate(raw)
except ValidationError as error:
metrics.increment("llm.contract_violation", tags={"field": first_field(error)})
logger.bind(errors=error.errors()).warning("resposta violou o contrato")
return None # ticket é criado sem avaliação; não falha o fluxo
Contagem de contract_violation por campo é o sinal mais barato de drift em produção. Quando ele sobe sem deploy do seu lado, algo mudou do outro.
Existe uma categoria pior: o schema continua perfeito e a distribuição muda.
O provedor promove um alias para uma versão nova. As respostas continuam válidas, os tipos continuam corretos, todos os testes continuam verdes — e a taxa de urgência classificada como alta cai 14% porque o modelo ficou mais conservador.
Nenhum contract test detecta isso. O que detecta:
def test_distribuicao_estavel_no_conjunto_dourado(live_client, golden_set):
"""Roda um conjunto fixo e compara agregados com o baseline versionado."""
results = [live_client.evaluate(c.state, QUESTIONS) for c in golden_set]
urgent_rate = mean(r["answers"]["is_urgent"]["noul"] > 0.5 for r in results)
mean_score = mean(r["answers"]["urgency"]["score"] for r in results)
baseline = load_baseline() # versionado junto do código
assert abs(urgent_rate - baseline["urgent_rate"]) < 0.08, (
f"taxa de urgência mudou: {baseline['urgent_rate']:.2f} → {urgent_rate:.2f}"
)
assert abs(mean_score - baseline["mean_score"]) < 0.25
Trinta a cinquenta casos fixos bastam. O objetivo não é medir qualidade — é detectar que algo mudou. A investigação vem depois.
E fixe a versão quando o provedor permitir. Alias é conveniência de upgrade, não garantia de reprodutibilidade:
MODEL = os.environ.get("MODEL_ID", "provider/model-1.13.0") # não "model-latest"
Persista a versão efetiva junto de cada decisão. Sem isso, você não consegue responder "por que este ticket foi classificado assim em março?".
addopts padrão.O mock é um espelho. Ele devolve o que você colocou nele, e por isso nunca contradiz você. É útil para testar o seu lado da integração — e completamente cego para o lado do provedor.
O contract test é a única camada que confronta a sua suposição com a realidade. Ele é lento, depende de rede, custa alguns centavos por execução e precisa rodar longe do caminho de merge. Tudo isso é preço, não defeito.
Na POC que abriu este artigo, um único assert sobre o tipo enviado teria transformado uma suposição silenciosa em um teste vermelho. Foi barato demais para ter sido esquecido — e é exatamente por ser barato que costuma ser.
Os exemplos usam Python 3.12, pytest 8 e Pydantic 2. Nomes de campos como
noul,choiceescoreseguem a convenção de primitivos tipados descrita no artigo sobre JEV; adapte aos nomes do seu provedor antes de copiar.
Produtos gratuitos e pagos para transformar ideias em uma base que você consegue executar.
13 produtos disponíveisContinue explorando tópicos similares
Seis meses depois, alguém pergunta por que o sistema classificou aquele caso assim. Se a resposta depende de lembrar qual prompt estava no ar, você não tem sistema auditável — tem folclore.
Preço por token é o número do fornecedor. Custo por tarefa concluída é o seu. Entre os dois existem retries, tarefas que falham, contexto que cresce e um nível de esforço que ninguém varre.
Se você não consegue desligar a IA sem derrubar a funcionalidade principal, o boundary está no lugar errado. Este artigo mostra como descobrir isso antes do incidente.
Checklist de 47 pontos para encontrar bugs, riscos de segurança e problemas de performance antes do lançamento.
Templates testados em produção, usados por desenvolvedores. Economize semanas de setup no seu próximo projeto.