Mathew K Analytics

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…

⬇ Download notebook

📓 Full notebook

Download .ipynb

FastAPI 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))
True

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)
============================= test session starts =============================
collecting ... collected 1 item

..\..\..\Users\makmw\AppData\Local\Temp\tmpg4kir9t6_test.py::test_ping_returns_ok PASSED [100%]

============================== 1 passed in 0.63s ==============================
<ExitCode.OK: 0>

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)
============================= test session starts =============================
collecting ... collected 1 item

..\..\..\Users\makmw\AppData\Local\Temp\tmpa8swkp5w_test.py::test_version PASSED [100%]

============================== 1 passed in 0.10s ==============================
<ExitCode.OK: 0>

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)
============================= test session starts =============================
collecting ... collected 2 items

..\..\..\Users\makmw\AppData\Local\Temp\tmp2c27fjx4_test.py::test_get_item_success PASSED [ 50%]
..\..\..\Users\makmw\AppData\Local\Temp\tmp2c27fjx4_test.py::test_get_item_not_found PASSED [100%]

============================== 2 passed in 0.13s ==============================
<ExitCode.OK: 0>

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)
============================= test session starts =============================
collecting ... collected 1 item

..\..\..\Users\makmw\AppData\Local\Temp\tmpda8v6_bp_test.py::test_create_product_missing_field PASSED [100%]

============================== 1 passed in 0.10s ==============================
<ExitCode.OK: 0>

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)
============================= test session starts =============================
collecting ... collected 4 items

..\..\..\Users\makmw\AppData\Local\Temp\tmpimik83h2_test.py::test_double[1-2] PASSED [ 25%]
..\..\..\Users\makmw\AppData\Local\Temp\tmpimik83h2_test.py::test_double[5-10] PASSED [ 50%]
..\..\..\Users\makmw\AppData\Local\Temp\tmpimik83h2_test.py::test_double[0-0] PASSED [ 75%]
..\..\..\Users\makmw\AppData\Local\Temp\tmpimik83h2_test.py::test_double[-3--6] PASSED [100%]

============================== 4 passed in 0.15s ==============================
<ExitCode.OK: 0>

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)
============================= test session starts =============================
collecting ... collected 1 item

..\..\..\Users\makmw\AppData\Local\Temp\tmp5h076zpt_test.py::test_profile_with_fake_user PASSED [100%]

============================== 1 passed in 0.11s ==============================
<ExitCode.OK: 0>

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)
============================= test session starts =============================
collecting ... collected 2 items

..\..\..\Users\makmw\AppData\Local\Temp\tmpim15ugfx_test.py::test_first_note_is_alone PASSED [ 50%]
..\..\..\Users\makmw\AppData\Local\Temp\tmpim15ugfx_test.py::test_second_test_also_starts_empty PASSED [100%]

============================== 2 passed in 0.12s ==============================
<ExitCode.OK: 0>

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)
============================= test session starts =============================
collecting ... collected 1 item

..\..\..\Users\makmw\AppData\Local\Temp\tmp4_19tfgz_test.py::test_create_order_full_body PASSED [100%]

============================== 1 passed in 0.10s ==============================
<ExitCode.OK: 0>

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)
============================= test session starts =============================
collecting ... collected 5 items

..\..\..\Users\makmw\AppData\Local\Temp\tmp7_1btjwu_test.py::test_signup_succeeds[alex] PASSED [ 20%]
..\..\..\Users\makmw\AppData\Local\Temp\tmp7_1btjwu_test.py::test_signup_succeeds[sam] PASSED [ 40%]
..\..\..\Users\makmw\AppData\Local\Temp\tmp7_1btjwu_test.py::test_signup_succeeds[jordan] PASSED [ 60%]
..\..\..\Users\makmw\AppData\Local\Temp\tmp7_1btjwu_test.py::test_signup_duplicate_rejected PASSED [ 80%]
..\..\..\Users\makmw\AppData\Local\Temp\tmp7_1btjwu_test.py::test_delete_requires_admin_override PASSED [100%]

============================== 5 passed in 0.21s ==============================
<ExitCode.OK: 0>

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.