Back to Blog
Python

Python FastAPI Testing with pytest

python fastapi testing with pytest: Learn how to test FastAPI applications with pytest: TestClient, dependency overrides, async tests, and database isolation.

FastAPIpytestTestClientasync testingdependency injection
A stylized test tube with a FastAPI logo and a pytest checkmark, symbolizing API testing.

python fastapi testing with pytest requires a clear understanding of the core syntax, runtime behavior, and practical implementation patterns demonstrated in the examples below.

When you build a FastAPI application, you need a testing strategy that covers request handling, dependency injection, and database interactions. The standard toolchain is pytest combined with FastAPI's TestClient. This article explains how to set up that workflow, write reliable tests, and avoid common pitfalls that appear when your application grows beyond a single module.

Setting Up the Test Environment

FastAPI's TestClient is built on top of the httpx library, so your test dependencies should include both pytest and httpx. You can install them with pip:

pip install pytest httpx

If you use pytest-asyncio for testing async functions directly, add that as well. The TestClient itself handles async endpoints internally, so you do not need pytest-asyncio for basic endpoint testing, but it becomes useful when you test async utility functions or database sessions.

A minimal pytest.ini file keeps test discovery consistent:

[pytest] testpaths = tests python_files = test_*.py

Place your tests in a tests/ directory. FastAPI applications are typically created in a separate module, and your tests import the app instance from that module.

Writing Your First Test with TestClient

Assume you have a simple FastAPI app in app.py:

from fastapi import FastAPI app = FastAPI() @app.get("/health") async def health(): return {"status": "ok"}

A test for this endpoint looks like:

from fastapi.testclient import TestClient from app import app client = TestClient(app) def test_health(): response = client.get("/health") assert response.status_code == 200 assert response.json() == {"status": "ok"}

The TestClient works as a context manager to trigger lifespan events, which we will cover later. For a simple test like this, the client can be instantiated at module level, but if your app has startup code that must run, you need to use the context manager form.

Each test should use its own client instance to avoid state leakage between tests. Creating a client inside each test function is the safest pattern, especially when you override dependencies.

Using Dependency Overrides to Isolate Tests

FastAPI's dependency injection system is one of its strongest features, and it also makes testing easier. You can replace a dependency with a mock or a test double without changing the application code.

Suppose your app has a dependency that returns a database session:

from fastapi import Depends def get_db(): db = SomeDatabaseConnection() try: yield db finally: db.close() @app.get("/items") def read_items(db = Depends(get_db)): return db.fetch_all()

In your test, you override get_db with a fake that returns canned data:

from app import app, get_db from fastapi.testclient import TestClient def fake_db(): yield [{"id": 1, "name": "test"}] app.dependency_overrides[get_db] = fake_db client = TestClient(app) def test_read_items(): response = client.get("/items") assert response.status_code == 200 assert response.json() == [{"id": 1, "name": "test"}]

After the test, clear the override to avoid affecting other tests:

app.dependency_overrides.clear()

A better pattern is to use a pytest fixture that sets and clears the override automatically:

import pytest from app import app, get_db @pytest.fixture def client(): def fake_db(): yield [{"id": 1, "name": "test"}] app.dependency_overrides[get_db] = fake_db with TestClient(app) as c: yield c app.dependency_overrides.clear()

This ensures each test gets a fresh client and the override is cleaned up.

Testing Async Endpoints Directly

Sometimes you need to test an async function that is not a route, such as a service layer function. The TestClient runs the ASGI application in a separate thread, so it does not give you direct access to the async context. For unit tests of async functions, use pytest-asyncio:

import pytest from app.services import fetch_data @pytest.mark.asyncio async def test_fetch_data(): result = await fetch_data(123) assert result is not None

You need to configure pytest-asyncio in pytest.ini:

[pytest] asyncio_mode = auto

With asyncio_mode = auto, every async test function is automatically treated as an async test, so you do not need to add the marker manually.

When you test an async endpoint through TestClient, the client handles the event loop for you. The endpoint is awaited internally, so you do not need to worry about running the loop yourself.

Database Testing with Fixtures

Database tests are where most integration testing complexity appears. The goal is to isolate each test from the others, typically by using a transaction that is rolled back or by using a temporary database.

A common approach is to create a pytest fixture that sets up a database session and overrides the get_db dependency to use that session. If you are using SQLAlchemy, you can use a StaticPool for an in-memory SQLite database to share the connection across threads:

from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from sqlalchemy.pool import StaticPool engine = create_engine( "sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool, ) TestingSession = sessionmaker(bind=engine) @pytest.fixture def db_session(): Base.metadata.create_all(bind=engine) session = TestingSession() yield session session.close() Base.metadata.drop_all(bind=engine)

Then you override the get_db dependency to use this session. This gives each test a clean database without touching your real data.

For PostgreSQL or MySQL, you might use a test database and truncate tables between tests. The principle remains the same: the fixture must ensure isolation.

Handling Lifespan Events in Tests

FastAPI supports lifespan events such as startup and shutdown via @app.on_event("startup") or the newer lifespan parameter. The TestClient triggers these events only when used as a context manager:

from fastapi.testclient import TestClient from app import app def test_with_lifespan(): with TestClient(app) as client: response = client.get("/health") assert response.status_code == 200

If your application opens a database connection pool during startup, you must use this form so the pool is created before the test request and closed after. Forgetting the context manager can lead to errors like RuntimeError: Task attached to a different loop when using async resources.

If you need to test the lifespan logic itself, you can use the asgi-lifespan library to run the lifespan events without sending requests, but for most endpoint tests the TestClient context manager is sufficient.

Common Pitfalls and Performance Considerations

One frequent mistake is reusing a single TestClient across tests without clearing dependency overrides. This can cause tests to interfere with each other. Always create a fresh client per test or use a fixture that resets state.

Another issue is mixing sync and async database sessions. If your dependency yields an async session, you cannot use it in a sync test function. You need to either use pytest-asyncio and test the endpoint via TestClient (which runs the endpoint in the event loop) or use a sync session for tests. The TestClient runs the app in a separate thread, so async dependencies work fine, but the test function itself remains synchronous.

Performance-wise, creating a new TestClient for each test is cheap because it only instantiates the ASGI transport. The heavier cost is usually database setup and teardown. Use fixtures that run once per session for expensive operations, but be careful about state leakage. For example, you can create the database schema once per session and truncate tables between tests, which reduces overhead while keeping isolation.

When testing endpoints that perform file uploads or streaming, remember that TestClient buffers the response by default. For large responses, you may need to use client.stream to avoid memory issues, but that is rarely necessary in unit tests.

Finally, do not rely on the order of tests. Each test should be independent. Use pytest's fixture scoping to control setup and teardown, and always clean up overrides and database sessions after each test.

python fastapi testing with pytest: Practical Usage and Code | RYUSLOG DEV