CtrlK
BlogDocsLog inGet started
Tessl Logo

tessl-labs/pytest-api-testing

Pytest patterns for Python APIs -- httpx AsyncClient, conftest fixtures, database isolation, parametrize edge cases, error response testing, auth flows, factory fixtures

99

1.23x
Quality

99%

Does it follow best practices?

Impact

100%

1.23x

Average score across 5 eval scenarios

SecuritybySnyk

Passed

No known issues

Overview
Quality
Evals
Security
Files
name:
pytest-api-testing
description:
Pytest patterns for testing Python APIs (FastAPI, Flask). Covers httpx AsyncClient setup, conftest.py fixture organization, fixture scoping, parametrize for edge cases, factory fixtures, database isolation, testing error responses and auth flows. Triggers when writing tests for any Python web API, adding test coverage, or building a new API that needs tests.
keywords:
pytest, fastapi testing, flask testing, httpx, test client, api testing, conftest, fixtures, parametrize, factory fixture, monkeypatch, auth testing, database fixture, fixture scope, error response testing, schema assertion
license:
MIT

Pytest API Testing

Patterns that catch real bugs in FastAPI and Flask APIs.


1. Test Client Setup -- FastAPI

Use httpx.AsyncClient with ASGITransport. Never use requests or urllib.

# tests/conftest.py
import pytest
from httpx import AsyncClient, ASGITransport
from app.main import app

@pytest.fixture
async def client():
    transport = ASGITransport(app=app)
    async with AsyncClient(transport=transport, base_url="http://test") as ac:
        yield ac
# pyproject.toml
[tool.pytest.ini_options]
asyncio_mode = "auto"

Required packages:

pip install pytest httpx pytest-asyncio

Flask

# tests/conftest.py
import pytest
from app import create_app

@pytest.fixture
def client():
    app = create_app(testing=True)
    with app.test_client() as c:
        yield c

2. conftest.py Fixture Organization

Put fixtures in tests/conftest.py -- pytest discovers them automatically. Never import fixtures manually.

Fixture hierarchy for larger projects:

tests/
  conftest.py          # client, db reset, auth helpers
  test_users.py
  test_orders.py
  orders/
    conftest.py        # order-specific factories
    test_create.py
    test_status.py

Fixtures in a nested conftest.py are available only to tests in that directory and below. Put shared fixtures (client, db, auth) in the root tests/conftest.py.


3. Fixture Scope -- function vs session

Default to function scope. Each test gets a fresh fixture. This prevents test pollution.

@pytest.fixture  # scope="function" is the default
async def client():
    ...

Use session scope only for expensive, read-only setup like creating a test database schema:

@pytest.fixture(scope="session")
def db_engine():
    engine = create_engine("sqlite:///:memory:")
    Base.metadata.create_all(engine)
    yield engine
    engine.dispose()

@pytest.fixture(autouse=True)
def clean_tables(db_engine):
    """Truncate all tables before each test -- function-scoped."""
    with db_engine.connect() as conn:
        for table in reversed(Base.metadata.sorted_tables):
            conn.execute(table.delete())
        conn.commit()
    yield

Never use session-scoped fixtures that hold mutable state. Tests will leak into each other.


4. Database Fixtures with Cleanup

Always reset database state before each test, not after. Tests that fail mid-way still leave a clean slate for the next test.

# tests/conftest.py
@pytest.fixture(autouse=True)
def clean_db(db_session):
    """Reset DB before each test. autouse=True means every test gets this."""
    db_session.execute(text("DELETE FROM order_items"))
    db_session.execute(text("DELETE FROM orders"))
    db_session.execute(text("DELETE FROM users"))
    db_session.commit()
    yield

For SQLAlchemy with FastAPI dependency override:

@pytest.fixture
def db_session(db_engine):
    connection = db_engine.connect()
    transaction = connection.begin()
    session = Session(bind=connection)
    yield session
    session.close()
    transaction.rollback()
    connection.close()

@pytest.fixture
async def client(db_session):
    def override_get_db():
        yield db_session

    app.dependency_overrides[get_db] = override_get_db
    transport = ASGITransport(app=app)
    async with AsyncClient(transport=transport, base_url="http://test") as ac:
        yield ac
    app.dependency_overrides.clear()

5. Factory Fixtures

When multiple tests need different variations of test data, use factory fixtures instead of static fixtures. A factory fixture returns a callable that creates data on demand.

# tests/conftest.py
@pytest.fixture
def create_user(client):
    """Factory fixture -- call it to create a user with custom fields."""
    created_ids = []

    async def _create_user(**overrides):
        defaults = {
            "email": f"user{len(created_ids)}@test.com",
            "name": "Test User",
            "password": "securepass123",
        }
        defaults.update(overrides)
        res = await client.post("/api/users", json=defaults)
        assert res.status_code == 201, f"User creation failed: {res.text}"
        user = res.json()["data"]
        created_ids.append(user["id"])
        return user

    return _create_user

Usage in tests:

async def test_users_have_unique_emails(client, create_user):
    await create_user(email="alice@test.com")
    res = await client.post("/api/users", json={
        "email": "alice@test.com", "name": "Duplicate", "password": "pass123"
    })
    assert res.status_code == 409

6. parametrize for Edge Cases

Use @pytest.mark.parametrize to test multiple inputs without duplicating test functions. Especially valuable for validation and error paths.

@pytest.mark.parametrize("payload,expected_status", [
    ({}, 422),                                          # empty body
    ({"email": "not-an-email"}, 422),                   # invalid email
    ({"email": "a@b.com"}, 422),                        # missing required fields
    ({"email": "a@b.com", "name": "", "password": "x"}, 422),  # blank name
    ({"email": "a@b.com", "name": "A", "password": "short"}, 422),  # weak password
])
async def test_create_user_validation(client, payload, expected_status):
    res = await client.post("/api/users", json=payload)
    assert res.status_code == expected_status
    body = res.json()
    assert "detail" in body or "error" in body

Also use parametrize for testing multiple invalid status transitions, permission levels, or filter combinations.


7. Testing Error Responses -- Not Just Happy Path

Every endpoint needs error tests. Common mistake: testing only 200 responses.

Test these error cases for every resource endpoint:

class TestUserErrors:
    async def test_get_nonexistent_returns_404(self, client):
        res = await client.get("/api/users/99999")
        assert res.status_code == 404
        # Verify error response shape, not just status code
        body = res.json()
        assert "detail" in body or "error" in body

    async def test_create_with_invalid_json_returns_422(self, client):
        res = await client.post("/api/users", content="not json",
                                headers={"Content-Type": "application/json"})
        assert res.status_code == 422

    async def test_create_duplicate_returns_409(self, client, create_user):
        await create_user(email="taken@test.com")
        res = await client.post("/api/users", json={
            "email": "taken@test.com", "name": "Dup", "password": "pass123"
        })
        assert res.status_code == 409

    async def test_delete_nonexistent_returns_404(self, client):
        res = await client.delete("/api/users/99999")
        assert res.status_code == 404

Assert the response body shape, not just status codes. A 404 that returns HTML instead of JSON will break your frontend.


8. Testing Authentication Flows

Test protected endpoints both with and without valid credentials.

# tests/conftest.py
@pytest.fixture
async def auth_headers(client, create_user):
    """Get auth headers for a logged-in test user."""
    user = await create_user(email="auth@test.com", password="testpass123")
    res = await client.post("/api/auth/login", json={
        "email": "auth@test.com", "password": "testpass123"
    })
    token = res.json()["access_token"]
    return {"Authorization": f"Bearer {token}"}


class TestProtectedEndpoints:
    async def test_requires_auth(self, client):
        """Endpoint without token returns 401."""
        res = await client.get("/api/users/me")
        assert res.status_code == 401

    async def test_rejects_invalid_token(self, client):
        res = await client.get("/api/users/me",
                               headers={"Authorization": "Bearer fake.token.here"})
        assert res.status_code == 401

    async def test_returns_user_with_valid_token(self, client, auth_headers):
        res = await client.get("/api/users/me", headers=auth_headers)
        assert res.status_code == 200
        assert res.json()["data"]["email"] == "auth@test.com"

9. monkeypatch vs mock

Use monkeypatch (built into pytest) for replacing environment variables, attributes, and dictionary items. It automatically restores originals after the test.

def test_uses_test_database(monkeypatch):
    monkeypatch.setenv("DATABASE_URL", "sqlite:///:memory:")
    # app code that reads os.getenv("DATABASE_URL") now sees the test value

async def test_email_service_failure(client, monkeypatch, create_user):
    """Simulate external service failure."""
    def fake_send_email(*args, **kwargs):
        raise ConnectionError("SMTP down")

    monkeypatch.setattr("app.services.email.send_email", fake_send_email)
    user = await create_user()
    res = await client.post(f"/api/users/{user['id']}/verify")
    assert res.status_code == 503  # graceful failure, not 500

Use unittest.mock only when you need call-count assertions or complex side_effect sequences. Prefer monkeypatch for simple replacements.


10. Asserting Response Schema Shape

Do not just check res.status_code == 200. Verify the response body has the expected structure.

async def test_list_users_response_shape(client, create_user):
    await create_user()
    res = await client.get("/api/users")

    assert res.status_code == 200
    body = res.json()

    # Verify top-level structure
    assert "data" in body
    assert isinstance(body["data"], list)
    assert len(body["data"]) > 0

    # Verify each item has expected fields
    user = body["data"][0]
    assert "id" in user
    assert "email" in user
    assert "name" in user
    # Verify sensitive fields are NOT exposed
    assert "password" not in user
    assert "password_hash" not in user

Common Mistakes

MistakeFix
Using requests to test FastAPIUse httpx.AsyncClient with ASGITransport
scope="session" on a client fixtureKeep client fixture function-scoped
No asyncio_mode = "auto" in configAdd to pyproject.toml or pytest.ini
Cleaning up after tests instead of beforeUse autouse=True fixture that resets before yield
Only testing happy pathsTest 404, 422, 409, 401 for every resource
Asserting only status codesAlso assert response body shape and content
Importing fixtures from conftestNever import -- pytest discovers them automatically
Mocking the databaseHit the real database, use :memory: SQLite or transactions
Hardcoded test data across testsUse factory fixtures
Testing framework internalsTest your routes, not Pydantic/FastAPI behavior

Verifiers

  • pytest-tests-created -- Create API tests with pytest and httpx
  • test-fastapi-user-service -- Write tests for a FastAPI user CRUD service
  • test-fastapi-auth-endpoints -- Write tests for authentication endpoints
  • test-order-api-edge-cases -- Write tests covering edge cases for an order API
  • test-fastapi-with-database -- Write tests for a FastAPI app with database fixtures
  • test-flask-api -- Write tests for a Flask REST API
Workspace
tessl-labs
Visibility
Public
Created
Last updated
Publish Source
CLI
Badge
tessl-labs/pytest-api-testing badge