O contrato entre o seu front e o seu back não existe
Um campo renomeado numa refatoração inocente liberou 41 ordens de compra que deveriam ter parado na alçada. Quatro camadas de teste ficaram verdes. Nenhuma delas testava o contrato.
Numa quinta-feira de manhã, alguém renomeou valorTotal para valorLiquido no retorno de GET /ordens-compra/:id. Foi uma boa decisão: o campo já descontava bonificação e frete havia meses, e o nome antigo mentia. O commit tinha três arquivos e a descrição "ajusta nomenclatura do total da ordem".
Na segunda-feira seguinte, 41 ordens de compra de insumo tinham sido aprovadas sem passar pela alçada.
O motivo é quase engraçado. A tela de aprovação lia ordem.valorTotal, que passou a vir undefined. E o front, defensivo como todo front que já apanhou, fazia ordem.valorTotal ?? 0 antes de formatar. Então não teve tela branca, não teve exceção no console, não teve erro no Sentry. Teve R$ 0,00 renderizado com toda a confiança do mundo — e a regra de alçada, que compara o valor da ordem com o teto do aprovador, deixou passar. Zero é menor que qualquer teto.
O código defensivo transformou uma quebra barulhenta num problema de negócio silencioso. Isso vai voltar a acontecer com você.
Quatro camadas verdes, uma explicação para cada
A parte que me interessa não é o bug. É por que nada pegou. Eu fui atrás de cada camada, uma por uma, e cada uma tinha uma desculpa perfeitamente razoável.
Os testes unitários do back passaram porque foram atualizados no mesmo commit. Óbvio quando você fala em voz alta, e é o ponto mais importante do artigo: um teste que afirma sobre o objeto que o próprio código montou não testa contrato nenhum. Ele testa que o desenvolvedor escreveu a mesma palavra duas vezes, uma no DTO e outra no expect. Renomeou nos dois lugares, verde. Esse teste é um espelho.
Os testes do front passaram porque mockam a resposta. O fixture do componente de aprovação vivia em src/ordens/__fixtures__/ordemCompra.ts, tinha sido escrito onze meses antes e ainda dizia valorTotal. O componente lia valorTotal. Combinação perfeita entre duas coisas erradas.
O E2E passou por um motivo pior. Ele quebrou, sim — uma vez. E alguém abriu, viu que o back tinha renomeado o campo, atualizou o fixture do cy.intercept para valorLiquido e o teste voltou ao verde. Só que a asserção do teste era "a ordem aparece na listagem e o botão de aprovar está habilitado". Nunca foi sobre o valor. Então o fixture novo, correto, alimentou um componente que lia o campo velho, e a checagem que existia não olhava para o número.
Repara na simetria cruel: existiam dois fixtures da mesma resposta, em pastas diferentes, mantidos por pessoas diferentes, e eles divergiram sem que ninguém pudesse perceber. É o mesmo problema de massa de teste virar dívida técnica, só que agora a dívida está no formato, não no conteúdo.
E o Swagger? Estava certo. Gerado pelos decorators do NestJS, publicado em /docs, atualizadíssimo. Ninguém leu. Documentação que não é consumida por máquina nenhuma é literatura.
Se o único artefato que descreve a sua API é lido por humanos, ele não é contrato — é folclore. Contrato é o que impede um deploy. Enquanto a violação não derruba um build, o que existe entre os seus dois times é um acordo verbal de uma reunião de abril.
Mock é empréstimo, não garantia
Já escrevi aqui sobre o preço de mockar demais, e o caso da ordem de compra é a versão mais cara do mesmo argumento. Mock de resposta HTTP tem uma propriedade que quase ninguém enuncia: ele congela uma versão do mundo e não tem nenhum mecanismo interno para descobrir que o mundo mudou.
Isso não é defeito de implementação, é a natureza da coisa — você mocka justamente para não depender do outro lado, e o ganho é real. Não vou defender que se apague os mocks. Vou defender que se pare de tratá-los como evidência.
O mock prova que o seu componente funciona se a API responder daquele jeito. Quem prova o "se" é outra pessoa. Se ninguém foi designado para isso, nunca é provado.
A escada de contrato, com o preço de cada degrau
Degrau zero: a interface copiada à mão
Alguém abre o Swagger, olha o schema, digita uma interface no front. Custo de entrada: quinze minutos. Custo de manutenção: invisível até virar incidente.
Eu mantive um arquivo desses por sete meses. src/api/tipos-api.ts, 640 linhas escritas à mão, porque configurar geração "era chato". Quando finalmente gerei os tipos do OpenAPI e comparei, o diff apontou 31 divergências em 14 interfaces. Quatro eram campos que a API tinha deixado de retornar e continuavam no tipo — o front tinha branch morto tratando eles. Duas eram opcionais declarados como obrigatórios, fazendo o TypeScript garantir presença de campo que às vezes não vinha. Uma era um enum de situação de romaneio com um valor a menos do que o back já emitia.
Sete meses de "é chato" custaram uma tarde de geração e uma semana de arrumar o que o diff revelou. A conta nunca fecha do jeito que a gente conta pra si mesmo.
Degrau um: tipos gerados do OpenAPI
Barato e resolve uns 70% do problema. Você pega a spec, roda openapi-typescript, commita o arquivo gerado. Renomeou campo no back, regerou, o front não compila mais. Perfeito.
O custo escondido é que a spec precisa ser verdade. No NestJS isso significa decorator preenchido — @ApiProperty com tipo, required e enum, DTO de resposta declarado no @ApiOkResponse. Rota que devolve any gera spec que descreve object, e tipo gerado de object não protege ninguém.
Degrau dois: validação em runtime na fronteira
Aqui está o buraco que o degrau anterior não tapa, e é o que mais me incomoda em discussão sobre contrato: tipo do TypeScript não existe em runtime. Some na compilação. O que sobra é JavaScript recebendo um JSON de origem desconhecida.
Por isso as OrdemCompraResponse é uma mentira que o compilador aceita de bom grado. Você não está afirmando nada sobre o dado. Está mandando o compilador calar a boca.
// O compilador fica feliz. Em runtime isso é `any` com terno e gravata.
export async function buscarOrdemCompra(id: string): Promise<OrdemCompraResponse> {
const resposta = await fetch(`/api/v2/ordens-compra/${id}`);
return (await resposta.json()) as OrdemCompraResponse;
}
// Consumidor. `valorLiquido` some do payload e nada aqui reclama:
// o ?? 0 formata R$ 0,00 e a alçada aprova. Erro vira número plausível.
const ordem = await buscarOrdemCompra(idOrdem);
exibirValor(ordem.valorLiquido ?? 0);
A versão com validação na fronteira faz uma coisa só, mas faz cedo e alto: recusa o payload que não bate com o combinado, no ponto exato em que ele entra no sistema, com o nome da rota no erro.
src/ordens/ordem-compra.schema.ts — a versão que falha altoimport { z } from 'zod';
export const ordemCompraSchema = z.object({
id: z.string().uuid(),
cdFornecedor: z.string().min(1),
dtEmissao: z.coerce.date(),
situacao: z.enum(['RASCUNHO', 'APROVADA', 'CANCELADA']),
// number de verdade: se o back trocar para string decimal, morre aqui
valorLiquido: z.number().nonnegative(),
itens: z.array(
z.object({
cdInsumo: z.string(),
quantidade: z.number().positive(),
unidade: z.enum(['KG', 'L', 'SC']),
}),
).min(1),
});
export type OrdemCompra = z.infer<typeof ordemCompraSchema>;
export async function buscarOrdemCompra(id: string): Promise<OrdemCompra> {
const resposta = await fetch(`/api/v2/ordens-compra/${id}`);
const bruto = await resposta.json();
const validado = ordemCompraSchema.safeParse(bruto);
if (!validado.success) {
// nome da rota + campo divergente. Sem isso o erro chega sem endereço.
throw new ContratoViolado('GET /ordens-compra/:id', validado.error.issues);
}
return validado.data;
}
O custo real aqui é a duplicação: o schema Zod e o tipo gerado descrevem a mesma coisa. Dá para gerar o Zod direto da spec (o orval faz isso), e vale quando o número de rotas passa de umas trinta. Abaixo disso, escrever à mão nas rotas críticas sai mais rápido do que discutir a ferramenta.
E não valide tudo. Valide onde o erro custa dinheiro: valor, quantidade, situação, identificador. Campo de descrição textual pode entrar como string e pronto.
Degrau três: consumer-driven contract testing
O Pact inverte a direção. Em vez de o back publicar um documento e torcer, cada consumidor declara o que precisa da resposta, isso vira um pacto verificado contra o provedor no pipeline dele, e o provedor não consegue subir uma mudança que quebre um consumidor conhecido.
É a única coisa da lista que resolve o problema quando você tem vários consumidores independentes. Web, app Flutter, integração de terceiro puxando romaneio, job de sincronização: quatro expectativas diferentes, e o back sozinho não sabe qual campo cada um usa. Volto no custo dele mais pra frente, porque tem uma objeção boa a enfrentar.
Travar no CI é o degrau que se paga sozinho
Gerar tipo é metade. A outra metade é impedir que a spec versionada e o código andem separados, e isso é um problema de pipeline, não de biblioteca.
A ideia: a spec vive versionada no repositório. O CI regera a spec a partir do código e falha se o arquivo commitado estiver diferente. Depois compara a spec nova com a da branch principal e falha se houver quebra não declarada.
package.json + .github/workflows/contrato.yml// package.json — a spec é artefato de build, não documentação
{
"scripts": {
"spec:gerar": "ts-node scripts/gerar-openapi.ts > contracts/openapi.json",
"tipos:gerar": "openapi-typescript contracts/openapi.json -o src/api/gerado.ts",
"contrato:check": "npm run spec:gerar && npm run tipos:gerar && git diff --exit-code contracts src/api/gerado.ts"
}
}
.github/workflows/contrato.yml
- name: Spec e tipos batem com o código
# git diff --exit-code devolve 1 se algo mudou.
# Traduz: "você mexeu no DTO e não commitou a spec regerada".
run: npm run contrato:check
- name: Baixar spec da main para comparar
run: git show origin/main:contracts/openapi.json > /tmp/base.json
- name: Quebra não declarada reprova o PR
# oasdiff classifica: remover campo e apertar validação são ERR.
# Adicionar campo opcional é INFO e passa reto.
run: npx oasdiff breaking /tmp/base.json contracts/openapi.json --fail-on ERR
- name: Quebra intencional exige label explícita
if: contains(github.event.pull_request.labels.*.name, 'breaking-change')
run: echo "Quebra declarada. Avisar consumidores antes do merge."
O que faz esse pipeline funcionar na vida real é o último passo. Quebrar contrato às vezes é necessário e correto; o que não pode é quebrar sem saber. Uma label no PR transforma decisão implícita em decisão assinada, e custa um clique.
Quando eu rodei o oasdiff pela primeira vez contra os últimos dois meses de commits de uma API interna, ele apontou 6 mudanças classificadas como quebra. Três eram intencionais e comunicadas. Uma era o valorTotal. As outras duas ninguém sabia que tinham acontecido.
O que é breaking de verdade
Metade das discussões sobre versionamento morre porque as pessoas usam "breaking" para coisas diferentes. Breaking não é o que quebra o seu teste. É o que quebra o consumidor.
| Mudança na API | É breaking? | Quem quebra | O que detecta |
|---|---|---|---|
| Adicionar campo opcional na resposta | Não | Só quem valida com strict |
Diff de spec (informativo) |
| Remover campo da resposta | Sim | Todo consumidor que lê o campo | Tipo gerado não compila; oasdiff |
Renomear campo (valorTotal → valorLiquido) |
Sim — é remover e adicionar | Todos, e em silêncio se houver ?? |
Tipo gerado; validação em runtime |
Mudar tipo (number → string decimal) |
Sim, e o pior tipo | Quem faz conta com o valor | Só validação em runtime pega de fato |
| Campo do request opcional vira obrigatório | Sim | Cliente na versão anterior | oasdiff; teste de integração |
Novo valor em enum de resposta (ESTORNADA) |
Sim, na prática | Front que mapeia rótulo; switch sem default |
Tipo gerado muda a union e quebra o build |
| Aceitar mais coisas no request | Não | Ninguém | — |
| Mudar o default de um campo omitido | Sim, e invisível | Quem depende do default | Nenhum tipo pega. Só teste de integração |
| Erro de validação sai de 422 para 400 | Sim | Cliente que trata por status | Teste de integração no status |
As duas linhas que mais machucam são a mudança de tipo e a mudança de default, porque nenhuma delas altera a forma do payload. O JSON continua com as mesmas chaves. Só o significado mudou.
É aí que a regra de Postel — seja liberal no que aceita, conservador no que envia — envelheceu mal. Ela nasceu para protocolo público, onde recusar uma mensagem levemente torta derruba um sistema que você não controla. Numa API interna com três consumidores que você conhece pelo nome, ser liberal na entrada é só adiar o erro: você aceita o payload torto, grava um apontamento de aplicação com unidade errada, e o problema reaparece três semanas depois no relatório de custo por talhão, sem rastro. É a mesma falha silenciosa que faz pipeline de dados mentir em vez de quebrar. Internamente, recusar cedo é mais barato do que investigar tarde.
Testar o back contra o schema, não contra campos escolhidos a dedo
O teste que faltava naquela quinta-feira não é sofisticado. É um teste de integração no NestJS que sobe a aplicação de verdade, bate na rota com supertest e valida a resposta inteira contra o schema — não contra três campos que o autor do teste achou importantes.
A diferença entre expect(body.situacao).toBe('APROVADA') e validar o corpo todo é exatamente a diferença entre testar o que você lembrou e testar o contrato.
describe('GET /api/v2/ordens-compra/:id — formato da resposta', () => {
let app: INestApplication;
let idOrdem: string;
beforeAll(async () => {
// AppModule inteiro, Postgres real do docker-compose.
// Serviço mockado aqui destruiria o propósito do teste.
const modulo = await Test.createTestingModule({ imports: [AppModule] }).compile();
app = modulo.createNestApplication();
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
await app.init();
idOrdem = await semearOrdemComItens(app.get(DataSource), { qtItens: 3 });
});
afterAll(() => app.close());
it('responde exatamente o schema publicado', async () => {
const { body } = await request(app.getHttpServer())
.get(`/api/v2/ordens-compra/${idOrdem}`)
.expect(200);
// .strict() reprova campo não declarado: coluna nova vazando
// no response é mudança de contrato, mesmo que "não quebre ninguém".
const resultado = ordemCompraSchema.strict().safeParse(body);
// comparar issues com [] faz o Jest imprimir campo e motivo no diff
expect(resultado.error?.issues ?? []).toEqual([]);
});
it('recusa apontamento de aplicação sem talhão com 422', async () => {
const { body } = await request(app.getHttpServer())
.post('/api/v2/apontamentos-aplicacao')
.send({ cdInsumo: 'UREIA-45', dose: 180 }) // falta cdTalhao
.expect(422);
// o formato do erro também é contrato. Cliente parseia isso.
expect(erroValidacaoSchema.strict().safeParse(body).success).toBe(true);
});
});
O segundo teste é o que quase todo time esquece. O formato da resposta de erro é contrato tanto quanto o do sucesso. O front lê body.errors[0].campo para pintar o input de vermelho; mude a estrutura do erro e a tela para de destacar o campo, sem nenhum teste ficar vermelho.
Esse teste sobe banco de verdade e leva alguns segundos. Vale cada um deles: é a única coisa na sua suíte que responde "o que essa rota realmente devolve" em vez de "o que eu acho que ela devolve".
"Pact é overhead de infra pra um time de oito pessoas"
Concordo. Na maior parte das vezes, concordo.
Pact com broker exige serviço rodando, versionamento de pactos, pipeline do provedor disparado por mudança do consumidor, tags de ambiente, alguém para consertar quando a verificação falha por motivo bobo. Isso é uma pessoa dedicada em parte do tempo, permanentemente. Num time onde o dev do front senta ao lado do dev do back e os dois repos abrem no mesmo editor, esse aparato substitui uma conversa de dois minutos por um sistema distribuído de coordenação.
Mas a objeção costuma vir com um final que eu não aceito: "então a gente não precisa de contrato". Precisa. O que muda é o mecanismo.
Para time pequeno com um front e um back, o pacote que eu defenderia é curto: spec OpenAPI versionada e gerada do código, tipos gerados dessa spec commitados no front, oasdiff reprovando quebra não declarada no PR, validação Zod nas quatro ou cinco rotas onde o erro custa caro, e teste de integração com supertest validando corpo inteiro contra schema. Isso cabe em dois dias de trabalho, roda em CI, não precisa de servidor nenhum e cobre a maior parte do que Pact cobreria.
Migrar para consumer-driven quando aparecer o terceiro consumidor independente, com time e calendário próprios. Antes disso, é canhão. Depois disso, a conversa de dois minutos não escala e você vai descobrir do jeito difícil.
Aquela renomeação estava certa. valorLiquido é um nome melhor que valorTotal, o commit foi bem-intencionado, o autor não fez nada errado dentro das regras que existiam. O que faltou não foi cuidado — faltou um lugar onde o cuidado pudesse falhar visivelmente.
Todo time que eu conheço acha que tem um contrato de API. Tem um Swagger que ninguém abre, um arquivo de tipos que alguém digitou em outubro e dois fixtures que divergiram em silêncio. Isso não é contrato. É um conjunto de crenças compartilhadas sobre o formato do JSON, e crença não reprova build.
Escolhe uma rota. A mais cara de errar. Gera o tipo, valida na fronteira, trava o diff no CI. Amanhã alguém vai renomear um campo — e você vai querer descobrir isso num PR vermelho, e não numa segunda-feira de manhã com 41 aprovações para estornar.