FastAPI Complete Guide to pytest-unit-testing
📂 Stage: Stage 5 - Engineering and Deployment (Practical)
🔗 Related chapters: FastAPIdependency-injection · FastAPI多环境配置
Unit testing is a core practice in modern software development to ensure code quality and maintainability. FastAPI, as a high-performance Python web framework, is deeply integrated with Pytest, making writing tests simple and powerful. This tutorial will start from scratch and take you through the entire testing process of the FastAPI project, including synchronous/asynchronous testing, database testing, coverage analysis and TDD development methods.
Why do we need unit testing?
Before we begin, let’s first intuitively feel the value of testing through two comparison scenarios.
No testing routine
You modified the user login logic, clicked the page manually a few times, and it seems to be fine. The day after launch, customers reported that the registration function crashed. You have to roll back urgently, work until late at night, and user complaints keep coming 😱. Over time, the team was afraid of modifying the code, and the project gradually became a "mess that they dare not touch."
Have a test routine
To modify the login logic, you only need to run a commandpytest, all tests are completed within three seconds. If a registration-related test failure is found, locate and fix it immediately. Push the code, automatically build, pass the test, and go online with confidence 🚀. The development experience is smooth and the code is always of high quality.
Testing is not a burden, but a safety net and design tool during development.
Understanding the Testing Pyramid
In the FastAPI application, we follow the classic testing pyramid principle to allocate the proportion of various types of tests.
▲
╱ ╲
╱ ╲ ← 10% 端到端测试 (E2E)
╱─────╲ 模拟用户完整操作流程
╱ ╲
╱─────────╲ ← 20% 集成测试 (Integration)
╱ ╲ 测试 API 端点、数据库交互等
╱─────────────╲
╱ ╲ ← 70% 单元测试 (Unit Tests)
─────────────────── 验证单个函数/类行为
- Unit Test: The fastest and most stable, directly testing pure logical functions or class methods.
- Integration Test: Check whether multiple components cooperate normally, such as routing + dependency injection + database.
- End-to-end testing: simulates real user behavior, covers the complete process, is the most expensive, and only covers core links.
This tutorial focuses on the first two categories: unit tests and integration tests.
environment-setup
Install core dependencies
Based on the existing FastAPI project, just add a few test-related libraries:
pip install pytest pytest-asyncio httpx pytest-cov pytest-mock factory-boy faker
- pytest: Python testing framework.
- pytest-asyncio: Let pytest support asynchronous test functions.
- httpx: for asynchronous HTTP requests (instead of requests).
- pytest-cov: Generate coverage report.
- pytest-mock: Simplify the use of mock objects.
- factory-boy/faker: An artifact for generating test data, optional.
Project configuration
Recommended inpyproject.tomlUnified management of pytest configuration:
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py", "*_test.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
asyncio_mode = "auto"
addopts = [
"-ra", # 显示所有简略结果
"--showlocals", # 失败时打印局部变量
"--tb=short", # 短回溯格式
"--strict-markers", # 未注册的标记会报错
"--strict-config", # 检测错误配置
]
markers = [
"slow: 标记长时间运行的测试",
"integration: 标记集成测试",
"unit: 标记单元测试",
"api: 标记 API 测试",
]
[tool.coverage.run]
source = ["src/", "app/"] # 根据项目结构调整
omit = [
"*/venv/*",
"*/tests/*",
"*/migrations/*",
"*/__init__.py"
]
asyncio_mode = "auto"Will automatically detect whether the test function is asynchronous, no need to add@pytest.mark.asyncio(This article remains explicitly marked for ease of reading).
Create conftest.py shared fixture
tests/conftest.pyIs the global configuration and fixture repository for pytest. Here's a complete example:
import pytest
from httpx import AsyncClient, ASGITransport
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from fastapi.testclient import TestClient
from app.main import app
from app.database import get_db, Base
TEST_DATABASE_URL = "sqlite:///./test.db"
@pytest.fixture(scope="function")
def test_engine():
"""为每个测试函数创建独立的数据库引擎"""
engine = create_engine(
TEST_DATABASE_URL,
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
yield engine
Base.metadata.drop_all(bind=engine)
engine.dispose()
@pytest.fixture(scope="function")
def test_session(test_engine):
"""提供 SQLAlchemy 会话"""
Session = sessionmaker(bind=test_engine)
session = Session()
yield session
session.rollback()
session.close()
@pytest.fixture(scope="function")
def client(test_session):
"""同步 TestClient,并注入测试数据库会话"""
def override_get_db():
try:
yield test_session
finally:
pass
app.dependency_overrides[get_db] = override_get_db
with TestClient(app) as test_client:
yield test_client
app.dependency_overrides.clear()
@pytest.fixture(scope="function")
async def async_client(test_session):
"""异步 AsyncClient,同样注入测试数据库"""
def override_get_db():
try:
yield test_session
finally:
pass
app.dependency_overrides[get_db] = override_get_db
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
app.dependency_overrides.clear()
This configuration provides a new SQLite database for each test function, and replaces the production environment database with the test library through dependency coverage (dependency_overrides) to achieve complete isolation.
TestClient Getting Started with Synchronous Testing
FastAPI has built-inTestClient,based onrequestsLibrary that can send HTTP requests just like calling functions, perfect for writing synchronous tests.
# tests/test_basic.py
import pytest
from fastapi.testclient import TestClient
from app.main import app
@pytest.mark.unit
def test_root_endpoint(client):
"""测试根路径返回正常"""
response = client.get("/")
assert response.status_code == 200
assert "message" in response.json()
@pytest.mark.api
def test_create_user_valid_data(client):
"""测试使用有效数据创建用户"""
test_data = {"name": "Test User", "email": "test@example.com"}
response = client.post("/users/", json=test_data)
assert response.status_code == 201
data = response.json()
assert data["name"] == "Test User"
assert data["email"] == "test@example.com"
@pytest.mark.unit
def test_404_for_unknown_endpoint(client):
"""测试不存在的路由返回 404"""
response = client.get("/nonexistent-endpoint")
assert response.status_code == 404
clientThe clamp is made ofconftest.pyProvided, it has completed the replacement of database dependencies.
- Each test function is independent and does not interfere with each other.
Detailed explanation of asynchronous testing
Used heavily by modern FastAPI projectsasync/await,Pytest passedpytest-asyncioandhttpx.AsyncClientProvides native asynchronous support.
# tests/test_async.py
import pytest
from httpx import AsyncClient
@pytest.mark.asyncio
@pytest.mark.api
async def test_async_root(async_client):
"""异步测试根端点"""
response = await async_client.get("/")
assert response.status_code == 200
data = response.json()
assert data["message"] == "Hello, World!"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_async_create_user(async_client):
"""异步 POST 请求创建用户"""
payload = {"name": "Async User", "email": "async@example.com", "password": "Str0ngPass!"}
response = await async_client.post("/users/", json=payload)
assert response.status_code == 201
user = response.json()
assert user["email"] == payload["email"]
assert "id" in user
async_clientThe fixture provides an asynchronous client based on ASGI transport that completely simulates real network requests, but runs in memory and is extremely fast.
Fixtures and Dependency Injection Testing
Pytest's fixture system is the core of test code reuse and isolation. You can freely combine fixtures and prepare environments for test functions through dependency injection.
# tests/test_fixtures.py
import pytest
from unittest.mock import AsyncMock
from app.services.user_service import UserService
@pytest.fixture
def mock_user_service():
"""模拟 UserService,避免访问真实数据库或外部 API"""
mock = AsyncMock(spec=UserService)
mock.get_user_by_id.return_value = {
"id": 1,
"email": "mock@example.com",
"name": "Mock User"
}
return mock
@pytest.fixture
def sample_user_data():
"""标准测试数据"""
return {
"email": "test@example.com",
"password": "TestPassword123!",
"name": "Test User"
}
class TestUserWithMockedService:
@pytest.mark.unit
def test_get_user_returns_mocked_data(self, mock_user_service):
"""直接测试模拟服务"""
user = mock_user_service.get_user_by_id(1)
assert user["email"] == "mock@example.com"
@pytest.mark.api
def test_create_user_with_sample_data(self, client, sample_user_data):
"""使用样本数据测试 API"""
response = client.post("/users/", json=sample_user_data)
assert response.status_code == 201
assert response.json()["email"] == sample_user_data["email"]
Best practices:
- Extract reusable test data into fixtures, such as
sample_user_data。
- For external dependencies (such as payment services, email services), use
unittest.mockCreate stand-ins to ensure fast and stable testing.
Database testing strategy
The core of database testing is transaction rollback and independent database. Isolation has been implemented previously through the SQLite memory library. Next we look at how to test database operations directly.
# tests/test_database.py
import pytest
from sqlalchemy import text
from app.models import User
from app.schemas import UserCreate
class TestUserCRUD:
@pytest.mark.unit
def test_create_user_persists_in_db(self, test_session):
"""测试用户创建后确实写入了数据库"""
from app.crud import create_user
user_data = UserCreate(
email="dbuser@example.com",
password="Secret123!",
name="DB User"
)
user = create_user(test_session, user_data)
assert user.email == user_data.email
# 通过原生 SQL 进一步验证
result = test_session.execute(
text("SELECT COUNT(*) FROM users WHERE email = :email"),
{"email": user_data.email}
)
count = result.scalar()
assert count == 1
@pytest.mark.unit
def test_duplicate_email_rejected(self, test_session):
"""测试重复邮箱会被拒绝或抛出异常"""
from app.crud import create_user
from app.exceptions import DuplicateEmailError
user_data = UserCreate(
email="dupe@example.com",
password="Password1!",
name="First User"
)
create_user(test_session, user_data)
with pytest.raises(DuplicateEmailError):
create_user(test_session, UserCreate(
email="dupe@example.com",
password="Another1!",
name="Second User"
))
Key points:
- After each test function ends,
test_sessionThe fixture automatically rolls back the transaction and the database returns to a clean state.
- Testing not only verifies successful paths, but also covers abnormal situations.
API endpoint integration testing
Integration testing verifies the entire "request → routing → business logic → database" link.
# tests/test_user_api.py
import pytest
class TestUserAPI:
@pytest.mark.integration
@pytest.mark.asyncio
async def test_full_user_lifecycle(self, async_client):
"""完整用户生命周期测试:创建 → 获取 → 更新 → 删除"""
# 1. 创建
new_user_data = {
"email": "lifecycle@example.com",
"password": "Lifecycle1!",
"name": "Lifecycle User"
}
create_res = await async_client.post("/users/", json=new_user_data)
assert create_res.status_code == 201
user = create_res.json()
user_id = user["id"]
# 2. 获取
get_res = await async_client.get(f"/users/{user_id}")
assert get_res.status_code == 200
assert get_res.json()["email"] == "lifecycle@example.com"
# 3. 更新
update_res = await async_client.put(f"/users/{user_id}", json={"name": "Updated User"})
assert update_res.status_code == 200
assert update_res.json()["name"] == "Updated User"
# 4. 删除
delete_res = await async_client.delete(f"/users/{user_id}")
assert delete_res.status_code == 204
# 5. 确认删除后获取返回 404
get_again = await async_client.get(f"/users/{user_id}")
assert get_again.status_code == 404
Integration tests can be run through the entire process with confidence because they use an isolated database.
Parameterized testing and boundary testing
Parameterization allows you to use the same test logic to cover multiple inputs, greatly reducing duplicate code.
# tests/test_parameterized.py
import pytest
class TestUserValidation:
@pytest.mark.unit
@pytest.mark.parametrize("email,expected_status", [
("valid@example.com", 201),
("user.name+tag@example.com", 201),
("plainaddress", 422), # 无效邮箱
("@example.com", 422), # 缺少用户名
("user@.com", 422), # 无效域名
])
@pytest.mark.asyncio
async def test_email_validation(self, async_client, email, expected_status):
"""参数化测试邮箱格式校验"""
payload = {
"email": email,
"password": "Password123!",
"name": "Test User"
}
response = await async_client.post("/users/", json=payload)
assert response.status_code == expected_status, f"邮箱 '{email}' 应返回 {expected_status}"
@pytest.mark.unit
@pytest.mark.parametrize("password,valid", [
("short", False),
("onlyLetters", False),
("NoDigits!", False),
("ValidPass1!", True),
("Str0ng!Pass", True),
])
@pytest.mark.asyncio
async def test_password_strength(self, async_client, password, valid):
"""参数化测试密码强度要求"""
payload = {
"email": "pwdtest@example.com",
"password": password,
"name": "Pwd User"
}
response = await async_client.post("/users/", json=payload)
if valid:
assert response.status_code == 201
else:
assert response.status_code == 422
- Each parameter combination will generate a test case independently. When it fails, you can clearly know which set of data has a problem.
- Ideal for testing validation logic, boundary conditions and business rules.
Test coverage analysis
Coverage is an important indicator of test adequacy, but 100% cannot be pursued blindly. A reasonable coverage goal is usually between 80% and 90%.
Generate coverage report
# 运行测试并输出覆盖率
pytest --cov=app --cov-report=html --cov-report=term-missing --cov-fail-under=80
--cov=app: Specify the source code directory to be counted.
--cov-report=html: Generate HTML report, openhtmlcov/index.htmlCan be viewed visually.
--cov-report=term-missing: Display uncovered line numbers in the terminal.
--cov-fail-under=80: If the coverage is less than 80%, the test process fails, suitable for CI/CD pipelines.
Coverage configuration file
In addition to command line parameters, you can also.coveragercorpyproject.tomlConfigure more fine-grained rules in:
# .coveragerc
[run]
source = app/
omit =
*/venv/*
*/tests/*
*/__init__.py
branch = True
[report]
exclude_lines =
pragma: no cover # 标记为不纳入统计的代码
def __repr__
raise AssertionError
raise NotImplementedError
if __name__ == .__main__.:
show_missing = True
Paired with an editor plug-in (such as VS Code's Coverage Gutters), you can see in real time whether each line of code is covered by tests.
TDD development practices
The core cycle of test-driven development (TDD) is: Red light → Green light → Refactor. We use a shopping cart function development example to experience the rhythm of TDD.
Step 1: Red light (write a failing test)
class TestShoppingCartTDD:
def test_new_cart_is_empty(self):
"""初始购物车应该是空的"""
cart = ShoppingCart()
assert len(cart.items) == 0
assert cart.total_price == 0
Running the test at this time will report an error:NameError: name 'ShoppingCart' is not defined. This is a red flag - tests are preventing unimplemented code from going live.
Step 2: Green light (make the test pass with the least amount of code)
class ShoppingCart:
def __init__(self):
self.items = []
@property
def total_price(self):
return sum(item["price"] * item["quantity"] for item in self.items)
Run the test again, all passed ✅. We implemented just enough functionality to pass the test without over-engineering.
Step 3: Refactor (optimize internal implementation and keep testing green)
class ShoppingCart:
def __init__(self):
self._items = {} # 改用字典存储,避免重复项
def add_item(self, item):
item_id = item["id"]
if item_id in self._items:
self._items[item_id]["quantity"] += item["quantity"]
else:
self._items[item_id] = item.copy()
@property
def items(self):
return list(self._items.values())
@property
def total_price(self):
return sum(item["price"] * item["quantity"]
for item in self._items.values())
After refactoring, the test still passes, and the code structure is clearer and easier to expand (can be added in the futureremove_itemetc.). This is the basic cycle of TDD: first set the goal, then achieve it, and finally improve it.
In a real FastAPI project, TDD also applies - first write an API test, then implement routing and business logic, and finally refactor (extract the service layer, add cache, etc.).
Summarize
Through this guide, we have completely covered the core points of Pytest testing in the FastAPI project:
- Concise assertion:
assertStatements make tests easy to read and write.
- Powerful Fixture system: Easily manage test dependencies and achieve reuse and isolation.
- Synchronous and asynchronous testing:
TestClientandAsyncClientMeet various needs.
- Database testing strategy: independent SQLite library + transaction rollback to ensure test purity.
- Parameterization and Coverage: Cover more boundary conditions and quantify test quality.
- TDD Practice: Integrate testing into the development process to drive high-quality code.
A good test suite is like a project's safety net and design documentation, which allows you to dare to refactor, quickly iterate, and continuously deliver value.
💡 Next step: Try adding a test file to your current project, starting with the simplesttest_root_endpointGet started and feel the confidence that testing brings. Then gradually expand to cover your API, business logic, and database layers.
If you are using a CI/CD pipeline, addpytestIntegrated with coverage checking, code quality is automatically verified with every submission.