Pytest with Flask

Difficulty: Advanced

pytest is the de facto standard testing framework for Python. Combined with Flask's test client, it provides a powerful and ergonomic testing experience. pytest fixtures handle test setup and teardown, conftest.py shares fixtures across test files, and parametrize lets you run the same test with different inputs.

The key fixtures for Flask testing are app (creates a configured application), client (creates a test client), and optionally runner (for CLI command testing). With the factory pattern, the app fixture calls create_app with a test configuration.

pytest fixtures use dependency injection: you declare a fixture name as a function parameter, and pytest automatically provides the fixture's value. Fixtures can have different scopes: function (default, runs for each test), class, module, or session. For Flask, function scope ensures each test gets a clean state.

The conftest.py file is where you define shared fixtures. Place it in the tests/ directory and all test files in that directory (and subdirectories) can use its fixtures without importing. This is where you put your app, client, and database fixtures.

Test isolation is critical. Each test should start with a clean state and not affect other tests. For database tests, create tables before each test and drop them after. Or use transactions: start a transaction before the test, let the test run, then roll back the transaction.

pytest.mark.parametrize lets you run the same test with different inputs. This is useful for testing multiple validation scenarios, edge cases, or input combinations. You decorate the test function with @pytest.mark.parametrize and provide the argument names and values.

Code coverage measures which lines of your code are executed during tests. Use pytest-cov to generate coverage reports. Aim for high coverage (80%+) but remember that 100% coverage does not mean no bugs. Focus on testing business logic, edge cases, and error handling.

Code examples

conftest.py Fixtures

# tests/conftest.py
import pytest
from app import create_app
from app.extensions import db as _db

@pytest.fixture
def app():
    """Create a fresh app for each test."""
    app = create_app('testing')
    with app.app_context():
        _db.create_all()
        yield app
        _db.session.remove()
        _db.drop_all()

@pytest.fixture
def client(app):
    """Test client for making requests."""
    return app.test_client()

@pytest.fixture
def db(app):
    """Database session for creating test data."""
    return _db

@pytest.fixture
def sample_user(db):
    """Create a sample user for tests."""
    from app.models import User
    user = User(username='testuser', email='test@example.com')
    user.set_password('password123')
    db.session.add(user)
    db.session.commit()
    return user

Fixtures provide test dependencies via injection. The app fixture creates a fresh app with test config. Client depends on app. sample_user depends on db. Each test gets a clean database.

Writing Tests with Fixtures

# tests/test_api.py

def test_list_users_empty(client):
    response = client.get('/api/users')
    assert response.status_code == 200
    assert response.get_json() == []

def test_list_users_with_data(client, sample_user):
    response = client.get('/api/users')
    assert response.status_code == 200
    users = response.get_json()
    assert len(users) == 1
    assert users[0]['username'] == 'testuser'

def test_create_user(client):
    response = client.post('/api/users', json={
        'username': 'newuser',
        'email': 'new@example.com',
        'password': 'securepass'
    })
    assert response.status_code == 201
    data = response.get_json()
    assert data['username'] == 'newuser'

def test_create_user_duplicate_email(client, sample_user):
    response = client.post('/api/users', json={
        'username': 'other',
        'email': 'test@example.com',  # Same as sample_user
        'password': 'securepass'
    })
    assert response.status_code == 400

Tests receive fixtures as parameters. sample_user populates the database before the test. Each test function gets a fresh database state thanks to the app fixture.

Parametrized Tests

import pytest

@pytest.mark.parametrize('email,expected_status', [
    ('valid@example.com', 201),
    ('', 400),
    ('invalid-email', 400),
    ('a@b', 400),
    ('user@domain.com', 201),
])
def test_email_validation(client, email, expected_status):
    response = client.post('/api/users', json={
        'username': 'testuser',
        'email': email,
        'password': 'password123'
    })
    assert response.status_code == expected_status

@pytest.mark.parametrize('method,path,expected', [
    ('GET', '/api/users', 200),
    ('GET', '/api/users/999', 404),
    ('DELETE', '/api/users/999', 404),
    ('GET', '/nonexistent', 404),
])
def test_status_codes(client, method, path, expected):
    response = getattr(client, method.lower())(path)
    assert response.status_code == expected

parametrize runs the test once for each set of arguments. This is much cleaner than writing separate test functions for each case. The test name includes the parameter values in the output.

Running Tests with Coverage

# Install
# pip install pytest pytest-cov

# Run all tests
# pytest

# Run with verbose output
# pytest -v

# Run specific test file
# pytest tests/test_api.py

# Run specific test function
# pytest tests/test_api.py::test_create_user

# Run with coverage
# pytest --cov=app --cov-report=html

# Run with coverage and show missing lines
# pytest --cov=app --cov-report=term-missing

# Example output:
# ---------- coverage: ----------
# Name                  Stmts   Miss  Cover   Missing
# app/__init__.py          15      0   100%
# app/models.py            32      2    94%   45-46
# app/views/api.py         48      5    90%   67-72
# TOTAL                    95      7    93%

# pyproject.toml or pytest.ini
# [tool.pytest.ini_options]
# testpaths = ["tests"]
# python_files = ["test_*.py"]
# python_functions = ["test_*"]

pytest discovers and runs test files/functions by naming convention. --cov generates coverage reports showing which lines are tested. Configure pytest in pyproject.toml for consistent settings.

Key points

Concepts covered

pytest, Fixtures, conftest, Test Isolation, Factory Fixtures, Coverage