Every time I want to teach someone API testing, I hit the same wall: there's nothing decent to test against.
Public APIs are either read-only (so you never touch a POST), rate-limited into uselessness, or they need a signup flow and a key in a .env file before anyone writes a single assertion. JSONPlaceholder is the usual fallback, but it fakes its writes — you POST something, get a 201, and the resource isn't there when you GET it. That's a terrible thing to learn on, because it teaches you that a status code is the same as a result.
I recently came across funapi.dev, which is a set of mock REST APIs built specifically for QA practice. No signup, no key. Writes actually persist. And — the part that made me sit up — each client gets its own isolated copy of the dataset.
I picked the Movies API and wrote a real suite against it. Below are the tests I found most worth showing, including the one that turned up a bug.
What we're testing
Base URL: https://funapi.dev/api/movies/v1
Nine endpoints, and they're chosen well. Filtering and sorting with strict query validation, full-text and fuzzy search, Bearer auth with two roles, range validation on ratings, and optimistic concurrency with If-Match. That covers most of what you'd actually assert against a production API.
A movie looks like this:
{
"id": 1,
"title": "Midnight Debugger",
"genre": "thriller",
"year": 2019,
"rating": 7.8,
"_v": 1
}
The titles are all QA in-jokes — Production Down, The 404 Identity, Gone in 60 Milliseconds. Ten seeded records, five per page.
Setup is two packages:
pip install pytest requests
The session wrapper
Before any tests, a small piece of plumbing that pays for itself immediately. I don't want BASE_URL + path scattered across forty call sites, and I definitely don't want to forget a timeout on one request and have CI hang for ten minutes.
Subclassing requests.Session handles both:
# conftest.py
import uuid
import pytest
import requests
BASE_URL = "https://funapi.dev/api/movies/v1"
class ApiSession(requests.Session):
"""A Session that prefixes the base URL and always sets a timeout."""
def request(self, method, path, **kwargs):
kwargs.setdefault("timeout", 10)
return super().request(method, BASE_URL + path, **kwargs)
Now every call reads api.get("/movies"). Small thing, but it keeps the tests about behaviour instead of about URLs.
The isolation problem
Here's the issue that ruins most API test suites, and it shows up fast.
My third test deletes a movie. My fourth test asserts there are ten movies. Run them in that order and the fourth fails — not because anything is broken, but because the third one ate its data. So you start writing teardown code, or you order your tests by hand, or you give up and make everything read-only. All three are bad.
The Movies API solves this with an X-Sandbox-Id header. Send whatever string you like and you get a private copy of the dataset under that key. Omit it and the server assigns you one automatically (you can see it come back in the response headers as x-sandbox-id: auto_66685498c9e...).
So the fixture generates a fresh ID per test:
@pytest.fixture
def api():
"""An anonymous client pinned to its own throwaway copy of the dataset."""
session = ApiSession()
session.headers["X-Sandbox-Id"] = f"pytest-{uuid.uuid4().hex[:12]}"
yield session
session.close()
@pytest.fixture
def admin(api):
api.headers["Authorization"] = "Bearer qa-admin-token"
return api
@pytest.fixture
def viewer(api):
api.headers["Authorization"] = "Bearer qa-viewer-token"
return api
Every test now starts from the same ten movies and can destroy them freely. No teardown, no ordering constraints, and pytest -n auto works without a second thought. I proved it to myself with a test:
def test_two_sandboxes_do_not_see_each_others_writes(admin):
admin.delete("/movies/1")
other = ApiSession()
other.headers["X-Sandbox-Id"] = f"pytest-{uuid.uuid4().hex[:12]}"
try:
assert other.get("/movies/1").status_code == 200
finally:
other.close()
If you're building your own mock service for a team, steal this idea. It's the single most useful property a test API can have.
Asserting the contract, not the data
The first real test checks the pagination envelope:
def test_list_returns_a_pagination_envelope(api):
response = api.get("/movies")
assert response.status_code == 200
body = response.json()
assert body["page"] == 1
assert body["pageSize"] == 5
assert body["totalPages"] == 2
assert len(body["data"]) == body["pageSize"]
Note the last line. len(body["data"]) == body["pageSize"] rather than == 5. The response has to be internally consistent, which is the actual rule — hardcoding 5 just re-states the fixture.
Same instinct on the shape check:
EXPECTED_KEYS = {"id", "title", "genre", "year", "rating", "_v"}
def test_every_movie_has_the_documented_shape(api):
for movie in api.get("/movies").json()["data"]:
assert EXPECTED_KEYS <= set(movie), f"missing keys in {movie}"
assert isinstance(movie["year"], int)
assert 0 <= movie["rating"] <= 10
EXPECTED_KEYS <= set(movie) is a subset check, so the test survives the API adding a field. Breaking your suite because someone shipped a new optional attribute is a good way to get everyone to stop trusting it.
Sorting gets the same treatment — don't assert the titles, assert the ordering:
def test_sort_by_rating_is_actually_sorted(api):
ratings = [m["rating"] for m in api.get("/movies", params={"sort": "rating"}).json()["data"]]
assert ratings == sorted(ratings, reverse=True)
Negative tests, compressed
This is where parametrize earns its keep. The Movies API uses strict query validation, so it rejects unknown parameters instead of ignoring them — which means three distinct failure modes worth covering:
@pytest.mark.parametrize("params,status,fragment", [
({"sort": "banana"}, 400, "sort must be"),
({"colour": "red"}, 400, "Unknown query parameter"),
({"page": 99}, 404, "out of range"),
])
def test_bad_query_parameters_are_rejected(api, params, status, fragment):
response = api.get("/movies", params=params)
assert response.status_code == status
assert fragment in response.json()["message"]
Three tests, seven lines, and each one reports its own name when it fails. The error bodies are properly descriptive too, which I appreciated:
{
"error": "Not Found",
"message": "page 99 is out of range (last page is 2).",
"requestId": "6d8e8e715a99a951"
}
Rating validation is a boundary problem, so it gets the boundaries:
@pytest.mark.parametrize("rating", [-1, 10.1, 42, "eight"])
def test_ratings_outside_zero_to_ten_are_rejected(admin, rating):
response = admin.put("/movies/1/rating", json={"rating": rating})
assert response.status_code == 400
assert "between 0 and 10" in response.json()["message"]
@pytest.mark.parametrize("rating", [0, 5.5, 10])
def test_ratings_inside_the_range_are_accepted(admin, rating):
response = admin.put("/movies/1/rating", json={"rating": rating})
assert response.status_code == 200
assert response.json()["rating"] == rating
0 and 10 are the ones that matter. Off-by-one on an inclusive range is a genuinely common bug, and it's invisible if you only ever test with 7.
Auth is two tests, not one
A missing token and a valid-but-insufficient token are different failures, and plenty of suites only check the first:
def test_writing_without_a_token_is_401(api):
response = api.post("/movies", json={"title": "Anonymous", "genre": "drama"})
assert response.status_code == 401
def test_viewer_cannot_delete(viewer):
response = viewer.delete("/movies/3")
assert response.status_code == 403
assert "viewer role" in response.json()["message"]
def test_admin_can_delete_and_the_movie_stays_gone(admin):
assert admin.delete("/movies/3").status_code == 204
assert admin.get("/movies/3").status_code == 404
That last test is the one JSONPlaceholder can't give you. The delete returns 204, and then the follow-up GET returns 404 because the record is genuinely gone from my sandbox. A status code on its own proves nothing; the read-back is the assertion.
The bug
Now the fun part. POST /movies requires an admin token, and the docs list six genres: thriller, drama, comedy, horror, action, documentary. So:
response = admin.post("/movies", json={"title": "Bad genre", "genre": "musical", "year": 2025})
201 Created. It cheerfully stored a musical.
It gets better. Post a body with nothing but a title and you get back a complete record with invented defaults:
{"id": 12, "title": "No genre", "genre": "drama", "year": 2026, "rating": null}
So PUT /movies/{id}/rating validates its input carefully and returns a clean 400 for anything out of range, while POST /movies accepts more or less whatever you hand it. That inconsistency between a create endpoint and an update endpoint on the same resource is extremely realistic — I've filed this exact bug against real services more than once. Validation gets written when someone remembers, and nobody remembers on every route.
I left it in the suite as a strict xfail rather than deleting it:
@pytest.mark.xfail(reason="POST /movies does not validate genre — reported upstream", strict=True)
def test_post_rejects_an_invalid_genre(admin):
response = admin.post("/movies", json={"title": "Bad genre", "genre": "musical", "year": 2025})
assert response.status_code == 400
strict=True is the important bit. The test is expected to fail, and if it ever passes, pytest fails the run and tells me — which is exactly what I want the day someone patches it. A commented-out test just rots.
One more: optimistic concurrency
The _v field on each movie isn't decoration. The rating endpoint honours If-Match, so you can test the lost-update scenario:
def test_stale_if_match_is_rejected(admin):
first = admin.put("/movies/2/rating", json={"rating": 5}, headers={"If-Match": '"v1"'})
assert first.status_code == 200
assert first.json()["_v"] == 2
second = admin.put("/movies/2/rating", json={"rating": 6}, headers={"If-Match": '"v1"'})
assert second.status_code == 412
Two clients read version 1, both try to write, the second one gets 412 Precondition Failed instead of silently clobbering the first. Being able to reproduce that in a unit test without spinning up threads is worth a lot.
One gotcha: the version token here is "v1", not the ETag value from the response header. I burned about ten minutes on that before reading the error message properly, which was telling me the answer the whole time.
Where this leaves us
Those few files cover pagination, filtering, sorting, search, auth, role permissions, boundary validation, and concurrency control. Nothing mocked, nothing stubbed — real HTTP against a real server, which means they also catch the things a mocked test never will.
If you want to take it further, this structure drops straight into GitHub Actions (no secrets to configure, which is the nicest part), and funapi.dev exports an OpenAPI 3.1 spec per API if you'd rather do schema validation with something like schemathesis. There's also a Fault Injection API on the same site for testing retry and timeout logic, which is my next thing to write about.
The larger point, though, is the sandbox header. Isolated-by-default test data is what let me write all of this without a single setup/teardown block or one line of cleanup code. If you own an internal API that other teams write tests against, that's the feature to build.
If you spot something I should be asserting and I'm not, tell me in the comments — I'd rather find out from you than from production.
Top comments (0)