Write correct Flask tests -- app factory with test config, application context fixtures, database isolation, file uploads, auth testing, error handlers, mock.patch placement, and essential API test patterns
98
99%
Does it follow best practices?
Impact
97%
1.15xAverage score across 5 eval scenarios
Passed
No known issues
The app factory must accept a test config. Set TESTING=True and use a separate database. Never test against the production database.
# WRONG -- no test config, shares production database
def create_app():
app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///prod.db'
return app# RIGHT -- app factory accepts test_config override
def create_app(test_config=None):
app = Flask(__name__)
app.config.from_mapping(
SECRET_KEY='dev',
DATABASE='instance/app.db',
)
if test_config is None:
app.config.from_pyfile('config.py', silent=True)
else:
app.config.update(test_config)
# ... register blueprints, init db, etc.
return appRule: The app factory must accept a test_config dict (or a testing=True flag). Tests pass TESTING=True and a separate database path. Never hardcode config inside the factory.
Flask operations (database access, url_for, current_app) require an application context. The test client must be created and used inside one.
# WRONG -- test_client() without application context
@pytest.fixture
def client():
app = create_app(test_config={'TESTING': True})
return app.test_client()
# RuntimeError: Working outside of application context.# RIGHT -- app_context wraps the entire test lifecycle
@pytest.fixture
def app():
app = create_app(test_config={
'TESTING': True,
'DATABASE': ':memory:',
})
with app.app_context():
init_db()
yield app
@pytest.fixture
def client(app):
return app.test_client()Rule: Always wrap the app fixture in with app.app_context(): and yield from inside the context manager. The client fixture depends on the app fixture so it inherits the context.
Put all shared fixtures in tests/conftest.py. Separate app, client, and optional runner fixtures. The app fixture owns the context and database setup.
# tests/conftest.py
import pytest
from myapp import create_app
from myapp.db import get_db, init_db
@pytest.fixture
def app():
"""Create application for testing with clean database."""
app = create_app(test_config={
'TESTING': True,
'DATABASE': ':memory:',
'SECRET_KEY': 'test-secret',
'WTF_CSRF_ENABLED': False,
})
with app.app_context():
init_db()
yield app
@pytest.fixture
def client(app):
"""Test client for making requests."""
return app.test_client()
@pytest.fixture
def runner(app):
"""CLI test runner for Click commands."""
return app.test_cli_runner()Rule: Three fixtures: app (owns context + DB), client (for HTTP requests), runner (for CLI commands). All in tests/conftest.py. The app fixture yields inside app.app_context().
Each test must start with a clean database. Use :memory: SQLite and reinitialize the schema before each test. Never share mutable state between tests.
# WRONG -- database shared across tests, order-dependent failures
@pytest.fixture(scope='session')
def app():
app = create_app(test_config={'TESTING': True})
with app.app_context():
init_db()
yield app# RIGHT -- function-scoped fixture (default), fresh DB each test
@pytest.fixture
def app():
app = create_app(test_config={
'TESTING': True,
'DATABASE': ':memory:',
})
with app.app_context():
init_db()
yield appFor SQLAlchemy apps, use transaction rollback:
@pytest.fixture
def app():
app = create_app(test_config={
'TESTING': True,
'SQLALCHEMY_DATABASE_URI': 'sqlite://',
})
with app.app_context():
db.create_all()
yield app
db.session.rollback()
db.drop_all()Rule: Use function-scoped fixtures (the default). Each test gets a fresh :memory: SQLite or a rolled-back transaction. Never use scope='session' or scope='module' for database fixtures.
Flask-Login, session-based auth, and token auth each need different test patterns.
# WRONG -- manually setting cookies or headers incorrectly
def test_protected_route(client):
client.set_cookie('session', 'some-random-value')
res = client.get('/dashboard')# RIGHT -- log in through the actual login endpoint
def test_protected_route_requires_login(client):
"""Unauthenticated request redirects or returns 401."""
res = client.get('/dashboard')
assert res.status_code in (302, 401)
def test_protected_route_after_login(client):
"""Authenticated request succeeds."""
# Log in through the app's login route
client.post('/auth/login', json={
'username': 'testuser',
'password': 'testpass'
})
res = client.get('/dashboard')
assert res.status_code == 200
# For token-based auth
def test_api_with_token(client):
# Get token via login
login_res = client.post('/api/auth/login', json={
'username': 'testuser',
'password': 'testpass'
})
token = login_res.get_json()['token']
res = client.get('/api/protected',
headers={'Authorization': f'Bearer {token}'})
assert res.status_code == 200Create an auth helper fixture:
# tests/conftest.py
@pytest.fixture
def auth_client(client, app):
"""Client that is already authenticated."""
with app.app_context():
# Seed a test user
create_user('testuser', 'testpass')
client.post('/auth/login', json={
'username': 'testuser',
'password': 'testpass'
})
return clientRule: Always test both authenticated and unauthenticated paths. Log in through the actual login endpoint (not by faking cookies). Create an auth_client fixture for convenience.
Use data with content_type='multipart/form-data', not json. Pass file-like objects using (BytesIO, filename) tuples or raw bytes.
# WRONG -- sending file data as JSON
def test_upload(client):
res = client.post('/api/upload', json={'file': 'data'})
# WRONG -- missing content_type
def test_upload(client):
res = client.post('/api/upload', data={'file': open('test.png', 'rb')})# RIGHT -- use data with BytesIO and content_type
from io import BytesIO
def test_upload_image(client):
data = {
'file': (BytesIO(b'\x89PNG\r\n\x1a\n' + b'\x00' * 64), 'photo.png')
}
res = client.post('/api/upload',
data=data,
content_type='multipart/form-data')
assert res.status_code == 200
def test_rejects_invalid_file_type(client):
data = {
'file': (BytesIO(b'#!/bin/bash\nrm -rf /'), 'evil.sh')
}
res = client.post('/api/upload',
data=data,
content_type='multipart/form-data')
assert res.status_code == 400Rule: File uploads use data= (not json=) with content_type='multipart/form-data'. Files are (BytesIO(bytes), filename) tuples. Test both valid and invalid file types.
Flask's @app.errorhandler must be tested explicitly. The test client does NOT propagate exceptions by default when TESTING=True.
# WRONG -- expecting raw exceptions in test client
def test_500_error(client):
with pytest.raises(ZeroDivisionError):
client.get('/api/broken')
# WRONG -- not testing error handler format
def test_not_found(client):
res = client.get('/api/nonexistent')
assert res.status_code == 404
# But never checks the response BODY# RIGHT -- test error handler returns correct JSON shape
def test_404_handler_returns_json(client):
res = client.get('/api/this-does-not-exist')
assert res.status_code == 404
body = res.get_json()
assert 'error' in body
assert 'message' in body['error']
def test_400_handler_returns_json(client):
res = client.post('/api/orders', json={})
assert res.status_code == 400
body = res.get_json()
assert 'error' in body
assert 'message' in body['error']
def test_error_responses_share_same_shape(client):
"""All error responses use the same envelope."""
endpoints = [
('GET', '/api/orders/99999'), # 404
('POST', '/api/orders', {}), # 400
]
for method, *args in endpoints:
if method == 'GET':
res = client.get(args[0])
else:
res = client.post(args[0], json=args[1])
body = res.get_json()
assert 'error' in body, f'{method} {args[0]} missing error key'
assert 'message' in body['error'], f'{method} {args[0]} missing message'Rule: Test that every error handler (404, 400, 500) returns a JSON body with a consistent shape. Check both the status code and the response body structure.
Same rule as in Django/Python: patch the name in the importing module, not the source module.
# WRONG -- patches the original module
from unittest.mock import patch
@patch('services.email.send_email')
def test_order_sends_email(mock_send, client):
client.post('/api/orders', json={...})
mock_send.assert_called_once()
# RIGHT -- patch where the view imports it
@patch('myapp.routes.orders.send_email')
def test_order_sends_email(mock_send, client):
client.post('/api/orders', json={...})
mock_send.assert_called_once()With pytest fixtures, the mock parameter comes BEFORE the fixture parameters:
# WRONG -- argument order
@patch('myapp.routes.orders.send_email')
def test_order_sends_email(client, mock_send): # WRONG ORDER
...
# RIGHT -- mock args first, then fixtures
@patch('myapp.routes.orders.send_email')
def test_order_sends_email(mock_send, client):
...Rule: @patch('module.where.name.is.looked.up.name'). In pytest, mock arguments come before fixture arguments.
Flask uses Click for CLI commands. Test them with app.test_cli_runner().
def test_init_db_command(runner):
result = runner.invoke(args=['init-db'])
assert 'Initialized' in result.output
assert result.exit_code == 0
def test_seed_command(runner):
runner.invoke(args=['init-db'])
result = runner.invoke(args=['seed-data'])
assert result.exit_code == 0
assert 'Seeded' in result.outputRule: Use app.test_cli_runner() (exposed via the runner fixture), not subprocess. Check both exit_code and output.
Every Flask API test suite should include these 5 patterns:
# tests/test_api.py
def test_list_returns_items(client):
res = client.get('/api/items')
assert res.status_code == 200
data = res.get_json()
assert isinstance(data['items'], list)
assert len(data['items']) > 0
def test_create_rejects_empty_body(client):
res = client.post('/api/items', json={})
assert res.status_code == 400
assert 'error' in res.get_json()
def test_get_nonexistent_returns_404(client):
res = client.get('/api/items/99999')
assert res.status_code == 404
def test_create_and_retrieve(client):
create_res = client.post('/api/items', json={'name': 'Test Item', 'price': 9.99})
assert create_res.status_code == 201
item_id = create_res.get_json()['id']
get_res = client.get(f'/api/items/{item_id}')
assert get_res.status_code == 200
assert get_res.get_json()['name'] == 'Test Item'
def test_error_format_consistent(client):
res_400 = client.post('/api/items', json={})
res_404 = client.get('/api/items/99999')
for res in [res_400, res_404]:
body = res.get_json()
assert 'error' in body
assert 'message' in body['error']Run with: pytest -v
test_config dict or testing=True)TESTING=True for test runsconftest.py with app, client, and optionally runner fixturesapp fixture wraps in with app.app_context(): and yields inside it:memory: SQLite or transaction rollback per testscope='session' for DB fixtures)json= for JSON POST requests (not data= with manual content type)data= with content_type='multipart/form-data' for file uploads(BytesIO(bytes), filename) tuplesauth_client fixture for tests that need authentication{"error": {"message": ...}})mock.patch targets the import location, not the definition locationapp.test_cli_runner(), not subprocessCSRF disabled in test config (WTF_CSRF_ENABLED=False) if using Flask-WTFpytest -v documented as run command