Write automated tests with pytest, check that errors are raised, reuse setup with fixtures, and run the same test over many inputs.
Why: a test is code that checks your other code still works. pytest finds any function named test_* and runs it. A plain assert statement is all you need — if it’s false, the test fails and pytest shows you why.
# file: math_utils.py
def add(a, b):
return a + b# file: test_math_utils.py
from math_utils import add
def test_add():
assert add(2, 3) == 5
assert add(-1, 1) == 0Why: install pytest, then run it in your project. It discovers every test_*.py file and reports passes and failures with helpful output.
pip install pytestpytestpytest -vWhy: sometimes the correct behaviour is to raise an exception. pytest.raises checks that the expected error happens — the test fails if it does not.
import pytest
def withdraw(balance, amount):
if amount > balance:
raise ValueError('insufficient funds')
return balance - amount
def test_withdraw_too_much():
with pytest.raises(ValueError):
withdraw(100, 200)Why: a fixture provides reusable setup (like sample data) to many tests. parametrize runs the same test over a list of inputs, so you cover many cases without copy-pasting.
import pytest
@pytest.fixture
def sample_user():
return {'name': 'Ada', 'age': 30}
def test_name(sample_user):
assert sample_user['name'] == 'Ada'
@pytest.mark.parametrize('value, expected', [
(2, 4),
(3, 9),
(4, 16),
])
def test_square(value, expected):
assert value ** 2 == expectedWhy: a unit test checks one function alone; an integration test checks your code against a real dependency — usually a database. Use a real but throwaway database (an in-memory SQLite here), build the schema in a fixture, and hand a fresh connection to each test so they never interfere. You are exercising the actual SQL, not a mock.
# file: test_users_db.py
import sqlite3
import pytest
def create_user(conn, name):
conn.execute('INSERT INTO users (name) VALUES (?)', (name,))
conn.commit()
def list_users(conn):
rows = conn.execute('SELECT name FROM users ORDER BY id')
return [row[0] for row in rows]
@pytest.fixture
def conn():
c = sqlite3.connect(':memory:') # a fresh, real DB per test
c.execute('CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)')
yield c
c.close()
def test_persists_and_reads_back(conn):
create_user(conn, 'Ada')
assert list_users(conn) == ['Ada']Why: a functional (end-to-end) test calls your API the way a client would — a real request in, the real JSON out, through routing and validation. FastAPI ships with TestClient (built on httpx): wrap your app, send requests, and assert on the status code and body — no running server needed. TestClient needs httpx installed: pip install httpx.
# file: test_api.py
from fastapi import FastAPI
from fastapi.testclient import TestClient
app = FastAPI()
@app.get('/health')
def health():
return {'status': 'ok'}
client = TestClient(app) # wraps the app directly — no server to start
def test_health_returns_ok():
res = client.get('/health')
assert res.status_code == 200
assert res.json() == {'status': 'ok'}