Setup
Install the required packages:
pip install pytest pytest-asyncio httpx pytest-cov
Create a pytest.ini or pyproject.toml config:
# pyproject.toml
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
Writing Async Tests
Use async def test functions with pytest-asyncio:
import pytest
@pytest.mark.asyncio
async def test_async_operation():
result = await some_async_function()
assert result == expected_value
With asyncio_mode = "auto", you don’t need the @pytest.mark.asyncio decorator - pytest detects async functions automatically.
Fixtures
Fixtures provide reusable setup and teardown:
import pytest
@pytest.fixture
def sample_item():
return {"id": 1, "name": "Laptop", "price": 999.99}
@pytest.fixture
def db_session():
db = create_test_db()
yield db
db.close()
@pytest.mark.asyncio
async def test_create_item(sample_item, db_session):
result = await create_item_in_db(db_session, sample_item)
assert result["name"] == "Laptop"
Async Fixtures
@pytest.fixture
async def async_client():
async with AsyncClient() as client:
yield client
@pytest.mark.asyncio
async def test_fetch_data(async_client):
response = await async_client.get("/data")
assert response.status_code == 200
Scope and Autouse
@pytest.fixture(scope="module")
def expensive_resource():
return create_resource()
@pytest.fixture(autouse=True)
def setup_and_teardown():
setup()
yield
cleanup()
Parametrize
Test multiple input combinations with @pytest.mark.parametrize:
import pytest
from app.validators import validate_email
@pytest.mark.parametrize(
"email,expected",
[
("user@example.com", True),
("user.name+tag@example.co.uk", True),
("", False),
("not-an-email", False),
("@example.com", False),
("user@", False),
(None, False),
],
)
def test_email_validation(email, expected):
assert validate_email(email) == expected
Multiple Parameter Combinations
@pytest.mark.parametrize(
"a,b,expected",
[
(1, 2, 3),
(0, 0, 0),
(-1, 1, 0),
(100, 200, 300),
],
)
@pytest.mark.parametrize("operation", ["add", "multiply"])
def test_calculator(operation, a, b, expected):
if operation == "add":
assert add(a, b) == expected
elif operation == "multiply":
assert multiply(a, b) == a * b
Mocking Dependencies
Use unittest.mock to replace external dependencies:
from unittest.mock import AsyncMock, patch, MagicMock
@pytest.mark.asyncio
async def test_get_user_calls_database():
mock_user = {"id": 1, "name": "Alice"}
with patch("app.services.get_user_from_db", new_callable=AsyncMock) as mock_get:
mock_get.return_value = mock_user
result = await get_user_service(1)
mock_get.assert_awaited_once_with(1)
assert result == mock_user
Using pytest-mock
The mocker fixture from pytest-mock gives a cleaner API:
pip install pytest-mock
@pytest.mark.asyncio
async def test_send_email(mocker):
mock_send = mocker.patch("app.services.send_email", new_callable=AsyncMock)
mock_send.return_value = {"status": "sent"}
result = await notification_service("user@example.com", "Hello")
mock_send.assert_awaited_once()
assert result["status"] == "sent"
Mocking FastAPI Dependencies
from app.dependencies import get_db
@pytest.mark.asyncio
async def test_endpoint_with_mocked_db(mocker):
mock_db = AsyncMock()
mock_db.fetch_one.return_value = {"id": 1, "name": "Test"}
app.dependency_overrides[get_db] = lambda: mock_db
# Run your test...
app.dependency_overrides.clear()
Testing Validators
Unit-test your Pydantic models and custom validators in isolation:
from pydantic import BaseModel, field_validator
import pytest
class UserCreate(BaseModel):
username: str
email: str
age: int
@field_validator("username")
@classmethod
def username_must_be_valid(cls, v):
if len(v) < 3:
raise ValueError("Username must be at least 3 characters")
if not v.isalnum():
raise ValueError("Username must be alphanumeric")
return v
@field_validator("age")
@classmethod
def age_must_be_valid(cls, v):
if v < 0 or v > 150:
raise ValueError("Age must be between 0 and 150")
return v
class TestUserCreateValidator:
def test_valid_user(self):
user = UserCreate(username="alice", email="a@b.com", age=25)
assert user.username == "alice"
def test_username_too_short(self):
with pytest.raises(ValueError, match="at least 3 characters"):
UserCreate(username="ab", email="a@b.com", age=25)
def test_username_not_alphanumeric(self):
with pytest.raises(ValueError, match="alphanumeric"):
UserCreate(username="alice!", email="a@b.com", age=25)
def test_negative_age(self):
with pytest.raises(ValueError, match="must be between"):
UserCreate(username="alice", email="a@b.com", age=-5)
@pytest.mark.parametrize("age", [0, 25, 150])
def test_boundary_ages(self, age):
user = UserCreate(username="alice", email="a@b.com", age=age)
assert user.age == age
Conftest Pattern
Shared fixtures go in conftest.py:
# tests/conftest.py
import pytest
@pytest.fixture
def sample_payload():
return {"username": "testuser", "email": "test@example.com", "age": 20}
@pytest.fixture(autouse=True)
def mock_external_api(mocker):
mocker.patch("app.services.external_api_call", return_value={"ok": True})
Key Takeaways
- Use
pytest-asynciowithasyncio_mode = "auto"for async test support. - Fixtures handle setup, teardown, and reusable dependencies across tests.
@pytest.mark.parametrizetests multiple input combinations without duplication.- Mock external services with
unittest.mock.AsyncMockorpytest-mock’smockerfixture. - Override FastAPI dependencies with
app.dependency_overridesfor isolated endpoint testing. - Test Pydantic validators directly with
pytest.raisesfor focused unit tests. - Place shared fixtures in
conftest.pyfor automatic discovery across test modules.