Flask Testing Guide: Unit and Integration Testing with pytest

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 u

Testing 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 == 200

Testing 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 trustrequest.remote_addr returns the wrong value when you forget PREFERRED_URL_SCHEME or ProxyFix
  • Static file serving — your app serves static files in dev, fails in production where nginx should handle it
  • Session cookie issuesSameSite settings 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 seconds

These 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=True and 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.

Read more