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

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

  1. 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
  2. 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 400

    The field name is in the body. An error that says only "bad request" forces the client developer to guess.

  3. 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
  4. 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 400

    Type checking and range checking are separate code. Many APIs have the first and not the second.

  5. 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
  6. 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
  7. 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
  8. 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)"
    
    30

    Thirty is the default page size. The value was dropped without a word. Repeating the same request with per_page=-5 returns 200 and 30 items, and per_page=999 returns 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

1 MB request body, above which nginx answers 413 Source: https://nginx.org/en/docs/http/ngx_http_core_module.html#client_max_body_size

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

Sign: Every invalid-input test passes and bad rows still appear in the table.Cause: The assertion is that the call did not crash. A silent ignore answers 200, so a test that accepts any non-5xx status passes while the value is dropped or coerced. Assert the exact status and the field named in the body.
Sign: A field with the wrong type answers 500 instead of 400.Cause: The value passed validation because only presence was checked, then failed later in arithmetic or in the database driver. The status tells you the input reached code that trusted it.
Sign: The oversized body is refused locally and accepted on staging.Cause: Two limits exist. The application limit runs in the local target, the proxy limit runs in front of staging, and whichever is smaller answers. Test the size rule through the same proxy the client uses.
Sign: An unknown extra field is accepted without comment.Cause: Most parsers ignore fields they do not know. That is legal, and it hides a client typo: a renamed field arrives, the old name is silently dropped, and the record is saved with the old value untouched.

What to check next

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.

intermediate10 minpublished updated Maks Verny