Tutorial 7: Test an API End-to-End
- Contributor
- Apr 20
- 3 min read
API tests verify your service from the outside — real HTTP requests, real responses. This tutorial walks through testing one endpoint thoroughly. The pattern scales to the full API.
What You'll Build
Tests for a single endpoint (POST /api/users) covering happy path, validation errors, auth, and side effects.
Step 1: Pick the Endpoint (5 min)
POST /api/users — creates a new user. Common pattern, covers most concerns.
The test will verify:
Successful creation (201)
Email validation (400)
Auth required (401)
Duplicate email (409)
Database side effect
Step 2: Set Up the Test Client (10 min)
For Python with FastAPI:
from fastapi.testclient import TestClient
from yourapp import app
client = TestClient(app)
For Node with Express + supertest:
import request from 'supertest';
import app from '../src/app';
The client makes HTTP requests against your app in-process.
Step 3: Happy Path (10 min)
def test_create_user_returns_201_with_user_data(db_session, auth_token):
response = client.post(
"/api/users",
json={"name": "Sam Example", "email": "sam@example.com"},
headers={"Authorization": f"Bearer {auth_token}"}
)
assert response.status_code == 201
body = response.json()
assert body["id"]
assert body["name"] == "Sam Example"
assert body["email"] == "sam@example.com"
assert "password" not in body # Never leak secrets
Notice the negative assertion at the end. Useful for sensitive fields.
Step 4: Verify the Side Effect (5 min)
The endpoint should have persisted the user. Verify:
def test_create_user_persists_to_database(db_session, auth_token):
response = client.post(
"/api/users",
json={"name": "Sam", "email": "sam@example.com"},
headers={"Authorization": f"Bearer {auth_token}"}
)
user_id = response.json()["id"]
# Direct query to verify
user = db_session.query(User).filter_by(id=user_id).first()
assert user is not None
assert user.email == "sam@example.com"
The API said it created the user. The database confirms it.
Step 5: Validation Errors (10 min)
def test_create_user_with_invalid_email_returns_400(auth_token):
response = client.post(
"/api/users",
json={"name": "Sam", "email": "not-an-email"},
headers={"Authorization": f"Bearer {auth_token}"}
)
assert response.status_code == 400
assert "email" in response.json()["errors"]
def test_create_user_with_missing_name_returns_400(auth_token):
response = client.post(
"/api/users",
json={"email": "sam@example.com"},
headers={"Authorization": f"Bearer {auth_token}"}
)
assert response.status_code == 400
assert "name" in response.json()["errors"]
Each test exercises one validation rule.
Step 6: Auth (10 min)
def test_create_user_without_auth_returns_401():
response = client.post(
"/api/users",
json={"name": "Sam", "email": "sam@example.com"},
# No Authorization header
)
assert response.status_code == 401
def test_create_user_with_invalid_token_returns_401():
response = client.post(
"/api/users",
json={"name": "Sam", "email": "sam@example.com"},
headers={"Authorization": "Bearer invalid-token"}
)
assert response.status_code == 401
def test_create_user_with_non_admin_token_returns_403(
standard_user_token
):
response = client.post(
"/api/users",
json={"name": "Sam", "email": "sam@example.com"},
headers={"Authorization": f"Bearer {standard_user_token}"}
)
# Only admins can create users
assert response.status_code == 403
401 (no auth) and 403 (auth but no permission) are different. Both worth testing.
Step 7: Conflict (5 min)
def test_create_user_with_duplicate_email_returns_409(
db_session, auth_token
):
# Set up: existing user
existing = create_user(db_session, email="exists@example.com")
response = client.post(
"/api/users",
json={"name": "Second", "email": "exists@example.com"},
headers={"Authorization": f"Bearer {auth_token}"}
)
assert response.status_code == 409
assert "duplicate" in response.json()["errors"]["email"].lower()
Step 8: Response Schema (5 min)
Add a quick check that responses match expected shape:
def test_create_user_response_has_expected_fields(db_session, auth_token):
response = client.post(
"/api/users",
json={"name": "Sam", "email": "sam@example.com"},
headers={"Authorization": f"Bearer {auth_token}"}
)
body = response.json()
expected_fields = {"id", "name", "email", "created_at", "role"}
assert set(body.keys()) == expected_fields
Catches accidental additions or removals of response fields.
Step 9: Organize the Tests (5 min)
Group by endpoint:
tests/api/
users/
test_create.py # POST /api/users
test_get.py # GET /api/users/:id
test_update.py # PUT /api/users/:id
test_delete.py # DELETE /api/users/:id
test_list.py # GET /api/users
workspaces/
...
Easy to find tests for a specific endpoint when that endpoint changes.
Step 10: Run and Iterate (5 min)
pytest tests/api/users/test_create.py -v
The -v shows each test by name. Iterate until each passes.
What You Just Did
You wrote 8-10 tests for a single endpoint that cover happy path, validation, auth, conflict, and response shape. The pattern repeats for every endpoint.
A team that tests each endpoint this way catches an enormous range of bugs early. The investment per endpoint is 1-2 hours; the long-term value is preventing regressions for the life of the API.
Common Failure Modes
Only happy path. Most bugs live in error paths. Test 400/401/403/404/409 explicitly.
Mocking the database. Test against real DB; that's what's running in production.
Vague assertions. assert response.status_code != 500. Be specific.
Forgetting side effects. Test that the DB actually changed.
Tests order-dependent. Each test creates the state it needs.
Next Tutorial
Most bugs hide in edge cases. Test them deliberately: Tutorial 8: Test Error Paths and Edge Cases.
Related reading
Keep learning. This article is part of the Test Automation path in the ShiftQuality Learning Center. Build test automation that lasts, with ROI you can defend.


