Test an API End-to-End — Hands-On Software Testing, Part 7
- Shawn West
- Apr 20
- 6 min read
Updated: Aug 24
Illustrative composite: Tolvern Freight's shipping service, the same system used in API Testing: A Practical Walkthrough. That article argues why an endpoint has four assertable surfaces. This tutorial builds the test, one surface at a time, and never re-argues the case.
Before you start
You need:
A service with at least one endpoint that creates something (a bare GET has no side effect to check, so the interesting steps won't apply).
A test client that runs your app in-process — TestClient for FastAPI, supertest for Express, WebApplicationFactory for ASP.NET.
Test-time access to your database session or ORM. This is the one hard prerequisite. If your tests can only reach the app through HTTP, Step 4 is impossible and you'll be stuck at surface 1.
A way to create a caller who is not your default user — a different plan tier, region, or permission set.
Roughly 50 minutes. Examples are pytest; the shape is identical elsewhere.
What you'll build
One test file for POST /shipments that proves four separate things: the response is well-formed, the carrier booking was actually written, the next reader sees the same object, and a duplicate request can't double-book.
Step 1: Pick the endpoint — and the caller (5 min)
Choose the endpoint that would embarrass you most if it silently did nothing.
Then choose the caller, which matters more than people expect. Tolvern's bug lived on the negotiated-rate-card branch; every existing test used the default customer and never entered it.
# tests/test_shipments_api.py
import pytest
@pytest.fixture
def rate_card_customer(db_session):
return make_customer(db_session, plan="negotiated", rate_card="TOLV-2026-A")
Check: list the branches in the handler that depend on who is calling rather than what they sent. You should be able to name at least one your suite has never exercised. If you can't name any, you haven't read the handler yet.
Step 2: Get a client that reaches a real database (10 min)
from fastapi.testclient import TestClient
from tolvern.app import app
client = TestClient(app)
The client must hit a real (test) database, not a mocked repository. A mock will happily confirm a write that the real schema would reject.
Check: write a throwaway test that POSTs once and then queries the table directly. If the query returns a row, your wiring is right. If it raises "no such table", your test database isn't migrated — fix that now, because every step below depends on it.
Step 3: Surface 1 — the response (10 min)
def test_create_shipment_returns_201(rate_card_customer):
r = client.post("/shipments", json=VALID_SHIPMENT, auth=rate_card_customer.token)
assert r.status_code == 201
body = r.json()
assert body["id"]
assert body["status"] == "pending"
assert "carrier_api_key" not in body # never leak upstream credentials
That last line is a negative assertion, and it's the one worth copying. Leaked fields are invisible to every positive check you write.
Check: temporarily add a secret field to the serializer and confirm the test fails. Then remove it. A negative assertion you haven't seen fail is not yet a test.
Step 4: Surface 2 — the work the response claimed (10 min)
This is the step that catches the Tolvern bug, and the step most suites skip.
def test_create_shipment_books_the_carrier(db_session, rate_card_customer):
r = client.post("/shipments", json=VALID_SHIPMENT, auth=rate_card_customer.token)
shipment_id = r.json()["id"]
booking = db_session.query(CarrierBooking).filter_by(shipment_id=shipment_id).one_or_none()
assert booking is not None, "201 returned but no carrier booking was written"
assert booking.rate_card == "TOLV-2026-A"
If the effect is an outbound call rather than a row, assert on the stub instead — assert carrier_stub.book.call_count == 1 — and assert the arguments, not just that it happened.
Check: comment out the line in the handler that writes the booking. This test must fail and the Step 3 test must still pass. If both fail, you're asserting through the response again. If neither fails, you're querying the wrong session — a very common wiring problem where the test holds a different transaction from the app.
That last check is the whole point of the exercise. Run it once and you will know, for the rest of your career, which of your tests are load-bearing.
Step 5: Surface 3 — read it back (5 min)
def test_shipment_reads_back_consistently(rate_card_customer):
r = client.post("/shipments", json=VALID_SHIPMENT, auth=rate_card_customer.token)
got = client.get(f"/shipments/{r.json()['id']}", auth=rate_card_customer.token)
assert got.status_code == 200
for field in ("origin", "destination", "estimated_delivery", "service_level"):
assert got.json()[field] == r.json()[field], f"{field} differs between write and read"
The write response and the read response are produced by different code — often different serializers. When they drift, response-only tests stay green and clients break.
Check: run it. If it passes first time, list the fields you didn't include and ask why. If it fails, you have found a real defect in about ninety seconds — Tolvern's was estimated_delivery, a date on write and a full timestamp on read.
Step 6: The decision point — when disagreement is correct (10 min)
You will hit a field where write and read legitimately differ, and here two choices are both plausible.
The signal that decides it: is the difference a value or a state?
A value that the server computes — updated_at, an ETag, a sequence number. Exclude it by name. Never reach for a loose "ignore extra fields", which also swallows the field you meant to catch.
A state that legitimately advances — POST returns pending, the GET a moment later returns booked. Excluding it hides the transition. Assert the transition is legal instead:
assert got.json()["status"] in {"pending", "booked"}
Get this backwards — excluding a state, or polling on a computed value — and the test either passes forever or fails at random.
Check: for every field you exclude, say why out loud. Any field where the honest answer is "because the test failed and this made it pass" is a defect you have found and are now suppressing. Write it down instead.
Step 7: The negative paths worth having (5 min)
Not all of them. Three earn their place:
def test_rejects_invalid_destination(rate_card_customer):
r = client.post("/shipments", json={**VALID_SHIPMENT, "destination": ""},
auth=rate_card_customer.token)
assert r.status_code == 400
assert "destination" in r.json()["errors"] # shape, not just status
def test_requires_auth():
assert client.post("/shipments", json=VALID_SHIPMENT).status_code == 401
def test_other_customer_cannot_read(rate_card_customer, other_customer):
r = client.post("/shipments", json=VALID_SHIPMENT, auth=rate_card_customer.token)
got = client.get(f"/shipments/{r.json()['id']}", auth=other_customer.token)
assert got.status_code in (403, 404)
The third is the one teams forget, and it's the one that becomes a breach notification.
Check: each of these must fail for a different reason when you break the corresponding rule. If two of them fail together, they're testing the same thing twice.
Step 8: Decide on surface 4 — deliberately (5 min)
Concurrency tests are slow and are the largest single source of flaky suites. Buy one only where a duplicate costs money.
If it does, assert the invariant, never the sequence:
def test_duplicate_key_books_once(db_session, rate_card_customer):
results = run_in_parallel(
lambda: client.post("/shipments", json={**VALID_SHIPMENT, "idempotency_key": "K1"},
auth=rate_card_customer.token), n=10)
assert sum(r.status_code == 201 for r in results) == 1
assert db_session.query(CarrierBooking).filter_by(idempotency_key="K1").count() == 1
Check: ask whether the assertion would still hold if the OS scheduled the threads in reverse order. "Exactly one succeeded" survives that. "The second one got a 409" does not — and will fail on a busy CI day, which is how flakes are born.
The wrong test beside the right one
Both of these pass today. Only one of them will catch the next bug.
Response-only | Four-surface | |
Asserts | status_code == 201, body shape | ...plus the row, plus the read-back |
Catches | Wrong status, wrong shape, leaked fields | ...plus work the response claimed and never did |
Fails when you delete the write | No | Yes |
Cost | 6 lines | 12 lines |
Runtime | ~80 ms | ~110 ms |
Thirty milliseconds and six lines is the entire price of the difference.
You're done when
Commenting out the handler's write line makes exactly the Step 4 test fail.
Your read-back test names every field it excludes, and you can say why for each.
At least one test runs as a non-default caller.
No test in the file asserts on which concurrent request won.
Troubleshooting
Step 4 passes even with the write commented out. Your test session and your app session are different transactions. Bind both to the same connection, or commit before querying.
Read-back returns 404 immediately after a 201. Either the write isn't committed, or the resource is genuinely async — check the status field before assuming a bug.
The auth test passes but returns 500, not 401. Your app is erroring before it authenticates. Assert the status code explicitly; assert not r.ok would have hidden this.
Everything passes but the suite is slow. You're probably recreating the schema per test. That's a test data problem, not an API testing one.
Next
Two services agreeing with each other is a different problem from one service being honest — that's contract testing.


