Flask Testing Guide: Unit and Integration Testing with pytest
Flask is intentionally minimal. It doesn't tell you how to test. Most Flask tutorials stop at "run the dev server and check in the browser."
That's fine for getting started. It's not a testing strategy.
Flask apps are actually easier to test than most frameworks because everything is explicit. Here's how to do it properly.
Flask Testing Basics
Flask ships with a built-in test client. You access it through the app.test_client() method on your application instance.
# app.py
from flask import Flask, jsonify
app = Flask(__name__)
@app.route('/health')
def health_check():
return jsonify({'status': 'ok'})# test_app.py
import pytest
from app import app
@pytest.fixture
def client():
app.config['TESTING'] = True
with app.test_client() as client:
yield client
def test_health_check(client):
response = client.get('/health')
assert response.status_code == 200
assert response.json['status'] == 'ok'The key settings: TESTING = True gives you better error messages. WTF_CSRF_ENABLED = False disables CSRF tokens in form tests. DATABASE_URI should point to a test database.
Structuring Tests with conftest.py
For real applications, set up shared fixtures in conftest.py:
# conftest.py
import pytest
from myapp import create_app
from myapp.extensions import db as _db
@pytest.fixture(scope='session')
def app():
app = create_app({
'TESTING': True,
'SQLALCHEMY_DATABASE_URI': 'sqlite:///:memory:',
'WTF_CSRF_ENABLED': False,
'SECRET_KEY': 'test-secret-key',
})
with app.app_context():
_db.create_all()
yield app
_db.drop_all()
@pytest.fixture(scope='function')
def client(app):
return app.test_client()
@pytest.fixture(scope='function')
def db(app):
with app.app_context():
yield _db
_db.session.rollback() # Roll back between tests
@pytest.fixture
def user(db):
from myapp.models import User
u = User(email='test@example.com', username='testuser')
u.set_password('testpass123')
db.session.add(u)
db.session.commit()
return uTesting Routes and Blueprints
Test every route: status code, response body, redirects, and error cases.
# tests/test_auth_routes.py
import pytest
from flask import url_for
def test_login_page_loads(client):
response = client.get('/auth/login')
assert response.status_code == 200
assert b'Login' in response.data
def test_login_with_valid_credentials(client, user):
response = client.post('/auth/login', data={
'email': 'test@example.com',
'password': 'testpass123',
}, follow_redirects=True)
assert response.status_code == 200
assert b'Dashboard' in response.data # Redirected to dashboard
def test_login_with_wrong_password(client, user):
response = client.post('/auth/login', data={
'email': 'test@example.com',
'password': 'wrongpassword',
})
assert response.status_code == 200 # Re-renders form
assert b'Invalid credentials' in response.data
def test_protected_route_redirects_anonymous_user(client):
response = client.get('/dashboard')
assert response.status_code == 302
assert '/auth/login' in response.headers['Location']
def test_protected_route_accessible_after_login(client, user):
# Log in
client.post('/auth/login', data={
'email': 'test@example.com',
'password': 'testpass123',
})
# Access protected route
response = client.get('/dashboard')
assert response.status_code == 200Testing JSON APIs
For API routes that return JSON:
# tests/test_api.py
import json
def test_get_products_returns_list(client):
response = client.get('/api/v1/products')
assert response.status_code == 200
assert response.content_type == 'application/json'
data = response.get_json()
assert isinstance(data, list)
def test_create_product_requires_auth(client):
response = client.post('/api/v1/products',
json={'name': 'Widget', 'price': 29.99})
assert response.status_code == 401
def test_create_product_with_valid_data(client, auth_headers):
response = client.post('/api/v1/products',
json={'name': 'Widget', 'price': 29.99, 'stock': 100},
headers=auth_headers)
assert response.status_code == 201
data = response.get_json()
assert data['name'] == 'Widget'
assert 'id' in data
def test_create_product_rejects_negative_price(client, auth_headers):
response = client.post('/api/v1/products',
json={'name': 'Widget', 'price': -5.00, 'stock': 10},
headers=auth_headers)
assert response.status_code == 400
data = response.get_json()
assert 'price' in data['errors']
@pytest.fixture
def auth_headers(client, user):
"""Returns auth headers for API requests."""
response = client.post('/api/v1/auth/token',
json={'email': user.email, 'password': 'testpass123'})
token = response.get_json()['access_token']
return {'Authorization': f'Bearer {token}'}Testing SQLAlchemy Models
Test model methods and properties directly, without going through HTTP:
# tests/test_models.py
import pytest
from myapp.models import User, Order, Product
from decimal import Decimal
def test_user_password_hashing(db):
user = User(email='hash@test.com')
user.set_password('mypassword')
db.session.add(user)
db.session.commit()
# Stored password should be hashed
assert user.password_hash != 'mypassword'
# Verification should work
assert user.check_password('mypassword') is True
assert user.check_password('wrong') is False
def test_order_total_calculation(db):
product_a = Product(name='A', price=Decimal('10.00'))
product_b = Product(name='B', price=Decimal('25.00'))
db.session.add_all([product_a, product_b])
order = Order(customer_email='buyer@test.com')
order.add_item(product_a, qty=2)
order.add_item(product_b, qty=1)
db.session.add(order)
db.session.commit()
assert order.total == Decimal('45.00') # (10 × 2) + (25 × 1)
def test_soft_delete_excludes_deleted_records(db):
Product.objects.create(name='Active', deleted_at=None)
Product.objects.create(name='Deleted', deleted_at=datetime.utcnow())
active = Product.query.active().all()
assert len(active) == 1
assert active[0].name == 'Active'Testing Flask Forms
Test WTForms validation explicitly:
# tests/test_forms.py
from myapp.forms import RegistrationForm
def test_valid_registration_form(app):
with app.app_context():
form = RegistrationForm(data={
'email': 'new@test.com',
'username': 'newuser',
'password': 'StrongPass123!',
'confirm_password': 'StrongPass123!',
})
assert form.validate()
def test_registration_form_rejects_weak_password(app):
with app.app_context():
form = RegistrationForm(data={
'email': 'new@test.com',
'username': 'newuser',
'password': '123',
'confirm_password': '123',
})
assert not form.validate()
assert 'password' in form.errors
def test_registration_form_rejects_mismatched_passwords(app):
with app.app_context():
form = RegistrationForm(data={
'email': 'new@test.com',
'username': 'newuser',
'password': 'StrongPass123!',
'confirm_password': 'DifferentPass!',
})
assert not form.validate()Testing Email Sending
Don't send real emails in tests. Use Flask-Mail's suppress=True:
# conftest.py (add to existing fixtures)
@pytest.fixture
def mail(app):
from myapp.extensions import mail
with mail.record_messages() as outbox:
yield outbox
def test_registration_sends_confirmation_email(client, db, mail):
client.post('/auth/register', data={
'email': 'new@test.com',
'username': 'newuser',
'password': 'StrongPass123!',
'confirm_password': 'StrongPass123!',
})
assert len(mail) == 1
assert mail[0].recipients == ['new@test.com']
assert 'confirm' in mail[0].body.lower()What Tests Don't Cover
A passing pytest suite proves your Flask app works in isolation. Deployed Flask apps fail for different reasons:
- Gunicorn worker configuration — a route that works fine with Flask's dev server deadlocks under gunicorn's threading model
- Proxy header trust —
request.remote_addrreturns the wrong value when you forgetPREFERRED_URL_SCHEMEorProxyFix - Static file serving — your app serves static files in dev, fails in production where nginx should handle it
- Session cookie issues —
SameSitesettings in production differ from development, breaking auth across subdomains - Memory leaks in long-running workers — a Flask app that passes tests leaks memory because a request context isn't being torn down correctly
Monitoring Flask Apps in Production
HelpMeTest lets you write behavioral tests against your deployed Flask app and run them continuously:
Test: user login — success path
Navigate to https://your-app.com/auth/login
Fill email field with test@example.com
Fill password field
Click "Sign In"
Verify: dashboard is visible
Verify: welcome message includes username
Response time under 2 secondsThese tests run on a schedule. If your auth breaks after a session config change, your API starts returning 500 errors, or a database migration breaks a view, you know before your users do.
Free tier: 10 tests, unlimited health checks. Try HelpMeTest →
Flask Testing Checklist
- Test client configured with
TESTING=Trueand test database URI - Route tests: status codes, response bodies, redirects
- Auth tests: unauthenticated access blocked, login/logout flow works
- JSON API tests: valid payloads succeed, invalid payloads return 400
- Model tests: business logic, computed properties, validation
- Form tests: valid data passes, invalid data returns errors
- Email tests: correct recipient, subject, and content
- Error handler tests: 404, 500 handlers return correct format
- Production monitoring: behavioral tests running after every deploy
Flask doesn't tell you how to test. Now you know.