Lesson 17 · FastAPI deep dive
FastAPI Tutorial #17: Testing FastAPI Apps with pytest
Video seventeen of the eighteen-part series: real pytest tests, not just eyeballing output. TestClient with pytest, fixtures, parametrize, and…
- CourseFastAPI deep dive
- Lesson17 of 18
- FormatJupyter notebook · 10 code cells
What you'll learn
- A Shared Helper - Running pytest Against a String of Code
- A Basic Test Function
- A Fixture for the Client
- Testing the Happy Path and the Error Path
- Testing Validation Errors
- @pytest.mark.parametrize - One Test, Many Inputs
- dependencyoverrides Inside a Fixture
- A Fixture That Resets Shared State Between Tests
Data
No separate download needed — the notebook creates or downloads everything it uses.
📓 Full notebook
Download .ipynbFastAPI Deep-Dive, Video 17: Testing FastAPI Apps#
- Video seventeen of the eighteen-part series: real pytest tests, not just eyeballing output.
- TestClient with pytest, fixtures, parametrize, and dependency_overrides for clean, isolated tests.
- Let's get into it.
Part 1: A Shared Helper - Running pytest Against a String of Code#
import pytest, tempfile, os
def run_pytest(code_str):
with tempfile.NamedTemporaryFile(mode='w', suffix='_test.py', delete=False) as f:
f.write(code_str)
path = f.name
result = pytest.main([path, '-v', '--no-header'])
os.unlink(path)
return result
print(callable(run_pytest))
Part 2: A Basic Test Function#
basic_test = '''
from fastapi import FastAPI
from fastapi.testclient import TestClient
app = FastAPI()
@app.get('/ping')
def ping():
return {'status': 'ok'}
client = TestClient(app)
def test_ping_returns_ok():
response = client.get('/ping')
assert response.status_code == 200
assert response.json() == {'status': 'ok'}
'''
run_pytest(basic_test)
Part 3: A Fixture for the Client#
fixture_test = '''
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
app = FastAPI()
@app.get('/version')
def version():
return {'version': '1.0'}
@pytest.fixture
def client():
return TestClient(app)
def test_version(client):
response = client.get('/version')
assert response.json()['version'] == '1.0'
'''
run_pytest(fixture_test)
Part 4: Testing the Happy Path and the Error Path#
paths_test = '''
import pytest
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
app = FastAPI()
ITEMS = {1: 'Mug'}
@app.get('/items/{item_id}')
def get_item(item_id: int):
if item_id not in ITEMS:
raise HTTPException(status_code=404, detail='not found')
return {'id': item_id, 'name': ITEMS[item_id]}
@pytest.fixture
def client():
return TestClient(app)
def test_get_item_success(client):
response = client.get('/items/1')
assert response.status_code == 200
def test_get_item_not_found(client):
response = client.get('/items/99')
assert response.status_code == 404
'''
run_pytest(paths_test)
Part 5: Testing Validation Errors#
validation_test = '''
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel
app = FastAPI()
class Product(BaseModel):
name: str
price: float
@app.post('/products')
def create_product(product: Product):
return product.model_dump()
@pytest.fixture
def client():
return TestClient(app)
def test_create_product_missing_field(client):
response = client.post('/products', json={'name': 'Mug'})
assert response.status_code == 422
body = response.json()
assert body['detail'][0]['loc'] == ['body', 'price']
'''
run_pytest(validation_test)
Part 6: @pytest.mark.parametrize - One Test, Many Inputs#
parametrize_test = '''
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
app = FastAPI()
@app.get('/double/{n}')
def double(n: int):
return {'result': n * 2}
@pytest.fixture
def client():
return TestClient(app)
@pytest.mark.parametrize('n,expected', [(1, 2), (5, 10), (0, 0), (-3, -6)])
def test_double(client, n, expected):
response = client.get(f'/double/{n}')
assert response.json()['result'] == expected
'''
run_pytest(parametrize_test)
Part 7: dependency_overrides Inside a Fixture#
override_test = '''
import pytest
from fastapi import FastAPI, Depends
from fastapi.testclient import TestClient
app = FastAPI()
def get_current_user():
raise Exception('this would genuinely require a real login')
@app.get('/profile')
def profile(user: dict = Depends(get_current_user)):
return user
@pytest.fixture
def authed_client():
app.dependency_overrides[get_current_user] = lambda: {'username': 'test-user'}
yield TestClient(app)
app.dependency_overrides.clear()
def test_profile_with_fake_user(authed_client):
response = authed_client.get('/profile')
assert response.json() == {'username': 'test-user'}
'''
run_pytest(override_test)
Part 8: A Fixture That Resets Shared State Between Tests#
reset_test = '''
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
app = FastAPI()
NOTES = []
@app.post('/notes')
def add_note(text: str):
NOTES.append(text)
return {'count': len(NOTES)}
@pytest.fixture
def client():
NOTES.clear()
yield TestClient(app)
NOTES.clear()
def test_first_note_is_alone(client):
response = client.post('/notes', params={'text': 'a'})
assert response.json()['count'] == 1
def test_second_test_also_starts_empty(client):
response = client.post('/notes', params={'text': 'b'})
assert response.json()['count'] == 1
'''
run_pytest(reset_test)
Part 9: Asserting a Full Response Body#
full_body_test = '''
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
app = FastAPI()
@app.post('/orders')
def create_order(item: str, quantity: int):
return {'item': item, 'quantity': quantity, 'status': 'placed'}
@pytest.fixture
def client():
return TestClient(app)
def test_create_order_full_body(client):
response = client.post('/orders', params={'item': 'Mug', 'quantity': 2})
assert response.status_code == 200
assert response.json() == {'item': 'Mug', 'quantity': 2, 'status': 'placed'}
'''
run_pytest(full_body_test)
Part 10: A Real Pattern - a Small, Realistic Test Suite#
suite_test = '''
import pytest
from fastapi import FastAPI, Depends, HTTPException
from fastapi.testclient import TestClient
from pydantic import BaseModel
app = FastAPI()
USERS = {}
class Signup(BaseModel):
username: str
password: str
@app.post('/signup', status_code=201)
def signup(payload: Signup):
if payload.username in USERS:
raise HTTPException(status_code=409, detail='username taken')
USERS[payload.username] = payload.password
return {'username': payload.username}
def require_admin():
raise Exception('would genuinely require real admin credentials')
@app.delete('/users/{username}')
def delete_user(username: str, admin: bool = Depends(require_admin)):
USERS.pop(username, None)
return {'deleted': username}
@pytest.fixture
def client():
USERS.clear()
yield TestClient(app)
USERS.clear()
@pytest.fixture
def admin_client():
app.dependency_overrides[require_admin] = lambda: True
yield TestClient(app)
app.dependency_overrides.clear()
@pytest.mark.parametrize('username', ['alex', 'sam', 'jordan'])
def test_signup_succeeds(client, username):
response = client.post('/signup', json={'username': username, 'password': 'hunter22'})
assert response.status_code == 201
def test_signup_duplicate_rejected(client):
client.post('/signup', json={'username': 'alex', 'password': 'a'})
response = client.post('/signup', json={'username': 'alex', 'password': 'b'})
assert response.status_code == 409
def test_delete_requires_admin_override(admin_client):
admin_client.post('/signup', json={'username': 'alex', 'password': 'a'})
response = admin_client.delete('/users/alex')
assert response.json() == {'deleted': 'alex'}
'''
run_pytest(suite_test)
Wrap-Up: What You Learned#
- A pytest test is just a function starting with test_, using plain assert statements.
- @pytest.fixture builds something once and hands it to every test that requests it by name.
- A happy-path test and an error-path test both deserve their own dedicated test function.
- A dedicated test can confirm a validation error's specific field, not just its status code.
- @pytest.mark.parametrize runs one test function once per listed input, reported separately.
- A fixture can set dependency_overrides before yielding the client, and clear them after.
- A yield-based fixture resets shared state before and after each test, preventing leakage.
- Asserting a full response body at once catches unexpected extra or missing fields too.
- A realistic suite combines fixtures, overrides, and parametrize to keep tests isolated and thorough.
- That wraps up testing FastAPI apps. Next up: the Capstone - End-to-End Production API.
Found this useful?
All lessons, notebooks and datasets here are free. If they helped you, a coffee keeps new lessons coming.



