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

pip install pytest-django; set DJANGO_SETTINGS_MODULE under [tool.pytest.ini_options] in pyproject.toml.

pytest-django: installing and pointing it at your settings

Touch the database

Mark the test @pytest.mark.django_db or request the db fixture; transaction=True only when you need real commits.

Database access: the django_db marker and the db fixture

Client, admin, request factory, settings

Request the client, admin_client, rf and settings fixtures; share your own in conftest.py.

Fixtures: client, admin_client, rf, settings and query counting

Guard against N+1 queries

with django_assert_num_queries(n): around the request — and still request db.

Fixtures: client, admin_client, rf, settings and query counting

Realistic test objects

A factory_boy DjangoModelFactory per model, SubFactory for foreign keys, Trait for named variants.

Test data with factory_boy

"Any valid object will do"

baker.make(Model) from model_bakery; Recipe when a shape repeats.

Test data with model_bakery

A partner API in the code path

Patch the boundary (mock.patch / monkeypatch) where your code looks it up; test the error path too.

Mocking external services

How much is exercised?

coverage run -m pytest or pytest --cov; branch coverage on, migrations omitted, fail_under in CI.

Measuring coverage with coverage.py

A real browser

pytest-playwright’s page with pytest-django’s live_server; or a StaticLiveServerTestCase with Playwright or Selenium.

Browser tests with Playwright and pytest-playwright, StaticLiveServerTestCase with Playwright or Selenium

Run it on every push

GitHub Actions: a Python matrix, a postgres service container, browsers installed with --with-deps.

Continuous integration with GitHub Actions

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.

flowchart LR subgraph proc["pytest process"] F["factories
(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

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.

catalog/models.py
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/views.py and catalog/forms.py
# 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:

catalog/covers.py
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.

pyproject.toml
[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

--reuse-db

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.

--create-db

Force the database to be re-created, even when --reuse-db is set (in addopts, for example). Run it after adding or changing migrations.

--no-migrations (alias --nomigrations)

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

@pytest.mark.django_db or the db fixture

django.test.TestCase: the test runs inside a transaction (and savepoints) that is rolled back at the end.

Almost always. Fast; transaction.atomic() blocks work, but nothing is ever really committed.

@pytest.mark.django_db(transaction=True) or the transactional_db fixture

django.test.TransactionTestCase: real commits, tables flushed after the test.

Code that needs a real commit: on_commit callbacks without capturing them, select_for_update across connections, and any test where another thread (a live server) must see the data.

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.

catalog/tests/test_views.py (excerpt)
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.

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

client, async_client

A django.test.Client / AsyncClient, as self.client in a TestCase.

admin_client, admin_user

A client already logged in as a superuser, and that user (username admin); both imply database access.

rf, async_rf

A RequestFactory / AsyncRequestFactory for calling a view function directly.

settings

A wrapper around django.conf.settings; any attribute you set is restored after the test.

django_user_model, django_username_field

The active user model (whatever AUTH_USER_MODEL points to) and its USERNAME_FIELD name.

django_assert_num_queries, django_assert_max_num_queries

Context managers that fail when the block runs a different (or larger) number of queries.

django_capture_on_commit_callbacks

Like TestCase.captureOnCommitCallbacks(); execute=True runs the captured callbacks.

mailoutbox

The list of emails sent during the test (the locmem backend), emptied for each test.

live_server

A Django server in a background thread; live_server.url is its address. It depends on transactional_db, because the server thread cannot share the test’s transaction (Browser tests with Playwright and pytest-playwright).

db, transactional_db, django_db_reset_sequences, django_db_serialized_rollback

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.

conftest.py
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.

catalog/tests/test_views.py (continued)
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

@pytest.mark.urls("myapp.test_urls")

Use a different root URLconf for the test, like @override_settings(ROOT_URLCONF=…​).

@pytest.mark.ignore_template_errors

Do not fail on invalid template variables when pytest-django’s --fail-on-template-vars option is on.

@pytest.mark.django_isolate_apps("catalog")

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 isolate_apps).

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

factory.Sequence(lambda n: …​)

A value built from a per-factory counter — the way to satisfy unique fields.

factory.Faker("provider", **kwargs)

A realistic random value from the Faker library ("name", "isbn13", "date_between", …​).

factory.LazyAttribute(lambda o: …​)

A value computed from the object’s other fields.

factory.SubFactory(OtherFactory)

Builds (or creates) the related object for a foreign key; override its fields with author__name="…​".

class Params: with factory.Trait(…​)

Named bundles of overrides, switched on with BookFactory(draft=True).

factory.django.Password(value)

Hashes the value with make_password() before the object is saved.

Meta.django_get_or_create = ("username",)

Use get_or_create() on those fields, so asking twice for user "ana" returns the same row.

Meta.skip_postgeneration_save = True

For factories with @factory.post_generation hooks: skip the extra save() factory_boy makes after them. Without it factory_boy 3.3 emits a DeprecationWarning, because a future major release stops saving there.

catalog/tests/factories.py
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.

catalog/tests/test_factories.py
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.

catalog/tests/test_factories.py (continued)
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:

catalog/baker_recipes.py
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, SubFactory overrides with __, post_generation hooks for many-to-many and side effects.

Recipe.extend(), foreign_key(), related().

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.

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.py does from urllib.request import urlopen, so the test patches catalog.covers.urlopen; patching urllib.request.urlopen would leave the module’s own reference untouched.

  • Mock at the edge you own. Patching urlopen tests the JSON handling of fetch_cover_url(); patching fetch_cover_url() tests refresh_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.

catalog/tests/test_covers.py
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):

pyproject.toml (continued)
[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
catalog/tests/test_browser_playwright.py
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. Setting DJANGO_ALLOW_ASYNC_UNSAFE lifts 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 in conftest.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_server already depends on transactional_db; the explicit transaction=True in pytestmark documents 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 no sleep().

  • 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:

catalog/tests/test_browser_live_server.py (Playwright)
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.

catalog/tests/test_browser_live_server.py (Selenium)
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 expect() assertions.

Explicit WebDriverWait + expected conditions.

Browsers

Its own pinned Chromium, Firefox and WebKit builds (playwright install).

The installed Chrome, Firefox, Edge or Safari, driver resolved by Selenium Manager.

pytest integration

pytest-playwright (page fixture, --browser, tracing).

None built in; use StaticLiveServerTestCase or your own fixtures.

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:

config/settings.py (excerpt)
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):

requirements-dev.txt
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.

.github/workflows/tests.yml
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.matrix creates three jobs (Python 3.12, 3.13, 3.14); fail-fast: false lets 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 example django: ["5.2", "6.1"]) and install "Django~=${{ matrix.django }}.0" in its own step.

  • services.postgres runs the official postgres:18 image. POSTGRES_HOST_AUTH_METHOD: trust lets 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, set POSTGRES_PASSWORD from ${{ secrets.<name> }} in both the service and the job environment. The options string passes Docker health-check flags, which is how GitHub knows when the database is ready.

  • Job-level env gives every step the database variables the settings read. The settings deliberately have no SECRET_KEY fallback, 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 with get_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 as config/settings_test.py, never in the settings production uses.

  • cache: pip with cache-dependency-path reuses downloaded wheels between runs, keyed on the requirements file.

  • manage.py check and makemigrations --check --dry-run fail 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 chromium downloads the browser and the Linux system libraries it needs.

  • Two pytest runs: the fast suite in parallel with coverage (fail_under applies 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 == 200 passes 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 db or @pytest.mark.django_db; django_assert_num_queries does not provide it.

  • Data invisible to the live server. Rows created in a plain db test are never committed, so a browser test that uses them sees an empty site. Browser tests need transaction=True (or live_server, which implies it).

  • SynchronousOnlyOperation in Playwright tests. Set DJANGO_ALLOW_ASYNC_UNSAFE for 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 loaddata fixtures for reference data that really ships with the app.

  • Random data with unique constraints. Faker values can collide; use Sequence (factory_boy) or seq() (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 did from 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-waiting expect() or Selenium’s WebDriverWait, 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 unittest and 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 explicit WebDriverWait`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 TestCase only, 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)

Client, AsyncClient, RequestFactory and AsyncRequestFactory accept a headers= dictionary instead of HTTP_* keyword arguments.

Django 5.0 (2023-12-04)

Async client methods (alogin(), aforce_login(), alogout(), asession()); AsyncClient supports follow; manage.py test --durations.

Django 5.1 (2024-08-07)

query_params= on the client and request factories; assertNotInHTML(); database connections inside threads are no longer allowed in SimpleTestCase.

Django 5.2 LTS (2025-04-02)

Stack frames of Django’s assertions are hidden from failure tracebacks; fixtures and serialized_rollback data are available in TransactionTestCase.setUpClass().

Django 6.0 (2025-12-03)

DiscoverRunner supports parallel execution with the forkserver start method.

Django 6.1 (2026-08-05)

assertContains() and assertNotContains() can be called several times on the same StreamingHttpResponse.

pytest-django 4.12.0 (2026-02-14)

Official Django 6.0 and Python 3.14 support; Python 3.9 dropped; @pytest.mark.django_isolate_apps and the django_isolated_apps fixture; multi-database support no longer experimental.

pytest-django 4.13.0 / 4.14.0 (2026-08)

4.13.0 dropped Django 4.2 and 5.1 and exported pytest_django.Settings for type annotations; 4.14.0 restored unofficial 5.0/5.1 support and added a django extra. Its classifiers stop at Django 6.0, but it ran unchanged against 6.1.1 in the scratch project.

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.

References