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.
# 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.
# 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.
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.
# 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.
pytest, Fixtures, conftest, Test Isolation, Factory Fixtures, Coverage