Flask – Testing

Introduction

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:

  • Unit tests — Testing individual functions and methods in isolation. Does your calculate_discount() function return the correct value? Does your User model validate email addresses properly?
  • Integration tests — Testing how components work together. Does hitting the /register endpoint actually create a user in the database and send a welcome email?
  • End-to-end tests — Testing complete user workflows from request to response. Can a user register, log in, create a resource, and log out without errors?

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.


Test Setup

Installing pytest and Plugins

Start by installing pytest and the essential testing libraries:

pip install pytest pytest-cov pytest-flask flask

Here is what each package does:

  • pytest — The test framework itself. Discovers and runs tests, provides assertions, fixtures, and plugins.
  • pytest-cov — Measures code coverage. Tells you which lines of your application are actually exercised by tests.
  • pytest-flask — Provides Flask-specific fixtures and utilities. Optional but convenient.

Project Structure for Tests

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 files start with test_ (pytest discovers them automatically)
  • Test functions start with test_
  • The tests/ directory has its own __init__.py
  • conftest.py holds fixtures shared across all test files (pytest picks it up automatically)

pytest Configuration

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_*"]

Application Factory for Testing

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 Test Client

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.

Creating a Test Client

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.

Making Requests

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

Inspecting Responses

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

Setting Headers and Cookies

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")

Test Fixtures

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.

The conftest.py File

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}"
    }

Fixture Scopes

Understanding fixture scopes is critical for writing efficient tests:

  • function (default) — Runs once per test function. Use for anything that could leak state between tests (database connections, authenticated clients).
  • class — Runs once per test class. Use when multiple tests in a class share expensive setup.
  • module — Runs once per test module (file). Use for setup that is expensive but safe to share.
  • session — Runs once per test session. Use for immutable objects like the app instance or configuration.
# 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()

Fixture Composition

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

Testing Routes

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.

Testing GET Routes

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

Testing POST with Form Data

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()

Testing POST with JSON

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()

Testing File Uploads

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

Testing with Database

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.

Strategy 1: Drop and Recreate (Simple)

@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.

Strategy 2: Transaction Rollback (Fast)

@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.

Testing Model CRUD Operations

# 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

Seeding Test Data

@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

Testing Authentication

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).

Testing Session-Based Authentication

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

Testing JWT Authentication

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

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.

Basic Mocking with unittest.mock

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"]

Patching External APIs

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()

Mocking Database Queries

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)

Mocking datetime

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"]

Using pytest-mock (Cleaner Syntax)

# 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()

Testing Templates

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.

Checking Rendered Content

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()

Testing Template Context

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)

Testing Navigation Elements

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

Testing Error Handling

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.

Testing 404 Errors

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

Testing 500 Errors

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

Testing Custom Error Responses

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

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.

Setting Up pytest-cov

pip install pytest-cov

Running Coverage

# 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/

Coverage Configuration

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

Reading Coverage Reports

$ 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.

Coverage Targets

  • 80% — A reasonable minimum for most projects. Catches most issues without wasting time testing trivial code.
  • 90% — A good target for mature projects and critical business logic.
  • 100% — Usually not worth pursuing for an entire application. The last 5-10% often covers error handling for impossible scenarios. Aim for 100% on critical modules (authentication, payment processing) but not the whole codebase.

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.


Practical Example: Testing a Complete App

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.

Application Code

# 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}>"

Complete conftest.py

# 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

Test User Registration

# 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()

Test Login/Logout

# 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

Test CRUD Operations

# 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

Test API Endpoints

# 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"

Test Error Handling

# 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

CI/CD Integration

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 Actions Workflow

# .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

pytest Configuration for CI

# 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

Marking Tests

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

Common Pitfalls

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.

1. Not Isolating Tests

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

2. Testing Implementation Details

# 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

3. Slow Tests

# 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

4. Not Testing Edge Cases

# 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

5. Ignoring Test Output

# 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

Best Practices

1. Follow the AAA Pattern

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

2. Use Descriptive Test Names

# 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

3. Keep Tests Independent

# 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

4. Compose Fixtures for Complex Scenarios

@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

5. Use Parametrize for Data-Driven Tests

@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

6. Fast Feedback Loop

# 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

Key Takeaways

  • Use the application factory pattern. It is the foundation of testable Flask applications. Without it, you cannot create isolated test instances with different configurations.
  • Invest in fixtures. Good fixtures in conftest.py eliminate duplication and make tests readable. Compose small fixtures into larger ones. Use the right scope for each fixture.
  • The test client is your best friend. Flask’s test_client() lets you make HTTP requests without a running server. It is fast, reliable, and supports all HTTP methods, headers, cookies, and redirects.
  • Isolate every test. Each test must create its own state and clean up after itself. Use transaction rollbacks for speed, or drop/recreate tables for simplicity. Never let tests depend on each other.
  • Mock external dependencies, not your own code. Mock API calls, email sending, and file operations. Do not mock your models or routes — that is what you are testing.
  • Test behavior, not implementation. Check what your endpoints return, not how they compute it internally. This makes tests resilient to refactoring.
  • Cover edge cases. Empty strings, missing fields, duplicate data, unauthorized access, expired tokens, malformed input. These are where real bugs live.
  • Measure coverage but do not worship it. 80% coverage is a reasonable floor. Focus coverage efforts on business-critical code paths like authentication, payment, and data validation.
  • Automate everything. Run tests on every push with GitHub Actions. Enforce coverage minimums. Fail the build on test failures. Fast, automated feedback is the whole point.
  • Follow AAA. Arrange, Act, Assert. Every test. No exceptions. It makes tests scannable and consistent across your entire team.

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.




Subscribe To Our Newsletter
You will receive our latest post and tutorial.
Thank you for subscribing!

required
required


Leave a Reply

Your email address will not be published. Required fields are marked *