APIs testáveis: contrato, erros consistentes e idempotência

Neste guia, você vai ver como desenhar uma API que facilita a vida de quem testa: um contrato OpenAPI que vale de verdade, códigos HTTP usados com critério, um corpo de erro padronizado, idempotência em operações de escrita, paginação estável, versionamento previsível e autenticação que dá para exercitar em testes automatizados. A ideia é simples: quanto mais previsível a API, menos testes frágeis e menos conversas do tipo “mas na minha máquina funciona”.

Trabalho com QA desde 2010 e, em boa parte dos projetos, a maior dificuldade em testar APIs não foi a ferramenta, e sim a falta de regras claras do lado de quem a construiu. Este texto é escrito para devs, mas com o olhar de quem vai automatizar os testes depois.

OpenAPI como contrato, não como enfeite

Muita equipe gera a especificação OpenAPI no fim, só para ter uma página bonita de documentação. O problema é que a spec acaba descrevendo o que alguém lembrou, não o que a API faz. Para ser testável, o OpenAPI precisa ser tratado como contrato: a fonte da verdade sobre rotas, parâmetros, esquemas de request e response e, principalmente, sobre as respostas de erro.

Algumas práticas que fazem diferença:

  • Documente todas as respostas, não só o 200. Se a rota pode devolver 400, 404 ou 409, isso precisa estar na spec com o esquema do corpo.
  • Marque campos obrigatórios com required e use additionalProperties: false quando fizer sentido. Isso permite que testes de contrato detectem campos inesperados.
  • Use formatos e enums (format: uuid, format: date-time, listas fechadas de status). Eles viram validação automática nos testes.
  • Valide a spec no pipeline: um teste que compara a resposta real com o esquema declarado pega divergências antes do cliente.

Um trecho mínimo de contrato para criar um pedido ficaria assim:

paths:
  /pedidos:
    post:
      parameters:
        - in: header
          name: Idempotency-Key
          required: true
          schema: { type: string, format: uuid }
      responses:
        "201":
          description: Pedido criado
        "409":
          description: Conflito de estado
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "422":
          description: Regra de negócio violada
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }

Códigos HTTP corretos: cada um com seu significado

Responder 200 com {"erro": true} no corpo, ou 500 para qualquer problema, obriga o teste a abrir o corpo e interpretar texto. Quando o status é usado com critério, a primeira asserção já diz muito. Um guia prático:

  • 400 Bad Request: a requisição está malformada. JSON inválido, tipo errado, parâmetro obrigatório ausente.
  • 401 Unauthorized: não há credencial válida. Token ausente, expirado ou com assinatura inválida.
  • 403 Forbidden: a pessoa está autenticada, mas não tem permissão para aquela ação ou aquele recurso.
  • 404 Not Found: o recurso não existe (ou, por segurança, você opta por não revelar que existe).
  • 409 Conflict: a requisição conflita com o estado atual. Cadastro duplicado, edição sobre versão desatualizada, mesma chave de idempotência com corpo diferente.
  • 422 Unprocessable Content: o formato está certo, mas o conteúdo viola uma regra de negócio. Data de entrega no passado, quantidade acima do estoque.
  • 429 Too Many Requests: limite de taxa atingido. Idealmente acompanhado do cabeçalho Retry-After.
  • 500 Internal Server Error: falha inesperada do servidor. Nunca deveria ser resposta “normal” para entrada inválida; se um teste de dados ruins recebe 500, isso é bug.

A fronteira entre 400 e 422 costuma gerar debate. O mais importante é que a equipe escolha uma regra, registre no contrato e aplique em todas as rotas. Consistência vale mais do que a escolha perfeita. A semântica oficial desses códigos está na RFC 9110, “HTTP Semantics” (https://www.rfc-editor.org/rfc/rfc9110, consultado em 13/10/2026), com exceção do 429, definido na RFC 6585 (https://www.rfc-editor.org/rfc/rfc6585, consultado em 13/10/2026).

Corpo de erro padronizado com Problem Details

Se cada rota devolve erro num formato diferente, cada teste precisa de um parser diferente. A RFC 9457, “Problem Details for HTTP APIs” (https://www.rfc-editor.org/rfc/rfc9457, consultado em 13/10/2026), define um formato comum com o tipo de mídia application/problem+json e os membros type, title, status, detail e instance. Ela também permite membros de extensão, úteis para listar erros por campo.

HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json

{
  "type": "https://api.exemplo.dev/problemas/validacao",
  "title": "Dados do pedido inválidos",
  "status": 422,
  "detail": "Um ou mais campos violam regras de negócio.",
  "instance": "/pedidos",
  "erros": [
    { "campo": "quantidade", "codigo": "ACIMA_DO_ESTOQUE" },
    { "campo": "dataEntrega", "codigo": "DATA_NO_PASSADO" }
  ],
  "traceId": "4f1c2a9e-7b3d-4c8a-9e21-0d5f6a7b8c90"
}

Para testes, os ganhos são diretos: o type é estável e pode ser comparado sem depender de texto traduzido; os códigos por campo permitem asserções precisas; e um traceId ajuda a cruzar a falha com os logs. Evite colocar stack trace ou detalhes internos no detail: isso é ruim para segurança e deixa o teste dependente de mensagens que mudam a cada refatoração.

Idempotência com Idempotency-Key

Redes falham. Um cliente que envia POST /pedidos, não recebe resposta e tenta de novo pode criar dois pedidos. O padrão do cabeçalho Idempotency-Key resolve isso: o cliente gera uma chave única (um UUID, por exemplo) e o servidor guarda o resultado da primeira execução associado a ela. Ao receber a mesma chave de novo, devolve a mesma resposta, sem repetir o efeito.

POST /pedidos
Idempotency-Key: 9b2e6c1a-3f4d-4e8b-a7c2-5d1e0f9a8b37
Content-Type: application/json

{ "produtoId": "p-100", "quantidade": 2 }

Regras que tornam esse comportamento testável:

  • Mesma chave e mesmo corpo: mesma resposta (mesmo status, mesmo ID do recurso criado).
  • Mesma chave com corpo diferente: erro claro, em geral 409 ou 422, documentado no contrato.
  • Chave ausente em rota que exige: 400 com Problem Details explicando o motivo.
  • Prazo de retenção da chave definido e documentado, para que o teste saiba o que esperar.

Num teste automatizado, basta enviar a mesma requisição duas vezes e comparar as respostas. Para gerar chaves rapidamente durante testes manuais, use o gerador de UUID. O cabeçalho ainda é tratado como proposta no IETF, então vale descrever no seu contrato exatamente o comportamento adotado.

Paginação e ordenação estáveis

Um teste que pega “o primeiro item da lista” só é confiável se a ordem for determinística. Ordenar apenas por data de criação, quando vários registros têm o mesmo instante, produz resultados que variam entre chamadas. Sempre inclua um critério de desempate único, como o ID.

  • Declare a ordenação padrão no contrato e aceite um parâmetro explícito, como sort=criadoEm,id.
  • Prefira paginação por cursor em listas que mudam muito; offset pode pular ou repetir itens quando há inserções no meio.
  • Devolva metadados consistentes, como o cursor da próxima página e o tamanho usado.
  • Defina um limite máximo de página e o que acontece se ele for excedido (ajuste silencioso ou 400).
{
  "dados": [
    { "id": "p-101", "criadoEm": "2026-10-13T12:00:00Z" },
    { "id": "p-102", "criadoEm": "2026-10-13T12:00:00Z" }
  ],
  "pagina": {
    "tamanho": 2,
    "proximoCursor": "eyJpZCI6InAtMTAyIn0"
  }
}

Para conferir se duas respostas de lista são equivalentes, ou descobrir o que mudou entre versões, o comparador de JSON poupa bastante tempo.

Versionamento sem surpresas

Não existe um único jeito certo de versionar: na URL (/v1/pedidos), em cabeçalho ou por tipo de mídia. O que torna a API testável é a política ser clara:

  • Mudanças compatíveis (novo campo opcional, nova rota) não exigem nova versão, e os testes devem tolerar campos extras quando o contrato permitir.
  • Mudanças incompatíveis (remover campo, mudar tipo, mudar significado de status) geram nova versão, com a anterior mantida por um período anunciado.
  • Cada versão tem sua própria spec OpenAPI, e a suíte de contrato roda contra todas as versões ativas.

Autenticação testável

Autenticação é onde muitos testes travam: tokens que só se obtêm por um fluxo manual, usuários de teste compartilhados que alguém bloqueia sem querer, ambientes que exigem captcha. Algumas decisões que ajudam:

  • Ofereça, em ambientes de teste, um jeito automatizável de obter token (por exemplo, client credentials ou um endpoint de login documentado).
  • Tenha usuários de teste com perfis conhecidos: um padrão, um bloqueado, um sem permissão. Assim 401 e 403 viram casos fáceis de reproduzir.
  • Diferencie claramente token ausente, expirado e com escopo insuficiente, cada um com seu status e seu type de problema.
  • Permita configurar tempos de expiração curtos em teste, para validar renovação sem esperar horas.
HTTP/1.1 403 Forbidden
Content-Type: application/problem+json

{
  "type": "https://api.exemplo.dev/problemas/escopo-insuficiente",
  "title": "Permissão insuficiente",
  "status": 403,
  "detail": "O token não possui o escopo pedidos:escrita."
}

Quando um teste falha com 401 ou 403, a primeira coisa a fazer é inspecionar o token: emissor, expiração, escopos. O decodificador de JWT mostra o cabeçalho e o payload sem enviar nada para servidores externos.

Checklist rápido antes de liberar uma rota

  • Todas as respostas, inclusive de erro, estão no OpenAPI?
  • Entrada inválida nunca retorna 500?
  • Erros seguem Problem Details com type estável?
  • Escritas que podem ser repetidas aceitam Idempotency-Key?
  • Listas têm ordenação determinística e paginação documentada?
  • Existe forma automatizável de autenticar com perfis de teste distintos?

Próximos passos

A melhor forma de fixar esses conceitos é praticar. A API de prática do AppsQuality Lab tem especificação OpenAPI e usuários de teste como standard_user e locked_user, ideais para exercitar casos de 401, 403 e validação de contrato. Use também: