API Testing
Test application programming interfaces directly — sending requests, validating status codes, headers, and response bodies — without going through the UI. This is where most of the interesting business logic actually lives.
1 The Hook
A KiwiSaver provider ships a new mobile app. The team tests it the way most teams test: open the app, log in, tap through the screens, check the balance shows. Everything looks fine in the browser-driven walkthrough, so it goes live.
Two weeks later, support is flooded. Some members are seeing another member’s balance. The bug was never in the UI — it was in the backend endpoint GET /members/{id}/balance, which returned a balance for any id the caller asked for, with no check that the logged-in member actually owned that account. The UI only ever requested the member’s own id, so clicking around the app could never surface it. A single direct request to the endpoint with someone else’s id would have caught it in minutes.
This is the pattern: the business logic, the authorisation rules, the data validation all live in the API, not the screen. Test only through the UI and you are testing a thin layer over the part that actually matters — and the most damaging defects sit underneath it, where a browser click never reaches.
2 The Rule
Test the API directly — send the request and assert the status code, the response body, and the authorisation behaviour — because the logic that matters lives in the service, not the screen that calls it.
3 The Analogy
Checking a building by talking to the front desk versus walking the wiring.
UI testing is asking the receptionist at a Te Whatu Ora hospital, “Is the building safe?” They smile and say yes — the lobby looks tidy, the lights are on. But the receptionist only sees the front desk. The fire wiring, the locked drug cupboards, the staff-only doors that should never open for a visitor — none of that is visible from the lobby.
API testing is the building inspector who walks the service corridors, opens the panels, and tries each locked door to see whether it actually stops the wrong person. The lobby (the UI) can look perfect while a staff-only door (a protected endpoint) swings open for anyone. You only find that by going behind the front desk and testing the wiring directly.
What it is
API testing means interacting directly with an application’s backend endpoints — sending HTTP requests and verifying the responses — rather than driving the application through a browser or mobile UI. Because most modern applications are built on top of APIs, testing them directly gives you faster feedback, earlier in the cycle, and with finer-grained assertions than UI testing allows.
An API test asks: given this request (URL, method, headers, body), does the system return the right response (status code, body schema, data values, error messages)? It’s specification-based testing applied to a service contract rather than a user interface.
Why test at the API level? UI tests are slow, brittle, and expensive to maintain. API tests run in milliseconds, don’t break when a button moves, and can be run thousands of times a day in CI. If the API is correct, the UI just needs to call it correctly — a much smaller testing problem.
When to use it
- When the UI is not yet built — test the backend immediately after backend development, without waiting for frontend work
- When you need to test business logic independent of the frontend — price calculations, eligibility rules, data transformations
- When validating integrations between services — does Service A send the right data to Service B?
- For regression testing after backend changes — fast, repeatable, and doesn’t require a browser
- For performance and load testing — drive hundreds of concurrent requests directly at the API
Key concepts
HTTP methods
Each HTTP method has a defined semantic meaning. Verify that your API uses them correctly:
| Method | Purpose | Idempotent? | Example |
|---|---|---|---|
| GET | Retrieve a resource | Yes | GET /orders/123 |
| POST | Create a new resource | No | POST /orders |
| PUT | Replace a resource entirely | Yes | PUT /orders/123 |
| PATCH | Update part of a resource | No | PATCH /orders/123 |
| DELETE | Remove a resource | Yes | DELETE /orders/123 |
Status codes to know
Status codes are the first thing you check. A wrong status code is a bug, even if the response body looks right.
| Code | Meaning | When to expect it |
|---|---|---|
| 200 OK | Success | Successful GET, PUT, PATCH |
| 201 Created | Resource created | Successful POST that creates a new record |
| 400 Bad Request | Client error — malformed input | Missing required field, wrong data type |
| 401 Unauthorised | Not authenticated | No auth token or invalid token |
| 403 Forbidden | Authenticated but not allowed | User doesn’t have permission for this resource |
| 404 Not Found | Resource doesn’t exist | GET /orders/99999 where that order doesn’t exist |
| 422 Unprocessable Entity | Validation failed | Input is syntactically valid but semantically wrong (e.g. invalid NZ postcode) |
| 500 Internal Server Error | Server crashed | Unhandled exception — always a bug |
Anatomy of a request
Every API request has four possible components. Understanding what goes where prevents common test setup errors:
- URL — the endpoint address, including path parameters:
https://api.example.co.nz/v1/orders/123 - Headers — metadata sent with every request. Key ones to test:
Authorization: Bearer <token>(authentication) andContent-Type: application/json(tells the server how to parse the body) - Query parameters — filtering and sorting options appended to the URL:
?status=pending&limit=10 - Request body — the data sent with POST/PUT/PATCH requests, usually as JSON
What to validate in the response
A thorough API test checks more than just the status code:
- Status code — does it match what the spec says?
- Response body schema — are all expected fields present? No extra unexpected fields?
- Data types — is
pricea number (not a string)? IsorderIda string UUID? - Required fields — if the spec says
customerIdis always returned, verify it’s never null or missing - Error messages — on 4xx responses, is the error message clear, actionable, and free of internal implementation details?
- Response time — does the endpoint respond within an acceptable SLA?
Contract testing
If your API has an OpenAPI (Swagger) specification, treat it as your test oracle. The spec defines the contract: what requests the API accepts, what responses it returns, and what each field means. If the live API doesn’t match the spec, that’s a bug — even if the API appears to “work.” Consumers of the API (frontend, mobile app, partner integrations) are coding to the spec, not to whatever the API happens to return today.
NZ worked example
Imagine you’re testing an e-commerce API for a New Zealand retailer. Here’s a sequence of API tests covering the order creation flow:
| Test | Request | Expected status | Key assertions |
|---|---|---|---|
| Create order with valid NZ address | POST /orders body: valid NZ shipping address (e.g. 123 Queen St, Auckland 1010) |
201 Created | Response contains orderId, status: "pending", total in NZD |
| Retrieve the created order | GET /orders/{orderId} | 200 OK | Returned order matches what was posted; orderId matches |
| Create order with invalid NZ postcode | POST /orders body: postcode "9999" (not a valid NZ postcode) |
422 Unprocessable Entity | Error message mentions postcode; no order created in the database |
| Create order without auth token | POST /orders No Authorization header |
401 Unauthorised | No order created; error body does not expose internals |
| Retrieve order belonging to another customer | GET /orders/{otherCustomerOrderId} | 403 Forbidden | System does not return another customer’s order data (IDOR check) |
| Get order that does not exist | GET /orders/00000000-0000-0000-0000-000000000000 | 404 Not Found | Clear error message; no stack trace in body |
Note that the NZ-specific context matters: NZ postcodes are 4-digit numbers (1010 for central Auckland, 6011 for central Wellington, 8011 for central Christchurch). A postcode of “9999” or “12345” is invalid and should be caught at the API layer, not just in the UI.
Common bugs API testing finds
- 200 when it should be 400 — the API accepts clearly invalid input without complaint, then fails silently later
- Stack traces in error responses — a 500 response that includes a Java stack trace or file path is a security risk, not just an aesthetic issue
- Missing required fields — the spec says
customerIdis always returned, but it’s absent for guest orders - Wrong data types —
pricereturned as a string ("49.99") instead of a number (49.99), breaking downstream calculations - No authentication required on protected endpoints — removing the
Authorizationheader and still getting a 200 is a critical security bug - CORS headers missing or too permissive —
Access-Control-Allow-Origin: *on an authenticated endpoint allows cross-site requests from any domain - Inconsistent IDs — POST returns
id: 123but GET expects/orders/00000123(zero-padded) — integration breaks
Tools
- Postman — the most widely used API testing tool; supports collections, environments, and automated test scripts written in JavaScript. Good for manual and automated API testing.
- Insomnia — lighter-weight alternative to Postman; good for individual API exploration
- REST-assured — Java library for writing API tests as code; integrates with JUnit/TestNG; preferred in Java-heavy teams
- k6 — designed for performance and load testing APIs; scripts in JavaScript; can run from CI
- Newman — Postman’s CLI runner; run your Postman collections from the terminal or CI pipeline without opening the GUI
ISTQB mapping
| Syllabus ref | Topic | Level |
|---|---|---|
| CTFL 4.0 — 2.2 | Component integration testing — testing interfaces between components | Foundation |
| CTAL-TA 3.3 | Structural testing at integration level; API contract verification | Advanced / Senior |
| CTAL-TA 4.2 | Test specification for service interfaces and integration points | Advanced / Senior |
Tips
Read the spec before you test. Start by reading the API documentation (OpenAPI/Swagger) before testing. Treat the spec as your test oracle — if the API doesn’t match the spec, that’s a bug even if the API “works.” Teams often discover that the spec and implementation have quietly diverged, causing silent failures in client applications.
- Test auth first — before testing happy paths, verify that removing or corrupting the auth token returns 401. If it doesn’t, stop and raise it immediately.
- Use environments in Postman — store your base URL, tokens, and IDs in environment variables so your test collection runs against dev, staging, and production with a single click.
- Chain requests — use the ID returned from a POST as input to the subsequent GET, PUT, and DELETE tests. This is more realistic than hardcoded IDs and finds sequencing bugs.
- Test the boundaries of optional fields — what happens when an optional field is null? Omitted entirely? An empty string? These three are often handled differently.
- Check idempotency — send the same PUT request twice. The second call should return the same result and not create a duplicate record.
Practice this technique: Try Test Lead Practice 06 — API contract bugs.
NZ example — testing a NZ payments API
A NZ-specific payments API (such as one built on the Payments NZ API Centre standards) has several NZ-specific test cases beyond the generic HTTP contract tests.
- Bank account format validation — NZ bank accounts follow the BB-bbbb-AAAAAAA-SS format (bank-branch-account-suffix). The API should accept
06-0000-0000000-00(ASB) but reject1234567890123456(card number format). Test the API directly with both formats and verify the response codes and error messages. - Currency — the API should only accept NZD amounts. Test with
"USD"in the currency field — expect a 400 or 422 with a clear error. - Amount precision — NZD amounts must have exactly 2 decimal places. Test
100,100.0,100.00,100.000— only100.00and100.0(if normalised) should succeed;100.000should be rejected or normalised. - POLi integration — POLi is a NZ/AU bank-to-bank payment method. If the API supports POLi, test the redirect flow, the callback handling on success/failure/timeout, and what happens if the bank session expires mid-flow.
- IBAN — NZ does not use IBAN. Any API that requires or accepts IBAN for NZ accounts has a design defect — test for this.
4 Now You Try
Three graded exercises — spot, fix, then build. Write your answer, run it for AI feedback, then compare to the model answer.
An IRD myIR API exposes GET /returns/{id} and POST /returns. For each request below, state the expected HTTP status code and one sentence on why. (a) POST a new valid return; (b) GET a return id that does not exist; (c) GET another taxpayer’s return id while logged in as yourself; (d) POST a return with no auth token; (e) POST a return whose IRD number fails the check-digit rule.
Show model answer
(a) POST valid return — 201 Created. A POST that creates a new resource returns 201, and the body should include the new return id. (b) GET non-existent id — 404 Not Found. The resource does not exist; the body should be a clear message with no stack trace. (c) GET another taxpayer's return — 403 Forbidden. You are authenticated but not allowed to see someone else's data. Returning 200 here would be a critical IDOR bug. (404 is also defensible to avoid confirming the id exists, but it must not be 200.) (d) POST with no auth token — 401 Unauthorised. No identity was supplied, so the request is rejected before any authorisation check. (e) POST with bad IRD check digit — 422 Unprocessable Entity (or 400). The request is well-formed JSON but semantically invalid; the error message should name the IRD-number field. The two that catch people out: 401 vs 403 (not authenticated vs not allowed) and 400 vs 422 (malformed vs semantically invalid).
A tester wrote the test below for an ANZ payments endpoint. It only checks the status code, so it would pass even while the API is badly broken. Rewrite it to add the assertions a senior would expect (response schema, data types, currency, authorisation, no internals leaked).
POST /payments with a valid NZD payment body.Assert: status code is 200.
(That is the only assertion.)
Rewrite with the missing assertions:
Show model answer
A POST that creates a payment should return 201, not 200 — so the original expected code is likely wrong too. Response body should contain: a paymentId, a status (e.g. "pending"/"settled"), the amount, and the currency. Data types to assert: amount is a number (not the string "49.99"); paymentId is a string; status is one of the allowed enum values; no null required fields. Currency / amount assertions: currency is "NZD"; amount has exactly 2 decimal places; a USD amount is rejected with 400/422. Authorisation tests to add: same request with no Authorization header returns 401; a request for another customer's account returns 403 (IDOR check). Security checks: a 4xx/5xx error body contains a clear message and no stack trace, file path, SQL, or internal field names. CORS is not Access-Control-Allow-Origin: * on this authenticated endpoint. The point: a status-code-only test passes while the body is wrong, the auth is missing, and internals leak. Status code is the first check, never the only one.
A Waka Kotahi vehicle-registration API supports the full lifecycle of a renewal: POST /renewals, GET /renewals/{id}, PUT /renewals/{id}, DELETE /renewals/{id}. Design a chained test sequence that uses the id returned by the POST in the following requests. List each step, its method, the expected status, and the key assertion. Include at least one negative and one idempotency check.
Show model answer
Chained sequence for a Waka Kotahi renewal (each step reuses the id from step 1):
Step 1 (create) — POST /renewals with a valid body — 201 Created — body returns a renewalId and status "pending"; capture renewalId.
Step 2 (read) — GET /renewals/{renewalId} — 200 OK — returned record matches what was posted; renewalId matches.
Step 3 (update) — PUT /renewals/{renewalId} with changed details — 200 OK — the changed field is reflected on a follow-up GET.
Step 4 (idempotency) — send the SAME PUT a second time — 200 OK — no duplicate created, same result returned.
Step 5 (negative) — GET /renewals/{someOtherId} you do not own — 403 Forbidden (or 404) — another customer's renewal is never returned.
Step 6 (delete) — DELETE /renewals/{renewalId} — 200 or 204 — a subsequent GET on that id returns 404.
Why chaining matters: hardcoded ids hide sequencing bugs. Feeding the real created id through read, update, delete catches mismatched/zero-padded id formats and state bugs that isolated tests miss.
Self-Check
Click each question to reveal the answer.
Q1: Why can a defect be invisible to UI testing but obvious in an API test?
The UI only ever sends the requests its own screens generate — usually the happy path for the logged-in user. Authorisation gaps, validation holes, and wrong data types live in the endpoint and are only exposed by sending requests the UI never would, such as another user’s id or a missing auth token.
Q2: What is the difference between 401 and 403, and between 400 and 422?
401 means not authenticated (no or invalid token); 403 means authenticated but not allowed (you are who you say, but you cannot have this resource). 400 means the request is malformed (bad JSON, wrong type); 422 means the request is well-formed but semantically invalid (e.g. an invalid NZ postcode or IRD number).
Q3: Beyond the status code, what should a thorough API test assert?
The response body schema (all expected fields present, no unexpected ones), data types (price is a number not a string), required fields are never null, error messages are clear and free of internal details, and the response time meets the SLA.
Q4: Why treat the OpenAPI/Swagger spec as the test oracle?
Consumers — the frontend, mobile app, and partner integrations — code to the spec, not to whatever the API happens to return today. If the live API drifts from the spec, that is a bug even when the API appears to work, because every client built against the contract will silently break.
Q5: A 500 response includes a Java stack trace and a file path. Why is that more than a cosmetic problem?
It is a security defect. Leaked stack traces, file paths, SQL, and internal field names hand an attacker a map of the system’s internals. Error bodies should carry a clear, generic message and nothing about the implementation.