19.4. API Testing
Table of Contents
Why API Testing Matters
APIs are the core of backend applications. The frontend, mobile apps, third‑party services, and other backends all depend on your API to behave correctly and consistently.
API testing checks your endpoints directly, without going through a browser UI. This gives you:
- Faster feedback than full end‑to‑end UI tests
- More stable tests, since they skip fragile click and DOM interactions
- Confidence that your backend contract is correct and does not break clients
In practice, you will usually combine unit tests, integration tests, and API tests. In this chapter we focus on tests that call HTTP endpoints and verify their behavior.
What Is API Testing?
API testing is the practice of sending requests to your API endpoints and verifying:
- The HTTP status code
- The response body content and structure
- The response headers
- Side effects, such as database changes, emails queued, or jobs scheduled
You interact with your API the same way a real client would, with HTTP requests over URLs. The difference is that tests do it automatically and repeatedly, often in a CI pipeline.
Key idea: API tests treat your application as a black box accessed through HTTP. They do not care how the code is implemented, only that the contract (inputs, outputs, side effects) is correct.
You can run API tests:
- Locally, against your development server
- In memory, using a test client that calls the app without real network
- Against a deployed environment, like staging or a test instance in the cloud
Each approach has different trade‑offs, which we will explore through examples.
What Do We Test in an API?
Status codes
Every HTTP response has a status code that indicates success or failure. API tests should assert that:
- Successful operations return correct 2xx codes
- Invalid input returns 4xx codes
- Server errors are rare and handled in a controlled way
Examples:
GET /tasks/1returns200 OKif the task existsGET /tasks/9999returns404 Not FoundPOST /taskswith invalid JSON returns400 Bad RequestPOST /loginwith wrong credentials returns401 Unauthorized
Typical status code assertions in tests:
assert response.status_code == 201
assert response.status_code == 400
assert response.status_code == 404Response bodies
You also verify the content of the response body:
- Types and formats (string, integer, array, ISO date, etc.)
- Required fields are present
- Optional fields behave as expected (null, missing, default values)
- Error messages are clear enough for clients
Example expected JSON for a task:
{
"id": 1,
"title": "Learn API testing",
"completed": false
}Useful checks:
idis an integertitleis a non‑empty stringcompletedis a boolean
In code:
data = response.json()
assert isinstance(data["id"], int)
assert isinstance(data["title"], str)
assert data["title"] != ""
assert isinstance(data["completed"], bool)Headers
Some behaviors are expressed in headers, not the body, for example:
Content-Type: application/jsonLocationheader after creating a resource (e.g.POST /tasks)- Caching headers (e.g.
ETag,Cache-Control) - Authentication headers (e.g.
WWW-Authenticate)
Tests can assert header presence and values:
assert response.headers["Content-Type"].startswith("application/json")
assert "ETag" in response.headersSide effects
Many API endpoints change state, for example:
- Create or update database rows
- Send an email
- Publish a message to a queue
- Write to a log or cache
API tests should verify that the expected state changes happen, usually by checking the database or a mock.
Example flow for POST /tasks:
- Count tasks in database
- Call the endpoint
- Assert count increased by 1
- Assert the new task has the expected attributes
Types of API Tests
Different API tests focus on different levels and use cases.
Positive tests
Positive tests check that the API behaves correctly when given valid input.
Examples:
POST /taskswith a validtitlereturns201 Createdand a proper JSON bodyGET /tasksreturns a list of tasksPUT /tasks/1updates the task and returns the updated data
You usually write at least one positive test per endpoint and per important scenario.
Negative tests
Negative tests verify that the API rejects invalid inputs properly.
Examples:
POST /taskswith missingtitlereturns422 Unprocessable EntityPOST /taskswith title that is too long returns400 Bad RequestGET /tasks/abcreturns422 Unprocessable Entityor400 Bad RequestPOST /taskswithout authentication returns401 Unauthorized
You often write multiple negative tests for validation rules and authentication.
Boundary tests
Boundary tests check values at or near the limits of allowed ranges.
Examples:
- Minimum and maximum lengths of a string field
- Minimum and maximum numeric values
- Empty lists, single‑item lists, very large lists
- First and last page in pagination
Example cases for a title that must be between 1 and 100 characters:
- Length 0, expect error
- Length 1, expect success
- Length 100, expect success
- Length 101, expect error
Security‑related API tests
Security also relies on API behavior. Common test scenarios:
- Access a protected endpoint without tokens, expect
401 Unauthorized - Access with an invalid or expired token, expect
401or403 - Try to access another user’s resource, expect
403 Forbidden - Try SQL injection payloads in parameters, expect safe handling and no errors
- Try sending very large bodies to see if proper limits or errors are in place
These tests are an important part of regression safety for authentication and authorization.
Designing Testable APIs
You make API testing easier by designing your API with testing in mind.
Clear and consistent contracts
Tests are much easier to write when the API is consistent:
- Always return JSON, even for errors
- Always include an
errorfield ordetailfield for error messages - Use consistent naming and structures across endpoints
Example of a consistent error format:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Title must not be empty",
"fields": {
"title": "This field is required"
}
}
}Tests can then assert specific fields:
data = response.json()
assert data["error"]["code"] == "VALIDATION_ERROR"
assert "title" in data["error"]["fields"]Deterministic behavior
Tests break when your API behaves unpredictably. Try to avoid:
- Returning random data that is not controlled in tests
- Depending on external services that may be down or slow
- Using the real current time without a way to control it in tests
In tests you often:
- Use a fixed seed for randomness
- Inject clocks or time providers
- Mock external HTTP calls
- Use a test database with known data
Idempotency and safe tests
Tests might run multiple times, especially in CI pipelines. Endpoints that create or delete data can cause conflicts if they are not handled carefully.
Patterns that help:
- Use unique values in tests, for example by adding a timestamp or random suffix to titles or emails
- Clean up created data in teardown or fixtures
- Prefer PUT or PATCH to update existing test data instead of creating many new records
Manual vs Automated API Testing
Manual API testing with tools
Manual testing is useful when you:
- Explore a new API
- Debug a tricky bug
- Build first endpoints before writing tests
Common tools:
| Tool | Type | Usage |
|---|---|---|
| curl | CLI | Quick one‑off HTTP calls |
| HTTPie | CLI | More human‑friendly than curl |
| Postman | GUI / scripting | Organized collections, environments, scripts |
| Insomnia | GUI | Light, developer‑friendly HTTP client |
Example curl request:
curl -X POST http://localhost:8000/tasks \
-H "Content-Type: application/json" \
-d '{"title": "Write API tests"}'Manual tools are excellent for understanding and trying things. However, they do not scale as a long‑term testing strategy.
Automated API tests
Automated tests are code that runs your HTTP calls and assertions:
- You can run them locally with one command
- Your CI pipeline can run them on every push
- You can keep them in version control with the code
In Python, you will often use:
pytestfor test discovery and running- FastAPI’s
TestClientorhttpxfor requests to your app - Fixtures for test setup and teardown
Example test file:
from fastapi.testclient import TestClient
from myapp.main import app
client = TestClient(app)
def test_create_task():
response = client.post("/tasks", json={"title": "Test task"})
assert response.status_code == 201
data = response.json()
assert data["title"] == "Test task"
assert data["completed"] is FalseA Simple API Testing Workflow
To make things concrete, imagine a small FastAPI application with a tasks endpoint.
Example API endpoints
Assume the API exposes:
GET /taskslist tasksPOST /taskscreate a taskGET /tasks/{id}get a single taskPATCH /tasks/{id}update a taskDELETE /tasks/{id}delete a task
A simple task model:
{
"id": 1,
"title": "Example task",
"completed": false
}Setting up a test client
Using FastAPI’s TestClient:
# tests/test_tasks_api.py
from fastapi.testclient import TestClient
from myapp.main import app
client = TestClient(app)
client behaves like an HTTP client, but it talks to the app in the same process. No network or external server is needed.
Writing your first API test
Test that creating a task works:
def test_create_task_returns_201_and_task_body():
payload = {"title": "Learn API testing"}
response = client.post("/tasks", json=payload)
assert response.status_code == 201
data = response.json()
assert data["title"] == payload["title"]
assert data["completed"] is False
assert isinstance(data["id"], int)What this does:
- Sends a JSON body with a POST request
- Asserts that the server responds with
201 Created - Checks that the JSON matches the contract
Testing validation errors
Test that missing title is rejected:
def test_create_task_without_title_returns_422():
response = client.post("/tasks", json={})
assert response.status_code == 422
data = response.json()
# Shape depends on your validation, adapt as needed
assert "detail" in dataYou can also test type errors:
def test_create_task_with_non_string_title_returns_422():
response = client.post("/tasks", json={"title": 123})
assert response.status_code == 422Testing retrieval
Assume that creating a task stores it in memory or in a test database.
def test_get_task_by_id_returns_correct_task():
create_response = client.post("/tasks", json={"title": "Persistent task"})
task = create_response.json()
task_id = task["id"]
get_response = client.get(f"/tasks/{task_id}")
assert get_response.status_code == 200
fetched = get_response.json()
assert fetched["id"] == task_id
assert fetched["title"] == "Persistent task"
assert fetched["completed"] is FalseTesting not found cases
def test_get_nonexistent_task_returns_404():
response = client.get("/tasks/999999")
assert response.status_code == 404Testing update and delete
Partial update:
def test_update_task_completion_status():
create_response = client.post("/tasks", json={"title": "To be completed"})
task_id = create_response.json()["id"]
update_response = client.patch(f"/tasks/{task_id}", json={"completed": True})
assert update_response.status_code == 200
updated = update_response.json()
assert updated["completed"] is True
get_response = client.get(f"/tasks/{task_id}")
assert get_response.json()["completed"] is TrueDelete:
def test_delete_task_removes_it():
create_response = client.post("/tasks", json={"title": "To be deleted"})
task_id = create_response.json()["id"]
delete_response = client.delete(f"/tasks/{task_id}")
assert delete_response.status_code == 204
get_response = client.get(f"/tasks/{task_id}")
assert get_response.status_code == 404Testing Authenticated APIs
Many APIs require authentication, for example with JWT tokens or sessions.
Testing with tokens
Common pattern:
- Call a login or signup endpoint in the test
- Extract the token from the response
- Use the token in the
Authorizationheader for the protected endpoints
Example:
def authenticate():
response = client.post(
"/login",
data={"username": "alice@example.com", "password": "password123"},
)
assert response.status_code == 200
token = response.json()["access_token"]
return {"Authorization": f"Bearer {token}"}Use in tests:
def test_get_current_user_profile_requires_authentication():
response = client.get("/me")
assert response.status_code == 401
def test_get_current_user_profile_with_token():
headers = authenticate()
response = client.get("/me", headers=headers)
assert response.status_code == 200
data = response.json()
assert data["email"] == "alice@example.com"Testing role based access
If you use roles like admin or user, your tests should verify permissions:
def test_non_admin_cannot_delete_other_users():
user_headers = authenticate_user() # returns normal user token
response = client.delete("/users/123", headers=user_headers)
assert response.status_code == 403Database and State in API Tests
Most nontrivial APIs use a database. You want tests to be:
- Isolated from each other
- Reproducible
- Not touching your production data
Using a test database
Common strategies:
- Use an in‑memory database where possible
- Use a separate test database with a different URL
- Run migrations only once before the test suite
- Wrap each test in a transaction and roll back afterward
Typical flow with fixtures (conceptual example):
import pytest
from fastapi.testclient import TestClient
from myapp.main import app
from myapp.database import get_session, create_test_session
@pytest.fixture
def client():
# Create a fresh test DB session
test_session = create_test_session()
# Override the dependency that provides DB session in FastAPI
def override_get_session():
return test_session
app.dependency_overrides[get_session] = override_get_session
with TestClient(app) as c:
yield c
# Clean up if needed
test_session.close()A test then uses this fixture:
def test_create_task_uses_test_database(client):
response = client.post("/tasks", json={"title": "DB test"})
assert response.status_code == 201Resetting data between tests
You can reset the database between tests by:
- Dropping and recreating tables
- Truncating all tables
- Using transactions with rollback after each test
The exact method depends on your ORM and test setup, which is covered in database testing topics. For API testing, the important part is that each test starts with a known state.
Contract and Schema‑Based Testing
APIs often have a formal description, such as an OpenAPI schema.
Validating against OpenAPI
If your application exposes an OpenAPI document, some tools can:
- Validate that responses match the schema
- Generate test cases from the schema
- Detect missing or incorrect fields
For example, you might:
- Load
openapi.jsonfrom your running app - Use a tool to generate test input combinations
- Run them and ensure the API responds according to spec
This is more advanced, but useful when your API is consumed by many clients and strict contracts are important.
Consumer‑driven contract tests
If you have multiple services, each consumer can define its expectations, usually in JSON or YAML. Tools can then verify that the provider API satisfies those expectations.
This helps avoid breaking changes in microservice environments, but it is beyond the scope of basic API testing. Just remember that your regular endpoint tests are a simpler form of contract testing.
Organizing API Tests
As your API grows, so does your test suite. Good organization makes it maintainable.
Directory structure
A common structure:
project/
app/
main.py
routes/
tasks.py
users.py
tests/
__init__.py
test_tasks_api.py
test_users_api.py
test_auth_api.py
conftest.pyGuidelines:
- Use one test file per resource or feature area
- Name test functions descriptively, for example
test_create_task_returns_201 - Group related assertions in the same test when they belong to the same scenario
Naming tests clearly
Good test names describe the behavior:
test_create_task_with_valid_data_returns_201test_create_task_without_title_returns_422test_get_task_with_invalid_id_returns_422
Poor names:
test_task_1test_posttest_error
Clear names help you quickly see what is broken when a test fails.
API Testing in CI Pipelines
Once you have automated API tests, run them on every push.
Typical CI workflow:
- Install dependencies
- Set environment variables for test configuration
- Start temporary services such as a test database (often via Docker)
- Run migrations or setup scripts
- Run
pytest - Clean up resources
If you use Docker, you can run:
- Application under test in one container
- Database in another
- Tests either inside the app container or a separate test container
This integrates API testing into your normal development lifecycle, so regressions are caught early.
Practical Tips and Common Pitfalls
Test the happy path and critical errors first
Start by writing tests for:
- The most used endpoints
- The main success scenarios (happy paths)
- The most dangerous failure scenarios (e.g. permissions, data corruption)
You can add more edge cases over time.
Do not over‑test third‑party APIs
If you call external APIs, avoid hitting them in every test:
- Use mocks or stubs where possible
- Write a few integration tests that hit a sandbox environment or a fake server
- Focus your main API tests on your own logic and contracts
Keep tests fast
Slow tests are often not run. To keep them fast:
- Avoid real network calls when possible
- Use in‑memory or lightweight test databases
- Reuse expensive setup using fixtures, not in every test
Use realistic data
Fake data is fine, but it should be realistic:
- Valid email formats, URLs, and names
- Typical text lengths
- Edge values where you expect specific behavior
Realistic data helps catch issues that only appear in real use.
Summary
API testing verifies your backend by calling its HTTP endpoints and checking:
- Status codes
- Response bodies and headers
- Side effects such as database changes
- Authentication and authorization behavior
With tools like pytest and a test client, you can write fast, automated API tests that run locally and in CI. By designing testable APIs, isolating your test environment, and organizing tests by feature, you build a reliable safety net that lets you change and extend your backend with confidence.
Views: 7
KAHIBARO