>_ Analyst Engineering

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.

Cover for how to write API test cases, showing a test case table with IDs, expected status codes, and error codes derived from one POST endpoint.

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 400 alone 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.

SourceDerive cases fromTypical cases
1. Field rulesEach field in the schemaMissing, null, empty, min and max length, over max, pattern, wrong type, enum including case
2. Business rulesEach rule in the requirements and descriptionsRule satisfied, rule broken, each boundary of the rule
3. Protocol behaviorHTTP and API conventionsIdempotent replay, key reuse, missing key, wrong Content-Type, malformed JSON, wrong method
4. SecurityThe auth modelNo token, expired token, missing scope, another client’s resource
5. State and async outcomesThe status lifecycleEach reachable final status, each forbidden transition, webhook delivery
6. Side effectsWhat the call changesCreated 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

IDChange from baselineExpected
TC-01None (baseline)201, status ACCP, paymentId present, endToEndId echoed
TC-02endToEndId absent400 FIELD_REQUIRED, field endToEndId
TC-03endToEndId = null400 FIELD_REQUIRED, field endToEndId
TC-04endToEndId = ""400 FIELD_FORMAT_INVALID (violates minLength: 1)
TC-05endToEndId = 35 characters201 (upper boundary)
TC-06endToEndId = 36 characters400 FIELD_TOO_LONG
TC-07value = "0.01"201 (lowest valid amount)
TC-08value = "0.00"422, code not defined in catalogue: FINDING
TC-09value = "100000.00"201 (upper boundary)
TC-10value = "100000.01"422 AMOUNT_LIMIT_EXCEEDED
TC-11value = "1250.5"400 FIELD_FORMAT_INVALID (one decimal)
TC-12value = 1250.00 (a number)400 FIELD_FORMAT_INVALID (type)
TC-13value = "-5.00"400 FIELD_FORMAT_INVALID
TC-14currency = "USD"400 FIELD_FORMAT_INVALID (enum)
TC-15currency = "eur"400 FIELD_FORMAT_INVALID (enums are case-sensitive)
TC-16creditor.name = 70 characters201
TC-17creditor.name = 71 characters400 FIELD_TOO_LONG, field creditor.name
TC-18creditor.name = "Müller & Söhne"201; name returned unchanged or transliterated: rule not stated: FINDING
TC-19creditor.iban = DE89370400440532013001422 IBAN_CHECKSUM_INVALID, field creditor.iban
TC-20debtor.iban = "NL91 ABNA 0417 1643 00"Normalize or reject: rule not stated: FINDING
TC-21remittanceInformation = 141 characters400 FIELD_TOO_LONG
TC-22Extra field "priority": "HIGH" added201, field ignored (schema does not forbid additional properties)

Source 2: business rules

IDChange from baselineExpected
TC-23creditor.iban = debtor.iban422 SAME_DEBTOR_CREDITOR
TC-24Resubmit with the same endToEndId, new idempotency keyRejected (“unique per debtor”), code not defined: FINDING

Source 3: protocol behavior

IDChange from baselineExpected
TC-25Send twice, same key, same bodyBoth 201 with the same paymentId; exactly one payment exists
TC-26Same key, value changed to "1300.00"409 IDEMPOTENCY_KEY_REUSED; original payment unchanged
TC-27Idempotency-Key header absent400 FIELD_REQUIRED, field Idempotency-Key
TC-28Content-Type: text/plain415, no payment created
TC-29Malformed JSON (trailing comma)400; body contains no stack trace or internal class names
TC-30PUT /v1/payments405

Source 4: security

IDChange from baselineExpected
TC-31No Authorization header401 UNAUTHENTICATED
TC-32Expired token401 UNAUTHENTICATED
TC-33Token with only payments:read403 INSUFFICIENT_SCOPE; no payment created
TC-34GET /v1/payments/{id} for another client’s payment404 PAYMENT_NOT_FOUND; response does not confirm it exists
TC-35Exceed the rate limit429 RATE_LIMITED with a Retry-After header

Sources 5 and 6: state, async outcomes, and side effects

IDChange from baselineExpected
TC-36Baseline, then waitpayment.settled webhook with a valid signature; GET shows ACSC
TC-37creditor.iban = sandbox closed-account IBAN201 ACCP, then RJCT with reason AC04 by webhook and on GET
TC-38Cancel after status is ACSC409 PAYMENT_NOT_CANCELLABLE
TC-39After TC-19GET /v1/payments?endToEndId=... returns an empty list; no event published
TC-40After TC-25Ledger 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.

PriorityCasesWhy
P1: money and accessTC-01, 10, 19, 23, 25, 26, 31, 33, 34, 37, 39, 40Wrong payment, duplicate payment, unauthorized access, silent failure
P2: contract correctnessRemaining field, protocol, and state casesConsumer integration defects
P3: hardeningTC-22, 29, 30, 35Robustness and information leakage
Smoke subsetTC-01, 25, 31, 36Proves 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. eur versus EUR is a classic integration defect.
  • Asynchronous outcomes out of scope. The 201 is tested; the RJCT that 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

  1. What is an API and how it works
  2. Your first API collection in Bruno and Postman: requests, environments, variables, and secrets
  3. How to analyze an API: capability, data, behavior, limits, and change
  4. How to document an API: the sections consumers need, OpenAPI, and the error catalogue
  5. How to write API test cases (you are here)
  6. Chaining API requests with JavaScript: variables, scripts, polling, and a full Stripe flow
  7. API proof of concept and demos: POCs and demos that settle decisions
  8. 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.

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.