pytest, Factories & Browser Tests
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Django 6.1 (6.1.1 on PyPI, released 2026-09-02; 6.1.2 is scheduled for 2026-10-06), Python
3.12-3.14 and PostgreSQL 15 or newer, all checked on 2026-10-03, with these tool versions from PyPI on the same date:
pytest 9.1.1, pytest-django 4.14.0, factory_boy 3.3.3 (with Faker 40.40.0), model_bakery 1.24.1, coverage.py 7.16.2,
pytest-cov 7.1.0, pytest-xdist 3.8.0, Playwright for Python 1.63.0 with pytest-playwright 0.9.0, and Selenium 4.50.0.
Everything also works on Django 5.2 LTS; the differences are pointed out where they matter. Every listing was run in a
scratch project on Python 3.13 and Django 6.1.1 with SQLite: manage.py check passes, the suite of 28 tests passes under
pytest, under pytest -n auto and (for the StaticLiveServerTestCase classes) under manage.py test, coverage was
measured with both coverage run and pytest --cov, and the browser tests drove a real headless Chromium through
Playwright and a real headless Chrome through Selenium. No PostgreSQL server was available, so the GitHub Actions
workflow was checked by parsing it and running each of its steps locally, and its PostgreSQL branch was only checked
with manage.py check.
Django’s own test framework — TestCase, the test client, RequestFactory, assertNumQueries, LiveServerTestCase — is complete and is covered in Testing. Most teams nevertheless run it through pytest,
create their test data with factories instead of fixture files, and add a thin layer of browser tests on top. This
page covers that tool chain: pytest-django (how it finds your settings, its fixtures and markers), factory_boy and
model_bakery, replacing external services with mocks, measuring coverage, Playwright and Selenium against a live
server, and a continuous-integration workflow that runs it all on several Python versions against PostgreSQL. The
language-level pytest material (assertions, fixtures in general, parametrisation, unittest.mock) is in
Unit Testing with pytest & Mocking, and the general theory of real-browser tests (locators,
waiting, flakiness, page objects) is in E2E Testing in Real Browsers;
this page shows how they meet Django. The running example is the Bookshelf catalogue: a public book list, a book
page, a review form for logged-in readers, and a small service that fetches cover images from Open Library.
In a hurry: what goes where
| Need | Rule of thumb | Section |
|---|---|---|
Run Django tests with pytest |
|
|
Touch the database |
Mark the test |
|
Client, admin, request factory, settings |
Request the |
Fixtures: client, admin_client, rf, settings and query counting |
Guard against N+1 queries |
|
Fixtures: client, admin_client, rf, settings and query counting |
Realistic test objects |
A factory_boy |
|
"Any valid object will do" |
|
|
A partner API in the code path |
Patch the boundary ( |
|
How much is exercised? |
|
|
A real browser |
pytest-playwright’s |
Browser tests with Playwright and pytest-playwright, StaticLiveServerTestCase with Playwright or Selenium |
Run it on every push |
GitHub Actions: a Python matrix, a |
Where these tests sit
Each layer of the suite exercises a different slice of the request/response cycle described in
Request/Response Cycle. Factory-built model tests and form tests never
touch HTTP; rf (the RequestFactory) builds a request and calls one view directly, skipping URL resolution and
middleware; client sends a request through the whole stack in-process — URLconf, middleware, view, template — but no
socket and no browser; and a browser test runs a real server thread (live_server or StaticLiveServerTestCase) that a
real browser talks to over HTTP, so JavaScript, CSS and cookies behave as they do for users. The lower layers are
fast and numerous; the browser layer is slow and should cover only the journeys that matter.
(factory_boy / model_bakery)"] --> DB[("test database
one transaction per test")] RF["rf fixture
RequestFactory"] --> V["view function"] C["client fixture
test Client"] --> MW["URLconf + middleware"] --> V V --> DB LS["live_server /
StaticLiveServerTestCase
(server thread)"] --> MW end B["Chromium / Chrome
(Playwright, Selenium)"] -- "HTTP on localhost" --> LS M["mock.patch"] -. "replaces" .-> X["Open Library API"] V -. "would call" .-> X
Source: Testing tools and pytest-django: Django helpers
The running example
The catalog app has three models. Book.is_published lets editors keep drafts out of the public list, and the
review rating is constrained to 1-5 by validators, which gives the tests a real rule to check.
from django.conf import settings
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.urls import reverse
class Author(models.Model):
name = models.CharField(max_length=200)
def __str__(self):
return self.name
class Book(models.Model):
title = models.CharField(max_length=200)
author = models.ForeignKey(Author, on_delete=models.CASCADE, related_name="books")
isbn = models.CharField(max_length=13, unique=True)
published = models.DateField()
is_published = models.BooleanField(default=True)
cover_url = models.URLField(blank=True)
def __str__(self):
return self.title
def get_absolute_url(self):
return reverse("catalog:book-detail", args=[self.pk])
class Review(models.Model):
book = models.ForeignKey(Book, on_delete=models.CASCADE, related_name="reviews")
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
rating = models.PositiveSmallIntegerField(
validators=[MinValueValidator(1), MaxValueValidator(5)]
)
text = models.TextField()
The views are deliberately ordinary: a list with select_related() (so it runs one query however many books there
are), a detail page, and a review endpoint that only accepts POST from a logged-in user and answers 400 with the form
errors when the input is invalid. The form lists its fields explicitly.
# catalog/forms.py
from django import forms
from .models import Review
class ReviewForm(forms.ModelForm):
class Meta:
model = Review
fields = ["rating", "text"]
widgets = {"rating": forms.NumberInput(attrs={"min": 1, "max": 5})}
# catalog/views.py
from django.contrib.auth.decorators import login_required
from django.shortcuts import get_object_or_404, redirect, render
from django.views.decorators.http import require_POST
from .forms import ReviewForm
from .models import Book
def book_list(request):
books = Book.objects.filter(is_published=True).select_related("author").order_by("title")
return render(request, "catalog/book_list.html", {"books": books})
def book_detail(request, pk):
book = get_object_or_404(Book.objects.select_related("author"), pk=pk, is_published=True)
return render(request, "catalog/book_detail.html", {"book": book, "form": ReviewForm()})
@login_required
@require_POST
def add_review(request, pk):
book = get_object_or_404(Book, pk=pk, is_published=True)
form = ReviewForm(request.POST)
if form.is_valid():
review = form.save(commit=False)
review.book, review.user = book, request.user
review.save()
return redirect(book)
return render(request, "catalog/book_detail.html", {"book": book, "form": form}, status=400)
The URLconf names the routes catalog:book-list, catalog:book-detail and catalog:add-review, and the project
includes django.contrib.auth.urls under accounts/ for the login page (with LOGIN_URL = "login"). The detail
template renders the reviews in <ul id="reviews"> and, for authenticated users, the review form with
{% csrf_token %} and a "Post review" button — the browser tests rely on those names. The external dependency the
Mocking external services section replaces is this small Open Library client:
import json
from urllib.request import urlopen
OPEN_LIBRARY = "https://openlibrary.org/isbn/{isbn}.json"
def fetch_cover_url(isbn: str) -> str:
"""Ask Open Library for the cover of a book; return "" when it has none."""
with urlopen(OPEN_LIBRARY.format(isbn=isbn), timeout=5) as response:
data = json.load(response)
covers = data.get("covers") or []
return f"https://covers.openlibrary.org/b/id/{covers[0]}-M.jpg" if covers else ""
def refresh_cover(book) -> bool:
"""Store the Open Library cover on the book; network errors keep the old value."""
try:
url = fetch_cover_url(book.isbn)
except OSError:
return False
book.cover_url = url
book.save(update_fields=["cover_url"])
return True
The ISBN in the URL comes from the database, not from the user, and the host is fixed in code — a cover service that fetched an arbitrary user-supplied URL would be a server-side request forgery hole. Models, views and forms are covered in depth in Models & Fields, Function-Based Views & Shortcuts and Model Forms & Formsets.
pytest-django: installing and pointing it at your settings
pytest-django is a pytest plugin: once installed it configures Django before collecting tests, creates the test
database on first use and destroys it at the end, wraps each database test in a transaction that is rolled back, and
exposes Django’s test helpers as fixtures and markers. Django’s own TestCase and SimpleTestCase classes still run
under it unchanged, so an existing manage.py test suite can move over one file at a time.
python -m pip install pytest pytest-django
pytest-django needs to know which settings module to load. It looks, in order, at the --ds command-line option, the
DJANGO_SETTINGS_MODULE environment variable, and the DJANGO_SETTINGS_MODULE key in the pytest configuration file;
putting it in pyproject.toml means every developer and the CI runner get the same value without exporting anything.
[tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = "config.settings"
python_files = ["test_*.py", "*_tests.py"]
addopts = "--strict-markers --reuse-db"
markers = [
"browser: end-to-end tests that drive a real browser",
]
python_files widens test discovery beyond pytest’s default test_*.py/*_test.py patterns; --strict-markers
turns a mistyped marker into an error instead of a silently ignored decoration; markers registers the project’s own
marker (used in Markers); --reuse-db is explained below. A dedicated test settings module (for example
config/settings_test.py that imports the base settings and overrides a few values) is a common alternative to
overriding values in fixtures; point DJANGO_SETTINGS_MODULE at it. The project directory must be importable — pytest-django adds the directory containing manage.py to sys.path automatically when it finds one.
$ python -m pytest -q
............................ [100%]
28 passed in 3.32s
$ python -m pytest -q -m "not browser"
......................... [100%]
25 passed, 3 deselected in 0.64s
The test database between runs
By default the test database is created at the start of every run and dropped at the end, as with manage.py test.
Three options change that:
| Option | Effect |
|---|---|
|
Keep the test database at the end of the run and reuse it next time. Much faster on PostgreSQL with many migrations; it does not notice schema changes on its own. |
|
Force the database to be re-created, even when |
|
Build the schema straight from the models instead of running migrations. Faster for very long migration histories, but it no longer tests the migrations themselves — keep at least one CI job without it. |
With pytest-xdist (pytest -n auto) each worker gets its own test database with a suffix (test_bookshelf_gw0,
test_bookshelf_gw1, …), so parallel runs do not collide; the same suite above ran in 7.7 s under -n auto with
coverage, most of it worker start-up — parallelism pays off on suites that take minutes, not seconds.
$ python -m pytest --create-db -q # after changing migrations
............................ [100%]
28 passed in 4.30s
$ python -m pip install pytest-xdist
$ python -m pytest -n auto -m "not browser" -q
Database access: the django_db marker and the db fixture
pytest-django blocks the database by default: a test that touches the ORM without asking fails with
RuntimeError: Database access not allowed, use the "django_db" mark, or the "db" or "transactional_db" fixtures to
enable it. That is deliberate — tests that do not need the database stay fast, and the ones that do say so. There
are two equivalent ways to ask, and a choice between the two isolation levels Django offers:
| Request | Behaves like | Use when |
|---|---|---|
|
|
Almost always. Fast; |
|
|
Code that needs a real commit: |
The marker suits module- or class-wide requests (pytestmark = pytest.mark.django_db); the fixture suits other
fixtures that create data, because a fixture cannot carry a marker. The marker’s other parameters are
reset_sequences (with transaction=True, restart primary-key sequences), databases (which aliases the test may use,
"__all__" for every one), serialized_rollback and available_apps.
import pytest
from django.urls import reverse
from catalog.tests.factories import BookFactory
def test_list_shows_only_published_books(client, db):
BookFactory(title="Dune")
BookFactory(title="Secret draft", draft=True)
response = client.get(reverse("catalog:book-list"))
assert response.status_code == 200
assert [b.title for b in response.context["books"]] == ["Dune"]
assert "Secret draft" not in response.content.decode()
@pytest.mark.django_db
def test_create_saves_the_whole_graph():
...
BookFactory is defined in Test data with factory_boy. Note the assertion on response.context: it checks which books reached
the template, which is stronger than searching the HTML for a title that might appear elsewhere on the page.
Source: pytest.mark.django_db and pytest-django: Database access
Fixtures: client, admin_client, rf, settings and query counting
pytest-django turns Django’s test helpers into fixtures, so a test lists what it needs as arguments instead of inheriting it from a base class. The ones used most:
| Fixture | What it gives you |
|---|---|
|
A |
|
A client already logged in as a superuser, and that user (username |
|
A |
|
A wrapper around |
|
The active user model (whatever |
|
Context managers that fail when the block runs a different (or larger) number of queries. |
|
Like |
|
The list of emails sent during the test (the locmem backend), emptied for each test. |
|
A Django server in a background thread; |
|
Database access, as in Database access: the django_db marker and the db fixture. |
Shared fixtures live in a conftest.py; pytest makes the fixtures in a conftest.py available to every test in that
directory and below, without imports. The Bookshelf suite keeps three domain fixtures at the project root, plus an
autouse fixture that swaps the password hasher for a fast one — Django’s default PBKDF2 hasher is slow on purpose,
and every force_login(), set_password() or factory-made user pays for it.
import pytest
from catalog.tests.factories import BookFactory, UserFactory
@pytest.fixture
def book(db):
return BookFactory(title="Dune")
@pytest.fixture
def reader(db):
return UserFactory(username="ana")
@pytest.fixture
def reader_client(client, reader):
client.force_login(reader)
return client
@pytest.fixture(autouse=True)
def _fast_passwords(settings):
settings.PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]
MD5PasswordHasher is acceptable only in tests, where speed matters and the hashes are thrown away; it must never
appear in production settings (see Passwords).
The fixtures in use
The review endpoint is where most bugs would hurt, so its tests check the status code, the redirect target and the
saved row — a POST test that only asserts "200" passes for the wrong reason when the view re-renders the form with
errors. The anonymous and GET cases prove that login_required and require_POST are really applied.
import pytest
from django.contrib.auth.models import AnonymousUser
from django.urls import reverse
from catalog.forms import ReviewForm
from catalog.models import Review
from catalog.tests.factories import BookFactory
from catalog.views import book_detail
def test_reader_posts_a_review(reader_client, book):
url = reverse("catalog:add-review", args=[book.pk])
response = reader_client.post(url, {"rating": 5, "text": "A classic."})
assert response.status_code == 302
assert response["Location"] == book.get_absolute_url()
assert Review.objects.get().text == "A classic."
def test_anonymous_user_is_sent_to_login(client, book):
response = client.post(reverse("catalog:add-review", args=[book.pk]), {})
assert response.status_code == 302
assert response["Location"].startswith(reverse("login"))
def test_get_is_not_allowed_on_review_endpoint(reader_client, book):
response = reader_client.get(reverse("catalog:add-review", args=[book.pk]))
assert response.status_code == 405
def test_invalid_review_returns_400(reader_client, book):
response = reader_client.post(reverse("catalog:add-review", args=[book.pk]), {"rating": ""})
assert response.status_code == 400
assert response.context["form"].errors["rating"]
def test_admin_can_open_the_changelist(admin_client):
response = admin_client.get(reverse("admin:auth_user_changelist"))
assert response.status_code == 200
def test_detail_view_with_request_factory(rf, book):
request = rf.get(f"/books/{book.pk}/")
request.user = AnonymousUser() # no middleware ran, so set what the view reads
response = book_detail(request, pk=book.pk)
assert response.status_code == 200
assert b"Dune" in response.content
def test_settings_fixture_is_restored(settings):
settings.LANGUAGE_CODE = "es" # back to the original value after this test
assert settings.LANGUAGE_CODE == "es"
@pytest.mark.parametrize("rating, ok", [(1, True), (5, True), (0, False), (6, False), ("x", False)])
def test_review_form_rating(rating, ok):
assert ReviewForm({"rating": rating, "text": "t"}).is_valid() is ok
rf skips the URLconf and every middleware, so anything middleware would normally attach — request.user,
request.session, messages — has to be set by hand; that is the price of calling one view in isolation. The
parametrised form test needs no database at all and runs in microseconds.
django_assert_num_queries pins the query count of a request. The book list uses select_related("author"), so ten
books cost one query; removing it would make the count grow with the data (the N+1 problem described in
Query Optimization & Fetch Modes) and this test would fail.
def test_list_query_count_does_not_grow(client, db, django_assert_num_queries):
BookFactory.create_batch(10)
with django_assert_num_queries(1):
client.get(reverse("catalog:book-list"))
The query-counting fixtures do not unlock the database by themselves: without db (or the marker) this test fails
with the "Database access not allowed" error before it counts anything. When the count is wrong, run pytest with -v
to see the executed queries; the context manager also yields Django’s CaptureQueriesContext, whose
captured_queries you can assert on. django_assert_max_num_queries(n) is the looser variant for pages
whose exact count legitimately varies.
$ python -m pytest catalog/tests/test_views.py -q
.............. [100%]
14 passed in 0.77s
Markers
Besides django_db, pytest-django registers markers that replace Django’s class-level decorators:
| Marker | Effect |
|---|---|
|
Use a different root URLconf for the test, like |
|
Do not fail on invalid template variables when pytest-django’s |
|
Run the test with an isolated app registry, so models defined inside the test do not leak (new in pytest-django
4.12.0, the pytest equivalent of Django’s |
The project’s own browser marker (registered in pyproject.toml above) labels the slow end-to-end tests, so the
everyday run can skip them with -m "not browser" and CI can run them in a separate step. A module applies markers to
all its tests with pytestmark:
import pytest
pytestmark = [pytest.mark.browser, pytest.mark.django_db(transaction=True)]
Test data with factory_boy
A test needs objects that are valid (every required field set, every foreign key pointing at a real row) but only
interesting in the one or two fields the test is about. Fixture files (JSON dumps loaded with loaddata, see
Fixtures, Serialization & Initial Data) break every time
a model gains a required field, and they hide the data a test depends on in another file. A factory builds the object
in code, fills the uninteresting fields with sensible defaults, and lets each test override what it cares about.
factory_boy’s factory.django.DjangoModelFactory saves through the model’s default manager. Its building blocks:
| Declaration | Meaning |
|---|---|
|
A value built from a per-factory counter — the way to satisfy |
|
A realistic random value from the Faker library ( |
|
A value computed from the object’s other fields. |
|
Builds (or creates) the related object for a foreign key; override its fields with |
|
Named bundles of overrides, switched on with |
|
Hashes the value with |
|
Use |
|
For factories with |
import datetime
import secrets
import factory
from django.contrib.auth import get_user_model
from catalog.models import Author, Book, Review
TEST_PASSWORD = secrets.token_urlsafe(16) # random per run, never a literal
class UserFactory(factory.django.DjangoModelFactory):
class Meta:
model = get_user_model()
django_get_or_create = ("username",)
username = factory.Sequence(lambda n: f"reader{n}")
email = factory.LazyAttribute(lambda o: f"{o.username}@example.com")
password = factory.django.Password(TEST_PASSWORD)
class AuthorFactory(factory.django.DjangoModelFactory):
class Meta:
model = Author
name = factory.Faker("name")
class BookFactory(factory.django.DjangoModelFactory):
class Meta:
model = Book
title = factory.Faker("sentence", nb_words=3)
author = factory.SubFactory(AuthorFactory)
isbn = factory.Faker("isbn13", separator="")
published = factory.Faker("date_between", start_date="-30y", end_date="today")
is_published = True
class Params:
draft = factory.Trait(is_published=False)
classic = factory.Trait(published=datetime.date(1925, 4, 10))
class ReviewFactory(factory.django.DjangoModelFactory):
class Meta:
model = Review
book = factory.SubFactory(BookFactory)
user = factory.SubFactory(UserFactory)
rating = factory.Faker("pyint", min_value=1, max_value=5)
text = factory.Faker("paragraph")
get_user_model() keeps the factory working with a custom user model. The test password is generated at import time,
so no credential is written in the repository, and the browser tests import TEST_PASSWORD to type it into the login
form. isbn13 with separator="" gives 13 digits that fit the max_length=13 column; Faker’s values are random but
not unique, so a column with unique=True and a small value space (a three-letter code, say) should use Sequence
instead.
Calling a factory uses the create strategy (save to the database); .build() makes unsaved instances — related
objects included — and needs no database at all. create_batch(n) and build_batch(n) make several.
import datetime
import pytest
from catalog.models import Author, Book
from catalog.tests.factories import BookFactory, ReviewFactory
def test_build_does_not_touch_the_database():
book = BookFactory.build() # no "db" fixture needed
assert book.pk is None
assert book.author.pk is None
assert len(book.isbn) == 13
@pytest.mark.django_db
def test_create_saves_the_whole_graph():
review = ReviewFactory(rating=4)
assert review.pk and review.book.pk and review.book.author.pk and review.user.pk
assert Book.objects.count() == Author.objects.count() == 1
@pytest.mark.django_db
def test_traits_and_overrides():
draft = BookFactory(draft=True)
classic = BookFactory(classic=True, author__name="F. Scott Fitzgerald")
assert draft.is_published is False
assert classic.published == datetime.date(1925, 4, 10)
assert classic.author.name == "F. Scott Fitzgerald"
@pytest.mark.django_db
def test_batches_share_one_author():
author = BookFactory().author
BookFactory.create_batch(3, author=author)
assert author.books.count() == 4
Keep factories minimal — only the fields a valid object needs — and put scenario-specific data in the test, so a reader sees the data that matters next to the assertion. Random data occasionally finds a real bug (a title with an apostrophe, a date on 29 February); when a failure depends on a value, pin it in the test. factory_boy’s PyPI classifiers stop at Django 5.1, but 3.3.3 ran unchanged against Django 6.1.1 in the scratch project.
Test data with model_bakery
model_bakery takes the opposite approach: no factory classes at all. baker.make(Model) inspects the model and fills
every required field with a random value of the right type — creating the related rows for required foreign keys — and you pass only the fields the test cares about. baker.prepare() is the unsaved variant (like factory_boy’s
.build()), and _quantity=n makes several objects.
from model_bakery import baker
from catalog.models import Book, Review
@pytest.mark.django_db
def test_model_bakery_fills_required_fields():
review = baker.make(Review, rating=5)
assert review.book.author.name # random but valid
books = baker.make(Book, _quantity=3)
assert len({b.isbn for b in books}) == 3
def test_model_bakery_prepare_is_unsaved():
book = baker.prepare(Book, title="Dune")
assert book.pk is None
Fields with null=True or blank=True are skipped and fields with a default keep it, unless you pass them or ask
with _fill_optional (a list of field names, or True for all). Model validation is skipped by default, so random
values ignore validators: in the scratch project baker.prepare(Review) produced ratings such as 23733, far outside
1-5. That is harmless for a row the test never inspects; pass the field explicitly, or _full_clean=True to run
full_clean(), when it matters.
When the same shape keeps coming back, a Recipe names it. Recipes in an app’s baker_recipes.py are addressed as
"app_label.recipe_name"; foreign_key() links recipes and seq() adds a counter:
from model_bakery.recipe import Recipe, foreign_key, seq
from catalog.models import Author, Book
tolkien = Recipe(Author, name="J. R. R. Tolkien")
book = Recipe(Book, title=seq("Volume "), author=foreign_key(tolkien), is_published=True)
draft = book.extend(is_published=False)
@pytest.mark.django_db
def test_model_bakery_recipes():
first, second = baker.make_recipe("catalog.book", _quantity=2)
draft = baker.make_recipe("catalog.draft")
assert first.title.startswith("Volume ") and first.title != second.title
assert first.author.name == "J. R. R. Tolkien"
assert draft.is_published is False
factory_boy or model_bakery?
Both are maintained and both work with Django 6.1 (model_bakery 1.24.1 lists 6.1 in its classifiers; factory_boy 3.3.3 ran without changes). They mix well in one suite, as above.
| factory_boy | model_bakery | |
|---|---|---|
Defining data |
A factory class per model, written by you. |
Nothing to write; the model is the definition. Recipes are optional. |
Field values |
Explicit declarations; realistic values via Faker. |
Random values of the right type for every required field. |
Variants |
Traits, |
|
Non-Django use |
Also SQLAlchemy, MongoEngine and plain classes. |
Django only. |
Best for |
A domain-rich suite where readable, realistic objects matter. |
Quick tests where "any valid row" is enough and you want no boilerplate. |
Source: factory_boy documentation and model_bakery documentation
Mocking external services
A unit or integration test must not call a real partner API: the network makes it slow and flaky, the partner may rate-limit or bill you, and you cannot make it fail on demand. Replace the boundary — the one function that talks to the outside world — and test both the happy path and the failure path. Three rules keep mocks honest:
-
Patch where the name is looked up, not where it is defined.
covers.pydoesfrom urllib.request import urlopen, so the test patchescatalog.covers.urlopen; patchingurllib.request.urlopenwould leave the module’s own reference untouched. -
Mock at the edge you own. Patching
urlopentests the JSON handling offetch_cover_url(); patchingfetch_cover_url()testsrefresh_cover()without caring about HTTP. Do not mock the code under test. -
Assert on the call when the request itself is part of the contract (URL, timeout), and make the mock raise to test error handling.
import io
import json
from unittest import mock
import pytest
from catalog import covers
from catalog.tests.factories import BookFactory
def fake_response(payload):
return io.BytesIO(json.dumps(payload).encode())
def test_fetch_cover_url_builds_the_cover_link():
with mock.patch.object(covers, "urlopen", return_value=fake_response({"covers": [42]})) as urlopen:
assert covers.fetch_cover_url("9780441013593") == "https://covers.openlibrary.org/b/id/42-M.jpg"
urlopen.assert_called_once_with("https://openlibrary.org/isbn/9780441013593.json", timeout=5)
@pytest.mark.django_db
def test_refresh_cover_saves_the_url():
book = BookFactory(cover_url="")
with mock.patch("catalog.covers.fetch_cover_url", return_value="https://img.test/1.jpg"):
assert covers.refresh_cover(book) is True
book.refresh_from_db()
assert book.cover_url == "https://img.test/1.jpg"
@pytest.mark.django_db
def test_refresh_cover_survives_network_errors():
book = BookFactory(cover_url="https://img.test/old.jpg")
with mock.patch.object(covers, "urlopen", side_effect=TimeoutError("slow partner")):
assert covers.refresh_cover(book) is False
book.refresh_from_db()
assert book.cover_url == "https://img.test/old.jpg"
def test_monkeypatch_variant(monkeypatch):
monkeypatch.setattr(covers, "urlopen", lambda url, timeout: fake_response({}))
assert covers.fetch_cover_url("0000000000000") == ""
io.BytesIO stands in for the HTTP response because it is a context manager with a read() method, which is all
json.load() needs. TimeoutError is a subclass of OSError, so it exercises the except OSError branch the way a
real network failure would. pytest’s built-in monkeypatch fixture is the lighter alternative to mock.patch when
you only need to replace a value; both undo the change at the end of the test. For code built on the Requests library,
the responses package intercepts calls at the transport level instead, so URLs and payloads are matched rather than
functions patched.
Other external services have Django-level substitutes that need no mocking library: email goes to the locmem backend
during tests (the mailoutbox fixture, or django.core.mail.outbox), django.tasks can use the DummyBackend or
ImmediateBackend (see Tasks Framework), the cache can be LocMemCache, and
file storage can be pointed at a temporary directory with the settings fixture.
Measuring coverage with coverage.py
Coverage tells you which lines (and, with branch coverage, which if/else outcomes) the suite never executed. It
cannot tell you whether the executed code was checked — a test without assertions still "covers" everything it
runs — so treat it as a map of blind spots, not a quality score. coverage.py reads its configuration from
pyproject.toml (built-in TOML support on Python 3.11 and later):
[tool.coverage.run]
source = ["catalog", "config"]
branch = true
omit = ["*/migrations/*", "*/tests/*", "config/asgi.py", "config/wsgi.py"]
[tool.coverage.report]
show_missing = true
skip_covered = true
fail_under = 90
source limits measurement to your own packages (and reports files no test imported at all, which plain line
counting would miss); branch = true adds branch coverage; omit drops generated migrations, the tests themselves and
the server entry points; fail_under makes coverage report exit with a non-zero status below the threshold, which is
what fails a CI job. There are two ways to run it — coverage.py directly, or the pytest-cov plugin, which also handles
pytest-xdist workers and subprocesses for you:
$ python -m pip install coverage pytest-cov
$ coverage run -m pytest -q -m "not browser"
......................... [100%]
25 passed, 3 deselected in 0.97s
$ coverage report
Name Stmts Miss Branch BrPart Cover Missing
----------------------------------------------------------------
catalog/models.py 24 1 0 0 96% 23
config/settings.py 24 1 2 1 92% 139
----------------------------------------------------------------
TOTAL 109 2 4 1 97%
9 files skipped due to complete coverage.
$ coverage html # browsable report in htmlcov/index.html
$ python -m pytest --cov --cov-report=term -q -m "not browser"
...
Required test coverage of 90.0% reached. Total coverage: 97.35%
25 passed, 3 deselected in 1.01s
The misses are the body of Book.__str__, which no test calls, and the PostgreSQL branch of the settings (shown in
Continuous integration with GitHub Actions), which only runs in CI — a fair picture of what coverage reports: harmless here, but the same columns would
reveal an untested except branch in refresh_cover(). --cov without a value uses the
source setting; --cov-report=xml writes coverage.xml for services such as Codecov or SonarQube. Django templates
are not Python files, so template logic is invisible to coverage.py unless you add the django_coverage_plugin
plugin. Do not chase 100% by testing trivial code; do look at every uncovered branch in views, forms and services.
Browser tests with Playwright and pytest-playwright
Client tests never run JavaScript, never apply CSS, and never click a real button, so a handful of journeys — here,
"log in and post a review" — deserve a real browser. Playwright drives Chromium, Firefox and WebKit through one API,
waits automatically for elements to be ready, and ships its own browser builds. The pytest-playwright plugin provides
the page fixture (a fresh page in a fresh browser context for every test) and the command-line options; pytest-django
provides live_server. Together they need no base class.
python -m pip install pytest-playwright
python -m playwright install chromium # once per machine; CI adds --with-deps
import os
import pytest
from playwright.sync_api import Page, expect
from catalog.tests.factories import TEST_PASSWORD, BookFactory, UserFactory
# pytest-playwright runs an event loop in the test thread; let the ORM run there.
os.environ.setdefault("DJANGO_ALLOW_ASYNC_UNSAFE", "true")
pytestmark = [pytest.mark.browser, pytest.mark.django_db(transaction=True)]
def test_reader_logs_in_and_reviews_a_book(page: Page, live_server):
book = BookFactory(title="Dune")
UserFactory(username="ana")
page.goto(f"{live_server.url}/accounts/login/?next=/books/{book.pk}/")
page.get_by_label("Username").fill("ana")
page.get_by_label("Password").fill(TEST_PASSWORD)
page.get_by_role("button", name="Log in").click()
expect(page.get_by_role("heading", name="Dune")).to_be_visible()
page.get_by_label("Rating").fill("5")
page.get_by_label("Text").fill("Still the best desert planet.")
page.get_by_role("button", name="Post review").click()
expect(page.locator("#reviews li")).to_have_text("5/5 -- Still the best desert planet.")
$ python -m pytest -m browser -q
... [100%]
3 passed, 25 deselected in 3.89s
Points worth noticing:
-
Why the environment variable. Playwright’s synchronous API runs an asyncio event loop in the test thread, and Django refuses to run its synchronous ORM in a thread with a running loop. Without the variable the factory call fails with
SynchronousOnlyOperation: You cannot call this from an async context - use a thread or sync_to_async.SettingDJANGO_ALLOW_ASYNC_UNSAFElifts the guard; it is acceptable in a test process where nothing else touches the ORM concurrently, and must never be set in production. Setting it in the module (or inconftest.py) keeps it out of every developer’s shell. -
Why a transactional database. The live server runs in another thread with its own database connection, so it can only see rows that were really committed.
live_serveralready depends ontransactional_db; the explicittransaction=Trueinpytestmarkdocuments it. -
Locators by role and label, not by CSS paths:
get_by_label("Username")finds the input through its<label>, which is how users and assistive technology find it, and keeps working when the markup is restyled.expect(…)assertions retry until they pass or time out, so there is nosleep(). -
Real login. The test logs in through the form because that is part of the journey. Tests that only need an authenticated browser can copy the session cookie of
client.force_login()into the browser context instead, which is faster.
Useful options: --headed shows the browser, --browser firefox (repeatable) changes engine, --slowmo 500 slows each
action, and --tracing=retain-on-failure keeps a trace (screenshots, DOM snapshots, network) in test-results/ for
every failing test, viewable with playwright show-trace. Locators, waiting and flakiness are covered tool-independently
in E2E Testing in Real Browsers.
StaticLiveServerTestCase with Playwright or Selenium
Django’s own browser-test base classes are django.test.LiveServerTestCase — a TransactionTestCase that starts a
live server on a free port and exposes its address as self.live_server_url — and
django.contrib.staticfiles.testing.StaticLiveServerTestCase, which additionally serves static files through the
staticfiles finders, as runserver does with DEBUG=True, so CSS and JavaScript load without collectstatic. They run
under manage.py test and, unchanged, under pytest. The browser is started once per class in setUpClass() (it is
the slow part) and closed in tearDownClass(), always around the super() calls.
With Playwright, the synchronous API is started by hand instead of through the pytest fixture; the same async-safety
rule applies — without DJANGO_ALLOW_ASYNC_UNSAFE the factory call in the test raises SynchronousOnlyOperation
under manage.py test as well:
import os
import pytest
from django.contrib.staticfiles.testing import StaticLiveServerTestCase
from playwright.sync_api import expect, sync_playwright
from catalog.tests.factories import BookFactory
@pytest.mark.browser
class PlaywrightReviewTests(StaticLiveServerTestCase):
@classmethod
def setUpClass(cls):
os.environ["DJANGO_ALLOW_ASYNC_UNSAFE"] = "true"
super().setUpClass()
cls.playwright = sync_playwright().start()
cls.browser = cls.playwright.chromium.launch()
@classmethod
def tearDownClass(cls):
cls.browser.close()
cls.playwright.stop()
super().tearDownClass()
def test_book_list(self):
BookFactory(title="Dune")
page = self.browser.new_page()
page.goto(f"{self.live_server_url}/")
expect(page.get_by_role("link", name="Dune")).to_be_visible()
page.close()
Selenium is the W3C WebDriver client used in Django’s own documentation. Since Selenium 4.6 its bundled Selenium Manager
finds or downloads the matching driver (chromedriver here) automatically, so no driver path is configured. Selenium
does not auto-wait the way Playwright does: after a click that loads a new page, wait explicitly for what the next
step needs, and do not mix implicit waits (implicitly_wait()) with explicit `WebDriverWait`s — the Selenium
documentation warns that the combination produces unpredictable timeouts.
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.ui import WebDriverWait
from catalog.tests.factories import TEST_PASSWORD, BookFactory, UserFactory
@pytest.mark.browser
class SeleniumReviewTests(StaticLiveServerTestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
options = webdriver.ChromeOptions()
options.add_argument("--headless=new")
cls.selenium = webdriver.Chrome(options=options)
cls.wait = WebDriverWait(cls.selenium, timeout=5)
@classmethod
def tearDownClass(cls):
cls.selenium.quit()
super().tearDownClass()
def test_login_and_review(self):
book = BookFactory(title="Dune")
UserFactory(username="ana")
self.selenium.get(f"{self.live_server_url}/accounts/login/?next=/books/{book.pk}/")
self.selenium.find_element(By.NAME, "username").send_keys("ana")
self.selenium.find_element(By.NAME, "password").send_keys(TEST_PASSWORD)
self.selenium.find_element(By.CSS_SELECTOR, "button[type=submit]").click()
rating = self.wait.until(EC.presence_of_element_located((By.NAME, "rating")))
rating.send_keys("4")
self.selenium.find_element(By.NAME, "text").send_keys("Dense but rewarding.")
self.selenium.find_element(By.XPATH, "//button[text()='Post review']").click()
self.wait.until(
EC.text_to_be_present_in_element((By.ID, "reviews"), "4/5 -- Dense but rewarding.")
)
$ python manage.py test catalog.tests.test_browser_live_server
Found 2 test(s).
System check identified no issues (0 silenced).
..
----------------------------------------------------------------------
Ran 2 tests in 2.785s
OK
The first version of this test ended with find_element(By.ID, "reviews") straight after the click and failed with an
empty text: the element was found on the old page, before the redirect had loaded the new one. That is the classic
Selenium race, and the explicit wait above is the fix. Choosing between the two tools:
| Playwright | Selenium | |
|---|---|---|
Waiting |
Built into actions and |
Explicit |
Browsers |
Its own pinned Chromium, Firefox and WebKit builds ( |
The installed Chrome, Firefox, Edge or Safari, driver resolved by Selenium Manager. |
pytest integration |
pytest-playwright ( |
None built in; use |
Debugging |
Trace viewer, codegen, headed mode, slow motion. |
Screenshots, browser devtools, Selenium Grid for remote and parallel runs. |
Continuous integration with GitHub Actions
The suite earns its keep when it runs on every push and pull request, against the database and Python versions you
deploy. SQLite is fine on a laptop, but PostgreSQL behaves differently (case-sensitive LIKE, stricter types,
select_for_update, its own constraint and JSON features — see
PostgreSQL & Database Backends), so CI should run on the real
thing. The settings switch to PostgreSQL only when the workflow provides it, and read every value from the environment:
import os
SECRET_KEY = os.environ["DJANGO_SECRET_KEY"] # no fallback: a missing key fails at start-up
# ...
# CI: switch to PostgreSQL when the workflow provides it
if os.environ.get("POSTGRES_DB"):
DATABASES["default"] = {
"ENGINE": "django.db.backends.postgresql",
"NAME": os.environ["POSTGRES_DB"],
"USER": os.environ.get("POSTGRES_USER", "postgres"),
"PASSWORD": os.environ.get("POSTGRES_PASSWORD", ""),
"HOST": os.environ.get("POSTGRES_HOST", "localhost"),
"PORT": os.environ.get("POSTGRES_PORT", "5432"),
}
The pinned test dependencies live in a requirements file (or a dependency group in pyproject.toml if you use uv):
Django==6.1.1
psycopg[binary]==3.3.6
pytest==9.1.1
pytest-django==4.14.0
pytest-cov==7.1.0
pytest-xdist==3.8.0
factory-boy==3.3.3
model-bakery==1.24.1
pytest-playwright==0.9.0
selenium==4.50.0
The workflow runs one job per Python version Django 6.1 supports, each with its own throwaway PostgreSQL 18 container
started as a service container. GitHub waits for the container’s health check before the steps run, and maps its
port to the runner, so the tests reach it on localhost:5432.
name: tests
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.12", "3.13", "3.14"]
services:
postgres:
image: postgres:18
env:
POSTGRES_DB: bookshelf
POSTGRES_HOST_AUTH_METHOD: trust # throwaway CI container, no secret to leak
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
POSTGRES_DB: bookshelf
POSTGRES_HOST: localhost
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: ${{ matrix.python-version }}
cache: pip
cache-dependency-path: requirements-dev.txt
- run: python -m pip install -r requirements-dev.txt
- name: Generate a throw-away SECRET_KEY for this job
run: |
key="$(python -c 'from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())')"
echo "::add-mask::$key"
echo "DJANGO_SECRET_KEY=$key" >> "$GITHUB_ENV"
- run: python manage.py check
- run: python manage.py makemigrations --check --dry-run
- run: python -m playwright install --with-deps chromium
- run: python -m pytest --cov --cov-report=xml -n auto -m "not browser"
- run: python -m pytest -m browser --tracing=retain-on-failure
- uses: actions/upload-artifact@v7
if: failure()
with:
name: playwright-traces-${{ matrix.python-version }}
path: test-results/
What each part does:
-
strategy.matrixcreates three jobs (Python 3.12, 3.13, 3.14);fail-fast: falselets the others finish when one fails, so you see whether a failure is version-specific. To test Django 5.2 LTS and 6.1 side by side, add a second matrix key (for exampledjango: ["5.2", "6.1"]) and install"Django~=${{ matrix.django }}.0"in its own step. -
services.postgresruns the officialpostgres:18image.POSTGRES_HOST_AUTH_METHOD: trustlets the test user connect without a password — acceptable only for a disposable container that lives for one job and is reachable only from the runner; it keeps a password out of the workflow file. If you prefer password authentication, setPOSTGRES_PASSWORDfrom${{ secrets.<name> }}in both the service and the job environment. Theoptionsstring passes Docker health-check flags, which is how GitHub knows when the database is ready. -
Job-level
envgives every step the database variables the settings read. The settings deliberately have noSECRET_KEYfallback, so that a production server with a missing variable refuses to start instead of silently signing sessions with a random key that changes on every restart. The test job therefore generates its own random key withget_random_secret_key()and exports it through$GITHUB_ENV: it needs no repository secret, works for pull requests from forks (where secrets are not available) and is masked in the log. Locally, export a generated key the same way, or put the fallback in a test-only settings module such asconfig/settings_test.py, never in the settings production uses. -
cache: pipwithcache-dependency-pathreuses downloaded wheels between runs, keyed on the requirements file. -
manage.py checkandmakemigrations --check --dry-runfail the build on configuration errors and on model changes someone forgot to turn into a migration — cheap checks that catch a whole class of deploy-day surprises. -
playwright install --with-deps chromiumdownloads the browser and the Linux system libraries it needs. -
Two pytest runs: the fast suite in parallel with coverage (
fail_underapplies here), then the browser tests serially with tracing, whose traces are uploaded as an artifact only when the job fails.
Locally, each step ran green against SQLite (check: no issues; makemigrations --check --dry-run: "No changes
detected"; the parallel run: 25 passed at 97.35% coverage; the browser run: 3 passed), the workflow parsed as valid
YAML, and with POSTGRES_DB set Django selected the postgresql backend. The PostgreSQL service itself only runs on
GitHub’s runners.
Pitfalls
-
Tests that pass for the wrong reason. A POST test asserting only
status_code == 200passes when the form was rejected and re-rendered; assert the redirect and the saved row, as in The fixtures in use. A "delete" test must check that the row is gone. -
Forgetting the database request. "Database access not allowed" means the test (or a fixture it uses) needs
dbor@pytest.mark.django_db;django_assert_num_queriesdoes not provide it. -
Data invisible to the live server. Rows created in a plain
dbtest are never committed, so a browser test that uses them sees an empty site. Browser tests needtransaction=True(orlive_server, which implies it). -
SynchronousOnlyOperationin Playwright tests. SetDJANGO_ALLOW_ASYNC_UNSAFEfor the test process (never in production), or keep ORM calls out of the browser test thread. -
Stale
--reuse-db. After a migration change, run once with--create-db, or new columns will be missing. -
Slow password hashing. Every factory-made user and
force_login()pays PBKDF2’s cost; use a fast hasher in tests only. -
Fixture files as test data. JSON fixtures drift from the models and hide what a test depends on; prefer factories, and keep
loaddatafixtures for reference data that really ships with the app. -
Random data with unique constraints. Faker values can collide; use
Sequence(factory_boy) orseq()(model_bakery) for unique columns, and pin the value in the test when a failure depends on it. -
Mocking the wrong name.
mock.patch("urllib.request.urlopen")does nothing to a module that already didfrom urllib.request import urlopen; patch the name in the module under test. -
Real network calls in the suite. A test that silently reaches the internet passes on your laptop and fails in CI, or worse, writes to a real partner account. Patch every external boundary.
-
Sleeping in browser tests.
time.sleep()makes tests both slow and flaky; use Playwright’s auto-waitingexpect()or Selenium’sWebDriverWait, and do not combine implicit and explicit waits. -
Coverage as a goal. A high number with weak assertions proves little; read the uncovered branches instead.
-
Secrets in test settings or workflows. Generate test passwords at runtime, read keys from the environment, and use GitHub secrets (or a passwordless throwaway service) in CI.
Where the books are outdated
The four reference books target Django 1.11 to 2.1; none uses model_bakery or Playwright, and only one mentions pytest.
-
Two Scoops of Django 1.11 already recommends factories over fixtures, mocking, coverage.py and a CI server; the advice stands. Its examples are written with
unittestand treat pytest-django (alongside django-nose, last released in 2020) as an alternative; pytest-django has since become a mainstream way to run Django suites. Its CI chapter centres on Jenkins and tox; the hosted equivalent most projects use now is GitHub Actions with a matrix and service containers, as in Continuous integration with GitHub Actions. Its Python 2.7 advice no longer applies — Django 6.1 needs Python 3.12 or newer. -
Practical Django 2 and Channels 2 uses Selenium with
StaticLiveServerTestCase, which is still the documented pattern, but has you download a browser driver (Geckodriver) by hand and relies on an implicit wait. Since Selenium 4.6, Selenium Manager resolves the driver, and explicitWebDriverWait`s are the recommended way to wait (StaticLiveServerTestCase with Playwright or Selenium). Its factory_boy usage still works; factory_boy 3.3 adds a deprecation warning for factories whose post-generation hooks rely on an extra save (`skip_postgeneration_save). -
Django for Beginners uses Django’s
TestCaseonly, and some of its tests pass for the wrong reason (a POST that asserts 200, a delete test that never deletes); the assertions on this page check redirects and database state. -
Django 2 by Example has no tests at all.
-
All four predate Playwright (first released in 2020) and therefore the auto-waiting, role-based locator style used in Browser tests with Playwright and pytest-playwright.
What changed recently
| Version | Change |
|---|---|
Django 4.2 (2023-04-03) |
|
Django 5.0 (2023-12-04) |
Async client methods ( |
Django 5.1 (2024-08-07) |
|
Django 5.2 LTS (2025-04-02) |
Stack frames of Django’s assertions are hidden from failure tracebacks; fixtures and |
Django 6.0 (2025-12-03) |
|
Django 6.1 (2026-08-05) |
|
pytest-django 4.12.0 (2026-02-14) |
Official Django 6.0 and Python 3.14 support; Python 3.9 dropped; |
pytest-django 4.13.0 / 4.14.0 (2026-08) |
4.13.0 dropped Django 4.2 and 5.1 and exported |
model_bakery 1.24.1 (2026-09-22) |
Current release; lists Django 5.2, 6.0 and 6.1 in its classifiers. |
Selenium 4.6 (2022) |
Selenium Manager ships with every release and resolves drivers automatically. |
The Django rows come from the "Tests" sections of each release’s notes, the pytest-django rows from its changelog, and the package versions from PyPI on 2026-10-03.