Skip to main content
Writing unit tests for your application lets you check that the code you wrote works the way you expect. Flask provides a test client that simulates requests to the application and returns the response data.

Why Test?

You should test as much of your code as possible. Benefits include:
  • Confidence in changes - The closer you get to 100% coverage, the more comfortable you can be that making a change won’t unexpectedly change other behavior
  • Catch bugs early - Tests help identify issues before deployment
  • Documentation - Tests serve as examples of how your code should be used
This is being introduced late in the tutorial, but in your future projects you should test as you develop.

Install Test Dependencies

We’ll use pytest and coverage to test and measure your code:
pip install pytest coverage

Setup and Fixtures

1

Create test directory

The test code is located in the tests directory, next to the flaskr package (not inside it).
mkdir tests
2

Create test data

Create tests/data.sql with sample data for testing:
tests/data.sql
INSERT INTO user (username, password)
VALUES
  ('test', 'pbkdf2:sha256:50000$TCI4GzcX$0de171a4f4dac32e3364c7ddc7c14f3e2fa61f2d17574483f7ffbb431b4acb2f'),
  ('other', 'pbkdf2:sha256:50000$kJPKsz6N$d2d4784f1b030a9761f5ccaeeaca413f27f2ecb76d6168407af962ddce849f79');

INSERT INTO post (title, body, author_id, created)
VALUES
  ('test title', 'test' || x'0a' || 'body', 1, '2018-01-01 00:00:00');
The passwords are hashed versions of 'test' for both users.
3

Create conftest.py

Create tests/conftest.py with pytest fixtures:
tests/conftest.py
import os
import tempfile

import pytest

from flaskr import create_app
from flaskr.db import get_db
from flaskr.db import init_db

# Read in SQL for populating test data
with open(os.path.join(os.path.dirname(__file__), "data.sql"), "rb") as f:
    _data_sql = f.read().decode("utf8")


@pytest.fixture
def app():
    """Create and configure a new app instance for each test."""
    # Create a temporary file for the database
    db_fd, db_path = tempfile.mkstemp()
    
    # Create the app with test config
    app = create_app({"TESTING": True, "DATABASE": db_path})

    # Create the database and load test data
    with app.app_context():
        init_db()
        get_db().executescript(_data_sql)

    yield app

    # Close and remove the temporary database
    os.close(db_fd)
    os.unlink(db_path)


@pytest.fixture
def client(app):
    """A test client for the app."""
    return app.test_client()


@pytest.fixture
def runner(app):
    """A test runner for the app's Click commands."""
    return app.test_cli_runner()


class AuthActions:
    def __init__(self, client):
        self._client = client

    def login(self, username="test", password="test"):
        return self._client.post(
            "/auth/login", data={"username": username, "password": password}
        )

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


@pytest.fixture
def auth(client):
    return AuthActions(client)
Key fixtures:
  • app - Creates a test app with a temporary database
  • client - Makes requests to the application without running the server
  • runner - Calls Click commands registered with the application
  • auth - Helper for authentication actions

Test Authentication

Create tests/test_auth.py:
tests/test_auth.py
import pytest
from flask import g
from flask import session

from flaskr.db import get_db


def test_register(client, app):
    # Test that viewing the page renders without template errors
    assert client.get("/auth/register").status_code == 200

    # Test that successful registration redirects to the login page
    response = client.post("/auth/register", data={"username": "a", "password": "a"})
    assert response.headers["Location"] == "/auth/login"

    # Test that the user was inserted into the database
    with app.app_context():
        assert (
            get_db().execute("SELECT * FROM user WHERE username = 'a'").fetchone()
            is not None
        )


@pytest.mark.parametrize(
    ("username", "password", "message"),
    (
        ("", "", b"Username is required."),
        ("a", "", b"Password is required."),
        ("test", "test", b"already registered"),
    ),
)
def test_register_validate_input(client, username, password, message):
    response = client.post(
        "/auth/register", data={"username": username, "password": password}
    )
    assert message in response.data


def test_login(client, auth):
    # Test that viewing the page renders without template errors
    assert client.get("/auth/login").status_code == 200

    # Test that successful login redirects to the index page
    response = auth.login()
    assert response.headers["Location"] == "/"

    # Login request set the user_id in the session
    with client:
        client.get("/")
        assert session["user_id"] == 1
        assert g.user["username"] == "test"


@pytest.mark.parametrize(
    ("username", "password", "message"),
    (("a", "test", b"Incorrect username."), ("test", "a", b"Incorrect password.")),
)
def test_login_validate_input(auth, username, password, message):
    response = auth.login(username, password)
    assert message in response.data


def test_logout(client, auth):
    auth.login()

    with client:
        auth.logout()
        assert "user_id" not in session
Key testing concepts:
  • client.get() makes a GET request and returns the Response object
  • client.post() makes a POST request, converting the data dict into form data
  • pytest.mark.parametrize runs the same test with different arguments
  • Using client in a with block allows accessing context variables like session

Test Blog

Create tests/test_blog.py:
tests/test_blog.py
import pytest

from flaskr.db import get_db


def test_index(client, auth):
    response = client.get("/")
    assert b"Log In" in response.data
    assert b"Register" in response.data

    auth.login()
    response = client.get("/")
    assert b"test title" in response.data
    assert b"by test on 2018-01-01" in response.data
    assert b"test\nbody" in response.data
    assert b'href="/1/update"' in response.data


@pytest.mark.parametrize("path", ("/create", "/1/update", "/1/delete"))
def test_login_required(client, path):
    response = client.post(path)
    assert response.headers["Location"] == "/auth/login"


def test_author_required(app, client, auth):
    # Change the post author to another user
    with app.app_context():
        db = get_db()
        db.execute("UPDATE post SET author_id = 2 WHERE id = 1")
        db.commit()

    auth.login()
    # Current user can't modify other user's post
    assert client.post("/1/update").status_code == 403
    assert client.post("/1/delete").status_code == 403
    # Current user doesn't see edit link
    assert b'href="/1/update"' not in client.get("/").data


@pytest.mark.parametrize("path", ("/2/update", "/2/delete"))
def test_exists_required(client, auth, path):
    auth.login()
    assert client.post(path).status_code == 404


def test_create(client, auth, app):
    auth.login()
    assert client.get("/create").status_code == 200
    client.post("/create", data={"title": "created", "body": ""})

    with app.app_context():
        db = get_db()
        count = db.execute("SELECT COUNT(id) FROM post").fetchone()[0]
        assert count == 2


def test_update(client, auth, app):
    auth.login()
    assert client.get("/1/update").status_code == 200
    client.post("/1/update", data={"title": "updated", "body": ""})

    with app.app_context():
        db = get_db()
        post = db.execute("SELECT * FROM post WHERE id = 1").fetchone()
        assert post["title"] == "updated"


@pytest.mark.parametrize("path", ("/create", "/1/update"))
def test_create_update_validate(client, auth, path):
    auth.login()
    response = client.post(path, data={"title": "", "body": ""})
    assert b"Title is required." in response.data


def test_delete(client, auth, app):
    auth.login()
    response = client.post("/1/delete")
    assert response.headers["Location"] == "/"

    with app.app_context():
        db = get_db()
        post = db.execute("SELECT * FROM post WHERE id = 1").fetchone()
        assert post is None

Running the Tests

1

Run pytest

pytest
Output:
========================= test session starts ==========================
platform linux -- Python 3.11.0, pytest-7.4.0, pluggy-1.3.0
rootdir: /home/user/Projects/flask-tutorial
collected 23 items

tests/test_auth.py ........                                      [ 34%]
tests/test_blog.py ............                                  [ 86%]
tests/test_db.py ..                                              [ 95%]
tests/test_factory.py ..                                         [100%]

====================== 24 passed in 0.64 seconds =======================
2

Run with verbose output

pytest -v
This shows each test function rather than dots.
3

Measure code coverage

coverage run -m pytest
4

View coverage report

coverage report
Output:
Name                 Stmts   Miss Branch BrPart  Cover
------------------------------------------------------
flaskr/__init__.py      21      0      2      0   100%
flaskr/auth.py          54      0     22      0   100%
flaskr/blog.py          54      0     16      0   100%
flaskr/db.py            24      0      4      0   100%
------------------------------------------------------
TOTAL                  153      0     44      0   100%
5

Generate HTML coverage report

coverage html
This generates files in the htmlcov directory. Open htmlcov/index.html in your browser to see which lines were covered in each file.

Test Configuration in pyproject.toml

Add test configuration to pyproject.toml:
pyproject.toml
[tool.pytest.ini_options]
testpaths = ["tests"]

[tool.coverage.run]
branch = true
source = ["flaskr"]
This makes running tests less verbose and configures coverage correctly.
100% coverage doesn’t guarantee that your application doesn’t have bugs. It doesn’t test how the user interacts with the application in the browser. Despite this, test coverage is an important tool to use during development.

Build docs developers (and LLMs) love