Flask Testing Patterns — Deep Dive
Conftest architecture for large projects
As test suites grow, organize fixtures in a hierarchy of conftest.py files:
tests/
├── conftest.py # App, client, db fixtures
├── factories.py # Factory Boy factories
├── api/
│ ├── conftest.py # API-specific fixtures (auth headers, etc.)
│ ├── test_users.py
│ └── test_products.py
├── views/
│ ├── conftest.py # Browser test fixtures
│ ├── test_auth.py
│ └── test_dashboard.py
└── services/
├── conftest.py # Service-layer fixtures (mocked dependencies)
└── test_billing.py
The root conftest.py provides the application and database. Subdirectory conftest files add domain-specific fixtures. Pytest discovers conftest files automatically based on test file location.
# tests/conftest.py
@pytest.fixture(scope='session')
def app():
"""Application factory — once per test session."""
app = create_app('testing')
return app
@pytest.fixture
def db(app):
"""Fresh database per test."""
with app.app_context():
_db.create_all()
yield _db
_db.session.remove()
_db.drop_all()
@pytest.fixture
def client(app, db):
return app.test_client()
# tests/api/conftest.py
@pytest.fixture
def admin_user(db):
user = UserFactory(role='admin')
db.session.commit()
return user
@pytest.fixture
def admin_headers(admin_user):
token = admin_user.generate_token()
return {'Authorization': f'Bearer {token}', 'Content-Type': 'application/json'}
Factory Boy integration
Manual object creation doesn’t scale. Factory Boy generates realistic test data:
import factory
from myapp.models import User, Post
class UserFactory(factory.alchemy.SQLAlchemyModelFactory):
class Meta:
model = User
sqlalchemy_session = None # Set dynamically
name = factory.Faker('name')
email = factory.LazyAttribute(lambda obj: f'{obj.name.lower().replace(" ", ".")}@test.com')
role = 'user'
is_active = True
@factory.lazy_attribute
def password_hash(self):
from werkzeug.security import generate_password_hash
return generate_password_hash('testpass123')
class PostFactory(factory.alchemy.SQLAlchemyModelFactory):
class Meta:
model = Post
sqlalchemy_session = None
title = factory.Faker('sentence')
body = factory.Faker('paragraph', nb_sentences=5)
author = factory.SubFactory(UserFactory)
Configure the session in conftest:
@pytest.fixture(autouse=True)
def set_factory_session(db):
UserFactory._meta.sqlalchemy_session = db.session
PostFactory._meta.sqlalchemy_session = db.session
Usage becomes concise:
def test_user_posts_endpoint(client, admin_headers):
user = UserFactory()
PostFactory.create_batch(5, author=user)
db.session.commit()
response = client.get(f'/api/users/{user.id}/posts', headers=admin_headers)
assert response.status_code == 200
assert len(response.get_json()['data']) == 5
Mocking external services
External API calls should never run in tests. Use responses or pytest-mock:
Mocking HTTP calls with responses
import responses
@responses.activate
def test_weather_integration(client):
responses.add(
responses.GET,
'https://api.weather.com/current',
json={'temp': 22, 'condition': 'sunny'},
status=200
)
response = client.get('/api/weather?city=berlin')
assert response.status_code == 200
assert response.get_json()['temp'] == 22
@responses.activate
def test_weather_api_failure(client):
responses.add(
responses.GET,
'https://api.weather.com/current',
json={'error': 'rate limited'},
status=429
)
response = client.get('/api/weather?city=berlin')
assert response.status_code == 503 # Your app's fallback response
Mocking with dependency injection
Structure services for testability:
# services/email.py
class EmailService:
def send(self, to, subject, body):
# Real SMTP sending
pass
class FakeEmailService:
def __init__(self):
self.sent = []
def send(self, to, subject, body):
self.sent.append({'to': to, 'subject': subject, 'body': body})
# In conftest
@pytest.fixture
def email_service(app):
fake = FakeEmailService()
app.config['EMAIL_SERVICE'] = fake
return fake
def test_registration_sends_welcome_email(client, email_service):
client.post('/register', json={
'name': 'Alice', 'email': 'alice@test.com', 'password': 'secure123'
})
assert len(email_service.sent) == 1
assert email_service.sent[0]['subject'] == 'Welcome!'
Parametrized testing
Test multiple scenarios without duplicating test functions:
@pytest.mark.parametrize('email,expected_status', [
('valid@example.com', 201),
('', 422),
('not-an-email', 422),
('a' * 256 + '@test.com', 422),
('<script>alert("xss")</script>@test.com', 422),
])
def test_registration_email_validation(client, email, expected_status):
response = client.post('/api/users', json={
'name': 'Test',
'email': email,
'password': 'securepass123'
})
assert response.status_code == expected_status
Parametrized tests generate distinct test cases. Each runs independently, so one failure doesn’t mask others.
Parametrized permission matrix
@pytest.mark.parametrize('role,endpoint,method,expected', [
('admin', '/api/users', 'GET', 200),
('admin', '/api/users', 'POST', 201),
('admin', '/api/users/1', 'DELETE', 204),
('user', '/api/users', 'GET', 200),
('user', '/api/users', 'POST', 403),
('user', '/api/users/1', 'DELETE', 403),
('anonymous', '/api/users', 'GET', 401),
('anonymous', '/api/users', 'POST', 401),
])
def test_permission_matrix(client, make_user, role, endpoint, method, expected):
headers = {}
if role != 'anonymous':
user = make_user(role=role)
headers = {'Authorization': f'Bearer {user.generate_token()}'}
if method == 'POST':
response = client.post(endpoint, json={'name': 'X', 'email': 'x@test.com'}, headers=headers)
elif method == 'DELETE':
target = make_user(name='Target')
endpoint = f'/api/users/{target.id}'
response = client.delete(endpoint, headers=headers)
else:
response = client.get(endpoint, headers=headers)
assert response.status_code == expected
Testing background tasks
Celery tasks need special handling. Use CELERY_ALWAYS_EAGER or mock the task:
# Option 1: Eager mode (tasks run synchronously)
@pytest.fixture
def app():
app = create_app('testing')
app.config['CELERY_TASK_ALWAYS_EAGER'] = True
return app
# Option 2: Mock the task
def test_order_triggers_email(client, mocker):
mock_task = mocker.patch('myapp.tasks.send_order_confirmation.delay')
client.post('/api/orders', json={'product_id': 1, 'quantity': 2})
mock_task.assert_called_once()
args = mock_task.call_args[0]
assert args[0] == 1 # order_id
Eager mode executes tasks inline, catching errors immediately. Mocking verifies the task was queued with correct arguments without executing it.
Testing file uploads
from io import BytesIO
def test_avatar_upload(auth_client):
data = {
'avatar': (BytesIO(b'fake image data'), 'avatar.png')
}
response = auth_client.post('/api/profile/avatar',
data=data,
content_type='multipart/form-data'
)
assert response.status_code == 200
def test_avatar_rejects_large_file(auth_client):
# Create a 6MB file (over 5MB limit)
large_data = b'x' * (6 * 1024 * 1024)
data = {
'avatar': (BytesIO(large_data), 'huge.png')
}
response = auth_client.post('/api/profile/avatar',
data=data,
content_type='multipart/form-data'
)
assert response.status_code == 413
Coverage strategy
Configure pytest-cov to measure coverage meaningfully:
# pyproject.toml
[tool.pytest.ini_options]
addopts = "--cov=myapp --cov-report=term-missing --cov-fail-under=80"
[tool.coverage.run]
branch = true
omit = [
"myapp/migrations/*",
"myapp/config.py",
"tests/*",
]
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"if TYPE_CHECKING:",
"if __name__ == .__main__.",
]
Branch coverage (branch = true) catches untested conditional paths. Without it, if error: handle() shows as covered even if you only test the non-error path.
Target coverage by layer:
- Models/services: 90%+ (core business logic)
- API views: 85%+ (all status codes tested)
- Utils/helpers: 80%+
- Config/migrations: Exclude from coverage
CI pipeline integration
# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
redis:
image: redis:7
ports: [6379:6379]
postgres:
image: postgres:15
env:
POSTGRES_DB: test
POSTGRES_PASSWORD: test
ports: [5432:5432]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- run: pip install -e ".[test]"
- run: pytest --cov --junitxml=results.xml
env:
DATABASE_URL: postgresql://postgres:test@localhost/test
REDIS_URL: redis://localhost:6379
- uses: actions/upload-artifact@v4
with:
name: test-results
path: results.xml
Use real service containers (Postgres, Redis) in CI instead of SQLite. SQLite behaves differently for constraints, JSON operations, and concurrent access. Tests that pass on SQLite can fail on Postgres.
Snapshot testing
For complex JSON responses, snapshot testing avoids maintaining expected values manually:
def test_user_detail_response(client, snapshot, make_user):
user = make_user(name='Alice', email='alice@test.com')
response = client.get(f'/api/users/{user.id}')
data = response.get_json()
data.pop('created_at') # Remove non-deterministic fields
data.pop('id')
assert data == snapshot
The first run saves the response as a snapshot file. Subsequent runs compare against it. When the response changes intentionally, update snapshots with pytest --snapshot-update.
One thing to remember: The goal of testing isn’t 100% coverage — it’s confidence that changes don’t break existing behavior. Prioritize tests for your application’s core flows (authentication, data mutation, business rules) and error handling. A focused test suite that runs in under 30 seconds catches more bugs than a comprehensive suite that developers skip because it takes 10 minutes.
See Also
- Python Django Admin Get an intuitive feel for Django Admin so Python behavior stops feeling unpredictable.
- Python Django Basics Get an intuitive feel for Django Basics so Python behavior stops feeling unpredictable.
- Python Django Celery Integration Why your Django app needs a helper to handle slow jobs in the background.
- Python Django Channels Websockets How Django can send real-time updates to your browser without you refreshing the page.
- Python Django Custom Management Commands How to teach Django new tricks by creating your own command-line shortcuts.