Skip to content

Testing with TestClient & pytest

In Node you use supertest to make HTTP requests against your Express or NestJS app in-process, and Jest as the test runner. In FastAPI you use TestClient (from Starlette, bundled with FastAPI) for in-process HTTP requests, and pytest as the test runner.

TypeScript
// Jest + supertest — app.test.ts
import request from 'supertest';
import { app } from '../src/app';
describe('GET /items/:id', () => {
it('returns an item', async () => {
const res = await request(app).get('/items/1');
expect(res.status).toBe(200);
expect(res.body.id).toBe(1);
});
it('returns 404 for unknown id', async () => {
const res = await request(app).get('/items/9999');
expect(res.status).toBe(404);
});
});
Python
# pytest + TestClient — test_items.py
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_get_item():
response = client.get("/items/1")
assert response.status_code == 200
assert response.json()["id"] == 1
def test_get_item_not_found():
response = client.get("/items/9999")
assert response.status_code == 404
TypeScript
// supertest POST
it('creates an item', async () => {
const res = await request(app)
.post('/items')
.send({ name: 'Widget', price: 9.99 })
.set('Content-Type', 'application/json');
expect(res.status).toBe(201);
expect(res.body.name).toBe('Widget');
});
it('rejects invalid input', async () => {
const res = await request(app)
.post('/items')
.send({ name: '', price: -1 });
expect(res.status).toBe(422);
});
Python
# TestClient POST
def test_create_item():
response = client.post(
"/items",
json={"name": "Widget", "price": 9.99},
)
assert response.status_code == 201
assert response.json()["name"] == "Widget"
def test_create_item_invalid():
response = client.post(
"/items",
json={"name": "", "price": -1},
)
assert response.status_code == 422

The key testing pattern in FastAPI is app.dependency_overrides — replacing real dependencies (like a database session) with test doubles. This is the equivalent of Jest’s jest.mock() or NestJS’s overrideProvider().

TypeScript
// NestJS — override a provider in tests
const moduleRef = await Test.createTestingModule({
providers: [ItemsService],
})
.overrideProvider(DatabaseService)
.useValue({ findById: jest.fn().mockResolvedValue({ id: 1 }) })
.compile();
Python
# FastAPI — dependency_overrides
from fastapi.testclient import TestClient
from app.main import app
from app.database import get_db
# A fake DB session for tests
def override_get_db():
yield FakeDBSession()
app.dependency_overrides[get_db] = override_get_db
client = TestClient(app)
def test_get_item_with_fake_db():
response = client.get("/items/1")
assert response.status_code == 200
# Restore after tests (or use a pytest fixture)
app.dependency_overrides.clear()

pytest’s @pytest.fixture is the equivalent of Jest’s beforeEach / afterEach. A conftest.py file shares fixtures across test files — like a Jest global setup file.

TypeScript
// Jest beforeEach / afterEach
let client: TestClient;
beforeAll(async () => {
const app = await NestFactory.create(AppModule);
await app.init();
client = app.getHttpServer();
});
afterAll(async () => { await app.close(); });
Python
# pytest — conftest.py fixtures
import pytest
from fastapi.testclient import TestClient
from app.main import app
from app.database import Base, engine, get_db
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
TEST_DB_URL = "sqlite:///./test.db"
test_engine = create_engine(TEST_DB_URL, connect_args={"check_same_thread": False})
TestSession = sessionmaker(bind=test_engine)
@pytest.fixture(scope="module")
def client():
Base.metadata.create_all(bind=test_engine)
def override_db():
db = TestSession()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_db
with TestClient(app) as c:
yield c
Base.metadata.drop_all(bind=test_engine)

TestClient runs your async handlers on its own event loop, which is fine for most tests. But when the test itself needs to be async — because it awaits a DB fixture or drives a background task — drive the app with httpx’s AsyncClient over ASGITransport. This is the current idiom for async FastAPI tests and needs no running server.

TypeScript
// Node — supertest is sync-ish; for async setup you await it yourself
import request from 'supertest';
test('GET /items', async () => {
await seedDatabase();
const res = await request(app).get('/items');
expect(res.status).toBe(200);
});
Python
# pytest + httpx AsyncClient — test_items_async.py
import pytest
from httpx import ASGITransport, AsyncClient
from app.main import app
@pytest.mark.anyio
async def test_list_items(seeded_db):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/items")
assert response.status_code == 200
assert response.json() == []
# anyio needs a backend; pin it once in conftest.py:
@pytest.fixture
def anyio_backend():
return "asyncio"

pytest.mark.anyio comes with the anyio plugin that ships with Starlette’s test dependencies; the pytest-asyncio plugin with @pytest.mark.asyncio is the common alternative. Either way you must tell pytest that the test function is a coroutine — unlike Jest, plain pytest will otherwise collect the coroutine and never await it.

Run this locally — needs FastAPI + httpx + pytest. Install: uv add --dev pytest pytest-cov httpx anyio (or pip install the same). Playground is skipped: TestClient requires a full FastAPI app and HTTP lifecycle.

What is the FastAPI equivalent of the supertest npm package for in-process HTTP testing?
How do you replace a real database session with a fake one in FastAPI tests?
What file shares pytest fixtures across all test files in a directory?