How to test API with invalid input
Send one request per rule you intend to break: curl -X POST http://localhost:8788/v1/users -H 'content-type: application/json' -d '{"name":"Ada","age":"thirty-six"}'. A wrong type, a missing field, an out-of-range number, malformed JSON and an oversized body must each answer 400 or 413 and name the field, never 200 and never 500.
Why check this
Validation is written once and then edited by everyone, so it drifts. Run this set when the request contract changes and again in regression before release. It catches the failure that costs the most to undo: an unvalidated field stored as sent, so a negative age or a 2 MB name sits in the database and every consumer of that row has to defend against it afterwards.
Prerequisites
- curl 7.0 or later. See the curl manual.
- Node 22, used here for the target and for building the oversized body.
- Save this as
validate-api.jsand start it withnode validate-api.js. It accepts a name and an integer age, and caps the body at 1024 bytes:
const http = require('node:http');
const LIMIT = 1024;
http.createServer((req, res) => {
const send = (code, body) => {
res.writeHead(code, { 'content-type': 'application/problem+json' });
res.end(JSON.stringify(body));
};
let raw = '';
req.on('data', (c) => {
raw += c;
if (raw.length > LIMIT) {
send(413, { title: 'Payload too large', status: 413, limitBytes: LIMIT });
req.destroy();
}
});
req.on('end', () => {
if (res.writableEnded) return;
let body;
try {
body = JSON.parse(raw);
} catch {
return send(400, { title: 'Body is not valid JSON', status: 400 });
}
const errors = [];
if (typeof body.name !== 'string') errors.push({ field: 'name', rule: 'required string' });
if (!Number.isInteger(body.age)) errors.push({ field: 'age', rule: 'required integer' });
else if (body.age < 0 || body.age > 130) errors.push({ field: 'age', rule: 'between 0 and 130' });
if (errors.length) return send(400, { title: 'Validation failed', status: 400, errors });
send(201, { id: 1, name: body.name, age: body.age });
});
}).listen(8788, () => console.log('validate-api on 8788'));
Point the commands below at your own endpoint once you have read what a correct answer looks like. Stop the target with Ctrl+C.
Steps
- Step 1.
Send a valid body first. Without this line the later results prove nothing.
curl -s -w '\nHTTP %{http_code}\n' -X POST http://localhost:8788/v1/users -H 'content-type: application/json' -d '{"name":"Ada","age":36}'{"id":1,"name":"Ada","age":36} HTTP 201 - Step 2.
Send the wrong type in one field and keep everything else valid.
curl -s -w '\nHTTP %{http_code}\n' -X POST http://localhost:8788/v1/users -H 'content-type: application/json' -d '{"name":"Ada","age":"thirty-six"}'{"title":"Validation failed","status":400,"errors":[{"field":"age","rule":"required integer"}]} HTTP 400The field name is in the body. An error that says only "bad request" forces the client developer to guess.
- Step 3.
Drop a required field.
curl -s -w '\nHTTP %{http_code}\n' -X POST http://localhost:8788/v1/users -H 'content-type: application/json' -d '{"age":36}'{"title":"Validation failed","status":400,"errors":[{"field":"name","rule":"required string"}]} HTTP 400 - Step 4.
Send a number of the right type and the wrong value.
curl -s -w '\nHTTP %{http_code}\n' -X POST http://localhost:8788/v1/users -H 'content-type: application/json' -d '{"name":"Ada","age":-1}'{"title":"Validation failed","status":400,"errors":[{"field":"age","rule":"between 0 and 130"}]} HTTP 400Type checking and range checking are separate code. Many APIs have the first and not the second.
- Step 5.
Break the JSON itself, so the parser fails before any validator runs.
curl -s -w '\nHTTP %{http_code}\n' -X POST http://localhost:8788/v1/users -H 'content-type: application/json' -d '{"name":"Ada",'{"title":"Body is not valid JSON","status":400} HTTP 400 - Step 6.
Send a body past the limit. The generator keeps the JSON valid, so only the size is wrong.
node -e "process.stdout.write(JSON.stringify({name:'a'.repeat(2000),age:36}))" | curl -s -w '\nHTTP %{http_code}\n' -X POST http://localhost:8788/v1/users -H 'content-type: application/json' --data-binary @-{"title":"Payload too large","status":413,"limitBytes":1024} HTTP 413 - Step 7.
Now the failure mode you cannot see from a status alone. Send a query parameter that no sane API should accept, against a live one.
curl -s -o labels.json -w 'HTTP %{http_code}\n' 'https://api.github.com/repos/curl/curl/labels?per_page=notanumber'HTTP 200 - Step 8.
Count what came back, because the status hid the decision.
node -e "console.log(JSON.parse(require('fs').readFileSync('labels.json','utf8')).length)"30Thirty is the default page size. The value was dropped without a word. Repeating the same request with
per_page=-5returns 200 and 30 items, andper_page=999returns 200 and 69 items, the whole label set. None of the three values is valid and none is refused.
How to read the result
| What you see | What it means | What to do | | --- | --- | --- | | 400 with the field and the rule named | Validation ran and reported | Nothing. Record the field names, clients match on them. | | 400 with a bare message | Validation ran, reporting is thin | Ask for the field name. Support tickets depend on it. | | 200 or 201 on a broken value | The input was ignored or coerced | Read the stored record. Silent coercion is worse than a refusal. | | 500 | Bad input reached code that assumed it was good | File it. The same path is what an attacker probes first. | | 413 | A size limit answered | Find which limit answered, the proxy or the app. They differ. | | 415 | The content type was refused before the body was read | Expected when the type is wrong, wrong when the type was correct. |
Thresholds
Anything above that size is refused by the proxy before the application starts reading, so an application-level limit larger than 1 MB is never reached behind a default nginx.
Common mistakes
What to check next
- How to test API error responses: the shape of the 400 you just triggered, and whether a client can read it.
- How to check HTTP status code: the status half of each assertion above.
- How to test API idempotency: the other request-level rule that fails quietly.
- How to test API with curl: the flags for sending bodies and headers, one at a time.
- JSON schema validator: pin the request body to a schema and the invalid cases write themselves.
FAQ
Which invalid values should I send first?
Wrong type, missing required field, out-of-range number, empty string, and a body that is not JSON. These five cover the code paths that most APIs implement separately, so one of them usually behaves differently from the rest.
Is a 422 acceptable instead of a 400?
Yes. A 400 for a body the parser cannot read and a 422 for a body that parses but breaks a rule is a common split. What matters is that one endpoint answers consistently and the choice is documented.
How do I test invalid input without a test environment?
Read-only endpoints take query parameters, so step 7 works anywhere. For anything that writes, run the local target above. Never send deliberately malformed requests at a third party service.
Should the API list every invalid field at once?
Listing all of them lets a form show every problem in one pass. Stopping at the first is acceptable for a machine client. Decide which one the endpoint does, then assert it, because clients are written against the behaviour they see.
Verified
Verified by Maks Vernycurl 8.21.0node 22.23.2
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
intermediate10 minpublished updated Maks Verny