Testing with Peewee
Testing a data layer means testing the queries and the models against a real database, not a mock of one - a mocked database hides the SQL bugs (the wrong join, the missing where, the null handling) that are the point of the ORM. The standard setup is an in-memory SQLite database per test, the tables created once, and each test starting from a clean state - which the :memory: database and a transaction rollback give for free. This chapter covers the fixture (the in-memory database, the table setup, the clean-slate per test), the query tests (the assertions on the results and on the generated SQL), and the model tests (the field defaults, the constraints, the relationship behavior).
The Fixture: In-Memory, Clean Per Test
import pytest
from peewee import *
# a test database, separate from the app's:
test_db = SqliteDatabase(":memory:")
class Author(Model):
name = CharField()
email = CharField(unique=True)
class Meta:
database = test_db
class Article(Model):
title = CharField()
author = ForeignKeyField(Author, backref="articles")
class Meta:
database = test_db
@pytest.fixture(autouse=True)
def clean_db():
# every test gets a fresh, empty database:
test_db.connect()
test_db.create_tables([Author, Article])
yield
# and the teardown empties it, so no test sees another's data
for m in (Article, Author):
m.delete().execute()
test_db.close()
The fixture is the foundation, and it has three parts. The SqliteDatabase(":memory:") is the test database - in-process, no server, no file, and a fresh empty database for the test process, which is exactly what a test wants. The create_tables builds the schema from the models (the same create_table the smoke test used), so the test schema is the model's schema - a field added to the model is in the test database without a separate setup. The autouse fixture gives every test a clean slate - the tables emptied (or recreated) before and after, so no test's data leaks into the next - which is the isolation that makes the tests order-independent and runnable in any sequence. The models in the test point at test_db (or the app's models point at it through the connections chapter's ProxyDatabase, which the test initializes with the in-memory database), and the swap is the proxy pattern doing the testing job.
Testing the Queries
def test_published_articles_filter(clean_db):
# arrange: the data the query will filter
ada = Author.create(name="Ada", email="[email protected]")
Article.create(title="Pub", author=ada)
# the Article model needs a published field for this; assume it
# Article.create(title="Draft", author=ada, published=False)
# act: the query under test
rows = (Article.select()
.where(Article.published == True)
.execute())
# assert: the result is exactly the matching rows
assert len(rows) == 1
assert rows[0].title == "Pub"
def test_get_raises_does_not_exist(clean_db):
# the error contract is part of the behavior under test:
try:
Author.get(Author.email == "[email protected]")
assert False, "expected DoesNotExist"
except Author.DoesNotExist:
pass # the contract held
def test_query_sql_shape(clean_db):
# the generated SQL is asserted when the SHAPE is the contract -
# the join is present, the where is on the right column
q = (Article.select()
.join(Author)
.where(Author.name == "Ada"))
sql = str(q)
assert "JOIN" in sql
assert "authors" in sql
The query tests assert three things, in increasing order of brittleness. The result - the rows the query returns are exactly the matching ones (the count, the field values) - is the primary assertion, because it is the behavior the app depends on. The error contract - get raising DoesNotExist on the empty case, MultipleResults on the ambiguous one - is tested as behavior, because the caller's except depends on the exact exception. The SQL shape - asserting the JOIN is present, the where is on the column - is the third, and it is the one to use sparingly: it pins the generated SQL, which changes across peewee versions and backends, so the shape assertion is for the case where the shape itself is the contract (the join must be there, the filter must be on the indexed column) and not a re-derivation of the result assertion. The discipline is the result first, the contract second, the SQL shape only when the shape is what matters.
Testing the Models: Defaults, Constraints, Relationships
def test_field_defaults_applied(clean_db):
a = Author.create(name="Ada", email="[email protected]")
# the defaults the model declares are in the row:
assert a.is_active is True # default=True
assert a.created_at is not None # default=datetime.now
def test_unique_constraint_enforced(clean_db):
Author.create(name="Ada", email="[email protected]")
try:
Author.create(name="Ada2", email="[email protected]")
assert False, "expected IntegrityError"
except IntegrityError:
pass # the unique=True on the field is enforced
def test_relationship_forward_and_reverse(clean_db):
ada = Author.create(name="Ada", email="[email protected]")
art = Article.create(title="Post", author=ada)
# forward: the article reaches its author
assert art.author.name == "Ada"
# reverse: the author lists the article
assert ada.articles.count() == 1
assert ada.articles.first().title == "Post"
def test_delete_cascade(clean_db):
ada = Author.create(name="Ada", email="[email protected]")
Article.create(title="Post", author=ada)
ada.delete_instance(recursive=True)
assert Author.select().count() == 0
assert Article.select().count() == 0 # the articles went too
The model tests cover the behavior the model declares and the relationship it defines. The field defaults - the default=True, the default=datetime.now - are asserted on a created row, because a wrong default is a data bug the queries inherit. The constraint - the unique=True raising IntegrityError on the duplicate - is tested as the rejection, because the caller's handling depends on the exception. The relationship is tested in both directions - the forward article.author and the reverse author.articles - and the delete behavior (recursive=True taking the related rows) is tested as the outcome on the counts, because the cascade is the data's fate when the parent goes. These are the tests that catch the model changes - a default removed, a constraint dropped, a backref renamed - at the test rather than in the query that first meets the changed behavior, and they are the suite that keeps the model's declared contract honest as the model evolves.
The Working Rules
- Test against a real (in-memory) database, not a mock. The SQL bugs - the wrong join, the missing where, the null handling - only surface against a database that executes the query, and the
:memory:SQLite is the real-enough target with none of the server's cost. - Every test starts clean. The
autousefixture empties or recreates the tables, so the tests are order-independent and a failure in one does not poison the next. - Assert the result first, the error contract second, the SQL shape only when the shape is the contract. The row values are the behavior; the
DoesNotExist/IntegrityErrorare the behavior the caller handles; the pinned SQL is the last resort, because it breaks on the version bump. - Test the model's declared behavior - the defaults, the constraints, the relationship in both directions, the delete cascade - so a model change is caught at the model test, not at the first query that meets it.
- Use the proxy to swap the test database in - the app's models point at the
ProxyDatabase, the testinitializes it with the in-memory database, and the same model code runs against the test target without a separate test model file.
Summary
The testing setup is an in-memory SQLite database per test process, the schema created from the models with create_tables, and an autouse fixture that gives every test a clean slate - the isolation that makes the suite order-independent. The queries are tested for their result (the exact matching rows), their error contract (DoesNotExist, IntegrityError as the behavior the caller handles), and their SQL shape only when the shape itself is the contract (the join present, the filter on the indexed column) - the result first, the pinned SQL last, because it breaks on the version bump. The models are tested for the declared behavior: the field defaults on a created row, the constraint as the rejection, the relationship in both directions, and the delete cascade as the outcome on the counts. The proxy is the seam that swaps the in-memory database into the app's models, so the same code runs against the test target. The through-line is the one from the whole tutorial: the database is real, the SQL is inspectable, and the test asserts the behavior the app depends on - the rows, the errors, the constraints - against a target that executes them.