JSON Toolkit

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.

Intermediário~8 min de leituraPublicado em 2026-11-05Atualizado em 2026-11-05
Diagrama de testes de contrato JSON Schema e portões de validação em CI

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

  1. Arquivos de schema no controle de versões, node scripts/lint-schemas.mjs no lint;
  2. Ao menos 1 fixture positivo e 1 negativo por restrição para cada schema; o nome do arquivo é a asserção;
  3. Objetos de erro em snapshots; na CI, passar --ci para impedir deriva automática;
  4. Instâncias do validador reutilizadas no nível do módulo — nunca new dentro de handlers;
  5. Diff de mudanças quebradoras pendurado nos PRs; alterações de required/enum com revisão obrigatória;
  6. 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.

Referências

Artigos relacionados

Sem additionalProperties, a validação de schema não valida nada

Validação bem-sucedida não significa dados limpos. O Pydantic ignora campos desconhecidos por padrão, o Zod os remove e o Ajv deixa passar — parâmetros alucinados e chaves com erro de digitação atravessam em silêncio. Este artigo mostra as três formas de aceitação silenciosa e dá receitas completas: fechar objetos, controlar coerção e apertar cada nível de aninhamento.

Intermediate~8 min de leituraAtualizado em 2026-11-02

JSON Schema: do básico à produção

Há muitas palavras-chave, mas só uma dúzia é usada todos os dias. Acerte `type`/`properties`/`required`/`$ref`, trate bem as mensagens de erro, e você cobre oitenta por cento dos casos reais.

Intermediário~14 min de leituraAtualizado em 2026-10-30