Testing Microservices in Practice with FastAPI and PostgreSQL
Testing microservices presents unique challenges compared to monolithic applications. With multiple services communicating over networks, databases, message queues, and external APIs, a comprehensive testing strategy becomes critical. This guide covers practical approaches to testing FastAPI microservices with PostgreSQL.
Table of Contents
Testing Philosophy for Microservices {#testing-philosophy}
The testing pyramid for microservices differs from traditional applications:
/\
/E2E\ ← Few (5-10%)
/------\
/Contract\ ← Some (15-20%)
/----------\
/Integration\ ← More (25-30%)
/--------------\
/ Unit Tests \ ← Most (40-50%)
/------------------\
Key Principles:
Test Environment Setup {#test-environment-setup}
Project Structure
my-microservice/
├── app/
│ ├── __init__.py
│ ├── main.py
│ ├── models/
│ ├── services/
│ ├── repositories/
│ └── api/
├── tests/
│ ├── __init__.py
│ ├── conftest.py # Shared fixtures
│ ├── unit/
│ │ ├── test_services.py
│ │ └── test_repositories.py
│ ├── integration/
│ │ ├── test_api.py
│ │ └── test_database.py
│ ├── contract/
│ │ └── test_user_service_contract.py
│ └── e2e/
│ └── test_user_flow.py
├── requirements.txt
├── requirements-dev.txt
├── docker-compose.test.yml
└── pytest.ini
Dependencies Installation
requirements-dev.txt:
# Testing
pytest==7.4.3
pytest-asyncio==0.21.1
pytest-cov==4.1.0
pytest-mock==3.12.0
httpx==0.25.2
faker==20.1.0
# Test Database
pytest-postgresql==5.0.0
sqlalchemy-utils==0.41.1
# Contract Testing
pact-python==2.0.1
# Mocking
responses==0.24.1
freezegun==1.4.0
Pytest Configuration
pytest.ini:
[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
asyncio_mode = auto
addopts =
-v
--strict-markers
--cov=app
--cov-report=term-missing
--cov-report=html
--cov-fail-under=80
markers =
unit: Unit tests
integration: Integration tests
contract: Contract tests
e2e: End-to-end tests
slow: Slow running tests
Test Configuration
tests/conftest.py:
import asyncio
import pytest
from typing import AsyncGenerator, Generator
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from sqlalchemy.pool import NullPool
from httpx import AsyncClient
from app.main import app
from app.database import Base, get_db
from app.config import get_settings
# Test database URL
TEST_DATABASE_URL = "postgresql+asyncpg://test_user:test_pass@localhost:5433/test_db"
@pytest.fixture(scope="session")
def event_loop() -> Generator:
"""Create event loop for async tests."""
loop = asyncio.get_event_loop_policy().new_event_loop()
yield loop
loop.close()
@pytest.fixture(scope="session")
async def test_engine():
"""Create test database engine."""
engine = create_async_engine(
TEST_DATABASE_URL,
poolclass=NullPool,
echo=False,
)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield engine
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await engine.dispose()
@pytest.fixture
async def db_session(test_engine) -> AsyncGenerator[AsyncSession, None]:
"""Create a fresh database session for each test."""
async_session = async_sessionmaker(
test_engine,
class_=AsyncSession,
expire_on_commit=False,
)
async with async_session() as session:
async with session.begin():
yield session
await session.rollback()
@pytest.fixture
async def client(db_session: AsyncSession) -> AsyncGenerator[AsyncClient, None]:
"""Create test client with overridden dependencies."""
async def override_get_db():
yield db_session
app.dependency_overrides[get_db] = override_get_db
async with AsyncClient(app=app, base_url="http://test") as test_client:
yield test_client
app.dependency_overrides.clear()
@pytest.fixture
def mock_settings():
"""Mock application settings."""
settings = get_settings()
settings.database_url = TEST_DATABASE_URL
settings.environment = "test"
return settings
Unit Testing {#unit-testing}
Unit tests focus on individual functions and classes in isolation.
Testing Services
app/services/user_service.py:
from typing import Optional
from app.models.user import User
from app.repositories.user_repository import UserRepository
from app.schemas.user import UserCreate, UserUpdate
from app.utils.password import hash_password, verify_password
from app.exceptions import UserAlreadyExistsError, InvalidCredentialsError
class UserService:
def __init__(self, user_repository: UserRepository):
self.user_repository = user_repository
async def create_user(self, user_data: UserCreate) -> User:
"""Create a new user."""
# Check if user exists
existing_user = await self.user_repository.get_by_email(user_data.email)
if existing_user:
raise UserAlreadyExistsError(f"User with email {user_data.email} already exists")
# Hash password
hashed_password = hash_password(user_data.password)
# Create user
user = User(
email=user_data.email,
username=user_data.username,
hashed_password=hashed_password,
)
return await self.user_repository.create(user)
async def authenticate(self, email: str, password: str) -> Optional[User]:
"""Authenticate user by email and password."""
user = await self.user_repository.get_by_email(email)
if not user or not verify_password(password, user.hashed_password):
raise InvalidCredentialsError("Invalid email or password")
return user
async def update_user(self, user_id: int, user_data: UserUpdate) -> User:
"""Update user information."""
user = await self.user_repository.get_by_id(user_id)
if not user:
raise ValueError(f"User {user_id} not found")
if user_data.email:
user.email = user_data.email
if user_data.username:
user.username = user_data.username
return await self.user_repository.update(user)
tests/unit/test_user_service.py:
import pytest
from unittest.mock import AsyncMock, Mock, patch
from app.services.user_service import UserService
from app.models.user import User
from app.schemas.user import UserCreate, UserUpdate
from app.exceptions import UserAlreadyExistsError, InvalidCredentialsError
@pytest.fixture
def mock_user_repository():
"""Mock user repository."""
return AsyncMock()
@pytest.fixture
def user_service(mock_user_repository):
"""Create user service with mocked repository."""
return UserService(mock_user_repository)
@pytest.mark.unit
class TestUserService:
@pytest.mark.asyncio
async def test_create_user_success(self, user_service, mock_user_repository):
"""Test successful user creation."""
# Arrange
user_data = UserCreate(
email="test@example.com",
username="testuser",
password="SecurePass123!"
)
mock_user_repository.get_by_email.return_value = None
mock_user_repository.create.return_value = User(
id=1,
email=user_data.email,
username=user_data.username,
hashed_password="hashed_password"
)
# Act
with patch('app.services.user_service.hash_password') as mock_hash:
mock_hash.return_value = "hashed_password"
result = await user_service.create_user(user_data)
# Assert
assert result.email == user_data.email
assert result.username == user_data.username
mock_user_repository.get_by_email.assert_called_once_with(user_data.email)
mock_user_repository.create.assert_called_once()
@pytest.mark.asyncio
async def test_create_user_already_exists(self, user_service, mock_user_repository):
"""Test user creation when email already exists."""
# Arrange
user_data = UserCreate(
email="existing@example.com",
username="testuser",
password="SecurePass123!"
)
mock_user_repository.get_by_email.return_value = User(
id=1,
email=user_data.email,
username="existinguser",
hashed_password="hashed"
)
# Act & Assert
with pytest.raises(UserAlreadyExistsError):
await user_service.create_user(user_data)
mock_user_repository.create.assert_not_called()
@pytest.mark.asyncio
async def test_authenticate_success(self, user_service, mock_user_repository):
"""Test successful authentication."""
# Arrange
email = "test@example.com"
password = "SecurePass123!"
mock_user = User(
id=1,
email=email,
username="testuser",
hashed_password="hashed_password"
)
mock_user_repository.get_by_email.return_value = mock_user
# Act
with patch('app.services.user_service.verify_password') as mock_verify:
mock_verify.return_value = True
result = await user_service.authenticate(email, password)
# Assert
assert result == mock_user
mock_user_repository.get_by_email.assert_called_once_with(email)
@pytest.mark.asyncio
async def test_authenticate_invalid_credentials(self, user_service, mock_user_repository):
"""Test authentication with invalid credentials."""
# Arrange
mock_user_repository.get_by_email.return_value = User(
id=1,
email="test@example.com",
username="testuser",
hashed_password="hashed_password"
)
# Act & Assert
with patch('app.services.user_service.verify_password') as mock_verify:
mock_verify.return_value = False
with pytest.raises(InvalidCredentialsError):
await user_service.authenticate("test@example.com", "wrong_password")
Testing Repositories
tests/unit/test_user_repository.py:
import pytest
from app.repositories.user_repository import UserRepository
from app.models.user import User
@pytest.mark.unit
class TestUserRepository:
@pytest.mark.asyncio
async def test_create_user(self, db_session):
"""Test creating a user in the database."""
# Arrange
repository = UserRepository(db_session)
user = User(
email="test@example.com",
username="testuser",
hashed_password="hashed_password"
)
# Act
result = await repository.create(user)
await db_session.commit()
# Assert
assert result.id is not None
assert result.email == "test@example.com"
assert result.username == "testuser"
@pytest.mark.asyncio
async def test_get_by_email(self, db_session):
"""Test retrieving user by email."""
# Arrange
repository = UserRepository(db_session)
user = User(
email="find@example.com",
username="finduser",
hashed_password="hashed"
)
await repository.create(user)
await db_session.commit()
# Act
found_user = await repository.get_by_email("find@example.com")
# Assert
assert found_user is not None
assert found_user.email == "find@example.com"
@pytest.mark.asyncio
async def test_get_by_email_not_found(self, db_session):
"""Test retrieving non-existent user."""
# Arrange
repository = UserRepository(db_session)
# Act
result = await repository.get_by_email("nonexistent@example.com")
# Assert
assert result is None
Integration Testing {#integration-testing}
Integration tests verify that different components work together correctly.
Testing API Endpoints
tests/integration/test_user_api.py:
import pytest
from httpx import AsyncClient
from app.models.user import User
@pytest.mark.integration
class TestUserAPI:
@pytest.mark.asyncio
async def test_create_user_endpoint(self, client: AsyncClient):
"""Test POST /users endpoint."""
# Arrange
user_data = {
"email": "newuser@example.com",
"username": "newuser",
"password": "SecurePass123!"
}
# Act
response = await client.post("/api/v1/users", json=user_data)
# Assert
assert response.status_code == 201
data = response.json()
assert data["email"] == user_data["email"]
assert data["username"] == user_data["username"]
assert "id" in data
assert "password" not in data
assert "hashed_password" not in data
@pytest.mark.asyncio
async def test_create_user_duplicate_email(self, client: AsyncClient, db_session):
"""Test creating user with duplicate email."""
# Arrange
existing_user = User(
email="duplicate@example.com",
username="existing",
hashed_password="hashed"
)
db_session.add(existing_user)
await db_session.commit()
user_data = {
"email": "duplicate@example.com",
"username": "newuser",
"password": "SecurePass123!"
}
# Act
response = await client.post("/api/v1/users", json=user_data)
# Assert
assert response.status_code == 409
assert "already exists" in response.json()["detail"]
@pytest.mark.asyncio
async def test_get_user_by_id(self, client: AsyncClient, db_session):
"""Test GET /users/{id} endpoint."""
# Arrange
user = User(
email="getuser@example.com",
username="getuser",
hashed_password="hashed"
)
db_session.add(user)
await db_session.commit()
await db_session.refresh(user)
# Act
response = await client.get(f"/api/v1/users/{user.id}")
# Assert
assert response.status_code == 200
data = response.json()
assert data["id"] == user.id
assert data["email"] == user.email
@pytest.mark.asyncio
async def test_login_success(self, client: AsyncClient, db_session):
"""Test POST /auth/login endpoint."""
# Arrange
from app.utils.password import hash_password
user = User(
email="login@example.com",
username="loginuser",
hashed_password=hash_password("SecurePass123!")
)
db_session.add(user)
await db_session.commit()
login_data = {
"email": "login@example.com",
"password": "SecurePass123!"
}
# Act
response = await client.post("/api/v1/auth/login", json=login_data)
# Assert
assert response.status_code == 200
data = response.json()
assert "access_token" in data
assert "refresh_token" in data
assert data["token_type"] == "bearer"
@pytest.mark.asyncio
async def test_protected_endpoint_requires_auth(self, client: AsyncClient):
"""Test that protected endpoints require authentication."""
# Act
response = await client.get("/api/v1/users/me")
# Assert
assert response.status_code == 401
@pytest.mark.asyncio
async def test_protected_endpoint_with_auth(self, client: AsyncClient, db_session):
"""Test accessing protected endpoint with valid token."""
# Arrange
from app.utils.password import hash_password
from app.utils.jwt import create_access_token
user = User(
email="protected@example.com",
username="protecteduser",
hashed_password=hash_password("SecurePass123!")
)
db_session.add(user)
await db_session.commit()
await db_session.refresh(user)
token = create_access_token({"sub": str(user.id)})
# Act
response = await client.get(
"/api/v1/users/me",
headers={"Authorization": f"Bearer {token}"}
)
# Assert
assert response.status_code == 200
data = response.json()
assert data["email"] == user.email
Contract Testing {#contract-testing}
Contract tests ensure that services communicate correctly with each other.
tests/contract/test_user_service_contract.py:
import pytest
from pact import Consumer, Provider, Like, EachLike
import asyncio
@pytest.fixture(scope="module")
def pact():
"""Setup Pact consumer."""
pact = Consumer("OrderService").has_pact_with(Provider("UserService"))
pact.start_service()
yield pact
pact.stop_service()
@pytest.mark.contract
class TestUserServiceContract:
def test_get_user_by_id_contract(self, pact):
"""Verify contract for GET /users/{id}."""
expected = {
"id": Like(1),
"email": Like("user@example.com"),
"username": Like("username"),
"created_at": Like("2024-01-01T00:00:00Z"),
}
(pact
.given("user with id 1 exists")
.upon_receiving("a request for user with id 1")
.with_request("GET", "/api/v1/users/1")
.will_respond_with(200, body=expected))
with pact:
# Make actual request to mock server
import httpx
response = httpx.get(f"{pact.uri}/api/v1/users/1")
assert response.status_code == 200
assert "id" in response.json()
assert "email" in response.json()
def test_create_user_contract(self, pact):
"""Verify contract for POST /users."""
request_body = {
"email": "newuser@example.com",
"username": "newuser",
"password": "SecurePass123!"
}
expected_response = {
"id": Like(1),
"email": Like("newuser@example.com"),
"username": Like("newuser"),
"created_at": Like("2024-01-01T00:00:00Z"),
}
(pact
.given("user service is available")
.upon_receiving("a request to create a user")
.with_request("POST", "/api/v1/users", body=request_body)
.will_respond_with(201, body=expected_response))
with pact:
import httpx
response = httpx.post(
f"{pact.uri}/api/v1/users",
json=request_body
)
assert response.status_code == 201
assert response.json()["email"] == request_body["email"]
End-to-End Testing {#end-to-end-testing}
E2E tests verify complete user flows across multiple services.
tests/e2e/test_user_flow.py:
import pytest
from httpx import AsyncClient
@pytest.mark.e2e
@pytest.mark.slow
class TestUserFlow:
@pytest.mark.asyncio
async def test_complete_user_registration_and_login_flow(self, client: AsyncClient):
"""Test complete user flow: register -> verify email -> login -> access protected resource."""
# Step 1: Register new user
user_data = {
"email": "e2e@example.com",
"username": "e2euser",
"password": "SecurePass123!"
}
register_response = await client.post("/api/v1/users", json=user_data)
assert register_response.status_code == 201
user_id = register_response.json()["id"]
# Step 2: Login
login_data = {
"email": user_data["email"],
"password": user_data["password"]
}
login_response = await client.post("/api/v1/auth/login", json=login_data)
assert login_response.status_code == 200
tokens = login_response.json()
access_token = tokens["access_token"]
# Step 3: Access protected resource
headers = {"Authorization": f"Bearer {access_token}"}
profile_response = await client.get("/api/v1/users/me", headers=headers)
assert profile_response.status_code == 200
profile = profile_response.json()
assert profile["id"] == user_id
assert profile["email"] == user_data["email"]
# Step 4: Update profile
update_data = {"username": "updated_username"}
update_response = await client.patch(
f"/api/v1/users/{user_id}",
json=update_data,
headers=headers
)
assert update_response.status_code == 200
assert update_response.json()["username"] == "updated_username"
# Step 5: Logout (invalidate token)
logout_response = await client.post("/api/v1/auth/logout", headers=headers)
assert logout_response.status_code == 200
# Step 6: Verify token is invalid
invalid_response = await client.get("/api/v1/users/me", headers=headers)
assert invalid_response.status_code == 401
Database Testing Strategies {#database-testing-strategies}
Using Test Containers
tests/conftest.py (enhanced):
import pytest
import asyncio
from testcontainers.postgres import PostgresContainer
@pytest.fixture(scope="session")
def postgres_container():
"""Start PostgreSQL container for tests."""
with PostgresContainer("postgres:15-alpine") as postgres:
yield postgres
@pytest.fixture(scope="session")
async def test_engine(postgres_container):
"""Create test database engine with containerized PostgreSQL."""
database_url = postgres_container.get_connection_url().replace(
"psycopg2", "asyncpg"
)
engine = create_async_engine(
database_url,
poolclass=NullPool,
echo=False,
)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield engine
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await engine.dispose()
Database Fixtures and Factories
tests/factories.py:
from faker import Faker
from app.models.user import User
from app.models.post import Post
fake = Faker()
class UserFactory:
"""Factory for creating test users."""
@staticmethod
def build(**kwargs) -> User:
"""Build a user instance without saving."""
defaults = {
"email": fake.email(),
"username": fake.user_name(),
"hashed_password": "hashed_password",
"is_active": True,
"is_verified": False,
}
defaults.update(kwargs)
return User(**defaults)
@staticmethod
async def create(db_session, **kwargs) -> User:
"""Create and save a user to the database."""
user = UserFactory.build(**kwargs)
db_session.add(user)
await db_session.commit()
await db_session.refresh(user)
return user
class PostFactory:
"""Factory for creating test posts."""
@staticmethod
def build(user_id: int, **kwargs) -> Post:
"""Build a post instance without saving."""
defaults = {
"title": fake.sentence(),
"content": fake.text(),
"user_id": user_id,
"published": False,
}
defaults.update(kwargs)
return Post(**defaults)
@staticmethod
async def create(db_session, user_id: int, **kwargs) -> Post:
"""Create and save a post to the database."""
post = PostFactory.build(user_id, **kwargs)
db_session.add(post)
await db_session.commit()
await db_session.refresh(post)
return post
Usage in tests:
@pytest.mark.asyncio
async def test_user_can_create_post(db_session):
"""Test user can create a post."""
from tests.factories import UserFactory, PostFactory
# Arrange
user = await UserFactory.create(db_session)
# Act
post = await PostFactory.create(
db_session,
user_id=user.id,
title="My Test Post"
)
# Assert
assert post.id is not None
assert post.user_id == user.id
assert post.title == "My Test Post"
Testing Database Migrations
tests/integration/test_migrations.py:
import pytest
from alembic import command
from alembic.config import Config
@pytest.mark.integration
def test_migrations_can_run_forward_and_backward():
"""Test that all migrations can be applied and reverted."""
alembic_cfg = Config("alembic.ini")
# Downgrade to base
command.downgrade(alembic_cfg, "base")
# Upgrade to head
command.upgrade(alembic_cfg, "head")
# Downgrade one version
command.downgrade(alembic_cfg, "-1")
# Upgrade back to head
command.upgrade(alembic_cfg, "head")
Testing in CI/CD {#testing-in-cicd}
GitHub Actions Workflow
.github/workflows/test.yml:
name: Test Suite
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15-alpine
env:
POSTGRES_USER: test_user
POSTGRES_PASSWORD: test_pass
POSTGRES_DB: test_db
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.11'
cache: 'pip'
- name: Install dependencies
run: |
pip install -r requirements.txt
pip install -r requirements-dev.txt
- name: Run linting
run: |
ruff check .
black --check .
mypy app/
- name: Run unit tests
run: pytest tests/unit -v --cov=app --cov-report=xml
env:
DATABASE_URL: postgresql+asyncpg://test_user:test_pass@localhost:5432/test_db
REDIS_URL: redis://localhost:6379/0
- name: Run integration tests
run: pytest tests/integration -v
env:
DATABASE_URL: postgresql+asyncpg://test_user:test_pass@localhost:5432/test_db
REDIS_URL: redis://localhost:6379/0
- name: Run contract tests
run: pytest tests/contract -v
- name: Run E2E tests
run: pytest tests/e2e -v --maxfail=1
env:
DATABASE_URL: postgresql+asyncpg://test_user:test_pass@localhost:5432/test_db
REDIS_URL: redis://localhost:6379/0
- name: Upload coverage reports
uses: codecov/codecov-action@v3
with:
file: ./coverage.xml
flags: unittests
name: codecov-umbrella
- name: Check coverage threshold
run: |
coverage report --fail-under=80
Docker Compose for Local Testing
docker-compose.test.yml:
version: '3.8'
services:
test-db:
image: postgres:15-alpine
environment:
POSTGRES_USER: test_user
POSTGRES_PASSWORD: test_pass
POSTGRES_DB: test_db
ports:
- "5433:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U test_user"]
interval: 5s
timeout: 5s
retries: 5
test-redis:
image: redis:7-alpine
ports:
- "6380:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 5
test-runner:
build:
context: .
dockerfile: Dockerfile.test
depends_on:
test-db:
condition: service_healthy
test-redis:
condition: service_healthy
environment:
DATABASE_URL: postgresql+asyncpg://test_user:test_pass@test-db:5432/test_db
REDIS_URL: redis://test-redis:6379/0
volumes:
- .:/app
- /app/.pytest_cache
command: pytest tests/ -v --cov=app --cov-report=html
Dockerfile.test:
FROM python:3.11-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
postgresql-client \
&& rm -rf /var/lib/apt/lists/*
# Install Python dependencies
COPY requirements.txt requirements-dev.txt ./
RUN pip install --no-cache-dir -r requirements.txt -r requirements-dev.txt
# Copy application code
COPY . .
# Run tests by default
CMD ["pytest", "tests/", "-v"]
Run tests locally:
# Start test services and run tests
docker-compose -f docker-compose.test.yml up --abort-on-container-exit
# Run specific test category
docker-compose -f docker-compose.test.yml run test-runner pytest tests/unit -v
# Clean up
docker-compose -f docker-compose.test.yml down -v
Common Pitfalls and Solutions {#common-pitfalls}
1. Test Isolation Issues
Problem: Tests fail when run together but pass individually.
Solution:
@pytest.fixture(autouse=True)
async def clean_database(db_session):
"""Clean database before each test."""
yield
# Rollback any uncommitted changes
await db_session.rollback()
# Clear all tables
for table in reversed(Base.metadata.sorted_tables):
await db_session.execute(table.delete())
await db_session.commit()
2. Async Test Deadlocks
Problem: Async tests hang or timeout.
Solution:
# Use pytest-timeout
@pytest.mark.timeout(5)
@pytest.mark.asyncio
async def test_with_timeout():
await some_async_operation()
# Or configure globally in pytest.ini
[pytest]
timeout = 10
3. Flaky Tests Due to Timing
Problem: Tests intermittently fail due to race conditions.
Solution:
import asyncio
from tenacity import retry, stop_after_delay, wait_fixed
@retry(stop=stop_after_delay(5), wait=wait_fixed(0.1))
async def wait_for_condition(check_func):
"""Wait for a condition to be true."""
result = await check_func()
if not result:
raise Exception("Condition not met")
return result
# Usage
async def test_eventual_consistency(client):
# Create resource
response = await client.post("/api/v1/resources", json=data)
resource_id = response.json()["id"]
# Wait for async processing
async def check_processed():
resp = await client.get(f"/api/v1/resources/{resource_id}")
return resp.json()["status"] == "processed"
await wait_for_condition(check_processed)
4. Mocking External Services
Problem: Tests depend on external APIs.
Solution:
import responses
import pytest
@pytest.fixture
def mock_external_api():
"""Mock external API calls."""
with responses.RequestsMock() as rsps:
rsps.add(
responses.GET,
"https://api.external.com/users/1",
json={"id": 1, "name": "Test User"},
status=200
)
yield rsps
@pytest.mark.asyncio
async def test_with_mocked_external_api(mock_external_api):
"""Test that uses mocked external API."""
result = await fetch_external_user(1)
assert result["name"] == "Test User"
5. Testing Background Tasks
Problem: Background tasks don't complete during tests.
Solution:
from fastapi import BackgroundTasks
import asyncio
@pytest.mark.asyncio
async def test_background_task(client):
"""Test endpoint with background task."""
# Use a flag to track task completion
task_completed = asyncio.Event()
async def mock_background_task(*args, **kwargs):
# Perform task
await asyncio.sleep(0.1)
task_completed.set()
# Replace background task with mock
with patch('app.tasks.send_email', side_effect=mock_background_task):
response = await client.post("/api/v1/send-notification")
assert response.status_code == 200
# Wait for background task
await asyncio.wait_for(task_completed.wait(), timeout=1.0)
6. Database Connection Pool Exhaustion
Problem: Tests fail with "connection pool exhausted" errors.
Solution:
from sqlalchemy.pool import NullPool
# Use NullPool for tests
test_engine = create_async_engine(
TEST_DATABASE_URL,
poolclass=NullPool, # Don't pool connections in tests
echo=False,
)
# Or configure pool size
test_engine = create_async_engine(
TEST_DATABASE_URL,
pool_size=20,
max_overflow=10,
pool_pre_ping=True,
)
Best Practices Summary
Conclusion
Testing microservices requires a multi-layered approach. By combining unit tests for business logic, integration tests for component interaction, contract tests for service boundaries, and E2E tests for critical flows, you can build confidence in your FastAPI microservices.
Remember:
Start with unit tests for your core business logic, add integration tests for database operations and API endpoints, implement contract tests for service boundaries, and reserve E2E tests for critical user journeys.
With these practices in place, you'll ship more reliable microservices with confidence.
---
Additional Resources: