KAHIBARO
Discord Login Register

19.4. API Testing

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:

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:

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:

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:

Examples:

Typical status code assertions in tests:

python
assert response.status_code == 201
assert response.status_code == 400
assert response.status_code == 404

Response bodies

You also verify the content of the response body:

Example expected JSON for a task:

json
{
  "id": 1,
  "title": "Learn API testing",
  "completed": false
}

Useful checks:

In code:

python
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:

Tests can assert header presence and values:

python
assert response.headers["Content-Type"].startswith("application/json")
assert "ETag" in response.headers

Side effects

Many API endpoints change state, for example:

API tests should verify that the expected state changes happen, usually by checking the database or a mock.

Example flow for POST /tasks:

  1. Count tasks in database
  2. Call the endpoint
  3. Assert count increased by 1
  4. 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:

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:

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:

Example cases for a title that must be between 1 and 100 characters:

Security‑related API tests

Security also relies on API behavior. Common test scenarios:

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:

Example of a consistent error format:

json
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Title must not be empty",
    "fields": {
      "title": "This field is required"
    }
  }
}

Tests can then assert specific fields:

python
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:

In tests you often:

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:

Manual vs Automated API Testing

Manual API testing with tools

Manual testing is useful when you:

Common tools:

ToolTypeUsage
curlCLIQuick one‑off HTTP calls
HTTPieCLIMore human‑friendly than curl
PostmanGUI / scriptingOrganized collections, environments, scripts
InsomniaGUILight, developer‑friendly HTTP client

Example curl request:

bash
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:

In Python, you will often use:

Example test file:

python
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 False

A Simple API Testing Workflow

To make things concrete, imagine a small FastAPI application with a tasks endpoint.

Example API endpoints

Assume the API exposes:

A simple task model:

json
{
  "id": 1,
  "title": "Example task",
  "completed": false
}

Setting up a test client

Using FastAPI’s TestClient:

python
# 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:

python
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:

  1. Sends a JSON body with a POST request
  2. Asserts that the server responds with 201 Created
  3. Checks that the JSON matches the contract

Testing validation errors

Test that missing title is rejected:

python
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 data

You can also test type errors:

python
def test_create_task_with_non_string_title_returns_422():
    response = client.post("/tasks", json={"title": 123})
    assert response.status_code == 422

Testing retrieval

Assume that creating a task stores it in memory or in a test database.

python
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 False

Testing not found cases

python
def test_get_nonexistent_task_returns_404():
    response = client.get("/tasks/999999")
    assert response.status_code == 404

Testing update and delete

Partial update:

python
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 True

Delete:

python
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 == 404

Testing Authenticated APIs

Many APIs require authentication, for example with JWT tokens or sessions.

Testing with tokens

Common pattern:

  1. Call a login or signup endpoint in the test
  2. Extract the token from the response
  3. Use the token in the Authorization header for the protected endpoints

Example:

python
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:

python
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:

python
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 == 403

Database and State in API Tests

Most nontrivial APIs use a database. You want tests to be:

Using a test database

Common strategies:

Typical flow with fixtures (conceptual example):

python
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:

python
def test_create_task_uses_test_database(client):
    response = client.post("/tasks", json={"title": "DB test"})
    assert response.status_code == 201

Resetting data between tests

You can reset the database between tests by:

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:

For example, you might:

  1. Load openapi.json from your running app
  2. Use a tool to generate test input combinations
  3. 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.py

Guidelines:

Naming tests clearly

Good test names describe the behavior:

Poor names:

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:

  1. Install dependencies
  2. Set environment variables for test configuration
  3. Start temporary services such as a test database (often via Docker)
  4. Run migrations or setup scripts
  5. Run pytest
  6. Clean up resources

If you use Docker, you can run:

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:

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:

Keep tests fast

Slow tests are often not run. To keep them fast:

Use realistic data

Fake data is fine, but it should be realistic:

Realistic data helps catch issues that only appear in real use.

Summary

API testing verifies your backend by calling its HTTP endpoints and checking:

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

Comments

Please login to add a comment.

Don't have an account? Register now!