top of page

API Testing: A Practical Walkthrough

  • Contributor
  • Apr 29
  • 5 min read

API tests verify your service from the outside — exercising HTTP endpoints with real requests and checking real responses. They sit between unit tests (your code in isolation) and end-to-end tests (full UI through the stack). They're often the highest-leverage test type: fast enough to run often, realistic enough to catch real bugs, focused enough to maintain.

This guide is the practical walkthrough for an API-centric service.

What to Test

For each endpoint:

  • Happy path. Valid request produces expected response.

  • Authentication. Unauthenticated requests are rejected; auth works correctly.

  • Authorization. Users see only what they should; permissions are enforced.

  • Validation. Bad inputs produce appropriate error responses.

  • Boundary conditions. Empty inputs, large inputs, edge values.

  • Error handling. Server errors produce appropriate responses, not stack traces.

  • Side effects. Database state, downstream calls, event emissions.

For each business workflow that spans endpoints:

  • End-to-end flow. Create resource, read it, update it, delete it.

  • State transitions. Operations in expected sequence work; operations out of sequence are rejected.

  • Concurrency. Two operations on the same resource handle correctly.

A Working Test Structure

def test_create_user_returns_201_with_user_data():
    response = client.post("/users", json={
        "name": "Sam",
        "email": "sam@example.com"
    })
    
    assert response.status_code == 201
    body = response.json()
    assert body["id"]
    assert body["name"] == "Sam"
    assert body["email"] == "sam@example.com"
    assert "password" not in body  # Never leak secrets

Note what the test does:

  • Makes the HTTP request directly (not through helper that hides details)

  • Verifies status code explicitly

  • Verifies expected fields

  • Verifies what shouldn't be in the response (negative assertion)

Test Organization

Organize by endpoint or resource:

tests/
  api/
    users/
      test_create.py
      test_read.py
      test_update.py
      test_delete.py
      test_list.py
    workspaces/
      ...

Each file holds tests for one endpoint. Tests are short and focused.

For workflows that span endpoints, a separate "workflows" or "scenarios" folder:

tests/
  api/
    scenarios/
      test_signup_flow.py
      test_workspace_creation_flow.py

Status Codes

Get the status codes right:

  • 200 OK: successful request, returning data

  • 201 Created: successful creation, often with Location header

  • 204 No Content: successful, nothing to return (e.g., DELETE)

  • 400 Bad Request: malformed request

  • 401 Unauthorized: auth missing or invalid

  • 403 Forbidden: auth valid but permission denied

  • 404 Not Found: resource doesn't exist (or user has no permission to know it exists)

  • 409 Conflict: request conflicts with current state

  • 422 Unprocessable Entity: request well-formed but semantically wrong

  • 429 Too Many Requests: rate limited

  • 500 Internal Server Error: something broke; not the client's fault

Tests should verify the specific status code, not just "not 200." assert response.status_code == 400 is better than assert response.status_code != 200.

Authentication in Tests

Most APIs require auth. Two approaches:

Real auth flow: test sets up a user, signs in, gets a token, uses it.

Bypass for tests: test framework provides pre-authenticated client.

The bypass is faster but tests less. A mix often works — use the bypass for most tests, run a few tests through the real auth flow to verify it.

Test Data and Fixtures

API tests typically need data — users, workspaces, resources to operate on.

Patterns:

@pytest.fixture
def test_user(client):
    return client.create_user(
        name="Test User",
        email=f"test+{uuid4()}@example.com"
    )

def test_user_can_view_own_profile(client, test_user):
    response = client.get(f"/users/{test_user.id}", auth=test_user.token)
    assert response.status_code == 200

Fixtures isolate setup. Tests focus on what they're verifying.

Contract Tests

A specialized form: contract tests verify the API's contract with consumers.

def test_create_user_response_matches_schema():
    response = client.post("/users", json=valid_user_data)
    
    schema = load_schema("user_create_response.json")
    assert matches_schema(response.json(), schema)

Useful when:

  • Multiple clients consume the API

  • API changes need backward-compatibility verification

  • Frontend and backend are separate teams

Tools like Pact formalize this; for simpler cases, JSON schema validation works.

Testing Error Responses

Error responses are often under-tested.

def test_create_user_with_invalid_email_returns_400():
    response = client.post("/users", json={
        "name": "Sam",
        "email": "not-an-email"
    })
    
    assert response.status_code == 400
    assert "email" in response.json()["errors"]

Test the error response structure, not just the status code. Clients depend on consistent error responses.

Idempotency Testing

For operations that should be idempotent (PUT, DELETE):

def test_delete_is_idempotent():
    response1 = client.delete(f"/users/{user_id}")
    response2 = client.delete(f"/users/{user_id}")
    
    assert response1.status_code == 204
    assert response2.status_code == 204  # Or 404, depending on design

Idempotency assumptions often break in production. Tests pin them down.

Concurrency Testing

API endpoints can be hit concurrently. Tests can verify behavior:

def test_concurrent_creates_dont_duplicate():
    import threading
    
    results = []
    def create():
        results.append(client.post("/users", json=user_data))
    
    threads = [threading.Thread(target=create) for _ in range(10)]
    for t in threads: t.start()
    for t in threads: t.join()
    
    # Verify only one succeeded; others got conflict
    successes = [r for r in results if r.status_code == 201]
    assert len(successes) == 1

Concurrency tests are slower and sometimes flaky, but catch real bugs.

Tools

Common options:

  • Code-based (recommended for sustained suites): pytest + requests (Python), Jest + supertest (JS), Postman with Newman, REST Assured (Java)

  • GUI tools (good for exploration, not for suites): Postman, Insomnia, Bruno

  • Specialized: Karate (Gherkin-flavored), Pact (contract testing)

For ongoing test suites, code-based wins on maintainability. GUI tools are great for exploration and one-off testing.

Speed

API tests should be fast. Targets:

  • Single test: 50-500ms

  • Full suite: under 5 minutes for medium-sized service

Speed strategies:

  • Run in parallel where possible

  • Use API shortcuts for setup (don't go through UI)

  • Avoid unnecessary database operations

  • Mock external services that don't need real verification

Common Mistakes

Testing through the UI when API is direct. Slower, more brittle, no benefit.

Vague assertions. "Response is successful" without checking what it returned.

Missing negative paths. Only testing happy paths; 400/401/403 untested.

Sharing state between tests. Order-dependent failures.

Hardcoded test data. Brittle as the system evolves.

Ignoring response headers. Some headers are part of the contract (Content-Type, Location, rate limits).

Coverage Targets

For an API:

  • 100% endpoint coverage (every endpoint has at least one test)

  • All major status codes tested per endpoint (200, plus relevant error codes)

  • Critical workflows have scenario tests

  • Authentication and authorization are verified, not assumed

100% isn't always achievable, but it's a reasonable target for an API surface.

Maintaining the Suite

As the API evolves:

  • Versioned endpoints get versioned tests

  • Deprecated endpoints get tests that verify the deprecation behavior, then deletion when removed

  • New endpoints get tests before merging the implementation

  • Breaking changes touch tests that verify the breaking behavior

A test suite that doesn't keep up with the API drifts into uselessness.

Key Takeaway

API tests are some of the highest-value tests for service-oriented codebases. Test each endpoint for happy path, auth, validation, errors, and side effects. Use code-based tests for maintainable suites; GUI tools for exploration. Verify specific status codes and response structure, not vague success. Test error paths and concurrency. Aim for 100% endpoint coverage. The combination of speed, realism, and maintainability makes API tests the workhorse layer of many test strategies.

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.

bottom of page