Code that is not tested is broken code. You might not know it yet, but it is broken. It works today because you just wrote it and manually clicked through every page. It will break tomorrow when your colleague changes a utility function, when a dependency updates, or when a customer submits a form field you never considered. Testing is the only reliable way to prove that your application does what you think it does, and the only way to change code with confidence.
Flask applications are particularly well-suited for testing. Flask was designed with testability in mind — the framework provides a built-in test client, application context management, and hooks for overriding configuration. Unlike monolithic frameworks where testing often feels like fighting the framework, Flask testing is straightforward and fast.
This tutorial covers every aspect of testing Flask applications, from your first unit test to running a full test suite in CI/CD. We will cover:
calculate_discount() function return the correct value? Does your User model validate email addresses properly?/register endpoint actually create a user in the database and send a welcome email?The testing pyramid suggests that you should have many unit tests, fewer integration tests, and even fewer end-to-end tests. Unit tests are fast and pinpoint failures precisely. Integration tests catch issues at boundaries between components. End-to-end tests verify that the whole system works, but they are slower and harder to debug when they fail.
We will use pytest as our testing framework. While Python ships with unittest, pytest is the industry standard for Python testing. It has cleaner syntax, powerful fixtures, better error messages, and a massive plugin ecosystem. Every serious Flask project uses pytest.
Start by installing pytest and the essential testing libraries:
pip install pytest pytest-cov pytest-flask flask
Here is what each package does:
A well-organized test structure mirrors your application structure. Here is the layout we will use throughout this tutorial:
flask_app/ ├── app/ │ ├── __init__.py # Application factory │ ├── models.py # SQLAlchemy models │ ├── routes/ │ │ ├── __init__.py │ │ ├── auth.py # Authentication routes │ │ └── api.py # API routes │ ├── services/ │ │ ├── __init__.py │ │ └── email_service.py # External service │ └── templates/ │ ├── base.html │ ├── login.html │ └── dashboard.html ├── tests/ │ ├── __init__.py # Makes tests a package │ ├── conftest.py # Shared fixtures │ ├── test_auth.py # Authentication tests │ ├── test_api.py # API endpoint tests │ ├── test_models.py # Model tests │ └── test_services.py # Service layer tests ├── config.py # Configuration classes ├── pytest.ini # pytest configuration └── requirements.txt
Key conventions:
test_ (pytest discovers them automatically)test_tests/ directory has its own __init__.pyconftest.py holds fixtures shared across all test files (pytest picks it up automatically)Create a pytest.ini file at the project root to configure pytest behavior:
[pytest]
testpaths = tests
python_files = test_*.py
python_functions = test_*
python_classes = Test*
addopts = -v --tb=short
filterwarnings =
ignore::DeprecationWarning
You can also use pyproject.toml if you prefer:
[tool.pytest.ini_options] testpaths = ["tests"] addopts = "-v --tb=short" python_files = ["test_*.py"] python_functions = ["test_*"]
The application factory pattern is essential for testing. It lets you create fresh application instances with different configurations for each test run. Here is a minimal application factory:
# config.py
import os
class Config:
"""Base configuration."""
SECRET_KEY = os.environ.get("SECRET_KEY", "dev-secret-key")
SQLALCHEMY_TRACK_MODIFICATIONS = False
class DevelopmentConfig(Config):
"""Development configuration."""
DEBUG = True
SQLALCHEMY_DATABASE_URI = "sqlite:///dev.db"
class TestingConfig(Config):
"""Testing configuration."""
TESTING = True
SQLALCHEMY_DATABASE_URI = "sqlite:///:memory:"
WTF_CSRF_ENABLED = False # Disable CSRF for testing
SERVER_NAME = "localhost"
class ProductionConfig(Config):
"""Production configuration."""
SQLALCHEMY_DATABASE_URI = os.environ.get("DATABASE_URL")
# app/__init__.py
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
db = SQLAlchemy()
def create_app(config_class="config.DevelopmentConfig"):
"""Application factory.
Args:
config_class: Configuration class path string.
Returns:
Configured Flask application instance.
"""
app = Flask(__name__)
app.config.from_object(config_class)
# Initialize extensions
db.init_app(app)
# Register blueprints
from app.routes.auth import auth_bp
from app.routes.api import api_bp
app.register_blueprint(auth_bp)
app.register_blueprint(api_bp, url_prefix="/api")
# Create database tables
with app.app_context():
db.create_all()
return app
The critical detail here is the TestingConfig class. It uses an in-memory SQLite database (sqlite:///:memory:) that is created fresh for every test session, it disables CSRF protection so form submissions work without tokens, and it sets TESTING = True which disables error catching during request handling so you get real exceptions in tests instead of error pages.
Flask provides a test client that simulates HTTP requests to your application without running a real server. No network calls, no ports, no server process. The test client sends requests directly through Flask’s request handling pipeline, making tests extremely fast.
from app import create_app
def test_homepage():
"""Test that the homepage returns 200."""
app = create_app("config.TestingConfig")
with app.test_client() as client:
response = client.get("/")
assert response.status_code == 200
The test_client() method returns a FlaskClient instance that you use to make requests. Using it as a context manager (with with) ensures proper cleanup of the application context.
The test client supports all HTTP methods:
def test_http_methods(client):
"""Demonstrate all HTTP methods with the test client."""
# GET request
response = client.get("/api/users")
assert response.status_code == 200
# GET with query parameters
response = client.get("/api/users?page=2&per_page=10")
assert response.status_code == 200
# POST with JSON body
response = client.post(
"/api/users",
json={"name": "Alice", "email": "alice@example.com"},
content_type="application/json"
)
assert response.status_code == 201
# POST with form data
response = client.post(
"/login",
data={"username": "alice", "password": "secret123"}
)
assert response.status_code == 200
# PUT request
response = client.put(
"/api/users/1",
json={"name": "Alice Updated"}
)
assert response.status_code == 200
# PATCH request
response = client.patch(
"/api/users/1",
json={"email": "newalice@example.com"}
)
assert response.status_code == 200
# DELETE request
response = client.delete("/api/users/1")
assert response.status_code == 204
The response object gives you everything you need to verify behavior:
def test_response_inspection(client):
"""Demonstrate response inspection methods."""
response = client.get("/api/users/1")
# Status code
assert response.status_code == 200
# Response body as bytes
raw_data = response.data
# Response body as string
text = response.get_data(as_text=True)
# Parse JSON response
json_data = response.get_json()
assert json_data["name"] == "Alice"
# Response headers
assert response.content_type == "application/json"
assert "Content-Length" in response.headers
# Check for redirects
response = client.post("/login", data={"username": "alice", "password": "secret"})
assert response.status_code == 302
assert response.location == "/dashboard"
# Follow redirects automatically
response = client.post(
"/login",
data={"username": "alice", "password": "secret"},
follow_redirects=True
)
assert response.status_code == 200
assert b"Dashboard" in response.data
def test_headers_and_cookies(client):
"""Demonstrate setting headers and cookies."""
# Custom headers
response = client.get(
"/api/users",
headers={
"Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR...",
"Accept": "application/json",
"X-Request-ID": "test-123"
}
)
# Set cookies on the client
client.set_cookie("session_id", "abc123", domain="localhost")
response = client.get("/dashboard")
# Read cookies from response
cookies = response.headers.getlist("Set-Cookie")
# Delete cookies
client.delete_cookie("session_id", domain="localhost")
Fixtures are the backbone of well-organized tests. They provide reusable setup and teardown logic, eliminate duplication, and make tests readable. pytest fixtures are functions decorated with @pytest.fixture that return a value your test functions can use.
Place shared fixtures in conftest.py. pytest automatically discovers this file and makes its fixtures available to all tests in the same directory and subdirectories. You never need to import it.
# tests/conftest.py
import pytest
from app import create_app, db as _db
from app.models import User
@pytest.fixture(scope="session")
def app():
"""Create application for the entire test session.
scope='session' means this fixture runs once and is shared
across all tests. This is efficient because creating the app
is expensive (loading config, registering blueprints, etc.)
but the app object itself is stateless.
"""
app = create_app("config.TestingConfig")
yield app
@pytest.fixture(scope="function")
def client(app):
"""Create a test client for each test function.
scope='function' (the default) means each test gets a fresh
client. This prevents state leakage between tests.
"""
with app.test_client() as client:
yield client
@pytest.fixture(scope="function")
def db(app):
"""Set up a clean database for each test.
Creates all tables before the test and drops them after.
This guarantees each test starts with an empty database.
"""
with app.app_context():
_db.create_all()
yield _db
_db.session.rollback()
_db.drop_all()
@pytest.fixture
def sample_user(db):
"""Create a sample user for tests that need one."""
user = User(
username="testuser",
email="test@example.com"
)
user.set_password("password123")
db.session.add(user)
db.session.commit()
return user
@pytest.fixture
def auth_client(client, sample_user):
"""Create an authenticated test client.
Logs in the sample user so tests that need authentication
do not have to repeat the login step.
"""
client.post("/login", data={
"username": "testuser",
"password": "password123"
})
return client
@pytest.fixture
def api_headers():
"""Return standard API headers."""
return {
"Content-Type": "application/json",
"Accept": "application/json"
}
@pytest.fixture
def auth_headers(sample_user):
"""Return headers with a valid JWT token."""
from app.services.auth_service import generate_token
token = generate_token(sample_user.id)
return {
"Content-Type": "application/json",
"Authorization": f"Bearer {token}"
}
Understanding fixture scopes is critical for writing efficient tests:
# Fixture scope examples
@pytest.fixture(scope="session")
def app():
"""Session-scoped: created once, shared by all tests."""
return create_app("config.TestingConfig")
@pytest.fixture(scope="module")
def expensive_resource():
"""Module-scoped: created once per test file."""
resource = create_expensive_resource()
yield resource
resource.cleanup()
@pytest.fixture(scope="function")
def db_session(app):
"""Function-scoped: fresh for every single test."""
with app.app_context():
_db.create_all()
yield _db.session
_db.session.rollback()
_db.drop_all()
Fixtures can depend on other fixtures. pytest resolves the dependency graph automatically:
@pytest.fixture
def user(db):
"""Depends on db fixture — db is set up first."""
user = User(username="alice", email="alice@example.com")
user.set_password("secret")
db.session.add(user)
db.session.commit()
return user
@pytest.fixture
def user_with_posts(user, db):
"""Depends on user fixture — user is created first."""
from app.models import Post
for i in range(3):
post = Post(
title=f"Post {i}",
content=f"Content for post {i}",
author_id=user.id
)
db.session.add(post)
db.session.commit()
return user
@pytest.fixture
def admin_user(db):
"""Create an admin user."""
admin = User(
username="admin",
email="admin@example.com",
is_admin=True
)
admin.set_password("admin123")
db.session.add(admin)
db.session.commit()
return admin
Route testing verifies that your endpoints accept the right inputs, return the right outputs, and handle edge cases correctly. This is the bread and butter of Flask testing.
def test_homepage_returns_200(client):
"""Test that the homepage is accessible."""
response = client.get("/")
assert response.status_code == 200
def test_homepage_contains_welcome_message(client):
"""Test that the homepage renders expected content."""
response = client.get("/")
assert b"Welcome" in response.data
def test_user_profile_requires_login(client):
"""Test that profile page redirects unauthenticated users."""
response = client.get("/profile")
assert response.status_code == 302
assert "/login" in response.location
def test_user_profile_shows_username(auth_client, sample_user):
"""Test that authenticated users see their profile."""
response = auth_client.get("/profile")
assert response.status_code == 200
assert sample_user.username.encode() in response.data
def test_nonexistent_page_returns_404(client):
"""Test that missing pages return 404."""
response = client.get("/this-page-does-not-exist")
assert response.status_code == 404
def test_register_with_valid_data(client, db):
"""Test successful user registration."""
response = client.post("/register", data={
"username": "newuser",
"email": "new@example.com",
"password": "StrongPass123!",
"confirm_password": "StrongPass123!"
}, follow_redirects=True)
assert response.status_code == 200
assert b"Registration successful" in response.data
# Verify user was actually created in database
user = User.query.filter_by(username="newuser").first()
assert user is not None
assert user.email == "new@example.com"
def test_register_with_duplicate_email(client, db, sample_user):
"""Test that duplicate email addresses are rejected."""
response = client.post("/register", data={
"username": "another_user",
"email": "test@example.com", # Already used by sample_user
"password": "StrongPass123!",
"confirm_password": "StrongPass123!"
})
assert response.status_code == 400
assert b"Email already registered" in response.data
def test_register_with_missing_fields(client, db):
"""Test that missing fields return validation errors."""
response = client.post("/register", data={
"username": "newuser"
# Missing email and password
})
assert response.status_code == 400
assert b"email" in response.data.lower() or b"required" in response.data.lower()
def test_create_post_via_api(auth_headers, client, db):
"""Test creating a resource via JSON API."""
response = client.post(
"/api/posts",
json={
"title": "Test Post",
"content": "This is a test post.",
"tags": ["python", "flask"]
},
headers=auth_headers
)
assert response.status_code == 201
data = response.get_json()
assert data["title"] == "Test Post"
assert data["id"] is not None
assert "created_at" in data
def test_create_post_with_invalid_json(auth_headers, client):
"""Test that invalid JSON returns 400."""
response = client.post(
"/api/posts",
data="this is not json",
content_type="application/json",
headers=auth_headers
)
assert response.status_code == 400
def test_create_post_missing_required_field(auth_headers, client, db):
"""Test that missing required fields return validation errors."""
response = client.post(
"/api/posts",
json={"content": "No title provided"},
headers=auth_headers
)
assert response.status_code == 400
data = response.get_json()
assert "title" in str(data).lower()
import io
def test_file_upload(auth_client, db):
"""Test uploading a file."""
# Create a fake file in memory
data = {
"file": (io.BytesIO(b"file content here"), "test.txt"),
"description": "A test file"
}
response = auth_client.post(
"/upload",
data=data,
content_type="multipart/form-data"
)
assert response.status_code == 200
assert b"File uploaded successfully" in response.data
def test_upload_image(auth_client, db):
"""Test uploading an image file."""
# Create a minimal valid PNG (1x1 pixel)
png_data = (
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR"
b"\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02"
b"\x00\x00\x00\x90wS\xde\x00\x00\x00\x0c"
b"IDATx\x9cc\xf8\x0f\x00\x00\x01\x01\x00"
b"\x05\x18\xd8N\x00\x00\x00\x00IEND\xaeB`\x82"
)
data = {
"image": (io.BytesIO(png_data), "photo.png"),
}
response = auth_client.post(
"/upload/image",
data=data,
content_type="multipart/form-data"
)
assert response.status_code == 200
def test_upload_rejects_invalid_extension(auth_client):
"""Test that dangerous file extensions are rejected."""
data = {
"file": (io.BytesIO(b"malicious content"), "hack.exe"),
}
response = auth_client.post(
"/upload",
data=data,
content_type="multipart/form-data"
)
assert response.status_code == 400
assert b"File type not allowed" in response.data
Database testing is where most Flask developers struggle. The challenge is isolation — each test must start with a known database state and must not affect other tests. There are two main strategies: drop and recreate tables, or use transaction rollbacks.
@pytest.fixture
def db(app):
"""Drop all tables, recreate, yield, then drop again."""
with app.app_context():
_db.drop_all()
_db.create_all()
yield _db
_db.session.remove()
_db.drop_all()
This is the simplest approach. It guarantees a clean slate but is slower because it rebuilds the schema for every test.
@pytest.fixture(scope="session")
def db(app):
"""Create tables once for the entire test session."""
with app.app_context():
_db.create_all()
yield _db
_db.drop_all()
@pytest.fixture(scope="function", autouse=True)
def db_session(db, app):
"""Wrap each test in a transaction that rolls back.
autouse=True means this fixture runs for every test
automatically, without being explicitly requested.
"""
with app.app_context():
connection = db.engine.connect()
transaction = connection.begin()
# Bind the session to this connection
options = dict(bind=connection, binds={})
session = db.create_scoped_session(options=options)
db.session = session
yield session
# Rollback the transaction, undoing all changes
transaction.rollback()
connection.close()
session.remove()
The transaction rollback strategy is significantly faster for large test suites. Instead of dropping and recreating tables for every test, it wraps each test in a database transaction and rolls it back when the test finishes. The data changes simply vanish.
# tests/test_models.py
from app.models import User, Post
class TestUserModel:
"""Tests for the User model."""
def test_create_user(self, db):
"""Test creating a user."""
user = User(username="alice", email="alice@example.com")
user.set_password("secret123")
db.session.add(user)
db.session.commit()
assert user.id is not None
assert user.username == "alice"
assert user.email == "alice@example.com"
def test_password_hashing(self, db):
"""Test that passwords are hashed, not stored in plain text."""
user = User(username="bob", email="bob@example.com")
user.set_password("mypassword")
assert user.password_hash != "mypassword"
assert user.check_password("mypassword") is True
assert user.check_password("wrongpassword") is False
def test_unique_username(self, db):
"""Test that duplicate usernames are rejected."""
user1 = User(username="alice", email="alice1@example.com")
user1.set_password("pass1")
db.session.add(user1)
db.session.commit()
user2 = User(username="alice", email="alice2@example.com")
user2.set_password("pass2")
db.session.add(user2)
with pytest.raises(Exception): # IntegrityError
db.session.commit()
def test_user_repr(self, db):
"""Test the string representation of User."""
user = User(username="alice", email="alice@example.com")
assert "alice" in repr(user)
class TestPostModel:
"""Tests for the Post model."""
def test_create_post(self, db, sample_user):
"""Test creating a post with an author."""
post = Post(
title="My First Post",
content="Hello, World!",
author_id=sample_user.id
)
db.session.add(post)
db.session.commit()
assert post.id is not None
assert post.author_id == sample_user.id
assert post.created_at is not None
def test_post_author_relationship(self, db, sample_user):
"""Test the relationship between Post and User."""
post = Post(
title="Test",
content="Content",
author_id=sample_user.id
)
db.session.add(post)
db.session.commit()
assert post.author.username == "testuser"
assert post in sample_user.posts
@pytest.fixture
def seed_data(db):
"""Seed the database with realistic test data."""
users = []
for i in range(5):
user = User(
username=f"user_{i}",
email=f"user_{i}@example.com"
)
user.set_password(f"password_{i}")
db.session.add(user)
users.append(user)
db.session.commit()
posts = []
for i, user in enumerate(users):
for j in range(3):
post = Post(
title=f"Post {i}-{j}",
content=f"Content by {user.username}",
author_id=user.id
)
db.session.add(post)
posts.append(post)
db.session.commit()
return {"users": users, "posts": posts}
def test_list_posts_pagination(client, seed_data):
"""Test that posts are paginated correctly."""
response = client.get("/api/posts?page=1&per_page=5")
data = response.get_json()
assert len(data["items"]) == 5
assert data["total"] == 15 # 5 users * 3 posts each
assert data["page"] == 1
assert data["pages"] == 3
Authentication tests verify that your security layer works correctly. This means testing both the happy path (valid credentials grant access) and the security boundaries (invalid credentials are rejected, protected routes are actually protected).
class TestLogin:
"""Tests for session-based login/logout."""
def test_login_page_renders(self, client):
"""Test that the login page is accessible."""
response = client.get("/login")
assert response.status_code == 200
assert b"Login" in response.data
def test_login_with_valid_credentials(self, client, sample_user):
"""Test successful login."""
response = client.post("/login", data={
"username": "testuser",
"password": "password123"
}, follow_redirects=True)
assert response.status_code == 200
assert b"Dashboard" in response.data or b"Welcome" in response.data
def test_login_with_wrong_password(self, client, sample_user):
"""Test login with incorrect password."""
response = client.post("/login", data={
"username": "testuser",
"password": "wrongpassword"
})
assert response.status_code == 401 or b"Invalid" in response.data
def test_login_with_nonexistent_user(self, client, db):
"""Test login with a username that does not exist."""
response = client.post("/login", data={
"username": "ghost",
"password": "password123"
})
assert response.status_code == 401 or b"Invalid" in response.data
def test_logout(self, auth_client):
"""Test that logout clears the session."""
response = auth_client.get("/logout", follow_redirects=True)
assert response.status_code == 200
# Verify session is cleared by accessing protected route
response = auth_client.get("/dashboard")
assert response.status_code == 302 # Redirected to login
def test_protected_route_without_login(self, client):
"""Test that protected routes redirect to login."""
response = client.get("/dashboard")
assert response.status_code == 302
assert "/login" in response.location
class TestJWTAuth:
"""Tests for JWT-based API authentication."""
def test_get_token_with_valid_credentials(self, client, sample_user):
"""Test obtaining a JWT token."""
response = client.post("/api/auth/login", json={
"username": "testuser",
"password": "password123"
})
assert response.status_code == 200
data = response.get_json()
assert "access_token" in data
assert "refresh_token" in data
def test_get_token_with_invalid_credentials(self, client, sample_user):
"""Test that invalid credentials do not return a token."""
response = client.post("/api/auth/login", json={
"username": "testuser",
"password": "wrong"
})
assert response.status_code == 401
data = response.get_json()
assert "access_token" not in data
def test_access_protected_endpoint_with_token(self, client, auth_headers):
"""Test accessing a protected API endpoint."""
response = client.get("/api/users/me", headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
assert data["username"] == "testuser"
def test_access_protected_endpoint_without_token(self, client):
"""Test that missing token returns 401."""
response = client.get("/api/users/me")
assert response.status_code == 401
data = response.get_json()
assert "msg" in data or "message" in data
def test_access_with_expired_token(self, client, sample_user):
"""Test that expired tokens are rejected."""
from app.services.auth_service import generate_token
# Generate a token that expires immediately
token = generate_token(sample_user.id, expires_in=-1)
headers = {"Authorization": f"Bearer {token}"}
response = client.get("/api/users/me", headers=headers)
assert response.status_code == 401
def test_refresh_token(self, client, sample_user):
"""Test refreshing an expired access token."""
# First, get tokens
login_response = client.post("/api/auth/login", json={
"username": "testuser",
"password": "password123"
})
refresh_token = login_response.get_json()["refresh_token"]
# Use refresh token to get new access token
response = client.post("/api/auth/refresh", json={
"refresh_token": refresh_token
})
assert response.status_code == 200
data = response.get_json()
assert "access_token" in data
def test_access_admin_endpoint_as_regular_user(self, client, auth_headers):
"""Test that non-admin users cannot access admin endpoints."""
response = client.get("/api/admin/users", headers=auth_headers)
assert response.status_code == 403
Mocking replaces real objects with controlled substitutes. You mock things that are external to the code you are testing: API calls, email sending, file system operations, time-dependent functions, and third-party services. You do not mock the code you are testing — that defeats the purpose.
from unittest.mock import patch, MagicMock
def test_send_welcome_email(client, db):
"""Test registration sends a welcome email without actually sending one."""
with patch("app.services.email_service.send_email") as mock_send:
mock_send.return_value = True
response = client.post("/register", data={
"username": "newuser",
"email": "new@example.com",
"password": "StrongPass123!",
"confirm_password": "StrongPass123!"
})
assert response.status_code == 200
# Verify send_email was called with the right arguments
mock_send.assert_called_once()
call_args = mock_send.call_args
assert call_args[1]["to"] == "new@example.com"
assert "Welcome" in call_args[1]["subject"]
def test_weather_endpoint(client):
"""Test weather endpoint without calling the real weather API."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"temperature": 72,
"condition": "sunny",
"city": "San Francisco"
}
with patch("app.services.weather_service.requests.get") as mock_get:
mock_get.return_value = mock_response
response = client.get("/api/weather?city=San+Francisco")
assert response.status_code == 200
data = response.get_json()
assert data["temperature"] == 72
assert data["condition"] == "sunny"
def test_weather_api_failure(client):
"""Test graceful handling when the weather API is down."""
with patch("app.services.weather_service.requests.get") as mock_get:
mock_get.side_effect = ConnectionError("API unavailable")
response = client.get("/api/weather?city=San+Francisco")
assert response.status_code == 503
data = response.get_json()
assert "unavailable" in data["message"].lower()
def test_user_service_with_mocked_db(app):
"""Test a service function without touching the database."""
from app.services.user_service import get_user_stats
mock_user = MagicMock()
mock_user.id = 1
mock_user.username = "alice"
mock_user.posts = [MagicMock(), MagicMock(), MagicMock()]
mock_user.created_at = "2024-01-01"
with app.app_context():
with patch("app.services.user_service.User") as MockUser:
MockUser.query.get.return_value = mock_user
stats = get_user_stats(1)
assert stats["username"] == "alice"
assert stats["post_count"] == 3
MockUser.query.get.assert_called_once_with(1)
from datetime import datetime
def test_time_dependent_feature(client, db, sample_user):
"""Test a feature that depends on the current time."""
# Mock datetime to control 'now'
frozen_time = datetime(2024, 12, 25, 10, 0, 0)
with patch("app.routes.api.datetime") as mock_dt:
mock_dt.utcnow.return_value = frozen_time
mock_dt.side_effect = lambda *args, **kwargs: datetime(*args, **kwargs)
response = client.get("/api/greeting")
data = response.get_json()
assert "Merry Christmas" in data["message"]
# pip install pytest-mock
def test_with_mocker(client, mocker):
"""pytest-mock provides a mocker fixture with cleaner syntax."""
# Instead of: with patch("app.services.email_service.send_email") as mock_send:
mock_send = mocker.patch("app.services.email_service.send_email")
mock_send.return_value = True
response = client.post("/register", data={
"username": "newuser",
"email": "new@example.com",
"password": "StrongPass123!",
"confirm_password": "StrongPass123!"
})
mock_send.assert_called_once()
Template tests verify that your views render the correct HTML content. You are not testing Jinja2 itself — you are testing that your templates receive the right context and produce the expected output.
def test_dashboard_shows_user_posts(auth_client, db, sample_user):
"""Test that the dashboard displays the user's posts."""
# Create some posts for the user
from app.models import Post
for i in range(3):
post = Post(
title=f"Post {i}",
content=f"Content {i}",
author_id=sample_user.id
)
db.session.add(post)
db.session.commit()
response = auth_client.get("/dashboard")
html = response.get_data(as_text=True)
assert "Post 0" in html
assert "Post 1" in html
assert "Post 2" in html
def test_login_page_has_form_fields(client):
"""Test that the login page contains required form elements."""
response = client.get("/login")
html = response.get_data(as_text=True)
assert 'name="username"' in html or 'name="email"' in html
assert 'name="password"' in html
assert 'type="submit"' in html
def test_error_messages_display(client, db, sample_user):
"""Test that validation errors appear in the template."""
response = client.post("/login", data={
"username": "testuser",
"password": "wrong"
})
html = response.get_data(as_text=True)
assert "Invalid" in html or "incorrect" in html.lower()
def test_template_context(app, sample_user):
"""Test that the correct context is passed to templates."""
with app.test_request_context():
with app.test_client() as client:
# Login first
client.post("/login", data={
"username": "testuser",
"password": "password123"
})
# Flask records template rendering with this signal
from flask import template_rendered
recorded_templates = []
def record(sender, template, context, **extra):
recorded_templates.append((template, context))
template_rendered.connect(record, app)
try:
client.get("/dashboard")
assert len(recorded_templates) > 0
template, context = recorded_templates[0]
assert template.name == "dashboard.html"
assert "user" in context
assert context["user"].username == "testuser"
finally:
template_rendered.disconnect(record, app)
def test_navbar_shows_login_for_anonymous(client):
"""Test that anonymous users see the login link."""
response = client.get("/")
html = response.get_data(as_text=True)
assert "Login" in html
assert "Logout" not in html
def test_navbar_shows_logout_for_authenticated(auth_client):
"""Test that logged-in users see the logout link."""
response = auth_client.get("/")
html = response.get_data(as_text=True)
assert "Logout" in html
assert "Login" not in html
Every production application needs proper error handling, and every error handler needs tests. Flask allows you to register custom error handlers for HTTP status codes and exceptions.
def test_404_returns_json_for_api(client):
"""Test that API 404s return JSON, not HTML."""
response = client.get(
"/api/nonexistent",
headers={"Accept": "application/json"}
)
assert response.status_code == 404
data = response.get_json()
assert data["error"] == "Not Found"
assert "message" in data
def test_404_returns_html_for_browser(client):
"""Test that browser 404s return a friendly HTML page."""
response = client.get("/nonexistent-page")
assert response.status_code == 404
html = response.get_data(as_text=True)
assert "Page Not Found" in html or "404" in html
def test_500_error_handler(app):
"""Test that 500 errors return a proper error response."""
@app.route("/force-error")
def force_error():
raise RuntimeError("Something went wrong")
# Turn off TESTING to enable error handlers
app.config["TESTING"] = False
with app.test_client() as client:
response = client.get("/force-error")
assert response.status_code == 500
# Restore TESTING
app.config["TESTING"] = True
def test_rate_limit_error(client):
"""Test that rate limiting returns 429."""
# Make requests until rate limit is hit
for _ in range(100):
response = client.get("/api/data")
assert response.status_code == 429
data = response.get_json()
assert "rate limit" in data["message"].lower()
def test_method_not_allowed(client):
"""Test that wrong HTTP methods return 405."""
response = client.delete("/login") # Login does not support DELETE
assert response.status_code == 405
def test_validation_error_format(client, auth_headers):
"""Test that validation errors have a consistent format."""
response = client.post(
"/api/users",
json={"email": "not-a-valid-email"},
headers=auth_headers
)
assert response.status_code == 400
data = response.get_json()
assert "errors" in data
assert isinstance(data["errors"], dict)
assert "email" in data["errors"]
Test coverage measures which lines of your application code are executed during testing. It does not guarantee your tests are good, but low coverage guarantees you have blind spots. Use it as a guide, not a goal.
pip install pytest-cov
# Basic coverage report pytest --cov=app tests/ # Coverage with line numbers for missed lines pytest --cov=app --cov-report=term-missing tests/ # Generate HTML report (opens in browser) pytest --cov=app --cov-report=html tests/ # Open htmlcov/index.html in your browser # Generate XML report (for CI/CD tools) pytest --cov=app --cov-report=xml tests/ # Multiple report formats at once pytest --cov=app --cov-report=term-missing --cov-report=html tests/
Create a .coveragerc file to configure what gets measured:
[run]
source = app
omit =
app/migrations/*
app/__init__.py
*/test_*
*/conftest.py
[report]
exclude_lines =
pragma: no cover
def __repr__
if __name__ == .__main__
raise NotImplementedError
pass
fail_under = 80
show_missing = true
[html]
directory = htmlcov
$ pytest --cov=app --cov-report=term-missing tests/ ---------- coverage: platform linux, python 3.11.5 ---------- Name Stmts Miss Cover Missing ------------------------------------------------------------ app/__init__.py 25 0 100% app/models.py 48 3 94% 72-74 app/routes/auth.py 65 8 88% 45-48, 92-95 app/routes/api.py 89 12 87% 34, 67-72, 101-105 app/services/email_service.py 22 2 91% 18-19 app/services/auth_service.py 35 0 100% ------------------------------------------------------------ TOTAL 284 25 91%
The “Missing” column tells you exactly which lines are not covered. Lines 72-74 in models.py, for example, might be an edge case you have not tested. Investigate those lines and decide whether they need tests.
The fail_under = 80 setting in .coveragerc will cause pytest to fail if coverage drops below 80%. Add this to your CI pipeline to prevent coverage regressions.
Let us put everything together. Here is a complete, realistic test suite for a Flask application with user registration, authentication, and CRUD operations. This is the code you would actually write in a production project.
# app/models.py
from app import db
from werkzeug.security import generate_password_hash, check_password_hash
from datetime import datetime
class User(db.Model):
"""User model with authentication support."""
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False)
email = db.Column(db.String(120), unique=True, nullable=False)
password_hash = db.Column(db.String(256), nullable=False)
is_admin = db.Column(db.Boolean, default=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
posts = db.relationship("Post", backref="author", lazy="dynamic")
def set_password(self, password):
self.password_hash = generate_password_hash(password)
def check_password(self, password):
return check_password_hash(self.password_hash, password)
def to_dict(self):
return {
"id": self.id,
"username": self.username,
"email": self.email,
"is_admin": self.is_admin,
"created_at": self.created_at.isoformat()
}
def __repr__(self):
return f"<User {self.username}>"
class Post(db.Model):
"""Blog post model."""
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(200), nullable=False)
content = db.Column(db.Text, nullable=False)
author_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
def to_dict(self):
return {
"id": self.id,
"title": self.title,
"content": self.content,
"author": self.author.username,
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat()
}
def __repr__(self):
return f"<Post {self.title}>"
# tests/conftest.py
import pytest
from app import create_app, db as _db
from app.models import User, Post
@pytest.fixture(scope="session")
def app():
"""Create the Flask application for testing."""
app = create_app("config.TestingConfig")
yield app
@pytest.fixture(scope="function")
def db(app):
"""Provide a clean database for each test."""
with app.app_context():
_db.create_all()
yield _db
_db.session.rollback()
_db.drop_all()
@pytest.fixture
def client(app):
"""Provide a Flask test client."""
with app.test_client() as client:
yield client
@pytest.fixture
def runner(app):
"""Provide a Flask CLI test runner."""
return app.test_cli_runner()
@pytest.fixture
def sample_user(db):
"""Create and return a standard test user."""
user = User(
username="testuser",
email="test@example.com"
)
user.set_password("password123")
db.session.add(user)
db.session.commit()
return user
@pytest.fixture
def admin_user(db):
"""Create and return an admin user."""
user = User(
username="admin",
email="admin@example.com",
is_admin=True
)
user.set_password("admin123")
db.session.add(user)
db.session.commit()
return user
@pytest.fixture
def auth_client(client, sample_user):
"""Provide a test client logged in as sample_user."""
client.post("/login", data={
"username": "testuser",
"password": "password123"
})
return client
@pytest.fixture
def admin_client(client, admin_user):
"""Provide a test client logged in as admin."""
client.post("/login", data={
"username": "admin",
"password": "admin123"
})
return client
@pytest.fixture
def api_token(client, sample_user):
"""Get a JWT token for the sample user."""
response = client.post("/api/auth/login", json={
"username": "testuser",
"password": "password123"
})
return response.get_json()["access_token"]
@pytest.fixture
def auth_headers(api_token):
"""Provide headers with JWT authentication."""
return {
"Authorization": f"Bearer {api_token}",
"Content-Type": "application/json"
}
@pytest.fixture
def sample_posts(db, sample_user):
"""Create sample posts for testing."""
posts = []
for i in range(5):
post = Post(
title=f"Test Post {i}",
content=f"Content for test post {i}. " * 10,
author_id=sample_user.id
)
db.session.add(post)
posts.append(post)
db.session.commit()
return posts
# tests/test_registration.py
import pytest
from app.models import User
class TestRegistration:
"""User registration tests."""
def test_register_success(self, client, db):
"""Test successful registration with valid data."""
response = client.post("/register", data={
"username": "alice",
"email": "alice@example.com",
"password": "SecurePass123!",
"confirm_password": "SecurePass123!"
}, follow_redirects=True)
assert response.status_code == 200
# Verify user exists in database
user = User.query.filter_by(username="alice").first()
assert user is not None
assert user.email == "alice@example.com"
assert user.check_password("SecurePass123!")
def test_register_duplicate_username(self, client, db, sample_user):
"""Test that duplicate usernames are rejected."""
response = client.post("/register", data={
"username": "testuser", # Already exists
"email": "different@example.com",
"password": "SecurePass123!",
"confirm_password": "SecurePass123!"
})
assert b"already" in response.data.lower() or response.status_code == 400
def test_register_duplicate_email(self, client, db, sample_user):
"""Test that duplicate emails are rejected."""
response = client.post("/register", data={
"username": "different_user",
"email": "test@example.com", # Already exists
"password": "SecurePass123!",
"confirm_password": "SecurePass123!"
})
assert b"already" in response.data.lower() or response.status_code == 400
def test_register_password_mismatch(self, client, db):
"""Test that mismatched passwords are rejected."""
response = client.post("/register", data={
"username": "alice",
"email": "alice@example.com",
"password": "SecurePass123!",
"confirm_password": "DifferentPass456!"
})
assert response.status_code == 400 or b"match" in response.data.lower()
def test_register_weak_password(self, client, db):
"""Test that weak passwords are rejected."""
response = client.post("/register", data={
"username": "alice",
"email": "alice@example.com",
"password": "123",
"confirm_password": "123"
})
assert response.status_code == 400 or b"password" in response.data.lower()
@pytest.mark.parametrize("field,value", [
("username", ""),
("email", ""),
("password", ""),
("email", "not-an-email"),
])
def test_register_invalid_input(self, client, db, field, value):
"""Test registration with various invalid inputs."""
data = {
"username": "alice",
"email": "alice@example.com",
"password": "SecurePass123!",
"confirm_password": "SecurePass123!"
}
data[field] = value
response = client.post("/register", data=data)
assert response.status_code == 400 or b"error" in response.data.lower()
# tests/test_auth.py
class TestAuthentication:
"""Login and logout tests."""
def test_login_success(self, client, sample_user):
"""Test successful login redirects to dashboard."""
response = client.post("/login", data={
"username": "testuser",
"password": "password123"
}, follow_redirects=True)
assert response.status_code == 200
assert b"testuser" in response.data
def test_login_wrong_password(self, client, sample_user):
"""Test login with wrong password fails."""
response = client.post("/login", data={
"username": "testuser",
"password": "wrongpassword"
})
assert response.status_code != 200 or b"Invalid" in response.data
def test_login_nonexistent_user(self, client, db):
"""Test login with nonexistent username fails."""
response = client.post("/login", data={
"username": "nobody",
"password": "password123"
})
assert response.status_code != 200 or b"Invalid" in response.data
def test_logout_clears_session(self, auth_client):
"""Test that logout clears the session."""
# Verify we are logged in
response = auth_client.get("/dashboard")
assert response.status_code == 200
# Logout
auth_client.get("/logout", follow_redirects=True)
# Verify we are logged out
response = auth_client.get("/dashboard")
assert response.status_code == 302 # Redirect to login
def test_session_persists_across_requests(self, auth_client):
"""Test that the session stays active across multiple requests."""
response1 = auth_client.get("/dashboard")
assert response1.status_code == 200
response2 = auth_client.get("/profile")
assert response2.status_code == 200
# tests/test_crud.py
from app.models import Post
class TestPostCRUD:
"""Test Create, Read, Update, Delete for posts."""
def test_create_post(self, auth_client, db, sample_user):
"""Test creating a new post."""
response = auth_client.post("/posts/new", data={
"title": "My New Post",
"content": "This is the post content."
}, follow_redirects=True)
assert response.status_code == 200
post = Post.query.filter_by(title="My New Post").first()
assert post is not None
assert post.author_id == sample_user.id
def test_read_post(self, client, db, sample_posts):
"""Test reading a single post."""
post = sample_posts[0]
response = client.get(f"/posts/{post.id}")
assert response.status_code == 200
assert post.title.encode() in response.data
def test_read_nonexistent_post(self, client, db):
"""Test reading a post that does not exist."""
response = client.get("/posts/99999")
assert response.status_code == 404
def test_update_post(self, auth_client, db, sample_posts):
"""Test updating a post."""
post = sample_posts[0]
response = auth_client.post(f"/posts/{post.id}/edit", data={
"title": "Updated Title",
"content": "Updated content."
}, follow_redirects=True)
assert response.status_code == 200
updated = Post.query.get(post.id)
assert updated.title == "Updated Title"
assert updated.content == "Updated content."
def test_cannot_update_others_post(self, auth_client, db, admin_user):
"""Test that users cannot edit posts they do not own."""
# Create a post owned by admin
post = Post(
title="Admin Post",
content="Admin content",
author_id=admin_user.id
)
db.session.add(post)
db.session.commit()
# Try to edit as regular user
response = auth_client.post(f"/posts/{post.id}/edit", data={
"title": "Hacked Title",
"content": "Hacked content."
})
assert response.status_code == 403
def test_delete_post(self, auth_client, db, sample_posts):
"""Test deleting a post."""
post = sample_posts[0]
post_id = post.id
response = auth_client.post(
f"/posts/{post_id}/delete",
follow_redirects=True
)
assert response.status_code == 200
assert Post.query.get(post_id) is None
def test_list_posts(self, client, db, sample_posts):
"""Test listing all posts."""
response = client.get("/posts")
assert response.status_code == 200
html = response.get_data(as_text=True)
for post in sample_posts:
assert post.title in html
# tests/test_api.py
import json
class TestPostAPI:
"""Test the REST API for posts."""
def test_list_posts(self, client, db, sample_posts):
"""Test GET /api/posts returns all posts."""
response = client.get("/api/posts")
assert response.status_code == 200
data = response.get_json()
assert len(data["items"]) == 5
def test_get_single_post(self, client, db, sample_posts):
"""Test GET /api/posts/:id returns a single post."""
post = sample_posts[0]
response = client.get(f"/api/posts/{post.id}")
assert response.status_code == 200
data = response.get_json()
assert data["title"] == post.title
assert data["author"] == "testuser"
def test_create_post_via_api(self, client, db, auth_headers):
"""Test POST /api/posts creates a new post."""
response = client.post("/api/posts",
json={
"title": "API Post",
"content": "Created via API."
},
headers=auth_headers
)
assert response.status_code == 201
data = response.get_json()
assert data["title"] == "API Post"
assert data["id"] is not None
def test_create_post_without_auth(self, client, db):
"""Test that creating a post requires authentication."""
response = client.post("/api/posts",
json={"title": "No Auth", "content": "Should fail."}
)
assert response.status_code == 401
def test_update_post_via_api(self, client, db, auth_headers, sample_posts):
"""Test PUT /api/posts/:id updates a post."""
post = sample_posts[0]
response = client.put(f"/api/posts/{post.id}",
json={"title": "Updated via API"},
headers=auth_headers
)
assert response.status_code == 200
data = response.get_json()
assert data["title"] == "Updated via API"
def test_delete_post_via_api(self, client, db, auth_headers, sample_posts):
"""Test DELETE /api/posts/:id removes a post."""
post = sample_posts[0]
response = client.delete(
f"/api/posts/{post.id}",
headers=auth_headers
)
assert response.status_code == 204
# Verify deletion
response = client.get(f"/api/posts/{post.id}")
assert response.status_code == 404
def test_api_pagination(self, client, db, sample_posts):
"""Test that the API paginates results."""
response = client.get("/api/posts?page=1&per_page=2")
data = response.get_json()
assert len(data["items"]) == 2
assert data["total"] == 5
assert data["pages"] == 3
assert data["has_next"] is True
def test_api_returns_json_content_type(self, client, db, sample_posts):
"""Test that API responses have correct content type."""
response = client.get("/api/posts")
assert response.content_type == "application/json"
# tests/test_errors.py
class TestErrorHandling:
"""Test error handling across the application."""
def test_404_page(self, client):
"""Test custom 404 page."""
response = client.get("/this-does-not-exist")
assert response.status_code == 404
def test_api_404_returns_json(self, client):
"""Test that API 404s return JSON."""
response = client.get("/api/posts/99999",
headers={"Accept": "application/json"})
assert response.status_code == 404
data = response.get_json()
assert "error" in data
def test_405_method_not_allowed(self, client):
"""Test that unsupported methods return 405."""
response = client.delete("/login")
assert response.status_code == 405
def test_400_bad_request(self, client, auth_headers):
"""Test that malformed requests return 400."""
response = client.post("/api/posts",
data="not json",
content_type="application/json",
headers=auth_headers
)
assert response.status_code == 400
Tests are only useful if they run automatically. Every push, every pull request should trigger your test suite. Here is how to set up testing in GitHub Actions.
# .github/workflows/tests.yml
name: Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12"]
services:
postgres:
image: postgres:15
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: test_db
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Cache pip packages
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
restore-keys: |
${{ runner.os }}-pip-
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install pytest pytest-cov
- name: Run tests with coverage
env:
DATABASE_URL: postgresql://test:test@localhost:5432/test_db
FLASK_ENV: testing
run: |
pytest --cov=app --cov-report=xml --cov-report=term-missing tests/
- name: Upload coverage to Codecov
if: matrix.python-version == '3.12'
uses: codecov/codecov-action@v4
with:
file: ./coverage.xml
fail_ci_if_error: true
# pyproject.toml
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-v --tb=short --strict-markers"
markers = [
"slow: marks tests as slow (deselect with '-m "not slow"')",
"integration: marks integration tests",
"e2e: marks end-to-end tests",
]
# Run fast tests in CI by default, all tests on main branch
# pytest -m "not slow" # Skip slow tests
# pytest -m "not e2e" # Skip e2e tests
# pytest # Run everything
import pytest
@pytest.mark.slow
def test_generate_large_report(client, db, seed_data):
"""This test takes 30+ seconds, mark it as slow."""
response = client.get("/api/reports/annual")
assert response.status_code == 200
@pytest.mark.integration
def test_full_registration_flow(client, db):
"""Integration test: register, verify email, login."""
# Register
client.post("/register", data={...})
# Verify email (mock the email, extract token)
# Login
# Check dashboard
@pytest.mark.e2e
def test_complete_user_journey(client, db):
"""End-to-end: register, login, create post, comment, logout."""
pass
# Run only fast tests pytest -m "not slow" # Run only integration tests pytest -m integration # Run everything except e2e pytest -m "not e2e" # Run specific test file pytest tests/test_auth.py # Run specific test class pytest tests/test_auth.py::TestLogin # Run specific test function pytest tests/test_auth.py::TestLogin::test_login_success # Run tests matching a keyword pytest -k "login or register" # Run tests in parallel (requires pytest-xdist) pytest -n auto
These are the mistakes I see most often in Flask test suites. Each one has burned me or someone on my team at some point.
The deadliest testing sin. When tests share state, they pass individually but fail when run together, or worse, they pass together but in a specific order.
# BAD: Tests depend on shared state
user_count = 0
def test_create_user(client, db):
global user_count
client.post("/register", data={...})
user_count += 1
def test_user_count(client, db):
# This only passes if test_create_user ran first!
response = client.get("/api/users/count")
assert response.get_json()["count"] == user_count
# GOOD: Each test creates its own state
def test_create_user(client, db):
response = client.post("/register", data={
"username": "alice",
"email": "alice@example.com",
"password": "SecurePass123!",
"confirm_password": "SecurePass123!"
})
assert response.status_code == 200
def test_user_count_with_seeded_data(client, db):
# Create known state within the test
from app.models import User
for i in range(3):
user = User(username=f"user_{i}", email=f"user_{i}@test.com")
user.set_password("pass")
db.session.add(user)
db.session.commit()
response = client.get("/api/users/count")
assert response.get_json()["count"] == 3
# BAD: Testing how the code works internally
def test_login_calls_check_password(client, sample_user, mocker):
mock_check = mocker.patch.object(User, "check_password", return_value=True)
client.post("/login", data={"username": "testuser", "password": "pass"})
mock_check.assert_called_once_with("pass")
# This test breaks if you refactor the login code, even if login still works
# GOOD: Testing observable behavior
def test_login_with_correct_password_succeeds(client, sample_user):
response = client.post("/login", data={
"username": "testuser",
"password": "password123"
}, follow_redirects=True)
assert response.status_code == 200
assert b"Dashboard" in response.data
# BAD: Creating the app for every test
def test_something():
app = create_app("config.TestingConfig") # Expensive!
with app.test_client() as client:
response = client.get("/")
assert response.status_code == 200
# GOOD: Use session-scoped app fixture
@pytest.fixture(scope="session")
def app():
return create_app("config.TestingConfig")
def test_something(client): # client fixture uses the session app
response = client.get("/")
assert response.status_code == 200
# BAD: Only testing the happy path
def test_create_user(client, db):
response = client.post("/register", data={
"username": "alice",
"email": "alice@example.com",
"password": "Pass123!"
})
assert response.status_code == 200
# GOOD: Test boundaries and edge cases
@pytest.mark.parametrize("username", [
"", # Empty
"a", # Too short
"a" * 256, # Too long
"user name", # Contains space
"user@name", # Contains special char
"admin", # Reserved word
"<script>alert(1)</script>", # XSS attempt
])
def test_create_user_invalid_username(client, db, username):
response = client.post("/register", data={
"username": username,
"email": "test@example.com",
"password": "SecurePass123!",
"confirm_password": "SecurePass123!"
})
assert response.status_code == 400
# BAD: Only checking status code
def test_create_user(client, db):
response = client.post("/api/users", json={...})
assert response.status_code == 201 # What if the response body is wrong?
# GOOD: Verify the full response
def test_create_user(client, db, auth_headers):
response = client.post("/api/users",
json={"username": "alice", "email": "alice@example.com"},
headers=auth_headers
)
assert response.status_code == 201
data = response.get_json()
assert data["username"] == "alice"
assert data["email"] == "alice@example.com"
assert "id" in data
assert "created_at" in data
assert "password" not in data # Sensitive fields should not leak
Every test should have three distinct phases: Arrange, Act, Assert. This makes tests readable and consistent.
def test_update_post_title(auth_client, db, sample_posts):
"""Test updating a post's title."""
# Arrange: Get a post to update
post = sample_posts[0]
original_content = post.content
# Act: Send the update request
response = auth_client.post(f"/posts/{post.id}/edit", data={
"title": "Brand New Title",
"content": original_content
}, follow_redirects=True)
# Assert: Verify the result
assert response.status_code == 200
updated = Post.query.get(post.id)
assert updated.title == "Brand New Title"
assert updated.content == original_content # Content unchanged
# BAD: Vague names
def test_user():
pass
def test_login_1():
pass
def test_post():
pass
# GOOD: Names describe the scenario and expected outcome
def test_register_with_valid_data_creates_user():
pass
def test_login_with_wrong_password_returns_401():
pass
def test_delete_post_by_non_owner_returns_403():
pass
def test_api_returns_paginated_results_with_metadata():
pass
# Each test must work regardless of execution order # Use fixtures for setup, not other tests # Never rely on database state from a previous test # Never rely on global variables or module-level state
@pytest.fixture
def user_with_published_posts(db, sample_user):
"""Build on existing fixtures for specific scenarios."""
posts = []
for i in range(3):
post = Post(
title=f"Published {i}",
content=f"Content {i}",
author_id=sample_user.id,
is_published=True
)
db.session.add(post)
posts.append(post)
db.session.commit()
return sample_user, posts
def test_user_public_profile(client, user_with_published_posts):
"""Fixture provides exactly the state this test needs."""
user, posts = user_with_published_posts
response = client.get(f"/users/{user.username}")
assert response.status_code == 200
html = response.get_data(as_text=True)
for post in posts:
assert post.title in html
@pytest.mark.parametrize("endpoint,expected_status", [
("/", 200),
("/login", 200),
("/register", 200),
("/about", 200),
("/nonexistent", 404),
])
def test_public_endpoints(client, endpoint, expected_status):
"""Test that public endpoints return expected status codes."""
response = client.get(endpoint)
assert response.status_code == expected_status
@pytest.mark.parametrize("method,endpoint", [
("GET", "/dashboard"),
("GET", "/profile"),
("POST", "/posts/new"),
("GET", "/settings"),
])
def test_protected_endpoints_require_login(client, method, endpoint):
"""Test that protected endpoints redirect unauthenticated users."""
response = getattr(client, method.lower())(endpoint)
assert response.status_code == 302
assert "/login" in response.location
# Run tests in watch mode (requires pytest-watch) pip install pytest-watch ptw tests/ # Run only tests that failed last time pytest --lf # Run tests that failed first, then the rest pytest --ff # Stop on first failure pytest -x # Stop after 3 failures pytest --maxfail=3 # Run only tests modified since last commit pytest --co -q | head # List what would run
conftest.py eliminate duplication and make tests readable. Compose small fixtures into larger ones. Use the right scope for each fixture.test_client() lets you make HTTP requests without a running server. It is fast, reliable, and supports all HTTP methods, headers, cookies, and redirects.Testing is not extra work — it is the work. Code without tests is a liability. Code with good tests is an asset you can refactor, extend, and deploy with confidence. Start with the patterns in this tutorial, adapt them to your project, and make testing a non-negotiable part of your development workflow.