API Testing in Practice: Build a Reliable Test Suite with Pytest and HTTPX

API Testing in Practice: Build a Reliable Test Suite with Pytest and HTTPX

API testing is one of the fastest ways to catch bugs before they reach users. Unlike UI tests, API tests are usually quick, stable, and easy to run in CI. For junior and mid-level developers, the goal is not to test every possible line of code. The goal is to verify that your API behaves correctly when clients send valid requests, invalid data, missing authentication, and edge-case inputs.

In this hands-on guide, we will build a practical API test suite using pytest and httpx. The examples assume a simple JSON API for managing tasks, but the same structure works for FastAPI, Laravel, Express, Django, Rails, or any HTTP-based backend.

What We Are Testing

Imagine your API exposes these endpoints:

  • GET /api/tasks — list tasks
  • POST /api/tasks — create a task
  • GET /api/tasks/{id} — fetch one task
  • PATCH /api/tasks/{id} — update a task
  • DELETE /api/tasks/{id} — delete a task

A task object looks like this:

{ "id": 1, "title": "Write API tests", "completed": false }

Good API tests should verify more than status codes. They should also check response shape, validation errors, authentication behavior, and whether data changes correctly after a request.

Project Setup

Install the testing tools:

pip install pytest httpx

Create a simple test structure:

project/ ├── tests/ │ ├── conftest.py │ └── test_tasks_api.py └── pytest.ini

Add this basic pytest.ini file:

[pytest] testpaths = tests python_files = test_*.py addopts = -v

The testpaths setting tells pytest where to look for tests. The -v option gives more readable output when tests run.

Create a Reusable API Client

Do not repeat your base URL, headers, and authentication setup in every test. Put them in conftest.py using pytest fixtures.

# tests/conftest.py import os import pytest import httpx API_BASE_URL = os.getenv("API_BASE_URL", "http://localhost:8000") @pytest.fixture def api_client(): with httpx.Client(base_url=API_BASE_URL, timeout=10.0) as client: yield client @pytest.fixture def auth_headers(): token = os.getenv("API_TOKEN", "dev-test-token") return { "Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json", }

This gives every test access to api_client and auth_headers. In local development, the tests point to http://localhost:8000. In CI, you can set API_BASE_URL to another environment.

Test a Successful GET Request

Start with a simple read test. This verifies that the endpoint responds with JSON and returns a list.

# tests/test_tasks_api.py def test_list_tasks_returns_json_array(api_client, auth_headers): response = api_client.get("/api/tasks", headers=auth_headers) assert response.status_code == 200 assert response.headers["content-type"].startswith("application/json") data = response.json() assert isinstance(data, list) if data: first_task = data[0] assert "id" in first_task assert "title" in first_task assert "completed" in first_task

This test is intentionally flexible. It does not require the database to contain a specific number of tasks. That makes it less brittle. It only verifies the response contract: the endpoint returns JSON, the JSON is a list, and task objects have expected fields.

Test Creating a Resource

Next, test POST /api/tasks. A useful creation test should verify the status code, response body, and returned data.

def test_create_task(api_client, auth_headers): payload = { "title": "Review pull request", "completed": False, } response = api_client.post( "/api/tasks", headers=auth_headers, json=payload, ) assert response.status_code == 201 data = response.json() assert isinstance(data["id"], int) assert data["title"] == payload["title"] assert data["completed"] is False

Notice the use of json=payload. With httpx, this automatically serializes the payload as JSON and sends the correct request body.

Use a Fixture to Create Test Data

Many tests need an existing task. Instead of duplicating the same POST request, create a fixture.

# tests/conftest.py @pytest.fixture def created_task(api_client, auth_headers): payload = { "title": "Temporary test task", "completed": False, } response = api_client.post( "/api/tasks", headers=auth_headers, json=payload, ) assert response.status_code == 201 task = response.json() yield task # Cleanup after the test api_client.delete(f"/api/tasks/{task['id']}", headers=auth_headers)

The code before yield prepares test data. The code after yield cleans it up. This keeps your test environment tidy and reduces the chance that one test affects another.

Now you can write tests that depend on a real task:

def test_get_single_task(api_client, auth_headers, created_task): task_id = created_task["id"] response = api_client.get( f"/api/tasks/{task_id}", headers=auth_headers, ) assert response.status_code == 200 data = response.json() assert data["id"] == task_id assert data["title"] == created_task["title"] assert data["completed"] == created_task["completed"]

Test Updating a Resource

Update tests should verify that a changed field is actually persisted.

def test_update_task_completion(api_client, auth_headers, created_task): task_id = created_task["id"] response = api_client.patch( f"/api/tasks/{task_id}", headers=auth_headers, json={"completed": True}, ) assert response.status_code == 200 data = response.json() assert data["id"] == task_id assert data["completed"] is True verify_response = api_client.get( f"/api/tasks/{task_id}", headers=auth_headers, ) assert verify_response.status_code == 200 assert verify_response.json()["completed"] is True

The second GET request is important. It confirms that the update was stored, not just echoed back in the immediate response.

Test Validation Errors

Happy-path tests are not enough. APIs must reject bad input clearly. For example, a task should not be created without a title.

def test_create_task_requires_title(api_client, auth_headers): payload = { "completed": False, } response = api_client.post( "/api/tasks", headers=auth_headers, json=payload, ) assert response.status_code in [400, 422] data = response.json() assert "title" in str(data).lower()

Some frameworks return 400 Bad Request; others return 422 Unprocessable Entity. If your team has a strict API standard, assert only the exact status code your backend should return.

You can also test invalid field types:

def test_create_task_rejects_invalid_completed_value(api_client, auth_headers): payload = { "title": "Invalid task", "completed": "not-a-boolean", } response = api_client.post( "/api/tasks", headers=auth_headers, json=payload, ) assert response.status_code in [400, 422]

Test Authentication Behavior

If an endpoint requires authentication, test what happens when the token is missing or wrong.

def test_list_tasks_requires_authentication(api_client): response = api_client.get("/api/tasks") assert response.status_code in [401, 403]

A 401 usually means “not authenticated.” A 403 usually means “authenticated but not allowed.” Your API should use these consistently. Tests help enforce that contract.

You can also test a bad token:

def test_list_tasks_rejects_invalid_token(api_client): headers = { "Authorization": "Bearer invalid-token", "Accept": "application/json", } response = api_client.get("/api/tasks", headers=headers) assert response.status_code in [401, 403]

Test Deleting a Resource

A delete test should verify both the delete response and the follow-up state.

def test_delete_task(api_client, auth_headers, created_task): task_id = created_task["id"] delete_response = api_client.delete( f"/api/tasks/{task_id}", headers=auth_headers, ) assert delete_response.status_code in [200, 204] get_response = api_client.get( f"/api/tasks/{task_id}", headers=auth_headers, ) assert get_response.status_code == 404

Because the created_task fixture also tries to clean up after the test, this may cause a second delete call during teardown. That is usually fine if your API returns 404 for already-deleted resources. For stricter cleanup, you can create a separate fixture for delete-specific tests.

Make Tests Easier to Debug

When an API test fails, the raw response body is often the most useful clue. Add helper functions for clearer assertions.

def assert_status(response, expected_status): assert response.status_code == expected_status, ( f"Expected {expected_status}, got {response.status_code}. " f"Response body: {response.text}" )

Now use it like this:

def test_create_task_with_helper(api_client, auth_headers): response = api_client.post( "/api/tasks", headers=auth_headers, json={"title": "Use better assertions", "completed": False}, ) assert_status(response, 201) data = response.json() assert data["title"] == "Use better assertions"

This small helper can save a lot of time, especially in CI logs where you cannot inspect the response interactively.

Run Tests Locally and in CI

Run the suite locally with:

pytest

To point tests at a different API environment:

API_BASE_URL="https://staging.example.com" API_TOKEN="your-token" pytest

In CI, keep secrets such as API_TOKEN in your platform’s encrypted secret storage. Do not commit real tokens to the repository.

Practical API Testing Checklist

  • Test successful requests and common failure cases.
  • Check response bodies, not only status codes.
  • Use fixtures for setup and cleanup.
  • Keep tests independent from one another.
  • Use environment variables for base URLs and tokens.
  • Print response bodies when assertions fail.
  • Run API tests before merging code into the main branch.

Conclusion

A useful API test suite does not need to be complicated. Start with the endpoints your frontend or external clients depend on most. Test the happy path, then add validation, authentication, update, and delete scenarios. With pytest and httpx, you can build readable tests that run quickly and give developers confidence before every deployment.

The best habit is consistency: every new endpoint should come with at least one success test and one failure test. Over time, this turns your API documentation from a promise into something your build pipeline verifies automatically.


Leave a Reply

Your email address will not be published. Required fields are marked *