When porting a Django TestCase suite to pytest-django, what replaces self.client, override_settings, assertNumQueries and assertContains in plain test functions?
answer
- fixtures instead of attributes
- settings changes undone after the test
- query counting as a context manager
- Django's assertions as functions
basics
~20 sFixtures replace the class machinery: client, admin_client and rf for requests, settings for overrides, django_assert_num_queries for query counts, mailoutbox for mail, and pytest_django.asserts for assertContains and friends. Old TestCase classes keep running, so port gradually.
solid answer
~40 sUnder pytest-django, what a `TestCase` offered as attributes and methods arrives as fixtures and helpers. `self.client` becomes the `client` fixture (plus `admin_client`, a client already logged in as a superuser, and `async_client`); `RequestFactory()` becomes `rf` or `async_rf`. `@override_settings` becomes the `settings` fixture: assigning `settings.FEATURE_X = True` applies an override that is undone after the test. `self.assertNumQueries(3)` becomes `with django_assert_num_queries(3):`, and there is `django_assert_max_num_queries` for an upper bound. `mail.outbox` becomes `mailoutbox`, emptied per test. Django's response assertions are importable as functions from `pytest_django.asserts`, for example `assertContains(response, "Paid")`. Database access needs the `django_db` marker or `db`. Existing `TestCase` classes still run unchanged under pytest, so the port can go file by file.
code
python · 8 linesimport pytest
from django.urls import reverse
@pytest.mark.django_db
def test_admin_can_open_invoice_admin(admin_client):
response = admin_client.get(reverse("admin:billing_invoice_changelist"))
assert response.status_code == 200go deeper
Know the everyday fixtures: client, admin_client, rf, settings and the django_db marker.
Map each TestCase idiom to its fixture, including django_assert_num_queries, mailoutbox and pytest_django.asserts, and explain how the settings fixture restores values.
Plan an incremental port: keep old classes running, avoid speed regressions from losing class-level data setup, and keep query-count guards intact.
Decide whether a migration to pytest-style tests is worth it for the team, and set the conventions for mixed-style suites during the transition.
## Two styles, one runner pytest can run Django's `unittest`-style test classes as they are: pytest-django recognises `SimpleTestCase` subclasses, sets up the test database for those that declare databases, and lets Django's own class machinery do the rest. That means **porting is optional and incremental**. Teams usually keep old classes running and write new tests as plain functions with fixtures. ## The translation table | Django `TestCase` idiom | pytest-django equivalent | |---|---| | `self.client` | `client` fixture | | client plus `force_login(superuser)` | `admin_client` fixture (and `admin_user`) | | `self.async_client` | `async_client` fixture | | `RequestFactory()` / `AsyncRequestFactory()` | `rf` / `async_rf` fixtures | | `get_user_model()` | `django_user_model` fixture | | `@override_settings(...)` | `settings` fixture, assign attributes | | `self.assertNumQueries(n)` | `with django_assert_num_queries(n):` | | upper bound on queries | `with django_assert_max_num_queries(n):` | | `mail.outbox` | `mailoutbox` fixture | | `self.captureOnCommitCallbacks()` | `django_capture_on_commit_callbacks` fixture | | `self.assertContains`, `assertRedirects`, `assertTemplateUsed` | functions in `pytest_django.asserts` | | database access implied by the class | `@pytest.mark.django_db` or `db` | ## Before and after ```python from django.test import TestCase, override_settings from django.urls import reverse from accounts.models import User class InvoicePageTests(TestCase): @classmethod def setUpTestData(cls): cls.user = User.objects.create_user("ana", password="pw") @override_settings(INVOICE_FOOTER="Thanks!") def test_footer(self): self.client.force_login(self.user) with self.assertNumQueries(4): response = self.client.get(reverse("invoice-list")) self.assertContains(response, "Thanks!") ``` ```python import pytest from django.urls import reverse from pytest_django.asserts import assertContains @pytest.mark.django_db def test_footer(client, settings, django_assert_num_queries, django_user_model): settings.INVOICE_FOOTER = "Thanks!" client.force_login(django_user_model.objects.create_user("ana", password="pw")) with django_assert_num_queries(4): response = client.get(reverse("invoice-list")) assertContains(response, "Thanks!") ``` ## Details worth knowing - **`settings` fixture.** Each assignment enables a Django `override_settings` under the hood, so `setting_changed` receivers fire, and all overrides are undone in reverse order after the test. Deleting an attribute (`del settings.CACHES`) also works. - **`admin_client`** depends on `admin_user`, which creates a superuser named `admin` (or `[email protected]` for an email-based user model) with password `password`, so it requests `db` and has database access. - **`client`** does not request `db`; a view that queries needs the marker. - **`django_assert_num_queries(num, connection=None, info=None, *, using=None)`** fails with "Expected to perform N queries but M were done"; run pytest with `-v` to have the executed SQL printed. - **`mailoutbox`** is emptied automatically before each test. - **Test data.** `setUpTestData` has no direct counterpart for plain functions: the `db` fixture rolls back per test, so data comes from function-scoped fixtures, typically factory calls. Class-level speed tricks do not carry over one-for-one. ## A sensible porting order 1. Add pytest-django and point it at the settings module; run the existing suite unchanged under pytest. 2. Write all **new** tests as functions with fixtures. 3. Convert old classes when you touch them, starting with ones that use few class-level hooks. 4. Leave classes that lean on `setUpTestData` or class-level fixtures for last; converting them may cost speed. ## Pitfalls seen during ports - **Silent settings leaks.** Replacing `@override_settings` with a plain assignment to `django.conf.settings` compiles and passes, but the value leaks into later tests. Always go through the `settings` fixture. - **Losing the query guard.** A class used `assertNumQueries` inside a helper; the port drops it and an N+1 regression slips in. Port the guard with `django_assert_num_queries` in the same change. - **Marker forgotten on a moved test.** A method that inherited database access from `TestCase` becomes a function without `django_db`, and fails with "Database access not allowed". That failure is loud, which is good. - **Assertions lost in translation.** Replacing `assertContains` with a bare `in response.content` check drops the status-code check; import `assertContains` from `pytest_django.asserts` instead. - **Mixed styles in one class.** pytest fixtures cannot be injected into `unittest.TestCase` methods as parameters; a class stays either Django-style or becomes plain functions. ## Measuring the result A port is only a success if the suite is at least as fast and as strict as before. Two quick checks after each converted file: 1. Compare the file's runtime before and after; a large slowdown usually means class-level data (`setUpTestData`, class `fixtures`) became per-test data and deserves a shared, read-only fixture or a lighter factory. 2. Grep the converted file for every assertion the old class made; response assertions, query counts and outbox checks should all have a counterpart.
- Do you have to convert every Django TestCase class before adopting pytest-django?No. pytest collects `unittest`-style Django test classes and pytest-django sets up their databases, so the old suite runs as it is. New tests can be plain functions with fixtures, and old classes are converted opportunistically, which keeps the migration low-risk.
- How is pytest-django's settings fixture different from assigning to django.conf.settings directly in a test?The fixture wraps every assignment in Django's `override_settings`, so `setting_changed` signal receivers run and the original value is restored after the test. A direct assignment to `django.conf.settings` leaks into every later test and skips those receivers, so caches keyed on settings may go stale.
saying these in an interview costs you the question
- Believing Django TestCase classes stop working once pytest-django is installed
- Assigning to django.conf.settings directly instead of using the settings fixture
- Thinking the client fixture comes with database access built in
- Expecting setUpTestData semantics from a function-scoped pytest fixture
- Assuming mailoutbox accumulates messages across tests