Testing a resource end to end
Create, read, update and delete, and checking that each one really happened.
Most APIs are built around resources: books, orders, users, invoices. For each resource there is usually a way to create one, read it, change it and delete it. Testers call this CRUD (create, read, update, delete), and a CRUD test follows one record through its whole life. It is the first thing you should test on any new resource, because almost every other feature depends on it working.
This lesson uses /api/Bookssign in to see. It supports every operation, and its reference page lists the fields. You already know the methods from HTTP methods. Here the question is different: how do you prove that each operation did what it claimed?
A response is a claim, not proof
When a server answers a POST with 201 and a copy of the record, it is telling you what it thinks it stored. That copy is usually built from the same object that was about to be saved, so it can look perfect even when the save failed, or when a field was dropped on the way to the database. The only way to know what was stored is to ask for it again in a separate request.
So a CRUD test alternates between doing something and checking it with a fresh read:
- Create a record. Check the response.
- Read it back by ID. Compare every field with what you sent.
- Find it in the list endpoint.
- Replace it with PUT. Read it back.
- Change one field with PATCH. Read it back.
- Delete it. Read it back and expect a 404.
- Delete it again and see what happens.
Create
curl -i -X POST https://api.testingapis.com/api/Books \
-H "Content-Type: application/json" \
-d '{
"title": "Testing Web APIs",
"author": "Mark Winteringham",
"isbn": "9781617299537",
"genre": "testing",
"publishedYear": 2022,
"pages": 264,
"language": "English",
"price": 49.99,
"rating": 4.6,
"available": true,
"tags": ["api", "testing"]
}'HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
{
"message": "Book created successfully",
"data": {
"id": 26,
"title": "Testing Web APIs",
"author": "Mark Winteringham",
"isbn": "9781617299537",
"genre": "testing",
"publishedYear": 2022,
"pages": 264,
"language": "English",
"price": 49.99,
"rating": 4.6,
"available": true,
"tags": ["api", "testing"]
}
}Things to check on this response, in rough order of importance:
- The status is 201, not 200. Both mean success, but 201 says a new resource exists, and clients sometimes branch on it.
- There is an
id, and it is a number (or whatever type the docs promise). Save it. Every later step needs it. - Every field you sent comes back with the same value. Compare them one by one, including the ones that seem boring. A price of
49.99that comes back as49.9899999or"49.99"is a bug. - Whether a
Locationheader points to the new record. Many REST APIs send one with a 201. This API doesn't. That is a design choice rather than a defect, but you should know which way your API goes and write it into your tests.
Now look at what happens with a small mistake in the body. The field is called publishedYear, and here it is sent as year:
curl -X POST https://api.testingapis.com/api/Books \
-H "Content-Type: application/json" \
-d '{"title": "The Test Book", "author": "Ada Tester", "year": 2026}'{
"message": "Book created successfully",
"data": {
"id": 26,
"title": "The Test Book",
"author": "Ada Tester",
"isbn": "",
"genre": "",
"publishedYear": 0,
"pages": 0,
"language": "",
"price": 0,
"rating": 0,
"available": false,
"tags": []
}
}The server accepted the request, ignored year without a word, and stored 0as the year. Nothing in the status code tells you anything went wrong. You only catch this by comparing the stored record with what you meant to send. Silently ignored fields are one of the most common sources of "the data is wrong but nobody knows why" bugs. Some APIs reject unknown fields instead; the next lesson covers that.
Fields the server fills in
Some fields don't come from you: the ID, and on many resources timestamps like createdAt or updatedAt. You can't compare them with a fixed value, because they change on every run. Test their properties instead:
- The ID is present, has the right type, and differs from IDs you created earlier.
- A
createdAtis a valid date in the documented format and falls within a few seconds of when you sent the request. Allow for clock differences between your machine and the server. - An
updatedAtchanges after an update andcreatedAtdoes not. - Sending your own
idin the body doesn't let you choose it. The Books docs say an ID in the body is ignored. Try it with"id": 1and check that book 1 is untouched afterwards.
Read it back
Fetch the record by the ID you were given, and compare the full body with what you sent. Then check the list. On this API you can search by title:
/api/Books?q=web%20apisIf your book isn't in the results, the create response lied or the list reads from a different place than the single-record endpoint (a cache, a search index, a read replica). All of those happen in real systems. Checking the list also catches a quieter problem: the record is saved but filtered out of lists, for example because a default filter hides records with an empty field.
Update with PUT and PATCH
PUT replaces the whole record. Anything you leave out is reset, usually to an empty or default value. People forget this all the time, on both the client and the server side, so test it on purpose. Here a book is updated with only three fields:
curl -X PUT https://api.testingapis.com/api/Books/27 \
-H "Content-Type: application/json" \
-d '{"title": "Lessons Learned in Software Testing", "author": "Cem Kaner", "price": 39.0}'{
"message": "Book updated successfully",
"data": {
"id": 27,
"title": "Lessons Learned in Software Testing",
"author": "Cem Kaner",
"isbn": "",
"genre": "",
"publishedYear": 0,
"pages": 0,
"language": "",
"price": 39.0,
"rating": 0,
"available": false,
"tags": []
}
}The ISBN, page count, year and tags are gone. That is correct PUT behaviour. If the server had kept the old values, it would be acting like PATCH, and a client relying on PUT to clear a field would never manage to. The same request also sent "id": 1 in the body; the record kept ID 27, and a separate GET confirmed that book 1 had not changed.
A PUT that is missing a required field fails with a 400, as it should: {"price": 35} alone returns Missing required field: title.
PATCH changes only the fields you send. This API uses JSON merge patch, and its response lists what it changed and what it ignored:
curl -X PATCH https://api.testingapis.com/api/Books/26 \
-H "Content-Type: application/merge-patch+json" \
-d '{"price": 35, "available": false, "colour": "blue", "id": 999}'{
"message": "Book patched successfully",
"changed": ["price", "available"],
"ignored": ["colour", "id"],
"data": {
"id": 26,
"title": "Testing Web APIs",
"author": "Mark Winteringham",
"isbn": "9781617299537",
"genre": "testing",
"publishedYear": 2022,
"pages": 264,
"language": "English",
"price": 35,
"rating": 4.6,
"available": false,
"tags": ["api", "testing"]
}
}Check that the two named fields changed, that every other field kept its old value, and that the ignored id really was ignored. Then do a fresh GET and check all of it again. A PATCH that updates the response but not the stored record is rare, but when it happens it is exactly the kind of bug that reaches production, because the client shows the new value until the page is reloaded.
Delete, then delete again
$ curl -i -X DELETE https://api.testingapis.com/api/Books/26
HTTP/1.1 200 OK
{"message":"Book deleted successfully"}
$ curl -i https://api.testingapis.com/api/Books/26
HTTP/1.1 404 Not Found
{"status":404,"error":"Not Found","message":"Book with ID 26 does not exist."}
$ curl -i -X DELETE https://api.testingapis.com/api/Books/26
HTTP/1.1 404 Not Found
{"status":404,"error":"Not Found","message":"Book with ID 26 does not exist."}After a delete, a GET must return 404. Also check that the record is gone from the list, and that the count went down (/api/Books/countsign in to see returns {"count": 25} on the seed data).
The second DELETE is where APIs differ. This one returns 404. Others return 204 or 200 on every delete, on the grounds that the end state (no such record) is the same. Both are defensible. What you are looking for is a 500, or a 200 that claims it deleted something that wasn't there. Also send PUT and PATCH to the deleted ID. Here PATCH gets a 404, but a PUT with an incomplete body gets a 400 for the missing field before the server notices the record doesn't exist. Which error wins when two apply is worth a sentence in the docs, and often has none.
Create your own data
It is tempting to test against record 1 because it is always there. Don't. Anyone can change or delete book 1 on a shared server, and a test that edits it breaks every other test that reads it. Each test should create the records it needs, work only with the IDs it was given, and delete them at the end.
This API is a good place to see why. It keeps its data in memory and is shared by everyone who uses it. While this lesson was being written, another user created a book with ID 26 in the few seconds between one of our deletes and the next create. A test that assumed "the next book will be 26" would have failed for reasons that had nothing to do with the code under test.
The same experiment showed something else. After book 26 was deleted, the next book created got ID 26 again. This server hands out the next free number, so IDs are reused. If another system stored "book 26" before the delete, it now points to a different book. Whether that is a bug depends on the product, but it is the kind of finding worth raising. Most databases never reuse IDs for this reason.
Bulk endpoints
Many APIs let you create or delete several records in one request. The interesting question is what happens when some items are bad.
curl -X POST https://api.testingapis.com/api/Books/bulk \
-H "Content-Type: application/json" \
-d '[
{"title": "Bulk A", "author": "Tester"},
{"title": "Bulk B", "author": "Tester"},
{"title": "", "author": "Tester"}
]'
{"status":400,"error":"Bad Request","message":"Item [2]: Missing required field: title"}The third item has an empty title, so the whole request fails and the error names the item by index. The Books docs say nothing is stored in that case, and you should verify that: check that "Bulk A" does not exist after the failed request. An API that saves the first two items and then fails leaves the client with no idea what to retry.
Bulk delete reports which IDs it found and which it didn't:
curl -X DELETE "https://api.testingapis.com/api/Books/bulk?ids=27,28,5000"
{"message":"2 book record(s) deleted","deleted":[27,28],"notFound":[5000]}| Bulk case | What to check |
|---|---|
| All items valid | Every item is stored, IDs are distinct, the count goes up by the number sent. |
| One invalid item | Nothing is stored, or the response says exactly which items were stored. |
| Empty array | A clear 400, not a 201 that created nothing. |
| More than the limit (100 here) | A 400 that states the limit. |
| Duplicate IDs in a bulk delete | Each record deleted once, no error from the second occurrence. |
Exercise
Run the full lifecycle on /api/Moviessign in to see, using its reference page for the fields. Create a movie with every field filled in, then:
- Read it back and compare every field with what you sent.
- Find it with
?q=and a word from its title. - PUT it with only the required fields. Which fields were reset?
- PATCH one field and send one field name that doesn't exist. Read the
ignoredlist. - Delete it, then GET, PATCH and DELETE it again. Write down each status.
Finish by writing down one thing you would ask the developers about (an ignored field, a reused ID, an error that won over another). Deciding what to report is as much a part of the test as the requests.