Skip to main content

Python for PHP Developers, Part 4: Testing FastAPI with pytest

Updated 4 min readMarkdown
Python for PHP Developers, Part 4: Testing FastAPI with pytest

This is Part 4 of a series. Catch up on Part 1: A Practical Crash Course, Part 2: FastAPI with a Real Database, and Part 3: JWT Authentication.

In Parts 2 and 3, we built an API with a database and JWT login. It works when you click around in /docs, but that doesn't scale. This time, we write automated tests, including tests for the protected routes. A reader asked about exactly this after Part 2, so thank you for the nudge.

1. Install what you need

pip install pytest httpx

pytest is the test runner (think PHPUnit), and FastAPI's TestClient uses httpx under the hood to send requests to your app without starting a server.

2. PHPUnit to pytest: the quick mapping

PHP / Laravel Python / pytest
setUp() fixtures
$this->assertEquals(a, b) assert a == b
RefreshDatabase a fixture that creates and drops tables
actingAs($user) a fixture that returns an auth header
tests/FooTest.php tests/test_foo.py

Tests are plain functions named test_..., and assertions are plain assert statements. No test class is required.

3. A test database via dependency override

In Part 2, every route got its database session from Depends(get_db). That pays off now: in tests, we swap get_db for a version that points at an in-memory SQLite database. No route code changes.

# tests/conftest.py
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool

from main import app, get_db
from database import Base

engine = create_engine(
    "sqlite://",                                # in-memory database
    connect_args={"check_same_thread": False},
    poolclass=StaticPool,                       # keeps one shared connection
)
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)

@pytest.fixture()
def client():
    Base.metadata.create_all(bind=engine)

    def override_get_db():
        db = TestingSessionLocal()
        try:
            yield db
        finally:
            db.close()

    app.dependency_overrides[get_db] = override_get_db
    yield TestClient(app)

    app.dependency_overrides.clear()
    Base.metadata.drop_all(bind=engine)

Everything before yield is setup, and everything after is teardown. Each test starts with empty tables and leaves nothing behind.

4. Testing public routes

# tests/test_items.py
def test_list_items_empty(client):
    response = client.get("/items")
    assert response.status_code == 200
    assert response.json() == []

Notice that client is just a function argument. pytest sees the name, finds the fixture, and passes it in.

5. Testing protected routes

Add a second fixture that registers a user, logs in, and returns the header:

# tests/conftest.py (continued)
@pytest.fixture()
def auth_headers(client):
    client.post("/register", params={"username": "himani", "password": "secret123"})
    response = client.post(
        "/login", data={"username": "himani", "password": "secret123"}
    )
    token = response.json()["access_token"]
    return {"Authorization": f"Bearer {token}"}

Now test both sides of the protection:

# tests/test_items.py (continued)
def test_create_item_requires_login(client):
    response = client.post("/items", json={"name": "Pen", "price": 1.5})
    assert response.status_code == 401

def test_create_item_with_token(client, auth_headers):
    response = client.post(
        "/items", json={"name": "Pen", "price": 1.5}, headers=auth_headers
    )
    assert response.status_code == 200
    assert response.json()["name"] == "Pen"

def test_login_wrong_password(client):
    client.post("/register", params={"username": "himani", "password": "secret123"})
    response = client.post(
        "/login", data={"username": "himani", "password": "wrong"}
    )
    assert response.status_code == 401

Always test the failure case too. A protected route that works with a token is only half the story. You also need proof it rejects requests without one.

6. Run the tests

pytest -v

7. Gotchas for PHP developers

  • In-memory SQLite needs StaticPool. Without it, each new connection gets its own empty database, and your tables seem to vanish between requests.

  • Always clear dependency_overrides. If you forget, the override leaks into other tests and they silently talk to the wrong database.

  • Fixtures are not setUp(). They only run for tests that ask for them by name, which keeps tests explicit and fast.

  • Don't test against your real database. One bad test run can wipe real data. The override pattern exists to prevent that.

  • Test the failure paths. Wrong password, missing token, and expired token deserve as many tests as the happy path.

What's next

The API now has a database, authentication, and a test suite. Natural next steps are refresh tokens, role-based permissions, and running these tests automatically in CI.

What do you test first when you build a new API? Let me know in the comments.

About the author

  • Building production-ready AI applications with Python, FastAPI, LangChain & Agentic AI. Sharing real-world engineering lessons along the way.

No comments yet. Be the first to comment.