Writing your first test cases
Expected results, negative tests, boundary values and equivalence classes.
Up to now you have been sending requests and looking at what comes back. Testing starts when you decide, before sending, what should come back, and then compare. That decision written down is a test case. This lesson is about writing them, and about choosing which ones to write, because you can't send every possible request.
What a test case contains
Teams use different templates, but nearly all of them have the same parts:
| Part | What goes in it | Example |
|---|---|---|
| ID | A short reference so people can talk about it | PROD-GET-03 |
| Title | What is being checked, in one line | Unknown product ID returns 404 |
| Preconditions | What must be true before you start | No product with ID 9999 exists |
| Request | Method, URL, headers and body | GET /api/Products/9999 |
| Expected result | Status, important headers, body | 404, JSON body whose message names ID 9999 |
| Actual result | Filled in when you run it | As expected |
The expected result is the part people write badly. "Returns an error" is not an expected result, because almost anything passes it. Be specific about the status code, the headers that matter (usually Content-Type, sometimes caching or location headers), and the body: which fields must be there, with which types and values. If you can't say what the right answer is, you have found a question for the developer or the product owner, and that is worth asking before you test anything.
Where does the expected result come from? The documentation, the ticket that described the feature, the way similar endpoints in the same API behave, and sometimes plain common sense (a price shouldn't be negative). When these sources disagree, write down which one you followed.
Positive and negative tests
A positive test sends a valid request and checks that it works: product 1 exists, so GET /api/Products/1 returns it. A negative test sends something the API should refuse and checks that it refuses properly: the right status, a useful message, and no side effects.
Beginners write mostly positive tests, because they follow the feature description. Developers have usually tried the positive path themselves before handing the work over. The negative ones are where you find things. As a rough guide, expect to write more negative tests than positive ones for any endpoint that takes input.
Equivalence classes
The list endpoint GET /api/Products accepts a limit parameter. The API reference says it is the page size, from 1 to 100. There are infinitely many values you could send. Equivalence partitioning means grouping the inputs that the server should treat the same way, and testing one value from each group instead of all of them:
| Class | Example value | Expected |
|---|---|---|
| Valid, in range | limit=10 | Ten products |
| Below range | limit=-5 | Rejected, or clamped to the minimum |
| Above range | limit=500 | Rejected, or clamped to 100 |
| Not a number | limit=abc | Rejected with 400 |
| Not an integer | limit=2.5 | Rejected with 400 |
| Empty | limit= | Treated as missing |
| Missing | no limit | Every product, as documented |
Notice the "rejected, or clamped" entries. The docs don't say what happens outside the range, so there are two reasonable behaviours. Either is defensible. What you are testing is that the API does one of them, consistently, and doesn't crash or return something nonsensical.
Boundary values
Bugs cluster at the edges of ranges, because that is where developers write < when they meant <=. Boundary value analysis means testing the edge and one step either side of it. For a range of 1 to 100 that gives 0, 1, 2, 99, 100 and 101. Here is what this API actually does with those and the other classes. The X-Per-Page header reports the page size the server used.
| Request | Status | Items returned | X-Per-Page |
|---|---|---|---|
?limit=0 | 200 | 1 | 1 |
?limit=1 | 200 | 1 | 1 |
?limit=-1 | 200 | 1 | 1 |
?limit=100 | 200 | 20 (all of them) | 100 |
?limit=101 | 200 | 20 (all of them) | 100 |
?limit=abc | 200 | 20 | 20 |
?limit=2.5 | 200 | 20 | 20 |
?limit= | 200 | 20 | 20 |
/api/Products?limit=0Reading the results: values below 1 are clamped up to 1, values above 100 are clamped down to 100, and anything that isn't an integer is ignored as if limit were missing. None of this is written down, but it is consistent, so it is probably deliberate. Two things still deserve a note in your test report. limit=0returning one item is surprising; a client that asks for zero items (to read the total count cheaply, say) gets a product it didn't want. And limit=abc being silently ignored means a typo in a client goes unnoticed, where a 400 would have caught it.
There is also a lesson about test data here. The API has only 20 products, so limit=100 and limit=101return the same body. You couldn't tell from the body that 101 was clamped. The header showed it. When a boundary is bigger than your data, either find another signal like this one or create enough records to reach the boundary.
Boundaries hide in IDs too
The reference lists the id in /api/Products/{id} as an int32, a 32-bit integer whose largest value is 2,147,483,647. That is a boundary worth testing:
| Request | Status | Body |
|---|---|---|
/api/Products/20 | 200 | Product 20, the last one |
/api/Products/21 | 404 | JSON error message |
/api/Products/0 | 404 | JSON error message |
/api/Products/-1 | 404 | JSON error message |
/api/Products/2147483647 | 404 | JSON error message |
/api/Products/2147483648 | 404 | Empty |
/api/Products/abc | 404 | Empty |
/api/Products/1.5 | 404 | Empty |
/api/Products/01 | 200 | Product 1 |
Two things stand out. Everything that isn't a valid int32 gets a 404 with no body at all, while valid-but-missing IDs get a JSON error. And 01 is accepted as 1. The second is harmless here, but on an API that caches responses by URL, /1 and /01 would be cached separately. Neither result is obviously wrong. Both are worth knowing about.
A set of test cases for one endpoint
Put together, a reasonable first set of test cases for GET /api/Products/{id}looks like this. It isn't everything you could test, but it covers each class once and each boundary.
| ID | Request | Expected result |
|---|---|---|
| PROD-GET-01 | GET /api/Products/1 | 200, JSON, id is 1, title is a non-empty string, price is a number above 0 |
| PROD-GET-02 | GET /api/Products/20 | 200, the highest seeded ID |
| PROD-GET-03 | GET /api/Products/21 | 404, JSON error body naming ID 21 |
| PROD-GET-04 | GET /api/Products/0 | 404, JSON error body |
| PROD-GET-05 | GET /api/Products/-1 | 404, JSON error body |
| PROD-GET-06 | GET /api/Products/abc | 400 or 404, with a JSON error body |
| PROD-GET-07 | GET /api/Products/2147483648 | 400 or 404, with a JSON error body |
| PROD-GET-08 | HEAD /api/Products/1 | Same status and headers as GET, no body |
| PROD-GET-09 | GET /api/Products/1 twice | Identical bodies both times |
PROD-GET-06 and 07 fail against the current API, because the body is empty. PROD-GET-08 fails too: HEAD /api/Products/1 returns 405 Method Not Allowed, while the newer resources such as /api/Books/{id}sign in to seesupport HEAD. Whether HEAD support is expected here is a question for whoever owns the API. That is fine. A test case records what should happen; a failing test case is how you find out it doesn't. PROD-GET-02 has a weakness: if someone deletes product 20 or adds product 21, it stops meaning what it says. The next lessonshows how to create your own test data so tests don't depend on records someone else controls.
Writing a bug report
When a test case fails, you write it up. A good bug report lets a developer reproduce the problem in under a minute without asking you anything. Using the empty 404s from above as the example, compare the two responses first:
HTTP/1.1 404 Not Found
Content-Type: application/json; charset=utf-8
{"status":404,"error":"Not Found","message":"Product with ID 9999 does not exist."}HTTP/1.1 404 Not Found
Content-Length: 0A report might read:
Title: GET /api/Products/{id} returns an empty 404 for non-numeric
and out-of-range IDs, instead of the JSON error body
Environment: https://api.testingapis.com, 30 Sep 2026
Steps:
curl -i https://api.testingapis.com/api/Products/9999
curl -i https://api.testingapis.com/api/Products/abc
curl -i https://api.testingapis.com/api/Products/2147483648
Expected:
All three return 404 with Content-Type: application/json and a body
like {"status":404,"error":"Not Found","message":"..."}, as documented
under "Responses and errors" in the API reference.
Actual:
9999 404, JSON body with a message
abc 404, Content-Length: 0, no Content-Type, no body
2147483648 404, empty body (one above the int32 maximum; 2147483647
returns the JSON error)
Impact:
Clients that parse every error body as JSON fail on the empty
responses. The empty 404 also looks like "this URL does not exist"
rather than "this product does not exist".
Notes:
Probably the route only matches int32 values, so other input never
reaches the controller. Could be intended; if so, the docs should say
which errors have no body.What makes this report useful:
- The title says what is wrong and where, so it is clear in a list of fifty tickets.
- The steps are commands someone can paste. No "call the product endpoint with a bad ID".
- Expected behaviour points at a source (the docs), so it isn't only your preference.
- It shows the pattern rather than a single example. Three requests with a working one for comparison say more than one failing request.
- It states the impact on a real client. Developers prioritise by impact, and "inconsistent" alone rarely gets fixed.
- It admits uncertainty. The behaviour may be intended, and the report says what would resolve it either way (fix the code, or fix the docs).
Exercise
Pick GET /api/Books/{id}sign in to see or GET /api/Countries/code/{code}sign in to see. Look up the endpoint in the API reference, then:
- List the equivalence classes for the path parameter.
- Pick the boundary values. For country codes, think about length and letter case.
- Write at least eight test cases in the table format above, with specific expected results.
- Run them with curl and fill in the actual results.
If anything behaves differently from what you expected, write a bug report for it using the structure above, including what you aren't sure about.