How to Write API Test Cases: 40 Tests Derived From One Endpoint
Written by Ahmed at Analyst Engineering, a Senior Technical Business Analyst with 10+ years in banking and payments delivery.
Key takeaways
- API test cases are derived, not brainstormed: six sources cover an endpoint systematically, namely field rules, business rules, protocol behavior, security, state and asynchronous outcomes, and side effects.
- A complete API test case states the request as a change from a known-good baseline, the expected status, the expected error code and field, and the expected side effects, including their absence on failure paths.
- Deriving test cases from the contract finds documentation gaps before testing starts: any row whose expected result cannot be filled in is a question for refinement, not a guess.
- Field validation cases share one shape, so they belong in a data file: a single Bruno or Postman request plus a JSON file of overrides and expected codes runs dozens of cases in one command.
- Object-level authorization, requesting another client's resource by id, is the top risk in the OWASP API Security Top 10 (2023) and belongs in every API test suite that exposes resources by identifier.
API test cases should be derived from the contract, not brainstormed. Take a known-good baseline request, then work through six sources, field rules, business rules, protocol behavior, security, state, and side effects, and each one produces cases mechanically. For one payment creation endpoint, that method produces 40 test cases, finds four gaps in the documentation, and fits most of the suite into a single data-driven request.
To write API test cases: define a baseline request that succeeds; for every field, derive required, null, empty, boundary, format, type, and enum cases; for every business rule, derive the pass and fail case; add protocol cases for idempotency, content type, and methods; add security cases for authentication, scope, and object-level authorization; add state and asynchronous outcome cases; and for every failure, assert that no side effect occurred. Record each case as a change from the baseline with its expected status, error code, and field.
APIs for Analysts, part 5 of 8. Previous: Part 4, how to document an API. Next: Part 6, chaining API requests with JavaScript. All parts: series overview.
This article is the derivation method and the artifact. What to check on every response, and why the API layer beats the UI, is covered in API testing: how to test an API end to end. The endpoint under test is POST /v1/payments from Part 4, so every expected code below comes from a documented contract you can read. The full test design discipline, from assertions to automation, is in API Testing and QA Mastery for BAs.
What makes a good API test case?
A good API test case is short, precise, and impossible to misread. The trick that makes that possible is the baseline: one request that is known to succeed. Every other case is described as a single change to it, so the reader sees instantly what is being tested.
The baseline for this endpoint:
{
"endToEndId": "E2E-INV-88213",
"instructedAmount": { "currency": "EUR", "value": "1250.00" },
"debtor": { "name": "ACME BV", "iban": "NL91ABNA0417164300" },
"creditor": { "name": "Beta GmbH", "iban": "DE89370400440532013000" },
"remittanceInformation": "INVOICE 88213"
}
And a single test case written against it:
id: TC-17
title: Rejects a creditor name longer than 70 characters
traces_to: [FR-PAY-012, "OpenAPI Party.name maxLength 70"]
preconditions: SIT, token with payments:write
request: baseline, with creditor.name = 71 x "A"
expected_status: 400
expected_body: errors[] contains { code: FIELD_TOO_LONG, field: creditor.name }
expected_side_effects: no payment exists for this endToEndId; no payment.created event
priority: P2
Four details make this a real test case rather than a note:
- The title is a behavior. “Rejects a creditor name longer than 70 characters” can be read by a product owner. “Test name field” cannot.
- The expected result names the error code and the field path. A
400alone passes even when the API rejects the request for the wrong reason. - The side effects include an absence. A rejected payment that still wrote a row or published an event is a serious defect, and only this line catches it.
- It traces to a requirement. That link is what makes coverage provable, which is the job of a requirements traceability matrix.
How do you derive API test cases systematically?
Work through six sources in order. Each one is a lens that produces cases almost mechanically, which is why the method scales and brainstorming does not.
| Source | Derive cases from | Typical cases |
|---|---|---|
| 1. Field rules | Each field in the schema | Missing, null, empty, min and max length, over max, pattern, wrong type, enum including case |
| 2. Business rules | Each rule in the requirements and descriptions | Rule satisfied, rule broken, each boundary of the rule |
| 3. Protocol behavior | HTTP and API conventions | Idempotent replay, key reuse, missing key, wrong Content-Type, malformed JSON, wrong method |
| 4. Security | The auth model | No token, expired token, missing scope, another client’s resource |
| 5. State and async outcomes | The status lifecycle | Each reachable final status, each forbidden transition, webhook delivery |
| 6. Side effects | What the call changes | Created on success, absent on every failure |
For field rules, the classic partitions are the core of negative test design: for a string with maxLength: 70, test 70 (valid boundary), 71 (invalid boundary), empty, null, and absent. Absent and null are different cases, because many APIs treat them differently and consumers send both.
What do 40 test cases for one endpoint look like?
Here is the complete derivation for POST /v1/payments. Every “Change from baseline” is a single edit; every expected code comes from the Part 4 error catalogue.
Source 1: field rules
| ID | Change from baseline | Expected |
|---|---|---|
| TC-01 | None (baseline) | 201, status ACCP, paymentId present, endToEndId echoed |
| TC-02 | endToEndId absent | 400 FIELD_REQUIRED, field endToEndId |
| TC-03 | endToEndId = null | 400 FIELD_REQUIRED, field endToEndId |
| TC-04 | endToEndId = "" | 400 FIELD_FORMAT_INVALID (violates minLength: 1) |
| TC-05 | endToEndId = 35 characters | 201 (upper boundary) |
| TC-06 | endToEndId = 36 characters | 400 FIELD_TOO_LONG |
| TC-07 | value = "0.01" | 201 (lowest valid amount) |
| TC-08 | value = "0.00" | 422, code not defined in catalogue: FINDING |
| TC-09 | value = "100000.00" | 201 (upper boundary) |
| TC-10 | value = "100000.01" | 422 AMOUNT_LIMIT_EXCEEDED |
| TC-11 | value = "1250.5" | 400 FIELD_FORMAT_INVALID (one decimal) |
| TC-12 | value = 1250.00 (a number) | 400 FIELD_FORMAT_INVALID (type) |
| TC-13 | value = "-5.00" | 400 FIELD_FORMAT_INVALID |
| TC-14 | currency = "USD" | 400 FIELD_FORMAT_INVALID (enum) |
| TC-15 | currency = "eur" | 400 FIELD_FORMAT_INVALID (enums are case-sensitive) |
| TC-16 | creditor.name = 70 characters | 201 |
| TC-17 | creditor.name = 71 characters | 400 FIELD_TOO_LONG, field creditor.name |
| TC-18 | creditor.name = "Müller & Söhne" | 201; name returned unchanged or transliterated: rule not stated: FINDING |
| TC-19 | creditor.iban = DE89370400440532013001 | 422 IBAN_CHECKSUM_INVALID, field creditor.iban |
| TC-20 | debtor.iban = "NL91 ABNA 0417 1643 00" | Normalize or reject: rule not stated: FINDING |
| TC-21 | remittanceInformation = 141 characters | 400 FIELD_TOO_LONG |
| TC-22 | Extra field "priority": "HIGH" added | 201, field ignored (schema does not forbid additional properties) |
Source 2: business rules
| ID | Change from baseline | Expected |
|---|---|---|
| TC-23 | creditor.iban = debtor.iban | 422 SAME_DEBTOR_CREDITOR |
| TC-24 | Resubmit with the same endToEndId, new idempotency key | Rejected (“unique per debtor”), code not defined: FINDING |
Source 3: protocol behavior
| ID | Change from baseline | Expected |
|---|---|---|
| TC-25 | Send twice, same key, same body | Both 201 with the same paymentId; exactly one payment exists |
| TC-26 | Same key, value changed to "1300.00" | 409 IDEMPOTENCY_KEY_REUSED; original payment unchanged |
| TC-27 | Idempotency-Key header absent | 400 FIELD_REQUIRED, field Idempotency-Key |
| TC-28 | Content-Type: text/plain | 415, no payment created |
| TC-29 | Malformed JSON (trailing comma) | 400; body contains no stack trace or internal class names |
| TC-30 | PUT /v1/payments | 405 |
Source 4: security
| ID | Change from baseline | Expected |
|---|---|---|
| TC-31 | No Authorization header | 401 UNAUTHENTICATED |
| TC-32 | Expired token | 401 UNAUTHENTICATED |
| TC-33 | Token with only payments:read | 403 INSUFFICIENT_SCOPE; no payment created |
| TC-34 | GET /v1/payments/{id} for another client’s payment | 404 PAYMENT_NOT_FOUND; response does not confirm it exists |
| TC-35 | Exceed the rate limit | 429 RATE_LIMITED with a Retry-After header |
Sources 5 and 6: state, async outcomes, and side effects
| ID | Change from baseline | Expected |
|---|---|---|
| TC-36 | Baseline, then wait | payment.settled webhook with a valid signature; GET shows ACSC |
| TC-37 | creditor.iban = sandbox closed-account IBAN | 201 ACCP, then RJCT with reason AC04 by webhook and on GET |
| TC-38 | Cancel after status is ACSC | 409 PAYMENT_NOT_CANCELLABLE |
| TC-39 | After TC-19 | GET /v1/payments?endToEndId=... returns an empty list; no event published |
| TC-40 | After TC-25 | Ledger or event stream shows exactly one debit for that endToEndId |
TC-34 deserves a note. Requesting another client’s resource by changing an id is broken object level authorization, ranked first in the OWASP API Security Top 10 2023. It is trivial to test, rarely covered, and devastating when it fails. If your API exposes anything by identifier, this case is mandatory. The full authorization test set, mapped to all ten OWASP API risks, is in API security testing for analysts.
TC-25 and TC-40 together are what prove duplicates are safe: the response proves the API recognized the replay, and the ledger proves money moved once. The full technique, including concurrent replays, is idempotency testing.
Why do four rows say FINDING?
Because the method is working. TC-08, TC-18, TC-20, and TC-24 are cases whose expected result cannot be determined from the documentation. Is a zero amount a format error or a business error? Is ü preserved or transliterated? Are spaces in an IBAN normalized? What code does a duplicate endToEndId return?
A tester who guesses writes a test that passes against whatever the developer happened to build. An analyst takes those four questions to refinement, gets a decision, and updates the contract, the catalogue, and the test. Deriving test cases before the build is one of the cheapest requirement reviews a team can run. The character set question in TC-18 is not academic in payments either; it is exactly the kind of loss documented in the ISO 20022 truncation ledger.
How do you prioritize when you cannot run them all?
Rank by what a failure would cost, not by how easy the case is to run.
| Priority | Cases | Why |
|---|---|---|
| P1: money and access | TC-01, 10, 19, 23, 25, 26, 31, 33, 34, 37, 39, 40 | Wrong payment, duplicate payment, unauthorized access, silent failure |
| P2: contract correctness | Remaining field, protocol, and state cases | Consumer integration defects |
| P3: hardening | TC-22, 29, 30, 35 | Robustness and information leakage |
| Smoke subset | TC-01, 25, 31, 36 | Proves the build is alive and safe in two minutes |
The smoke subset runs on every deployment; the full set runs nightly and before release. How those suites relate is laid out in smoke, sanity, and regression testing.
How do you automate the test cases in Bruno?
Notice that TC-02 to TC-21 all share one shape: baseline, one override, expected status, expected code. That is a data file, not twenty requests. One request plus a JSON file runs them all.
The data file, data/field-validation.json:
[
{ "caseId": "TC-02", "path": "endToEndId", "value": "__absent__", "expectedStatus": 400, "expectedCode": "FIELD_REQUIRED" },
{ "caseId": "TC-03", "path": "endToEndId", "value": null, "expectedStatus": 400, "expectedCode": "FIELD_REQUIRED" },
{ "caseId": "TC-10", "path": "instructedAmount.value", "value": "100000.01", "expectedStatus": 422, "expectedCode": "AMOUNT_LIMIT_EXCEEDED" },
{ "caseId": "TC-15", "path": "instructedAmount.currency", "value": "eur", "expectedStatus": 400, "expectedCode": "FIELD_FORMAT_INVALID" },
{ "caseId": "TC-17", "path": "creditor.name", "value": { "repeat": "A", "times": 71 }, "expectedStatus": 400, "expectedCode": "FIELD_TOO_LONG" },
{ "caseId": "TC-19", "path": "creditor.iban", "value": "DE89370400440532013001", "expectedStatus": 422, "expectedCode": "IBAN_CHECKSUM_INVALID" }
]
The request’s before-request script builds the body from the baseline and applies the row’s override:
const row = (key) => bru.runner.iterationData.get(key);
const body = {
endToEndId: `E2E-${row("caseId") || "RUN"}-${Date.now()}`,
instructedAmount: { currency: "EUR", value: "1250.00" },
debtor: { name: "ACME BV", iban: "NL91ABNA0417164300" },
creditor: { name: "Beta GmbH", iban: "DE89370400440532013000" },
remittanceInformation: "INVOICE 88213"
};
const path = row("path");
if (path) {
let value = row("value");
if (value && typeof value === "object" && value.repeat) {
value = value.repeat.repeat(value.times);
}
const keys = path.split(".");
const last = keys.pop();
const parent = keys.reduce((obj, key) => obj[key], body);
if (value === "__absent__") {
delete parent[last];
} else {
parent[last] = value;
}
}
req.setBody(body);
req.setHeader("Idempotency-Key", `${row("caseId")}-${Date.now()}`);
The tests script asserts the expected status and the exact error code, and names each assertion after the case so the report reads like the table:
const row = (key) => bru.runner.iterationData.get(key);
const caseId = row("caseId");
const expectedStatus = Number(row("expectedStatus"));
const expectedCode = row("expectedCode");
test(`${caseId}: returns HTTP ${expectedStatus}`, function () {
expect(res.getStatus()).to.equal(expectedStatus);
});
if (expectedCode) {
test(`${caseId}: returns error code ${expectedCode}`, function () {
const codes = (res.getBody().errors || []).map((e) => e.code);
expect(codes).to.include(expectedCode);
});
}
Run the folder against SIT with the data file, and publish an HTML report:
bru run 10-field-validation --env sit \
--json-file-path data/field-validation.json \
--reporter-html reports/field-validation.html
Adding a case is now adding one line of JSON, which means the analyst who found TC-18 can add its test the day refinement decides the rule, without touching a script.
The Postman equivalent uses the same data file. Read rows with pm.iterationData.get("path"), set the body with pm.request.body.update({ mode: "raw", raw: JSON.stringify(body) }), assert with pm.test and pm.expect, and run it headlessly with Newman:
newman run payments.postman_collection.json -e sit.postman_environment.json \
--folder "10-field-validation" -d data/field-validation.json -r cli,junit
The cases that are not one shape, idempotency replays, security with different tokens, and the asynchronous lifecycle, need chained requests with captured ids and polling. That scripting is the whole of Part 6, and running the suite on every build is covered in API tests in CI.
What gaps show up most often in API test suites?
From reviewing suites on payment programmes, the same holes appear repeatedly:
- Status asserted, error code not. The API rejects for the wrong reason and the test still passes.
- Absent tested, null not. Consumers send both, and the API handles them differently.
- Boundaries tested on one side only. 70 passes, 71 is never sent.
- No side-effect check on failures. Rejected requests that still write data go unnoticed.
- No object-level authorization case. The OWASP top risk, untested.
- Enum case sensitivity ignored.
eurversusEURis a classic integration defect. - Asynchronous outcomes out of scope. The
201is tested; theRJCTthat arrives two minutes later is not. - Guesses where the documentation was silent. Every guess is a finding that never reached refinement.
The APIs for Analysts series
- What is an API and how it works
- Your first API collection in Bruno and Postman: requests, environments, variables, and secrets
- How to analyze an API: capability, data, behavior, limits, and change
- How to document an API: the sections consumers need, OpenAPI, and the error catalogue
- How to write API test cases (you are here)
- Chaining API requests with JavaScript: variables, scripts, polling, and a full Stripe flow
- API proof of concept and demos: POCs and demos that settle decisions
- The analyst who can send a request: why it is an edge, and a 30-day plan
Beyond the core series, the APIs for Analysts learning path organizes companion articles by level: the API glossary and troubleshooting failed requests for beginners, webhooks and GraphQL at intermediate level, and API design review, versioning and breaking changes, API security testing, and API tests in CI for advanced analysts.
The takeaway
API test cases come from derivation, not inspiration. Fix a baseline request, then work through field rules, business rules, protocol behavior, security, state, and side effects, writing each case as one change with an expected status, error code, and field. Treat any case whose expected result you cannot fill in as a documentation finding for refinement. Put the single-shape validation cases in a data file behind one scripted request, rank the rest by the cost of failure, and never skip the object-level authorization case.
The complete test design method, including assertion strategy and automation, is in API Testing and QA Mastery for BAs, and ready-to-adapt test case and traceability templates are in Real-World BA Deliverables (20 Templates). If you want your own endpoint’s test design reviewed, book a 1:1 Tech BA Coaching Call.
Ahmed is a Senior Technical Business Analyst with 10+ years in banking and payments. He builds practical guides and tools for analysts at The Tech BA Toolkit.
Tags: API Testing, Test Cases, QA, Bruno, Payments
About the author
Analyst Engineering is written by Ahmed, a Senior Technical Business Analyst with 10+ years of banking and payments delivery experience: ISO 20022 and SWIFT messaging, payments API integration, Kafka event validation, and production support. Every article comes from real delivery work, and each one is reviewed and updated as tools and standards change.
Related articles
- API Testing: How to Test an API End to End A practitioner guide to API testing: status codes, response schemas, request chaining, authentication, error contracts, and the checks that actually catch defects.
- Negative Test Design: Engineering the Unhappy Path How to design negative tests systematically: boundary values, invalid inputs, state violations, and failure injection. The unhappy path is where the real defects live.
- Chaining API Requests With JavaScript in Bruno and Postman: The Scripts Analysts Need Chain API requests in Bruno and Postman: capture values, pre-request and post-response scripts, token refresh, polling, branching, and a Stripe sandbox flow.
- Idempotency Testing: Proving Duplicate Requests Are Safe How to test idempotency in APIs and event consumers: idempotency keys, duplicate requests, redelivered events, and the race conditions that cause double processing.
Free account
Practice on the Labs, keep your progress
A free account, no password: an email link signs you in. It saves your steps and self-assessments on the Labs, shows your missions on a dashboard, unlocks the solutions, and, if you tick the box, sends you new missions and articles when they ship.
Your email is used to sign you in. Nothing else, unless you ask. Privacy.