Testando validação e erros
Entradas ruins, campos ausentes, tipos errados e como é um bom erro.
A lição anterior enviou dados bons e conferiu se foram guardados. Esta envia dados ruins e confere se são recusados. Na maioria dos projetos, é aqui que estão os bugs. Os desenvolvedores escrevem o caminho feliz primeiro, testam à mão enquanto constroem e depois ficam sem tempo antes de pensar no que mais um cliente poderia enviar.
Um teste de validação tem duas partes. O servidor precisa recusar a requisição com o status certo, e não pode guardar nada. Depois disso, importa a qualidade da mensagem de erro, porque em algum momento outro desenvolvedor vai lê-la tentando descobrir o que fez de errado.
Um formulário com regras
POST /api/validation/registerentre para ver é um endpoint de cadastro com regras escritas. A página de referência lista todas: o nome de usuário tem de 3 a 20 letras, dígitos ou sublinhados, a idade é um inteiro de 18 a 120, o país é um de nove códigos, e assim por diante. Nada é guardado, então você pode enviar o mesmo corpo quantas vezes quiser. Uma requisição válida:
/api/validation/register{
"username": "ava_t",
"email": "ava@example.com",
"password": "Str0ng!Pass",
"confirmPassword": "Str0ng!Pass",
"age": 29,
"country": "us",
"phone": "+15550101",
"acceptTerms": true
}HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
{
"message": "User registered successfully",
"data": {
"id": 1001,
"username": "ava_t",
"email": "ava@example.com",
"age": 29,
"country": "US",
"createdAt": "2026-09-30T18:32:12Z"
}
}Repare que "us" voltou como "US". O servidor normalizou o valor em vez de recusá-lo. Tudo bem, mas é um comportamento, e um teste deve fixá-lo para que ninguém o mude sem querer.
Agora quebre quase todas as regras de uma vez:
/api/validation/register{
"username": "ab",
"email": "ava@example",
"password": "password",
"confirmPassword": "Password",
"age": 17,
"country": "XX",
"acceptTerms": false
}HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json; charset=utf-8
{
"type": "https://example.com/problems/validation-error",
"title": "One or more fields failed validation.",
"status": 422,
"detail": "7 field(s) failed validation.",
"instance": "/api/validation/register",
"errors": {
"username": ["Username must be 3-20 characters long."],
"email": ["Email must be a valid email address."],
"password": [
"Password must contain an uppercase letter.",
"Password must contain a digit.",
"Password must contain a special character."
],
"confirmPassword": ["Passwords do not match."],
"age": ["Age must be between 18 and 120."],
"country": ["Country must be one of: US, GB, IN, DE, FR, JP, CA, AU, BR."],
"acceptTerms": ["You must accept the terms and conditions."]
}
}Esta é uma boa resposta de erro, e vale a pena ver por quê, para reconhecer uma ruim depois.
- Ela reporta todos os problemas de uma vez. Um cliente corrigindo o formulário não precisa enviá-lo sete vezes para achar sete erros.
- Cada erro é indexado pelo nome do campo que o cliente enviou, então uma interface pode mostrá-lo ao lado do campo certo.
- As mensagens dizem a regra ("between 18 and 120") em vez de dizer que o valor está errado e deixar o cliente adivinhar por quê.
- Ela segue a RFC 7807 (atualizada como RFC 9457), um formato padrão para erros HTTP com os campos
type,title,status,detaileinstance, e o tipo de mídiaapplication/problem+json. Você não precisa exigir esse formato, mas se uma API diz usá-lo, confira se os campos estão lá e se ostatusno corpo bate com o código de status real.
O que enviar
Você não vai testar tudo em todo campo. Passe por cada campo e pergunte quais destes poderiam se aplicar:
| Tipo de entrada | Exemplos |
|---|---|
| Ausente | Deixe o campo de fora. Envie como null. Envie uma string vazia. Eles podem se comportar de jeitos diferentes. |
| Tipo errado | "29" para um número, 29 para uma string, true para uma string, um objeto onde se espera uma lista |
| Limites | O mínimo e o máximo exatos, um de cada lado, zero, números negativos, um decimal onde se espera um inteiro |
| Tamanho | Vazio, um caractere, o tamanho máximo, um a mais, e algo bem longo (10.000 caracteres) |
| Formato | E-mails, datas, telefones, URLs e códigos quase certos |
| Espaços e maiúsculas | Espaços no início, um título só de espaços, Admin quando admin já está em uso |
| Campos desconhecidos | Um nome de campo errado, um campo a mais, um campo que o cliente não deveria poder definir |
| O próprio corpo | Sem corpo, JSON malformado, um array em vez de um objeto, o Content-Type errado |
Limites no endpoint de cadastro
A regra de idade é de 18 a 120, então os valores interessantes são 17, 18, 120 e 121. Nesta API, 18 e 120 são aceitos, e 17 e 121 recebem "Age must be between 18 and 120." Um valor de 18.5recebe outra mensagem, "Age must be an integer.", e a string "29" também. Algumas APIs convertem "29" em 29 em silêncio. Nenhum dos dois comportamentos está errado em si, mas os clientes vão passar a depender daquele que for para a produção.
O nome de usuário tem de 3 a 20 caracteres: abc e um nome de 20 letras passam, um de 21 letras falha. Um nome com espaço é recusado com uma mensagem sobre os caracteres permitidos. O nome já em uso admin é recusado, e Admin também, o que mostra que a verificação de unicidade ignora maiúsculas. Se não ignorasse, dois usuários poderiam acabar com nomes que parecem iguais na maioria dos lugares.
A verificação de e-mail é a bagunça de sempre. ava@example, ava@@example.com e ava.example.com são recusados, e " ava@example.com", com um espaço no início, também. ava+qa@example.com e AVA@EXAMPLE.COM são aceitos, o que está certo. a@b.c também é aceito. A validação real de e-mail é uma discussão longa que ninguém vence, então o teste é menos sobre a regra perfeita e mais sobre os casos que importam para o seu produto: endereços com sinal de mais, maiúsculas e se os espaços em volta são removidos ou recusados.
Vazio não é o mesmo que ausente
Envie {} e todo campo obrigatório é reportado como "is required". Envie os mesmos campos com valores null e você recebe o mesmo resultado. É consistente, o que é bom. Em /api/Booksentre para ver, um título de três espaços também é tratado como ausente. Muitas APIs só conferem se uma string existe, então um título só de espaços, ou uma string vazia, passa direto e depois aparece como uma linha em branco na tabela de alguém.
Limites de tamanho muitas vezes simplesmente não existem. /api/Booksentre para ver aceitou um título de 5.000 caracteres com 201. Não há limite documentado, então pode ser proposital, mas é o tipo de coisa com que uma coluna do banco de dados ou o layout de uma interface vão discordar mais tarde. Pergunte qual deveria ser o limite e teste um caractere de cada lado.
Campos desconhecidos
Quando um cliente envia um campo que o servidor não conhece, o servidor pode ignorá-lo ou recusá-lo. Na lição de CRUD, /api/Booksentre para ver ignorou um campo year escrito errado e guardou o ano 0. /api/validation/strictentre para ver recusa:
curl -X POST https://api.testingapis.com/api/validation/strict \
-H "Content-Type: application/json" \
-d '{"name": "Widget", "email": "a@b.co", "quantity": 2, "discount": 50}'
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json; charset=utf-8
{
"type": "https://example.com/problems/unknown-fields",
"title": "Request body contains unknown fields.",
"status": 400,
"detail": "Allowed fields: name, email, quantity (names are case-sensitive).",
"instance": "/api/validation/strict",
"unknownFields": ["discount"]
}Aqui os nomes de campo diferenciam maiúsculas de minúsculas, então Name em vez de name também é um campo desconhecido. Recusar é mais seguro, já que os erros de digitação aparecem na hora. Ignorar é mais amigável quando uma API evolui e clientes antigos mandam campos que foram removidos. O seu trabalho é descobrir qual abordagem a API usa e se ela é usada de forma consistente entre os endpoints.
Há um tipo de campo desconhecido que é questão de segurança e não de estilo: um campo que existe no registro mas que o cliente não deveria poder definir, como role, isAdmin, o price de um pedido ou o id. Se o servidor copia todo campo recebido para o registro, um usuário pode se promover sozinho. A lição de segurança chama isso de mass assignment.
Corpos quebrados
Em seguida, teste a requisição como um todo. Estas são respostas reais do endpoint de cadastro:
# No body at all
HTTP/1.1 400 Bad Request
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"":["A non-empty request body is required."]},"traceId":"00-fc01be6d..."}
# {"username":"ava_t",} (trailing comma)
HTTP/1.1 400 Bad Request
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"$":["The JSON object contains a trailing comma at the end which is not supported in this mode. Change the reader options. Path: $ | LineNumber: 0 | BytePositionInLine: 20."]},"traceId":"00-e13ac011..."}
# [] instead of an object
HTTP/1.1 400 Bad Request
Content-Type: application/json; charset=utf-8
{"status":400,"error":"Bad Request","message":"Request body must be a JSON object."}
# Valid JSON sent as Content-Type: text/plain
HTTP/1.1 415 Unsupported Media Type
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.16","title":"Unsupported Media Type","status":415,"traceId":"00-4cadc1e1..."}As quatro são tratadas, e o 415 para o Content-Type errado está exatamente certo. Mas olhe os formatos. Três respostas são problem+json com um mapa errors. O caso do array devolve um formato totalmente diferente, {status, error, message}, com um tipo de conteúdo JSON comum. Um cliente que interpreta erros de um jeito vai quebrar com o outro. Isso acontece quando alguns erros vêm do framework e outros do código da aplicação, e é um dos achados mais comuns em teste de API. Reúna os formatos de erro que você vê numa API e coloque-os lado a lado.
O que um erro não deve conter
Aqui está o /api/Booksentre para ver recebendo uma string onde espera um número:
curl -X POST https://api.testingapis.com/api/Books \
-H "Content-Type: application/json" \
-d '{"title": "x", "author": "y", "pages": "many"}'
HTTP/1.1 400 Bad Request
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"item": ["The item field is required."],
"$.pages": ["The JSON value could not be converted to System.Int32. Path: $.pages | LineNumber: 0 | BytePositionInLine: 40."]
},
"traceId": "00-d027cc55a2925746a1bf93cc96d2306d-02234fa55d940604-00"
}O status está certo. O conteúdo tem dois problemas. A mensagem cita System.Int32, o que conta a um estranho que o servidor roda .NET e deixa o cliente com um nome de tipo em vez de "pages precisa ser um número inteiro". E o erro extra "The item field is required." se refere a um nome de parâmetro interno, não a algo que o cliente enviou. Nenhum dos dois é perigoso aqui, mas num produto real você reportaria os dois.
O que nunca pode aparecer num erro: stack traces, trechos de SQL, caminhos de arquivo, nomes de hosts internos e valores de segredos. Já um traceId é útil: ele permite que o suporte encontre o log correspondente no servidor sem expor nada.
Qual código de status
As equipes discutem 400 versus 422. Uma divisão comum é 400 para um corpo que o servidor não conseguiu ler (JSON malformado, tipos errados) e 422 para um corpo legível que quebra uma regra (idade 17). /api/validation/registerentre para ver devolve 400 só quando não consegue interpretar o corpo de jeito nenhum, e 422 para todo o resto, inclusive uma string onde deveria estar a idade. /api/validation/annotationsentre para ver usa 400 para tudo. Qualquer abordagem é aceitável se for consistente e documentada, e dois endpoints da mesma API fazendo diferente merece uma pergunta.
| Status | O que significa para uma entrada ruim |
|---|---|
| 400 Bad Request | A requisição está malformada ou inválida. O cliente precisa mudá-la. |
| 415 Unsupported Media Type | O Content-Type não é um dos que o endpoint aceita. |
| 422 Unprocessable Content | O corpo foi interpretado, mas os valores quebram uma regra. |
| 409 Conflict | Válido por si só, mas conflita com o estado atual, como um duplicado. (O endpoint de cadastro usa 422 para um nome de usuário já em uso.) |
| 500 Internal Server Error | Nunca é correto para entrada ruim. O servidor quebrou com algo que deveria ter recusado. |
A última linha é a única regra sem exceções. Se qualquer entrada que você enviar produzir um 500, você encontrou um bug, por mais estranha que fosse a entrada. O servidor deveria recusá-la e em vez disso caiu. Você pode ver como é um 500 bem formado em /api/validation/problem/internalentre para ver, e os outros documentos de problema de exemplo estão listados aqui:
/api/validation/problemExercício
Teste POST /api/validation/annotationsentre para ver. O exemplo válido dele está na página de referência. Ele usa a validação nativa do framework em vez de regras escritas à mão.
- Encontre os limites de
username,ageetags. - Experimente três telefones e sites inválidos e dois válidos.
- Envie um corpo inválido para
/annotationse para/registere compare as respostas de erro. Que status cada um usa? Como os nomes de campo emerrorsestão escritos em comparação com os que você enviou? - Envie uma string onde
ageespera um número. A mensagem vaza alguma coisa?
Descreva as diferenças entre os dois endpoints como se estivesse abrindo um ticket para um desenvolvedor: o que você enviou, o que voltou e o que você esperava.