Testing

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Flask 3.1.3 (released 2026-02-18), Werkzeug 3.1.9, Click 8.5.0 and pytest 9.1, checked on 2026-10-07, and every code block was executed on Python 3.13. Flask 3.2 is not released yet; the changes it lists that touch testing are described as upcoming. Flask ships no test framework of its own. What it ships is a test client (a Werkzeug client that calls the WSGI application in-process, with no server and no sockets) and a CLI runner (a Click runner that knows the application). Everything else, fixtures, parametrization, mocking and coverage, comes from pytest and its ecosystem. This page builds a small Bookshelf API and tests it the way the official documentation and tutorial recommend, then shows where the books that cover Flask unit testing are outdated.

What to Test and How

Three small ideas organise most of what follows, and the page uses them as vocabulary.

  • FIRST describes a good unit test: *F*ast (milliseconds, so the whole suite runs on every save), *I*ndependent (no test needs another to have run first, and tests pass in any order), *R*epeatable (same result on a laptop, in CI and at 3 a.m., so no live network, no wall clock, no shared database), *S*elf-validating (the test passes or fails by itself, with no human reading output) and *T*imely (written with or just before the code it covers).

  • Arrange, Act, Assert (3A) is the shape of one test: arrange the state it needs (a user, a book), act once (one request, one function call), assert on the outcome. A test with two acts is two tests.

  • The test pyramid says how many tests of each kind to write. Many fast unit tests of plain functions, fewer integration tests that go through the test client and a real database, and very few end-to-end tests against a running server and a browser. Higher tiers cost more to write, run and keep stable, so they cover only what the lower tiers cannot.

The test pyramid for a Flask application: many unit tests of plain functions at the base, test client and database tests in the middle, a few live-server and browser tests at the top
The test pyramid, with the tools each tier uses in a Flask project

The Flask documentation adds a rule of thumb that keeps the pyramid honest: test the code you wrote, not the code of the libraries you use, and extract complex behaviour into separate functions so it can be tested without a request. Most of the page lives in the middle tier, because that is where Flask-specific behaviour (routing, sessions, error handlers, extensions) lives, but the Bookshelf code deliberately keeps a pure validation function so the base tier has something to test.

The Application Under Test

The examples need something to test. Bookshelf here is a deliberately small catalogue API built with the application factory from Application Factory & Project Layout. It uses the standard library sqlite3 module so that the page has no database dependency, a session cookie for login, and one call to an external ISBN service, so that mocking has something real to replace. Each file is shown once; later sections only import from it. The page’s code blocks are extracted into a project folder and run with pytest exactly as shown.

bookshelf/db.py
import sqlite3

from flask import current_app, g

SCHEMA = """
CREATE TABLE IF NOT EXISTS user (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    username TEXT UNIQUE NOT NULL,
    password_hash TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS book (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    isbn TEXT UNIQUE NOT NULL,
    title TEXT NOT NULL,
    author TEXT NOT NULL
);
"""


def get_db():
    if "db" not in g:
        g.db = sqlite3.connect(current_app.config["DATABASE"])
        g.db.row_factory = sqlite3.Row
    return g.db


def close_db(error=None):
    db = g.pop("db", None)
    if db is not None:
        db.close()


def init_db():
    get_db().executescript(SCHEMA)


def init_app(app):
    app.teardown_appcontext(close_db)

    @app.cli.command("init-db")
    def init_db_command():
        """Create the tables."""
        init_db()
        print("Initialised the database.")
bookshelf/validation.py
from flask import request


def json_body():
    """The JSON object sent by the client, or an empty dict for anything else."""
    data = request.get_json(silent=True)
    return data if isinstance(data, dict) else {}


def validate_book(payload):
    """Return a dict of field -> problem; empty means the payload is valid."""
    if not isinstance(payload, dict):
        return {"body": "a JSON object is required"}
    errors = {}
    for field in ("isbn", "title", "author"):
        value = payload.get(field)
        if not isinstance(value, str) or not value.strip():
            errors[field] = "required"
    isbn = payload.get("isbn")
    if "isbn" not in errors and not isbn.replace("-", "").isdigit():
        errors["isbn"] = "digits and hyphens only"
    return errors
bookshelf/auth.py
import functools
import sqlite3

from flask import Blueprint, g, redirect, session, url_for
from werkzeug.security import check_password_hash, generate_password_hash

from .db import get_db
from .validation import json_body

bp = Blueprint("auth", __name__, url_prefix="/auth")
MIN_PASSWORD_LENGTH = 12


def login_required(view):
    @functools.wraps(view)
    def wrapped(*args, **kwargs):
        if g.user is None:
            return {"error": "authentication required"}, 401
        return view(*args, **kwargs)

    return wrapped


@bp.before_app_request
def load_logged_in_user():
    user_id = session.get("user_id")
    g.user = None
    if user_id is not None:
        g.user = get_db().execute(
            "SELECT id, username FROM user WHERE id = ?", (user_id,)
        ).fetchone()


@bp.post("/register")
def register():
    data = json_body()
    username = str(data.get("username", "")).strip()
    password = str(data.get("password", ""))
    if not username:
        return {"errors": {"username": "required"}}, 400
    if len(password) < MIN_PASSWORD_LENGTH:
        return {"errors": {"password": f"at least {MIN_PASSWORD_LENGTH} characters"}}, 400
    db = get_db()
    try:
        db.execute(
            "INSERT INTO user (username, password_hash) VALUES (?, ?)",
            (username, generate_password_hash(password)),
        )
        db.commit()
    except sqlite3.IntegrityError:
        return {"errors": {"username": "already registered"}}, 409
    return {"username": username}, 201


@bp.post("/login")
def login():
    data = json_body()
    user = get_db().execute(
        "SELECT id, username, password_hash FROM user WHERE username = ?",
        (str(data.get("username", "")),),
    ).fetchone()
    if user is None or not check_password_hash(user["password_hash"], str(data.get("password", ""))):
        return {"error": "invalid credentials"}, 401
    session.clear()
    session["user_id"] = user["id"]
    return {"username": user["username"]}


@bp.post("/logout")
def logout():
    session.clear()
    return redirect(url_for("index"))


@bp.get("/me")
@login_required
def me():
    return {"username": g.user["username"]}
bookshelf/isbn.py
import json
import urllib.error
import urllib.parse
import urllib.request

from flask import current_app


def lookup_title(isbn):
    """Ask the external ISBN service for a title; None when it does not know the ISBN."""
    url = f"{current_app.config['ISBN_SERVICE_URL']}/isbn/{urllib.parse.quote(isbn, safe='')}"
    try:
        with urllib.request.urlopen(url, timeout=3) as response:
            return json.load(response)["title"]
    except urllib.error.HTTPError as error:
        if error.code == 404:
            return None
        raise
bookshelf/books.py
import sqlite3

from flask import Blueprint, abort, request, url_for

from .auth import login_required
from .db import get_db
from .isbn import lookup_title
from .validation import json_body, validate_book

bp = Blueprint("books", __name__, url_prefix="/api")


@bp.get("/books")
def list_books():
    query = request.args.get("q", "")
    rows = get_db().execute(
        "SELECT id, isbn, title, author FROM book WHERE title LIKE ? ORDER BY id",
        (f"%{query}%",),
    ).fetchall()
    return {"books": [dict(row) for row in rows]}


@bp.post("/books")
@login_required
def create_book():
    payload = json_body()
    errors = validate_book(payload)
    if errors:
        return {"errors": errors}, 400
    db = get_db()
    try:
        cursor = db.execute(
            "INSERT INTO book (isbn, title, author) VALUES (?, ?, ?)",
            (payload["isbn"], payload["title"], payload["author"]),
        )
        db.commit()
    except sqlite3.IntegrityError:
        return {"errors": {"isbn": "already in the catalogue"}}, 409
    book = {"id": cursor.lastrowid, **{key: payload[key] for key in ("isbn", "title", "author")}}
    return book, 201, {"Location": url_for("books.get_book", book_id=book["id"])}


@bp.get("/books/<int:book_id>")
def get_book(book_id):
    row = get_db().execute(
        "SELECT id, isbn, title, author FROM book WHERE id = ?", (book_id,)
    ).fetchone()
    if row is None:
        abort(404)
    return dict(row)


@bp.delete("/books/<int:book_id>")
@login_required
def delete_book(book_id):
    db = get_db()
    cursor = db.execute("DELETE FROM book WHERE id = ?", (book_id,))
    db.commit()
    if cursor.rowcount == 0:
        abort(404)
    return "", 204


@bp.get("/isbn/<isbn>")
def isbn_title(isbn):
    try:
        title = lookup_title(isbn)
    except OSError:
        abort(502)
    if title is None:
        abort(404)
    return {"isbn": isbn, "title": title}
bookshelf/__init__.py
import os

from flask import Flask, redirect, url_for
from werkzeug.exceptions import HTTPException

from . import auth, books, db


def create_app(test_config=None, instance_path=None):
    app = Flask(__name__, instance_path=instance_path, instance_relative_config=True)
    app.config.from_mapping(ISBN_SERVICE_URL="https://isbn.invalid")
    app.config["DATABASE"] = os.path.join(app.instance_path, "bookshelf.sqlite")
    app.config.from_prefixed_env("BOOKSHELF")  # BOOKSHELF_SECRET_KEY -> SECRET_KEY
    if test_config is not None:
        app.config.update(test_config)
    os.makedirs(app.instance_path, exist_ok=True)

    db.init_app(app)
    app.register_blueprint(auth.bp)
    app.register_blueprint(books.bp)

    @app.get("/")
    def index():
        return redirect(url_for("books.list_books"))

    @app.errorhandler(HTTPException)
    def http_error(error):
        return {"error": error.name}, error.code

    return app

Two details matter for testing. The factory accepts a test_config mapping that is applied after every other source, so a test can override anything; and the secret key is not in the code, it comes from BOOKSHELF_SECRET_KEY in the environment (via from_prefixed_env) or from test_config. Tests must not reuse a production key and must not contain a literal one; the fixtures below generate a fresh random key per test with secrets.token_hex().

pytest Layout and Fixtures

pytest collects files named test_*.py (or *_test.py) under the configured test paths, and inside them functions whose names start with test_ and classes whose names start with Test. The convention, used by the official tutorial, is a tests/ folder next to the package, not inside it, so the tests are never shipped with the application:

bookshelf-project/
|-- bookshelf/            the package shown above
|-- tests/
|   |-- conftest.py       fixtures shared by every test module
|   |-- test_books.py
|   |-- test_auth.py
|   `-- ...
`-- pyproject.toml        pytest and coverage configuration
pyproject.toml (testing part)
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["."]          # flat layout; with an installed package (pip install -e .) this is not needed

[tool.coverage.run]
branch = true
source = ["bookshelf"]

[tool.coverage.report]
show_missing = true

conftest.py holds fixtures: functions that build the objects a test needs, discovered by name and injected into test functions that list them as parameters. Code after the yield is the teardown. The three fixtures every Flask suite starts from are the application, the test client and the CLI runner, in the shape the official documentation gives:

tests/conftest.py
import secrets

import pytest

from bookshelf import create_app
from bookshelf.db import init_db


@pytest.fixture()
def app(tmp_path):
    app = create_app(
        {
            "TESTING": True,
            "SECRET_KEY": secrets.token_hex(),
            "DATABASE": str(tmp_path / "test.sqlite"),
        },
        instance_path=str(tmp_path),
    )
    with app.app_context():
        init_db()
    yield app
    # teardown goes here; tmp_path is removed by pytest later


@pytest.fixture()
def client(app):
    return app.test_client()


@pytest.fixture()
def runner(app):
    return app.test_cli_runner()

Each of these decisions answers a pitfall:

  • TESTING=True. It tells Flask the application is under test. The practical effect is that exceptions raised by a view are propagated to the test instead of being turned into a generic 500 response, so a bug appears as a traceback at the line that raised it. Extensions also read the flag. It is the same as setting app.testing = True.

  • A fresh application per test. The factory exists for this reason: two tests never share configuration, extensions or database state. A module-level app = Flask(__name__) imported by every test leaks state between them.

  • A temporary database file. tmp_path is a per-test folder created by pytest. SQLite’s :memory: database does not work with the plain sqlite3 module here because every request opens a new connection and would see an empty database; a file does. (Flask-SQLAlchemy configures a static pool for sqlite://, which is why its tests use memory, see Flask-SQLAlchemy.) The official tutorial uses tempfile.mkstemp() for the same purpose.

  • A random secret key per test. A hard-coded key in a test file is a hard-coded secret in the repository, and scanners report it. secrets.token_hex() costs nothing.

tests/test_testing_flag.py
import pytest


def test_exceptions_propagate_when_testing(app, client):
    @app.get("/boom")
    def boom():
        raise RuntimeError("bug in a view")

    with pytest.raises(RuntimeError, match="bug in a view"):
        client.get("/boom")

    # What production does: the exception becomes a 500 handled by the error handler
    app.config["PROPAGATE_EXCEPTIONS"] = False
    response = client.get("/boom")
    assert response.status_code == 500
    assert response.json == {"error": "Internal Server Error"}

PROPAGATE_EXCEPTIONS defaults to None, in which case Flask propagates when TESTING or DEBUG is on. Testing the production behaviour (your 500 handler, as above) means switching it off for that one test. Keep in mind that the application then logs the traceback with log_exception; pytest captures it, and it is shown only if the test fails.

The fixtures depend on each other through their parameters, which is how pytest builds the object graph for each test:

flowchart LR tmp["tmp_path"] --> app["app fixture: create_app, init_db"] key["secrets.token_hex"] --> app app --> client["client: app.test_client"] app --> runner["runner: app.test_cli_runner"] creds["credentials"] --> auth["auth: AuthActions"] client --> auth client --> test["test function"] runner --> test auth --> test
How the fixtures of the Bookshelf suite are assembled for one test

Run the suite from the project root with pytest; -x stops at the first failure, -k expression selects tests by name, -q shortens the output and pytest tests/test_books.py::test_create_book runs one test.

The Test Client

app.test_client() returns a FlaskClient, an extension of Werkzeug’s Client. It builds a WSGI environment from the arguments, calls the application, and wraps the answer in a TestResponse. There is no server, no socket and no threading, which is why the tests are fast and why they can see inside the application (the session, g, the database). It has one method per HTTP verb (get, post, put, patch, delete, head, options) and a generic open. The arguments that matter, all passed through Werkzeug’s EnvironBuilder, are in the table.

Argument What it does

query_string

A dict (or a string) that becomes the query string: client.get("/api/books", query_string={"q": "dune"}).

headers

A dict of request headers, for example Authorization or Accept-Language.

json

Serialises the object with the application’s JSON provider and sets Content-Type: application/json.

data

A dict becomes form data (application/x-www-form-urlencoded, or multipart/form-data when a value is a file); raw bytes or a string are sent as they are.

content_type

Overrides the Content-Type header, needed with raw data.

follow_redirects

False by default. When True the client keeps requesting the Location until a non-redirect arrives, and response.history lists the intermediate responses.

base_url, subdomain, url_scheme

Build the request URL; defaults come from SERVER_NAME, APPLICATION_ROOT and PREFERRED_URL_SCHEME. base_url cannot be combined with the other two.

environ_base, environ_overrides

Extra WSGI environment entries; environ_base on the client defaults to a loopback REMOTE_ADDR and a Werkzeug User-Agent.

buffered

Convert the application’s response iterator to a list and call close() on it. Only needed for streamed responses that must be fully consumed.

import io

from flask import Flask, request

app = Flask(__name__)


@app.post("/echo")
def echo():
    return {
        "args": request.args.to_dict(),
        "form": request.form.to_dict(),
        "json": request.get_json(silent=True),
        "files": {name: f.read().decode() for name, f in request.files.items()},
        "client": request.headers.get("X-Client"),
        "mimetype": request.mimetype,
    }


client = app.test_client()

# Query string, header and a multipart form with a file upload
response = client.post(
    "/echo",
    query_string={"page": 2},
    headers={"X-Client": "tests"},
    data={"title": "Dune", "cover": (io.BytesIO(b"cover-bytes"), "cover.png")},
)
assert response.status_code == 200
assert response.json["args"] == {"page": "2"}
assert response.json["form"] == {"title": "Dune"}
assert response.json["files"] == {"cover": "cover-bytes"}
assert response.json["client"] == "tests"
assert response.json["mimetype"] == "multipart/form-data"

# A JSON body: Content-Type is set for you
response = client.post("/echo", json={"tags": ["sci-fi", "classic"]})
assert response.json["json"] == {"tags": ["sci-fi", "classic"]}
assert response.json["mimetype"] == "application/json"

# A raw body needs an explicit content type
response = client.post("/echo", data=b"plain text", content_type="text/plain")
assert response.json["json"] is None and response.json["mimetype"] == "text/plain"

A file value in data can be a binary file object, or a (file, filename, content_type) tuple to set the detected filename and content type; file objects are closed after the request, so there is no with open(...) ceremony. Larger fixtures live in a tests/resources folder located with pathlib.Path(__file__).parent.

Reading the response

TestResponse is a normal Flask response plus a few test conveniences:

Attribute Use

status_code, status

The integer code and the full line ("201 CREATED").

data

The body as bytes. Compare bytes to bytes.

text

The body decoded to str (Werkzeug 2.1 and later). Equivalent to get_data(as_text=True).

json, get_json()

The parsed body. A property, not a method. It is None when the response is not JSON (for example the default HTML 404 page), so an assert response.json == ... on an unexpected HTML error shows None instead of a decoding error; check status_code first.

headers, mimetype, content_type, location

Headers and their parsed forms.

history

Tuple of the redirect responses that led here, oldest first; each has a request attribute.

request

The request that produced this response, with path and the rest.

The most common mistakes come from the requests library: response.json() (a call on the property’s result, which raises TypeError for a dict) and response.content (the attribute is data). requests is for talking to real servers. Using it against a live server and calling the result a unit test mixes two tiers of the pyramid; the test client does the job in-process.

Redirects

tests/test_redirects.py
def test_root_redirects_to_the_catalogue(client):
    response = client.get("/")
    assert response.status_code in (302, 303)  # Flask 3.2 is expected to default to 303
    assert response.headers["Location"] == "/api/books"

    followed = client.get("/", follow_redirects=True)
    assert followed.status_code == 200
    assert followed.request.path == "/api/books"
    assert len(followed.history) == 1
    assert followed.history[0].status_code in (302, 303)

Without follow_redirects the test sees the redirect itself, which is what you want when the redirect is the behaviour under test (a login view that redirects to next). With it, response.request.path is the final URL. The in (302, 303) check is deliberate: redirect() answers 302 in Flask 3.1 and the unreleased 3.2 changelog lists 303 as the new default, and a test of the redirect target should not break on that.

Cookies

The client keeps a cookie jar and sends the matching cookies on later requests, like a browser, so a login persists across requests of one client. Since Werkzeug 2.3 the jar is managed with three methods, get_cookie, set_cookie and delete_cookie; the old cookie_jar attribute was deprecated then and does not exist in Werkzeug 3.

from flask import Flask, request

app = Flask(__name__)


@app.get("/whoami")
def whoami():
    return {"theme": request.cookies.get("theme", "light")}


client = app.test_client()
assert client.get("/whoami").json == {"theme": "light"}

client.set_cookie("theme", "dark")             # domain defaults to localhost
assert client.get("/whoami").json == {"theme": "dark"}

cookie = client.get_cookie("theme")
assert cookie is not None and cookie.value == "dark"

client.delete_cookie("theme")
assert client.get_cookie("theme") is None
assert client.get("/whoami").json == {"theme": "light"}

# A client without a cookie jar: every request starts clean
stateless = app.test_client(use_cookies=False)
try:
    stateless.set_cookie("theme", "dark")
except TypeError as error:
    assert "Cookies are disabled" in str(error)

set_cookie(key, value, *, domain="localhost", origin_only=True, path="/", ...) skips a request just to plant a cookie. The attributes of the cookie that get_cookie returns (value, http_only, secure, same_site, expires, max_age…​) let a test assert how the application sets cookies, which is how the Bookshelf suite checks the session cookie below.

Sessions: Reading and Presetting

Flask’s session is a signed cookie, so two things are interesting in a test: reading what a request put in it, and planting a value before a request without going through the login view. Both need the secret key, which the app fixture sets. Reading uses the client as a context manager: the application and request context stay active after the request, so session and g can be inspected until the with block ends. Planting uses session_transaction(), which opens the session, lets the test modify it and saves it into the client’s cookie jar when the block exits. First the helper that logs in, in the style of the AuthActions class of the official tutorial:

tests/conftest.py (additions)
import secrets


class AuthActions:
    """Drive the auth views of the Bookshelf API from tests."""

    def __init__(self, client, credentials):
        self._client = client
        self.credentials = credentials

    def register(self):
        return self._client.post("/auth/register", json=self.credentials)

    def login(self):
        return self._client.post("/auth/login", json=self.credentials)

    def logout(self):
        return self._client.post("/auth/logout")


@pytest.fixture()
def credentials():
    # Generated per test: nothing secret-looking lives in the repository
    return {"username": "ana", "password": secrets.token_urlsafe(16)}


@pytest.fixture()
def auth(client, credentials):
    return AuthActions(client, credentials)


@pytest.fixture()
def logged_in(auth):
    """A client that already has a registered, logged-in user."""
    assert auth.register().status_code == 201
    assert auth.login().status_code == 200
    return auth

Because credentials and auth are fixtures, a test can change the user (credentials["username"] = "ben") before the first call, and each test gets a different password. The auth helper is shared by every module through conftest.py.

tests/test_auth.py
from flask import g, session


def test_register_and_login(client, auth):
    assert auth.register().status_code == 201
    assert client.get("/auth/me").status_code == 401  # registered, not logged in

    response = auth.login()
    assert response.status_code == 200
    assert response.json == {"username": "ana"}

    # Reading the session after the request needs the client as a context manager
    with client:
        client.get("/auth/me")
        assert session["user_id"] == 1
        assert g.user["username"] == "ana"

    # Outside the block the contexts are gone again
    assert client.get("/auth/me").json == {"username": "ana"}


def test_logout_clears_the_session(client, logged_in):
    with client:
        response = logged_in.logout()
        assert response.status_code in (302, 303)
        assert "user_id" not in session


def test_preset_session_without_the_login_view(client, auth):
    auth.register()
    with client.session_transaction() as sess:
        sess["user_id"] = 1           # as if the user had logged in
    assert client.get("/auth/me").json == {"username": "ana"}


def test_session_cookie_flags(client, logged_in):
    cookie = client.get_cookie("session")
    assert cookie is not None
    assert cookie.http_only is True

The context kept alive by with client: is dropped when the next request is made inside the block, and the client cannot be nested (with client: inside with client: raises RuntimeError). session_transaction() raises RuntimeError ("Session backend did not open a session") when the application has no secret key, which is the usual reason a test cannot build a session. See Sessions, Cookies & Flashing for what the session is and how it is signed.

Testing Without a Request: Contexts

Code that reads current_app, g, request or session raises RuntimeError: Working outside of application context when called from a bare test function. Rather than routing a request through a view just to reach it, push a context yourself.

tests/test_contexts.py
import sqlite3

import pytest
from flask import current_app, g

from bookshelf.db import get_db


def test_get_db_returns_one_connection_per_context(app):
    with app.app_context():
        db = get_db()
        assert db is get_db()
        assert current_app.config["TESTING"] is True

    with pytest.raises(sqlite3.ProgrammingError):   # closed by teardown_appcontext
        db.execute("SELECT 1")


def test_using_a_proxy_outside_a_context_fails():
    with pytest.raises(RuntimeError, match="application context"):
        get_db()


def test_request_context_does_not_run_before_request(app):
    with app.test_request_context("/api/books?q=dune"):
        # Dispatching has not happened: the before_app_request hook has not run
        assert not hasattr(g, "user")
        app.preprocess_request()      # run before_request functions by hand
        assert g.user is None

app.app_context() is the right tool for database helpers, CLI-style code and anything that needs current_app. app.test_request_context(path, method=..., data=..., headers=...) takes the same arguments as the client’s request methods and is for functions that read request (a form validator, a helper that parses the Accept-Language header). It does not run Flask’s dispatching: before_request functions, url_value_preprocessor`s and error handlers are not called, so call `app.preprocess_request() yourself if the code needs them, or, usually better, make a full request. If a test needs data to exist in the database first, write it inside with app.app_context(): and commit before making the request. The mechanics of the contexts are in Application and Request Lifecycle.

Testing CLI Commands

app.test_cli_runner() returns a FlaskCliRunner, a Click CliRunner that already knows the application. invoke() runs a command the way the flask command would, in an isolated environment, and captures the output in a Result with output, exit_code and exception. Called with no cli argument it targets the application’s own command group, so the arguments are the command name and its options, without flask.

tests/test_cli.py
import click


def test_init_db_command(app, runner):
    result = runner.invoke(args=["init-db"])
    assert result.exit_code == 0
    assert "Initialised" in result.output


def test_custom_command_with_options(app, runner):
    @app.cli.command("greet")
    @click.argument("name")
    @click.option("--shout", is_flag=True)
    def greet(name, shout):
        text = f"hello {name}"
        click.echo(text.upper() if shout else text)

    assert runner.invoke(args=["greet", "ana"]).output == "hello ana\n"
    assert runner.invoke(args=["greet", "ana", "--shout"]).output == "HELLO ANA\n"


def test_unknown_command_fails(runner):
    result = runner.invoke(args=["no-such-command"])
    assert result.exit_code == 2
    assert "No such command" in result.output

Since Click 8.2 result.output is the interleaved standard output and error as a user would see them, and stdout and stderr give the streams separately; the old mix_stderr parameter of CliRunner was removed. Commands registered with @app.cli.command() run inside an application context automatically, so the @with_appcontext decorator of older recipes is not needed on them (Flask 2.2). A command that reads input is tested with runner.invoke(args=[...], input="text\n"). The commands themselves are covered in CLI & Shell.

Testing a JSON API

The CRUD tests of the catalogue show what a typical API test looks like: arrange through the logged_in fixture, act with one request, assert on status, headers and body.

tests/test_books.py
import pytest

BOOK = {"isbn": "978-0441172719", "title": "Dune", "author": "Frank Herbert"}


def test_catalogue_starts_empty(client):
    response = client.get("/api/books")
    assert response.status_code == 200
    assert response.json == {"books": []}


def test_create_then_read_a_book(client, logged_in):
    created = client.post("/api/books", json=BOOK)
    assert created.status_code == 201
    book_id = created.json["id"]
    assert created.headers["Location"] == f"/api/books/{book_id}"

    fetched = client.get(created.headers["Location"])
    assert fetched.status_code == 200
    assert fetched.json == {"id": book_id, **BOOK}
    assert client.get("/api/books").json["books"] == [fetched.json]


def test_search_by_title(client, logged_in):
    client.post("/api/books", json=BOOK)
    client.post("/api/books", json={**BOOK, "isbn": "9780553293357", "title": "Foundation"})
    titles = [b["title"] for b in client.get("/api/books", query_string={"q": "dun"}).json["books"]]
    assert titles == ["Dune"]


def test_duplicate_isbn_is_a_conflict(client, logged_in):
    client.post("/api/books", json=BOOK)
    response = client.post("/api/books", json=BOOK)
    assert response.status_code == 409
    assert response.json == {"errors": {"isbn": "already in the catalogue"}}


def test_delete_a_book(client, logged_in):
    book_id = client.post("/api/books", json=BOOK).json["id"]
    assert client.delete(f"/api/books/{book_id}").status_code == 204
    assert client.get(f"/api/books/{book_id}").status_code == 404
    assert client.delete(f"/api/books/{book_id}").status_code == 404


def test_missing_book_uses_the_json_error_handler(client):
    response = client.get("/api/books/999")
    assert response.status_code == 404
    assert response.json == {"error": "Not Found"}


@pytest.mark.parametrize("method", ["post", "delete"])
def test_writes_need_authentication(client, method):
    path = "/api/books" if method == "post" else "/api/books/1"
    response = getattr(client, method)(path, json=BOOK)
    assert response.status_code == 401
    assert response.json == {"error": "authentication required"}

The tests assert on the API contract: status, Location, body shape. They do not look inside the database. When a test does need to see storage (to prove a row was written or removed) it opens an application context and queries, as the security tests below do.

Checking the shape of a response

Asserting a whole body with == is exact but brittle once bodies grow. A JSON Schema states the shape once, and the jsonschema package validates a response against it (install it with pip install jsonschema).

tests/test_schema.py
import jsonschema

BOOK_SCHEMA = {
    "type": "object",
    "required": ["id", "isbn", "title", "author"],
    "properties": {
        "id": {"type": "integer"},
        "isbn": {"type": "string", "pattern": "^[0-9-]+$"},
        "title": {"type": "string", "minLength": 1},
        "author": {"type": "string", "minLength": 1},
    },
    "additionalProperties": False,
}
LIST_SCHEMA = {
    "type": "object",
    "required": ["books"],
    "properties": {"books": {"type": "array", "items": BOOK_SCHEMA}},
}


def test_responses_match_their_schemas(client, logged_in):
    client.post("/api/books", json={"isbn": "9780553293357", "title": "Foundation", "author": "Isaac Asimov"})
    jsonschema.validate(client.get("/api/books").json, LIST_SCHEMA)
    jsonschema.validate(client.get("/api/books/1").json, BOOK_SCHEMA)

Hand-written schemas drift from the code. When the API publishes an OpenAPI document (see OpenAPI & API Documentation), the better option is to generate the checks from that document: contract testing with Schemathesis and property-based testing with Hypothesis are the subject of the API Contract & Property-based Testing page of this section, and are not repeated here.

Parametrized Tests

@pytest.mark.parametrize runs one test body with several inputs, which is how the validation rules are covered without copy and paste. Give hard-to-read cases an id with pytest.param, so a failure names the case.

tests/test_validation.py
import pytest

from bookshelf.validation import validate_book

GOOD = {"isbn": "978-0441172719", "title": "Dune", "author": "Frank Herbert"}


def test_a_valid_book_has_no_errors():
    assert validate_book(GOOD) == {}


@pytest.mark.parametrize(
    ("change", "field", "problem"),
    [
        pytest.param({"title": ""}, "title", "required", id="empty-title"),
        pytest.param({"author": "   "}, "author", "required", id="blank-author"),
        pytest.param({"isbn": None}, "isbn", "required", id="missing-isbn"),
        pytest.param({"isbn": "12-ab"}, "isbn", "digits and hyphens only", id="letters-in-isbn"),
        pytest.param({"title": 42}, "title", "required", id="title-not-a-string"),
    ],
)
def test_invalid_fields_are_reported(change, field, problem):
    assert validate_book({**GOOD, **change})[field] == problem


@pytest.mark.parametrize("body", [[], "text", 5, None])
def test_the_body_must_be_an_object(body):
    assert validate_book(body) == {"body": "a JSON object is required"}


@pytest.mark.parametrize("body", ["[1, 2]", '"text"', "not json"])
def test_the_endpoint_rejects_bodies_that_are_not_objects(client, logged_in, body):
    response = client.post("/api/books", data=body, content_type="application/json")
    assert response.status_code == 400

The first tests in this module call validate_book with no Flask object at all: they are the base of the pyramid, and run in microseconds. The last test goes through the test client to prove that the view uses the validator and turns a bad body into a 400 instead of a crash. Parametrize also applies to fixtures (@pytest.fixture(params=[...])), which is useful to run the same suite against two configurations.

Mocking and Fakes

A test must not depend on a service it does not control. Three tools replace the real thing, in increasing order of fidelity, and the choice is a trade-off between speed and how much of the real integration the test still exercises.

monkeypatch

pytest’s monkeypatch fixture sets attributes, dictionary items and environment variables for the duration of one test and undoes them afterwards, so a failing test cannot leak its patch into the next. The rule that trips everybody: patch the name where it is looked up, not where it is defined. bookshelf/books.py does from .isbn import lookup_title, so the view holds its own reference and the test patches bookshelf.books.lookup_title; patching bookshelf.isbn.lookup_title would change nothing for the view.

tests/test_isbn.py
import json
import secrets
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

import pytest

from bookshelf import books as books_module


def test_the_view_returns_the_title_found_by_the_service(client, monkeypatch):
    monkeypatch.setattr("bookshelf.books.lookup_title", lambda isbn: "Dune")
    response = client.get("/api/isbn/9780441172719")
    assert response.json == {"isbn": "9780441172719", "title": "Dune"}


def test_an_unknown_isbn_is_a_404(client, monkeypatch):
    monkeypatch.setattr("bookshelf.books.lookup_title", lambda isbn: None)
    assert client.get("/api/isbn/0000000000").status_code == 404


def test_environment_variables_are_patched_per_test(monkeypatch, tmp_path):
    from bookshelf import create_app

    key = secrets.token_hex()
    monkeypatch.setenv("BOOKSHELF_SECRET_KEY", key)
    app = create_app(instance_path=str(tmp_path))
    assert app.config["SECRET_KEY"] == key

unittest.mock and pytest-mock

unittest.mock.patch from the standard library does the same job and adds call tracking: it replaces the target with a MagicMock that records how it was called. The mocker fixture of the pytest-mock plugin (pip install pytest-mock) has the same API as patch but is undone automatically at the end of the test, with no with or decorator, and it adds mocker.spy (record calls but keep the real behaviour) and mocker.stub. Passing autospec=True makes the mock reject calls that do not match the real signature, which catches tests that keep passing after the function they mock changes.

tests/test_isbn_mocker.py
from unittest import mock


def test_upstream_failure_becomes_a_502(client, mocker):
    patched = mocker.patch("bookshelf.books.lookup_title", side_effect=TimeoutError, autospec=True)
    response = client.get("/api/isbn/9780441172719")
    assert response.status_code == 502
    assert response.json == {"error": "Bad Gateway"}
    patched.assert_called_once_with("9780441172719")


def test_the_standard_library_patch_works_too(client):
    with mock.patch("bookshelf.books.lookup_title", return_value="Dune") as patched:
        assert client.get("/api/isbn/9780441172719").status_code == 200
    patched.assert_called_once()

Mocks are fast and easy, and they have a cost: a mock encodes your belief about the dependency. If the belief is wrong (the real service answers with {"name": ...} instead of {"title": ...}) the tests stay green while production fails. Patch at the boundary you own (lookup_title, not urllib), and keep one test that exercises the real parsing code.

A local fake server

For that test, run a tiny fake of the external service on the loopback interface, on a port chosen by the operating system, and point the configuration at it. The real lookup_title, including urllib, JSON decoding and the 404 branch, runs; only the remote end is fake, and nothing leaves the machine.

tests/test_isbn.py (continued)
TITLES = {"9780441172719": "Dune"}


class FakeIsbnService(BaseHTTPRequestHandler):
    def do_GET(self):
        title = TITLES.get(self.path.rsplit("/", 1)[-1])
        body = json.dumps({"title": title}).encode()
        self.send_response(200 if title else 404)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def log_message(self, *args):
        pass                    # keep the test output quiet


@pytest.fixture()
def isbn_service(app):
    server = ThreadingHTTPServer(("127.0.0.1", 0), FakeIsbnService)   # port 0: any free port
    thread = threading.Thread(target=server.serve_forever, daemon=True)
    thread.start()
    app.config["ISBN_SERVICE_URL"] = f"http://127.0.0.1:{server.server_port}"
    yield server
    server.shutdown()
    server.server_close()
    thread.join()


def test_real_client_code_against_a_fake_service(client, isbn_service):
    assert client.get("/api/isbn/9780441172719").json == {"isbn": "9780441172719", "title": "Dune"}
    assert client.get("/api/isbn/9780000000000").status_code == 404


def test_a_spy_records_the_call_and_keeps_the_real_code(client, isbn_service, mocker):
    spy = mocker.spy(books_module, "lookup_title")      # pytest-mock: the call is recorded, not replaced
    assert client.get("/api/isbn/9780441172719").json["title"] == "Dune"
    spy.assert_called_once_with("9780441172719")

A shell that routes traffic through an HTTP proxy (http_proxy in the environment) can send even loopback requests to the proxy; add 127.0.0.1 to no_proxy in such CI environments. A third-party package that records and replays HTTP (for requests or httpx) is the alternative when the code under test uses those libraries.

Test Data with Factories

Building the same dict in every test hides what each test cares about. A factory produces valid objects with sensible defaults and lets a test override only the fields that matter. factory_boy (pip install factory-boy) is the usual library; factory.DictFactory builds dicts, which is what the JSON API needs, factory.Sequence guarantees unique values (the ISBN has a unique constraint), and a factory based on factory.Factory can build any class. build() returns an object without saving it, and build_batch(n) returns a list. A create() strategy exists for factories that know how to persist, which ORM integrations provide; for a plain API it is the client call that persists.

tests/factories.py
import factory


class BookFactory(factory.DictFactory):
    isbn = factory.Sequence(lambda n: f"978000000{n:04d}")
    title = factory.Sequence(lambda n: f"Book number {n}")
    author = "Test Author"
tests/test_factories.py
from .factories import BookFactory


def test_factory_values_are_unique_and_overridable():
    first, second = BookFactory.build(), BookFactory.build(author="Ursula K. Le Guin")
    assert first["isbn"] != second["isbn"]
    assert second["author"] == "Ursula K. Le Guin"


def test_a_page_of_books(client, logged_in):
    for payload in BookFactory.build_batch(5):
        assert client.post("/api/books", json=payload).status_code == 201
    assert len(client.get("/api/books").json["books"]) == 5

tests/ needs an empty __init__.py for the relative import (from .factories import ...) to work, or the factory module is imported as a top-level module through pytest’s pythonpath. factory.Faker fills fields with random realistic values from the Faker library; it makes failures harder to reproduce, so prefer sequences unless the randomness is the point (a fixed seed with factory.random.reseed_random (after import factory.random) restores repeatability).

Security Tests

Security rules are requirements like any other, and the cheapest time to pin them down is when the code is written. The Bookshelf suite tests three that every Flask application should: authentication is enforced, passwords follow the rules and are never stored in clear, and user input cannot change the SQL.

tests/test_security.py
import pytest

from bookshelf.db import get_db


@pytest.mark.parametrize(
    "weak",
    ["", "short", "a" * 11],
    ids=["empty", "five-chars", "eleven-chars"],
)
def test_short_passwords_are_rejected(client, credentials, weak):
    response = client.post("/auth/register", json={**credentials, "password": weak})
    assert response.status_code == 400
    assert "password" in response.json["errors"]


def test_the_password_is_stored_hashed(app, auth, credentials):
    auth.register()
    with app.app_context():
        stored = get_db().execute("SELECT password_hash FROM user").fetchone()[0]
    assert credentials["password"] not in stored
    assert stored.startswith("scrypt:")      # Werkzeug 3 default


def test_wrong_password_and_unknown_user_look_the_same(client, auth, credentials):
    auth.register()
    wrong = client.post("/auth/login", json={**credentials, "password": "x" * 20})
    unknown = client.post("/auth/login", json={"username": "nobody", "password": "y" * 20})
    assert wrong.status_code == unknown.status_code == 401
    assert wrong.json == unknown.json


INJECTIONS = ["' OR '1'='1", "'; DROP TABLE book; --", "%' UNION SELECT username FROM user --"]


@pytest.mark.parametrize("payload", INJECTIONS)
def test_sql_injection_in_the_search_is_inert(app, client, logged_in, payload):
    client.post("/api/books", json={"isbn": "9780441172719", "title": "Dune", "author": "Frank Herbert"})
    response = client.get("/api/books", query_string={"q": payload})
    assert response.status_code == 200
    assert response.json == {"books": []}          # matched as text, not interpreted
    with app.app_context():                        # the table is still there
        assert get_db().execute("SELECT count(*) FROM book").fetchone()[0] == 1


def test_a_session_cookie_cannot_be_forged(client, auth):
    auth.register()
    client.set_cookie("session", "not-a-signed-value")
    assert client.get("/auth/me").status_code == 401

Parametrized injection payloads are a regression test, not a scanner: they prove that this endpoint uses bound parameters, and they fail the day somebody builds a query with an f-string. They do not replace review or a tool that explores inputs automatically (the property-based and contract testing page covers Schemathesis). Beyond this page’s examples:

  • CSRF. Extensions such as Flask-WTF let tests switch protection off with WTF_CSRF_ENABLED = False. That is convenient for the bulk of the form tests, but it hides a missing token in production, so keep a few tests that run with CSRF on and post both with and without the token.

  • Authentication plug-ins. Flask-Login’s LOGIN_DISABLED config makes @login_required pass everything; do not set it in the shared test config, or the suite stops proving that routes are protected. See Authentication & Flask-Login.

  • Secrets and data. No real credential, token or personal data in fixtures. Generate throwaway values, read anything else from the environment, and run a secret scanner over the repository including tests/.

  • Never test against production configuration. The app fixture builds its own configuration and database; a test that needs the real DATABASE value is a test that can damage it.

Coverage

Coverage measures which lines (and, with branch = true, which branches) the tests executed. It cannot say the tests are good, but it reliably finds code no test touches. Flask’s tutorial wraps pytest with coverage run -m pytest; the pytest-cov plugin does the same from the pytest command line. Configuration lives in pyproject.toml (shown earlier): branch and source under [tool.coverage.run], and show_missing and fail_under under [tool.coverage.report].

# 1. The tutorial's way: coverage runs pytest
coverage run -m pytest
coverage report            # terminal summary (with missing lines, from show_missing)
coverage html              # browsable report in htmlcov/

# 2. The plugin: pytest runs coverage
pytest --cov=bookshelf --cov-branch --cov-report=term-missing

# 3. Fail the run when coverage drops (a CI gate)
pytest --cov=bookshelf --cov-fail-under=90

The Bookshelf suite of this page produces this report. Run it yourself and the numbers will match:

Name                      Stmts   Miss Branch BrPart  Cover   Missing
---------------------------------------------------------------------
bookshelf/__init__.py        22      0      2      0   100%
bookshelf/auth.py            54      3     10      1    94%   40, 50-51
bookshelf/books.py           51      0      8      0   100%
bookshelf/db.py              20      0      4      0   100%
bookshelf/isbn.py            14      1      2      1    88%   18
bookshelf/validation.py      16      0      8      0   100%
---------------------------------------------------------------------
TOTAL                       177      4     34      2    97%
Required test coverage of 90% reached. Total coverage: 97.16%

The report is useful because it is specific. The Missing column points at line 40 of auth.py (registering with an empty username), lines 50 and 51 (the duplicate-username conflict) and line 18 of isbn.py (re-raising an HTTP error other than 404). Each is a behaviour with no test, and each is a cheap one to write.

Read the report column by column: Miss and Missing tell which lines no test ran; BrPart counts branches taken only one way (an if whose else was never exercised). Use a gate (fail_under, --cov-fail-under) as a floor that prevents regressions rather than a goal to push to 100%; # pragma: no cover (or exclude_lines) removes code that should not count, such as if __name__ == "__main__". A 100% figure does not mean the application works in a browser, and a test that asserts nothing still raises coverage. The tutorial’s own caveat is the same.

Test-Driven Development

TDD is the habit of writing the test before the code, in a loop of red (the new test fails, for the right reason), green (the least code that passes), refactor (clean up with the suite as a safety net). It fits Flask well because the test client makes a failing request test a one-minute job. As an example, the catalogue should be filterable by author. The test comes first, and it asserts the behaviour the endpoint does not have yet.

tests/test_author_filter.py
from .factories import BookFactory


def test_filter_the_catalogue_by_author(client, logged_in):
    client.post("/api/books", json=BookFactory.build(author="Frank Herbert"))
    client.post("/api/books", json=BookFactory.build(author="Isaac Asimov"))
    client.post("/api/books", json=BookFactory.build(author="Frank Herbert"))

    response = client.get("/api/books", query_string={"author": "Frank Herbert"})

    assert response.status_code == 200
    assert [b["author"] for b in response.json["books"]] == ["Frank Herbert", "Frank Herbert"]

Running it is the red step:

$ pytest tests/test_author_filter.py
E       AssertionError: assert ['Frank Herbe...rank Herbert'] == ['Frank Herbe...rank Herbert']
E
E         At index 1 diff: 'Isaac Asimov' != 'Frank Herbert'
E         Left contains one more item: 'Frank Herbert'

tests/test_author_filter.py:12: AssertionError
FAILED tests/test_author_filter.py::test_filter_the_catalogue_by_author
1 failed

It fails for the right reason: the endpoint ignores author and returns all three books. The green step is the smallest change to list_books that makes the test pass:

bookshelf/books.py, list_books after the change
@bp.get("/books")
def list_books():
    query = request.args.get("q", "")
    author = request.args.get("author")
    sql = "SELECT id, isbn, title, author FROM book WHERE title LIKE ?"
    params = [f"%{query}%"]
    if author is not None:
        sql += " AND author = ?"
        params.append(author)
    rows = get_db().execute(sql + " ORDER BY id", params).fetchall()
    return {"books": [dict(row) for row in rows]}

The query is still built from fixed fragments and bound parameters; the author never reaches the SQL text. The refactor step is where the parametrized and security tests earn their keep: the suite says at once whether tidying list_books changed anything it promised. TDD does not require 100% discipline to be useful. Write the test first for bug fixes (reproduce, then fix) and for logic with many edge cases; for exploratory code, write the tests soon after.

unittest, nose and nose2: Code You Will Meet

Flask itself and many older projects use the standard library’s unittest. A TestCase builds the application in setUp and cleans up in tearDown; the test client works exactly as above. pytest also runs unittest.TestCase classes, so a project can migrate a file at a time.

tests/test_unittest_style.py
import secrets
import tempfile
import unittest

from bookshelf import create_app
from bookshelf.db import init_db


class CatalogueTestCase(unittest.TestCase):
    def setUp(self):
        self.tmp = tempfile.TemporaryDirectory()
        self.addCleanup(self.tmp.cleanup)
        self.app = create_app(
            {"TESTING": True, "SECRET_KEY": secrets.token_hex(), "DATABASE": f"{self.tmp.name}/test.sqlite"},
            instance_path=self.tmp.name,
        )
        with self.app.app_context():
            init_db()
        self.client = self.app.test_client()

    def test_catalogue_starts_empty(self):
        response = self.client.get("/api/books")
        self.assertEqual(response.status_code, 200)
        self.assertEqual(response.get_json(), {"books": []})


if __name__ == "__main__":
    unittest.main()

python -m unittest discover -s tests runs it without pytest. What unittest lacks is the part that makes pytest pleasant: no fixtures (state goes on self), no parametrization without subTest, and assertion helpers (assertEqual) instead of plain assert.

nose was the third-party runner older recipes use (the 2019 edition of one cookbook, for example). It is unmaintained and does not work on current Python. pytest 7.2 deprecated and pytest 8.0 removed its support for nose-style plain setup() and teardown() methods, so the setup of such classes is no longer called. Rename them to setup_method and teardown_method, or better convert to fixtures. nose2 is the successor project: it extends unittest and is still released, but it is a marginal tool compared with pytest, which the Flask documentation and tutorial both use. New projects should use pytest; old ones that use nose2 keep working because nose2 runs unittest tests.

Beyond the Test Client

The test client calls the WSGI application and does not run HTTP, so some things are out of its reach:

  • A real server. Tests that need genuine HTTP (a browser driven by Playwright or Selenium, a WebSocket client, another process calling your API) need the application listening on a port. The pytest-flask plugin provides a live_server fixture that runs the application in a separate process, plus client and config fixtures; it requires you to define an app fixture, which the factory pattern gives you for free. Those tests sit at the top of the pyramid: keep few.

  • Async views. Flask 3 runs async def views when installed with the async extra (pip install "flask[async]"); the test client is synchronous and runs them for you, so the tests are written exactly as above. Quart, which is built on Flask’s Sans-IO classes, has an awaitable test client instead.

  • Real databases. In-process SQLite is fine for speed, but it differs from PostgreSQL in typing and constraints. Run the integration tier against the engine you deploy, in a throwaway container, in CI; see Flask-SQLAlchemy for the SQLAlchemy specifics and Docker for containers.

  • Logging and debugging. Reading logs and stepping through a failing test with pdb are the subject of the Debugging, Logging & Profiling page of this section.

Pitfalls

Pitfall What to do

A test passes alone and fails in the suite

Shared mutable state: a module-level app, a database file reused between tests, a fixture with scope="session" that tests mutate. Build the application and the database per test; run the failing test alone, and then the whole suite again, to see the difference.

The test asserts on response.json and gets None

The response was not JSON, usually an HTML error page. Assert status_code first, and give the application a JSON error handler (as create_app does) so API errors are JSON.

RuntimeError: Working outside of application context

The code uses current_app, g or the database outside a request. Wrap the call in with app.app_context():.

session_transaction raises "Session backend did not open a session"

The test configuration has no SECRET_KEY. Set one in the app fixture, from secrets.token_hex(), never as a literal.

The suite stays green while the code is broken

TESTING is off and a handler turns the exception into a 500 that the test did not assert on, or the test asserts nothing. Set TESTING=True and assert the status code and body.

A mock hides a real bug

Mocks encode assumptions. Mock at your own boundary, use autospec=True, and keep one test that runs the real code against a local fake.

Cookies leak from one request to the next

One client keeps one cookie jar. Create a second client (app.test_client()) to play another user, or call client.delete_cookie(...).

with client: keeps a context alive and breaks later code

The context lasts until the block ends or the next request. Keep the block short and do not nest it.

Slow suites

Password hashing and database setup dominate. Avoid per-request work the test does not need, share read-only fixtures with a wider scope only when nothing mutates them, and move logic into plain functions that need no client.

Recent and Upcoming Changes

Version Change that touches testing

Werkzeug 2.1

TestResponse.text is available, so a test can compare text without get_data(as_text=True).

Flask 2.2

@app.cli.command commands need no with_appcontext, so runner.invoke runs them inside an application context without extra decorators.

Werkzeug 2.3

The test client’s cookie implementation is refactored: get_cookie, set_cookie and delete_cookie are the API, the cookie_jar attribute is deprecated (it is gone in 3.x), and domain defaults to localhost.

Werkzeug 3.0

The server_name parameter of set_cookie and delete_cookie is removed (use domain); the first parameter is the key.

Click 8.2

CliRunner no longer accepts mix_stderr; Result.output interleaves stdout and stderr, and stdout and stderr are separate.

pytest 8.0

Support for nose-style setup() and teardown() methods is removed (deprecated in 7.2).

Flask 3.2 (not released)

The unreleased changelog describes merging the request context into the application context and a default of 303 for redirect(). Tests that push an application context by hand and also call the test client are the place to watch, and redirect tests should accept either code, as above. Verify against the 3.2 release notes when it ships.

Source: Flask: Changes

Where the Books Are Outdated

The testing material in the Flask books (the unit-testing chapter of the 2023 full-stack book, and the testing chapters of the two editions of the cookbook) predates or ignores several things that now matter. The table maps each to the current practice used on this page; the books are cited only as sources of the concept, and nothing is copied from them.

In the books Current practice

nose (2019 cookbook) and nose2 (2023 cookbook) as the runner, with setup and teardown methods.

pytest with fixtures, as in the Flask documentation. nose is dead; nose2 is marginal; nose-style setup/teardown were removed in pytest 8, use setup_method/teardown_method or fixtures.

unittest.TestCase classes everywhere.

Still valid and supported, but pytest fixtures remove the boilerplate; pytest can run existing TestCase classes unchanged.

Hitting a running server with requests and calling it a unit test.

A unit or integration test uses app.test_client(): no server, no sockets, faster and deterministic. Live-server tests are a separate, smaller tier.

response.json() and response.content (the requests API).

response.json is a property on the test client’s response (and None for a non-JSON body); the body is response.data (bytes) or response.text.

Fixtures that build Flask(__name__) once and share it, or leave TESTING unset, or point at the development database.

A factory-built application per test, with TESTING=True, a temporary database and a generated secret key.

Test applications configured with a literal, placeholder secret key and credentials committed to the repository.

Never reproduce them. Generate with secrets.token_hex() or read from the environment; tests must not contain real or secret-looking literals.

Mocks with unittest.mock.patch alone.

Still fine; the mocker fixture of pytest-mock and the monkeypatch fixture of pytest undo themselves, and the rule is to patch where the name is looked up.

FLASK_ENV=testing and ENV to select the test configuration (era of Flask 1.x and 2.x).

FLASK_ENV and app.env were removed in Flask 2.3. Select the test configuration by passing test_config to the factory.

Old test-client idioms: client.cookie_jar, set_cookie(server_name, key, value).

client.get_cookie, set_cookie(key, value, domain=...) and delete_cookie (Werkzeug 2.3 and 3.0).

Source: Flask: Changes