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