Testes de contrato JSON Schema: solde as regras de validação na CI
Resumo: Um schema não termina quando é escrito — é ali que começa a degradação. Um erro de digitação afrouxa a validação em silêncio, uma refatoração quebra todas as fixtures. Este guia apresenta uma arquitetura viável de teste de contrato: fixtures positivos, negativos e de snapshot, lint do próprio schema, compilação única reutilizada e quatro portões de CI que transformam quebra de contrato em build vermelho.

Conclusão: um schema é código, e código exige testes. Fixtures positivos provam que ele deixa passar o que deve; os negativos provam que ele barras o que precisa; snapshots vigiam mudanças involuntárias. Por fim, ajv compile e snapshots com --ci fazem a ronda noturna no seu pipeline.
No momento em que uma regra de validação quebra, nada lança erro: um afrouxamento da aceitação passa despercebido, um endurecimento da rejeição é pago por toda a equipe. Um schema não é arquivo de configuração — é código que ninguém testa.
1. O schema quebrou — quem fica sabendo primeiro
Comecemos por uma armadilha que pisamos de verdade. Declare um schema segundo o draft 2020-12 e compile com o pacote padrão do Ajv: uma exceção dispara na hora:
Error: no schema with key or ref "https://json-schema.org/draft/2020-12/schema"
O ponto de entrada padrão do Ajv 8 só conhece draft-07; para 2020-12 é preciso importar de ajv/dist/2020. Outra armadilha: em modo strict, escrever format: "email" sem instalar ajv-formats também derruba a compilação:
Error: unknown format "email" ignored in schema at path "#/properties/email"
Ver esses erros localmente na primeira compilação é bom. O problema: muitos projetos empurram a compilação para a chegada do request, e o schema quebrado espera a primeira vítima. Mais insidioso ainda é o schema meio quebrado: um required escrito por descuido como string em vez de array dispara schema is invalid: data/required must be array na compilação — mas se essa etapa nunca rodou na CI, ele dorme na produção.
Portanto, o primeiro portão não valida dados: valida o próprio schema. Um comando:
node scripts/lint-schemas.mjs
// scripts/lint-schemas.mjs
import { readFileSync, readdirSync } from 'node:fs';
import Ajv2020 from 'ajv/dist/2020.js';
import addFormats from 'ajv-formats';
const ajv = new Ajv2020({ allErrors: true, strict: true });
addFormats(ajv);
for (const file of readdirSync('schemas')) {
const schema = JSON.parse(readFileSync(`schemas/${file}`, 'utf8'));
ajv.compile(schema);
console.log(`ok ${file}`);
}
Por que não npx ajv compile: o pacote ajv v8 não traz CLI, então npx ajv falha com could not determine executable to run; a CLI fica no pacote separado ajv-cli (5.0.0 na nossa medição). Mas o ajv-cli 5 suporta apenas draft-07 e draft-2019-09 — apontado para o schema 2020-12 da seção 1, lança exatamente o mesmo no schema with key or ref "https://json-schema.org/draft/2020-12/schema". Portanto um projeto 2020-12 usa no lint o mesmo Ajv2020 do artigo; npx ajv-cli compile -s '**/*.schema.json' --strict só serve para projetos draft-07.
Rode-o como um linter: erros de digitação, palavras-chave obsoletas e formatos não registrados aparecem já no PR. É a primeira semelhança entre testar schema e testar código comum: antes, confirme que ele compila.
2. O trio: positivo, negativo, snapshot
O esqueleto do teste de schema tem três peças.
Fixtures positivos: amostras colhidas de respostas reais, que devem passar. Pegue-as do staging, não da memória — positivos escritos à mão cobrem só o happy path e esquecem null, strings vazias e bordas só com campos obrigatórios.
Fixtures negativos: para cada restrição do schema, ao menos uma entrada violadora. Para minimum: 0, teste o par -1 (rejeição) e 0 (passagem); para enum, um valor fora da lista; para additionalProperties: false, um campo supérfluo. Os negativos importam mais que os positivos: um schema que deixa silenciosamente passar dados ruins é um bug silencioso que polui até o banco de dados.
A convenção de nomes que transforma o nome do arquivo em asserção copia-se do open source:
schemas/order.schema.json
fixtures/order/valid-minimal.json # deve passar
fixtures/order/valid-full.json # deve passar
fixtures/order/invalid-type.json # deve ser rejeitado
fixtures/order/invalid-extra-field.json # deve ser rejeitado
O node:test embutido no Node basta:
import { test } from 'node:test';
import { readFileSync, readdirSync } from 'node:fs';
import assert from 'node:assert/strict';
import Ajv2020 from 'ajv/dist/2020.js';
import addFormats from 'ajv-formats';
const ajv = new Ajv2020({ allErrors: true });
addFormats(ajv);
const validate = ajv.compile(
JSON.parse(readFileSync('schemas/order.schema.json', 'utf8'))
);
for (const f of readdirSync('fixtures/order')) {
const data = JSON.parse(readFileSync(`fixtures/order/${f}`, 'utf8'));
if (f.startsWith('valid-')) {
test(`${f} deve passar`, () => { assert.ok(validate(data), JSON.stringify(validate.errors)); });
} else {
test(`${f} deve ser rejeitado`, () => { assert.ok(!validate(data)); });
}
}
Snapshots: serialize validate.errors num arquivo e compare. Alguém muda sem querer uma restrição — o snapshot vermelheia imediatamente. Em Jest/Vitest é uma linha: expect(errors).toMatchSnapshot(); na CI, passe sempre --ci: novos snapshots falham em vez de serem criados em silêncio, e a deriva não é tolerada.
3. Junte todos os erros numa única passada
Configuração frequentemente esquecida ao testar negativos: por padrão, o validador para no primeiro erro. Os dados estão quebrados em três lugares, você vê um, conserta, roda de novo — e assim vai. Com allErrors: true (Ajv) ou iter_errors (Python), tudo aparece de uma vez:
from jsonschema import Draft202012Validator
validator = Draft202012Validator(schema)
errors = sorted(validator.iter_errors(bad), key=lambda e: list(e.absolute_path))
for e in errors:
print(list(e.absolute_path), e.validator, e.message)
O Python reserva um fato contraintuitivo: qual erro o validate() reporta não é "o primeiro", mas o que o best_match escolhe. Na nossa medição, com três violações (tipo errado de id, role fora da lista, campo extra supérfluo), só a additionalProperties foi reportada — o erro de tipo do id ficou escondido. Na CI, use sempre iter_errors para a visão completa.
E uma armadilha ainda mais fácil: format não é asserção por padrão. O draft 2020-12 classifica format como anotação: o jsonschema do Python não o valida sem um format_checker passado — no nosso teste, "not-an-email" passou livremente. O Ajv exige instalar e registrar ajv-formats. Se um formato importa (email, data, uuid), deve existir um fixture negativo provando que sua configuração de validador realmente o verifica.
4. Compile uma vez — não use o compilador como descartável
Medições locais (Node v22.13.1, Ajv 8.17, mesmo schema com tripla aninhamento, mesmos dados violadores):
| Abordagem | Tempo para 10.000 validações |
|---|---|
new Ajv() e compile a cada vez |
42233 ms |
Instância no nível do módulo + compile uma vez |
6.9 ms |
Um fator de 6000. Um validador dentro do handler de request que cria um novo Ajv a cada chamada é o acidente de desempenho mais comum. Anotemos também um equívoco difundido: "ajv.validate(schema, data) recompila toda vez". Medido: 100 mil chamadas em 8.2 ms — comparável aos 11.2 ms da função compilada uma vez, pois o Ajv mantém um cache interno de schemas. O verdadeiro mal não é falha de cache, é a não reutilização da instância. Em Python é igual: um Draft202012Validator reutilizado é cerca de 5000 vezes mais rápido que chamar validate() a cada vez (2000 iterações: 0.3 ms contra 1566 ms), pois o último verifica o próprio schema a cada chamada.
5. Solde os portões na CI: quatro portas
Do mais barato ao mais caro: se os anteriores caem, não precisa rodar os seguintes:
| Portão | Forma do comando | O que pega | Custo |
|---|---|---|---|
| Lint do schema | node scripts/lint-schemas.mjs |
Erros de sintaxe, palavras-chave obsoletas | Milissegundos |
| Validação de fixtures | A suíte da seção 2 | Dessincronia fixtures/schema, restrições alteradas por engano | Milissegundos |
| Diff de mudanças quebradoras | json-schema-diff-validator contra main |
Remoção de required, estreitamento de enum, endurecimento de pattern | Segundos |
| Validação de respostas reais | Smoke tests contra staging com asserção .toMatchSchema() |
Deriva do endpoint real | Segundos |
Configuração mínima no GitHub Actions:
- run: npm ci
- run: npm run lint:schemas # Ajv2020 lint
- run: npm run test:schema # trio de fixtures
- run: npx jest --ci --testPathPattern=schema # guarda de deriva de snapshots
O portão de mudanças quebradoras merece destaque: ferramentas como json-schema-diff-validator marcam em vermelho "campo required removido" e "valor perdido do enum" já na revisão — justamente as duas mudanças que API-diffs comuns julgam de bom grado "não quebradoras". Remover um campo de resposta é inofensivo para um consumidor tolerante e fatal para um consumidor com switch exaustivo.
6. Asserte o objeto de erro, não só o booleano
Um teste negativo com apenas validate(data) === false não basta. Para o mesmo schema, confirme que ele rejeita pela razão certa: uma restrição estranha pode ter passado por coincidência. O objeto de erro do Ajv tem uma assimetria famosa: quando required falha, o instancePath aponta para o objeto pai, e o nome do campo ausente se esconde em params.missingProperty. Medido com id ausente:
{ "instancePath": "", "schemaPath": "#/required",
"keyword": "required", "params": { "missingProperty": "id" } }
A asserção se escreve "existe um erro com keyword=required e missingProperty=id", e não um vago errors.length > 0. A decifração completa da semântica dos campos de erro está em Erros de JSON Schema incompreensíveis: localizar erros do Ajv e cuidar das mensagens.
7. Armadilhas frequentes — tabela rápida
| Armadilha | Consequência | Remédio |
|---|---|---|
| Testar só os 200 happy path | Deriva dos envelopes de erro passa despercebida | Schema e fixtures também para respostas de erro |
| Schemas gerados sem revisão | required deduzidos de uma amostra rejeitam dados reais; a equipe desliga a validação | Revisão humana de required/enum após geração |
| Um schema global para todas as versões | Diferenças v1/v2 se apagam | Versionar schemas junto com os endpoints |
| Payloads inteiros nos logs em caso de falha | PII e segredos vazam nos logs da CI | Escrever só caminhos JSON Pointer e fragmentos truncados |
| Enums congelados | Renomeação no produto (canceled→cancelled) quebra a validação | Mudanças de enum passam por revisão de compatibilidade |
| Versões do validador sem pin | Comportamentos divergentes entre máquinas | Fixar versões do validador na CI |
| Substituir testes de autorização | Um 403 pode caber no schema de erro e ainda assim a lógica de papel estar errada | Schema cuida da estrutura; autorização se testa à parte |
| Esquecer a semântica de format | Endereços-lixo passam livremente | Instalar ajv-formats / passar format_checker, adicionar negativos |
8. Checklist de implantação
- Arquivos de schema no controle de versões,
node scripts/lint-schemas.mjsno lint; - Ao menos 1 fixture positivo e 1 negativo por restrição para cada schema; o nome do arquivo é a asserção;
- Objetos de erro em snapshots; na CI, passar
--cipara impedir deriva automática; - Instâncias do validador reutilizadas no nível do módulo — nunca new dentro de handlers;
- Diff de mudanças quebradoras pendurado nos PRs; alterações de required/enum com revisão obrigatória;
- O primeiro PR prova deliberadamente que o portão sabe falhar: remova um required, veja o vermelho, reverta.
A questão da severidade da validação (quando fechar additionalProperties) é outro tema — veja additionalProperties aberto: a validação de schema não serve de nada. Este artigo trata de manter regras escritas confiáveis continuamente. Para os fundamentos, comece por JSON Schema dos básicos à engenharia.
FAQ
Qual a relação entre validação de schema e teste de contrato à la Pact?
É divisão de trabalho. A validação de schema responde "este documento é estruturalmente correto" — uma asserção unidirecional. O teste de contrato dirigido pelo consumidor (Pact) responde "o provedor ainda satisfaz as necessidades concretas do consumidor X" e exige verificação bidirecional. Equipes pequenas começam com portões de schema e adotam Pact quando o número de serviços cresce.
Quantos testes negativos bastam?
Um negativo por restrição é o piso: tantos fixtures negativos quanto restrições (required, type, minimum, enum, pattern, additionalProperties…), mais pares de valor-limite rejeitado/aceito. Menos negativos que restrições significa que alguma restrição ficou sem vigilância.
Que mudança de schema conta como "quebradora"?
Julgue do ponto de vista do consumidor: remover um campo de resposta, remover ou estreitar um enum, endurecer um pattern, acrescentar campo obrigatório na requisição — tudo isso quebra; acrescentar campo opcional de resposta costuma ser seguro — desde que o decodificador do consumidor tolere chaves desconhecidas. Automatize a comparação com json-schema-diff-validator no momento do PR e arbitre as zonas cinzentas à mão.
Testar contra a API real ou mocks?
Ambos, em camadas: os testes de fixtures não tocam a rede, correm em milissegundos e rodam a cada PR; a validação real bate no staging, leva segundos, pertence ao pipeline de smoke e salta via skipif sem credenciais. Equipes que rodam só a API real na CI se desgastam com rede instável e limites de taxa e acabam desligando os testes.