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
Create test directory
The test code is located in the tests directory, next to the flaskr package (not inside it). Create test data
Create tests/data.sql with sample data for testing: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. Create conftest.py
Create tests/conftest.py with pytest fixtures: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:
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:
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
Run 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 =======================
Run with verbose output
This shows each test function rather than dots. View 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%
Generate HTML coverage report
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:
[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.