The hardest part of learning API testing isn't Postman. Postman is fine. The hard part is finding something to point it at.
I learned this the annoying way. When I started, our staging environment was the only API I had access to, and it was down roughly one day in three. When it was up, half my requests failed for reasons that had nothing to do with what I was practising — expired tokens, a colleague mid-deploy, a database someone had just truncated. I spent more time asking "is it me or is it the environment?" than actually learning.
So I went looking for a sandbox. Public APIs are easy to find, but most of them are read-only. You can GET all day and never touch a POST, never see a 401, never write a test that fails on purpose. Which is a problem, because in real QA work the interesting bugs live in the write paths and the error responses.
Then I found funapi.dev. It's a set of mock REST APIs — 37 of them, everything from a pizza ordering service to an OAuth 2.0 playground — built specifically so you can practise against them. No signup, no API key application form, no rate limit emails. And crucially, they're designed to return every status code you'd want to test, including the ugly ones.
I'm going to walk through one of them: the Rick and Morty API. By the end you'll have a working Postman collection with real assertions, and you'll have hit an auth wall, a 404, and a filter, on purpose.
What we're testing
Base URL:
https://funapi.dev/api/rickandmorty/v1
Nine endpoints across three resources. Here's the map (full docs here):
Characters
| Method | Path | Notes |
|---|---|---|
| GET | /characters |
Paginated, filter by status |
| GET | /characters/{id} |
Single character |
| GET | /characters/{id}/location |
Nested resource |
| POST | /characters |
Bearer auth required |
| PUT | /characters/{id} |
Bearer auth required |
| DELETE | /characters/{id} |
Bearer auth required |
Locations
| Method | Path | Notes |
|---|---|---|
| GET | /locations |
Filter by dimension |
Episodes
| Method | Path | Notes |
|---|---|---|
| GET | /episodes |
Filter by episode code |
| GET | /episodes/random |
Returns a random episode |
The dataset is deliberately small — eight characters, a handful of locations, five episodes. That's a feature. Small datasets mean you can actually hold the expected state in your head, which makes it obvious when an assertion is wrong versus when the API is wrong.
Two things I want to flag before we start, because they're the reason I picked this API over a plain read-only one:
- Three of the endpoints need a Bearer token. That gives us a real 401 to test.
- There's a character in the dataset who is dead. Filtering on status actually returns different results, rather than the same list every time.
Setting up the collection
Open Postman, create a new collection, call it something like Rick and Morty API.
Before you create a single request, go to the collection's Variables tab and add:
| Variable | Initial value | Current value |
|---|---|---|
baseUrl |
https://funapi.dev/api/rickandmorty/v1 |
same |
token |
qa-admin-token |
same |
I know it's tempting to skip this and paste the full URL into every request. Don't. The first time you need to point the same collection at a different host you'll be doing find-and-replace across twenty requests, and you'll miss one. Variables take ten seconds now.
(The tokens are qa-admin-token and qa-viewer-token — funapi.dev publishes them in its getting started guide. The viewer token is the interesting one, but we'll get to that.)
Request 1: list the characters
New request, GET, URL:
{{baseUrl}}/characters
Hit Send. You should get a 200 and a JSON body containing the list.
Look at the response body properly before you write anything else. Specifically, find out what wraps the array — some APIs return a bare array, some wrap it in data, some use results alongside a pagination object. Whatever key holds the array in your response is the key your assertions need to reference, and guessing here is the single most common reason a beginner's first test fails for no apparent reason.
Once you know the shape, go to the Scripts tab (older Postman versions call it Tests) and add:
pm.test("Status code is 200", function () {
pm.response.to.have.status(200);
});
pm.test("Response is JSON", function () {
pm.response.to.be.json;
});
pm.test("Responds in under 1000ms", function () {
pm.expect(pm.response.responseTime).to.be.below(1000);
});
Send again. Three green checks at the bottom of the response pane.
Those three are worth putting on almost every request you ever write. They're cheap, and between them they catch a surprising share of real regressions — an endpoint that starts returning HTML because someone broke routing, or one that quietly gets ten times slower after a bad query change.
Now something with actual content in it:
pm.test("Character list is not empty", function () {
const body = pm.response.json();
const characters = body.data || body.results || body;
pm.expect(characters.length).to.be.above(0);
});
pm.test("Each character has an id and a name", function () {
const body = pm.response.json();
const characters = body.data || body.results || body;
characters.forEach(function (character) {
pm.expect(character).to.have.property("id");
pm.expect(character).to.have.property("name");
});
});
The body.data || body.results || body line is a hedge so this works whatever envelope you got. In your own collection, once you've confirmed the shape, replace it with the actual path. Hedges in test code are a way of writing an assertion that can never fail, which defeats the point.
Request 2: filtering
This is where a lot of tutorials stop, and it's exactly where the bugs start.
{{baseUrl}}/characters?status=dead
Add the query param through Postman's Params tab rather than typing it into the URL bar — it's the same thing, but the table view is much easier to read when you're chaining three or four filters.
Now the assertion:
pm.test("Every returned character is dead", function () {
const body = pm.response.json();
const characters = body.data || body.results || body;
pm.expect(characters.length).to.be.above(0);
characters.forEach(function (character) {
pm.expect(character.status.toLowerCase()).to.equal("dead");
});
});
Two assertions in one test, and both matter. The length check is there because a filter that returns zero results will pass a forEach loop trivially — the loop body never runs, nothing gets asserted, test goes green. I have shipped that bug. A filter endpoint was returning an empty array for every query and my test suite was perfectly happy about it for two sprints.
If a filter test can pass on an empty response, it isn't testing the filter.
While you're here, try a value that shouldn't match anything — ?status=banana. What comes back? A 400 telling you the value isn't valid, or a 200 with an empty list? Both are defensible designs. Knowing which one your API does is the kind of thing that saves an argument later.
Request 3: the nested resource
{{baseUrl}}/characters/1/location
Nested routes like this are worth practising because they're where ID handling tends to break. Try it with an ID that definitely doesn't exist:
{{baseUrl}}/characters/99999/location
You should get a 404. Assert it:
pm.test("Unknown character returns 404", function () {
pm.response.to.have.status(404);
});
pm.test("Error response has a message", function () {
const body = pm.response.json();
pm.expect(body).to.have.property("message");
});
That second test is the one people skip. A 404 with an empty body is technically correct and practically useless — your frontend has nothing to show the user. Checking that errors carry a readable message is a legitimate test, not padding.
Request 4: hitting the auth wall on purpose
Now the write endpoints. New request, POST to {{baseUrl}}/characters, with a JSON body:
{
"name": "Birdperson",
"status": "Alive",
"species": "Bird-Person",
"origin": "Bird World"
}
Send it without any auth header first.
- That's the point.
pm.test("Unauthenticated POST is rejected", function () {
pm.response.to.have.status(401);
});
Now add the auth. Go to the Authorization tab, pick Bearer Token, and put {{token}} in the field. Send again — you should get a 201, and the response should contain the character you just created.
pm.test("Character created", function () {
pm.response.to.have.status(201);
});
pm.test("Created character echoes the name we sent", function () {
const body = pm.response.json();
const created = body.data || body;
pm.expect(created.name).to.equal("Birdperson");
pm.collectionVariables.set("createdCharacterId", created.id);
});
That last line is the trick that turns a pile of separate requests into an actual test flow. It grabs the ID out of the response and stores it in a collection variable, so the next request can use {{createdCharacterId}} instead of a hardcoded number. Chain a GET, a PUT and a DELETE behind it and you have a full CRUD lifecycle test that cleans up after itself.
Then try the same POST with qa-viewer-token instead of qa-admin-token. Different failure — 403, not 401. If the distinction is fuzzy: 401 means the API doesn't know who you are, 403 means it knows exactly who you are and you're not allowed. Testers get asked to explain this in interviews more often than you'd think.
Running the whole thing
Individual requests are fine while you're building. The payoff is running them together.
Open the collection, click Run. Postman fires every request in order and gives you a pass/fail summary.
Two things to check on your first run:
Order matters. If your DELETE runs before your POST, the delete has nothing to delete. Drag requests into a sensible sequence in the sidebar — that order is what the runner uses.
State leaks between runs. Run the collection twice. Does the second run pass? If your POST creates "Birdperson" and something in your suite asserts on a total character count, run two will fail. This is the single most valuable lesson the funapi.dev sandbox teaches you cheaply, because it's exactly what happens to real automated suites against real environments, usually at 2am in CI, usually when you're not on call but get called anyway.
Fix it the way you'd fix it in production: make each run either clean up what it created, or generate unique data. pm.variables.replaceIn('{{$randomFirstName}}') gives you a fresh name each run and takes about five seconds to wire up.
Where to go from here
Once the basic collection is green, funapi.dev has a few things worth graduating to:
-
Optimistic concurrency. The
PUTendpoint supports ETag /If-Match. Send a stale ETag, get a 412. This is one of those concepts that stays abstract until you've watched it reject a request in front of you. -
Idempotency keys. Send the same
POSTtwice with the sameIdempotency-Keyheader and see what happens. Then send it twice without one. - Export the collection. funapi.dev publishes an OpenAPI 3.1 spec and a ready-made Postman collection for each API. Import the spec, compare it to what you built by hand, and see what you missed. I missed two optional query params.
-
Run it headless.
newman run collection.jsongets you the same suite in a terminal, which is one small step from getting it into a CI pipeline.
The actual point
None of this is about Rick and Morty. It's that learning API testing needs an API that fails in interesting, predictable, repeatable ways — and staging environments fail in interesting, *un*predictable ways, which is a completely different and much less useful thing.
Get a sandbox. Break it on purpose. Write the test that catches it. Then go do the same thing to the API you actually get paid to test.
If you build something with this, the funapi.dev catalogue has 36 more APIs — the Coffee Shop one is home to a genuine 418, and the Fault Injection API lets you dial in latency and malformed payloads on demand. Both are more fun than they have any right to be.
Top comments (0)