Testing with Pytest

Bookshelf now has views, forms, real authentication, and a JSON API — enough moving parts that manually clicking through the app after every change stops being realistic. This lesson introduces automated testing with pytest, so a change to one part of Bookshelf can be verified not to have silently broken another.

Why Pytest

Python ships a built-in testing framework, unittest, and it works fine with Flask — nothing about Flask requires pytest specifically. But the Flask community has overwhelmingly settled on pytest as the tool of choice, for a simple reason: its tests are plain functions with plain assert statements, not classes inheriting from TestCase with a vocabulary of self.assertEqual()-style methods to remember. A pytest test reads closer to ordinary Python, and that lower ceremony is precisely why it's the community favorite.

pip install pytest

A client Fixture Using the App Factory

Recall the app factory pattern from Project Structure & App Factory: create_app() can be called with a different config each time, which is exactly what testing needs. A pytest fixture — a reusable setup function pytest injects into any test that asks for it by name — builds a fresh test app once per test and hands back its test client.

Example: conftest.py

# conftest.py
import pytest
from app import create_app
from extensions import db

@pytest.fixture
def client():
    app = create_app('testing')

    with app.app_context():
        db.create_all()
        yield app.test_client()
        db.session.remove()
        db.drop_all()

Any test function that declares a client parameter automatically receives whatever this fixture yields — pytest wires that up by matching names, no explicit import or setup call needed in the test itself.

A Dedicated Test Database

create_app('testing') loads the TestingConfig introduced back in Configuration & Environments — and critically, that config points SQLALCHEMY_DATABASE_URI somewhere completely separate from Bookshelf's real development or production database. Tests must never run against real data: they create rows, delete rows, and generally leave a database in whatever state the last test left it in.

# config.py
class TestingConfig(Config):
    TESTING = True
    SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:'

sqlite:///:memory: creates the entire test database in RAM rather than on disk — it exists only for the lifetime of the test process, which makes tests both fast and automatically clean between runs, with nothing to manually delete afterward.

Testing a Simple GET Request

The test client simulates a real HTTP request against the app, in-process, with no actual network call involved:

# test_views.py
def test_book_list_returns_200(client):
    response = client.get('/books')
    assert response.status_code == 200

Testing a POST That Creates Data

A more meaningful test submits Bookshelf's add-book form (from Forms with Flask-WTF) and confirms a row actually landed in the test database afterward — not just that the response looked fine:

# test_views.py
from models import Book

def test_add_book_creates_a_row(client):
    response = client.post('/books/add', data={
        'title': 'Dune',
        'pages': '412',
        'status': 'want_to_read',
    }, follow_redirects=True)

    assert response.status_code == 200
    assert Book.query.filter_by(title='Dune').first() is not None

The second assert is the important one — it checks the actual side effect (a new Book row existing) rather than trusting the HTTP response alone.

Testing a View That Requires Login

Authentication with Flask-Login covered protecting a view with @login_required. Testing that protection means checking both sides of it: an anonymous request should be turned away, and a logged-in request should succeed.

# test_auth.py
def test_anonymous_user_redirected_from_my_shelves(client):
    response = client.get('/shelves/mine')
    assert response.status_code == 302  # redirected to the login page

def test_logged_in_user_can_view_their_shelves(client):
    client.post('/login', data={'username': 'reader1', 'password': 'correct-password'})
    response = client.get('/shelves/mine')
    assert response.status_code == 200

Because client is a real test client backed by a real (test) session, logging in with client.post('/login', ...) in the second test genuinely sets the session cookie for every request that client makes afterward — no mocking required.

Testing the JSON API

REST APIs & jsonify and Marshmallow Serialization & JWT Auth built a JSON API — that's tested the same way, just asserting on the parsed JSON body instead of rendered HTML:

# test_api.py
def test_api_book_list_returns_json(client):
    response = client.get('/api/books')
    assert response.status_code == 200
    assert response.content_type == 'application/json'

    data = response.get_json()
    assert isinstance(data, list)
    if data:
        assert 'title' in data[0]
Note: Every test above is correct, runnable pytest code — but as with every lesson in this track, this website's own project doesn't run a live test suite, so you won't see these assertions actually execute on this page.

Aim for the Important Paths, Not 100% Coverage

It's tempting to treat "100% test coverage" as the goal itself. It isn't. A test suite's real job is catching the bugs that would actually hurt: anything touching authentication, anything that writes, updates, or deletes data, and anything an anonymous user shouldn't be able to reach. Bookshelf's login flow, its add/edit/delete book routes, and its API endpoints are worth testing thoroughly. A template that only ever renders static text is not — chasing a coverage percentage there is effort spent for very little real protection.

Next up: Caching speeds up Bookshelf's read-heavy pages by storing the result of an expensive query instead of recomputing it on every request.