pytest-django Tutorial: Fixtures, Markers, Database Access, and factory_boy
pytest-django replaces Django's built-in test runner with pytest, giving you parametrize, fixtures with dependency injection, better output, and a plugin ecosystem that Django's unittest-based runner simply can't match. This tutorial covers the complete setup and the features that matter most in practice.
Installation and Configuration
pip install pytest-django pytest factory-boy fakerCreate pytest.ini (or add to pyproject.toml) at your project root:
[pytest]
DJANGO_SETTINGS_MODULE = myproject.settings.test
python_files = test_*.py
python_classes = Test*
python_functions = test_*Or in pyproject.toml:
[tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = "myproject.settings.test"
python_files = ["test_*.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]Create a minimal conftest.py at your project root:
import django
from django.conf import settings
# conftest.py is picked up automatically by pytest
# pytest-django reads DJANGO_SETTINGS_MODULE from pytest.iniDatabase Access: The Core Marker
By default, pytest-django blocks database access. Any test touching the database must opt in:
import pytest
from myapp.models import Article, Author
@pytest.mark.django_db
def test_create_article():
author = Author.objects.create(name="Test Author", email="a@example.com")
article = Article.objects.create(
title="Test Article",
author=author,
status="draft"
)
assert article.pk is not None
assert Article.objects.count() == 1Each @pytest.mark.django_db test runs in a transaction that gets rolled back. For tests that need to run actual COMMIT operations (testing signal handlers that spawn threads, Celery tasks that read from DB in separate workers, etc.), use transaction=True:
@pytest.mark.django_db(transaction=True)
def test_celery_task_reads_committed_data():
author = Author.objects.create(name="Celery Author", email="c@example.com")
article = Article.objects.create(
title="Background Job Article",
author=author,
status="draft"
)
# Celery worker in another process will only see committed data
result = process_article_async.delay(article.pk)
result.get(timeout=5)
article.refresh_from_db()
assert article.status == "processing"Fixtures: pytest-django's Built-ins
pytest-django provides several ready-to-use fixtures.
client and admin_client
def test_article_list_view(client, django_db_setup):
pass
@pytest.mark.django_db
def test_unauthenticated_redirect(client):
response = client.get("/articles/create/")
assert response.status_code == 302
assert "/login/" in response["Location"]
@pytest.mark.django_db
def test_admin_can_access_list(admin_client):
response = admin_client.get("/admin/myapp/article/")
assert response.status_code == 200settings
Override Django settings for a single test without affecting others:
@pytest.mark.django_db
def test_email_backend_during_registration(client, settings):
settings.EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend"
settings.DEFAULT_FROM_EMAIL = "test@example.com"
response = client.post("/accounts/register/", {
"username": "newuser",
"email": "new@example.com",
"password1": "strongpass123",
"password2": "strongpass123"
})
assert response.status_code == 302
from django.core import mail
assert len(mail.outbox) == 1
assert "Welcome" in mail.outbox[0].subjectmailoutbox
@pytest.mark.django_db
def test_welcome_email_sent(client, mailoutbox):
client.post("/accounts/register/", {
"username": "emailtestuser",
"email": "email@example.com",
"password1": "strongpass123",
"password2": "strongpass123"
})
assert len(mailoutbox) == 1
assert mailoutbox[0].to == ["email@example.com"]
assert "Welcome" in mailoutbox[0].subjectrf — RequestFactory
Faster than client for testing views directly without middleware:
from myapp.views import ArticleDetailView
@pytest.mark.django_db
def test_article_detail_view_context(rf):
author = Author.objects.create(name="RF Author", email="rf@example.com")
article = Article.objects.create(
title="RF Article",
author=author,
status="published"
)
request = rf.get(f"/articles/{article.pk}/")
response = ArticleDetailView.as_view()(request, pk=article.pk)
assert response.status_code == 200
assert response.context_data["article"] == articleWriting Your Own Fixtures
pytest fixtures are functions that return values and can depend on other fixtures. They replace setUp/tearDown:
# conftest.py
import pytest
from django.contrib.auth.models import User
from myapp.models import Author, Article
@pytest.fixture
def author():
return Author.objects.create(
name="Fixture Author",
email="fixture@example.com"
)
@pytest.fixture
def published_article(author):
return Article.objects.create(
title="Published Article",
author=author,
status="published"
)
@pytest.fixture
def draft_article(author):
return Article.objects.create(
title="Draft Article",
author=author,
status="draft"
)
@pytest.fixture
def authenticated_client(client, django_user_model):
user = django_user_model.objects.create_user(
username="testuser",
password="testpass123"
)
client.login(username="testuser", password="testpass123")
return clientUse them in tests:
@pytest.mark.django_db
def test_published_article_visible_in_list(authenticated_client, published_article):
response = authenticated_client.get("/articles/")
assert response.status_code == 200
assert published_article.title in response.content.decode()
@pytest.mark.django_db
def test_draft_not_visible_in_public_list(client, draft_article):
response = client.get("/articles/")
assert draft_article.title not in response.content.decode()Fixture Scopes
@pytest.fixture(scope="session")
def django_db_setup():
"""Session-scoped DB setup — runs once for the entire test session."""
pass
@pytest.fixture(scope="module")
def expensive_author(django_db_blocker):
"""Module-scoped — runs once per test module."""
with django_db_blocker.unblock():
author = Author.objects.create(
name="Module Author",
email="module@example.com"
)
yield author
author.delete()
@pytest.fixture(scope="function") # default
def fresh_author():
"""Function-scoped — runs before each test function."""
return Author.objects.create(
name="Fresh Author",
email=f"fresh@example.com"
)factory_boy Integration
Fixtures work, but they become verbose fast. factory_boy generates model instances with sensible defaults and easy overrides.
# myapp/tests/factories.py
import factory
from faker import Faker
from django.contrib.auth.models import User
from myapp.models import Author, Article, Tag
fake = Faker()
class UserFactory(factory.django.DjangoModelFactory):
class Meta:
model = User
username = factory.LazyFunction(lambda: fake.user_name())
email = factory.LazyAttribute(lambda obj: f"{obj.username}@example.com")
password = factory.PostGenerationMethodCall("set_password", "testpass123")
class AuthorFactory(factory.django.DjangoModelFactory):
class Meta:
model = Author
name = factory.LazyFunction(fake.name)
email = factory.LazyFunction(fake.email)
bio = factory.LazyFunction(fake.paragraph)
user = factory.SubFactory(UserFactory)
class TagFactory(factory.django.DjangoModelFactory):
class Meta:
model = Tag
name = factory.Sequence(lambda n: f"tag-{n}")
class ArticleFactory(factory.django.DjangoModelFactory):
class Meta:
model = Article
title = factory.LazyFunction(fake.sentence)
content = factory.LazyFunction(fake.paragraphs)
author = factory.SubFactory(AuthorFactory)
status = "draft"
class Params:
published = factory.Trait(
status="published",
published_at=factory.LazyFunction(fake.date_time_this_year)
)
@factory.post_generation
def tags(self, create, extracted, **kwargs):
if not create:
return
if extracted:
for tag in extracted:
self.tags.add(tag)Use factories in tests:
@pytest.mark.django_db
def test_factory_creates_article():
article = ArticleFactory()
assert article.pk is not None
assert article.author is not None
assert article.status == "draft"
@pytest.mark.django_db
def test_published_trait():
article = ArticleFactory(published=True)
assert article.status == "published"
assert article.published_at is not None
@pytest.mark.django_db
def test_override_specific_fields():
article = ArticleFactory(
title="Specific Title",
status="published"
)
assert article.title == "Specific Title"
@pytest.mark.django_db
def test_batch_creation():
articles = ArticleFactory.create_batch(10, status="published")
assert len(articles) == 10
assert Article.objects.filter(status="published").count() == 10
@pytest.mark.django_db
def test_article_with_tags():
tags = TagFactory.create_batch(3)
article = ArticleFactory(tags=tags)
assert article.tags.count() == 3Factory Fixtures
Combine factories with pytest fixtures:
# conftest.py
import pytest
from myapp.tests.factories import ArticleFactory, AuthorFactory, UserFactory
@pytest.fixture
def user(db):
return UserFactory()
@pytest.fixture
def author(db):
return AuthorFactory()
@pytest.fixture
def published_articles(db, author):
return ArticleFactory.create_batch(5, author=author, published=True)Parametrize: Test Multiple Inputs
@pytest.mark.django_db
@pytest.mark.parametrize("status,expected_visible", [
("published", True),
("draft", False),
("archived", False),
])
def test_article_visibility_by_status(client, status, expected_visible):
author = AuthorFactory()
article = ArticleFactory(author=author, status=status)
response = client.get("/articles/")
content = response.content.decode()
if expected_visible:
assert article.title in content
else:
assert article.title not in content
@pytest.mark.parametrize("invalid_email", [
"notanemail",
"missing@",
"@nodomain.com",
"",
"spaces in@email.com",
])
def test_author_rejects_invalid_email(invalid_email):
with pytest.raises(ValueError, match="Invalid email"):
Author(email=invalid_email).full_clean()Custom Markers
Register custom markers to categorize tests:
# pytest.ini
[pytest]
DJANGO_SETTINGS_MODULE = myproject.settings.test
markers =
slow: marks tests as slow (select with -m slow)
integration: marks tests requiring external services
smoke: marks critical path tests for quick verification@pytest.mark.slow
@pytest.mark.django_db
def test_bulk_import_performance():
start = time.time()
ArticleFactory.create_batch(1000)
duration = time.time() - start
assert duration < 30 # must complete in 30 seconds
@pytest.mark.smoke
@pytest.mark.django_db
def test_homepage_returns_200(client):
response = client.get("/")
assert response.status_code == 200Run only smoke tests during deploy verification:
pytest -m smoke
pytest -m "not slow"
pytest -m <span class="hljs-string">"integration and not slow"Mocking with pytest-mock
pip install pytest-mock@pytest.mark.django_db
def test_article_publish_sends_notification(mocker, author):
mock_notify = mocker.patch("myapp.tasks.send_publish_notification")
article = ArticleFactory(author=author, status="draft")
article.publish()
mock_notify.assert_called_once_with(article.pk)
@pytest.mark.django_db
def test_external_api_call_handled(mocker, author):
mock_response = mocker.MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"indexed": True}
mocker.patch("requests.post", return_value=mock_response)
article = ArticleFactory(author=author, status="published")
result = article.submit_to_search_index()
assert result is TrueRunning Tests Efficiently
# Run all tests
pytest
<span class="hljs-comment"># Run with coverage
pytest --cov=myapp --cov-report=html
<span class="hljs-comment"># Run only changed files (requires pytest-testmon)
pytest --testmon
<span class="hljs-comment"># Stop on first failure
pytest -x
<span class="hljs-comment"># Run last failed tests first
pytest --lf
<span class="hljs-comment"># Run in parallel (requires pytest-xdist)
pytest -n auto
<span class="hljs-comment"># Run specific file
pytest myapp/tests/test_views.py
<span class="hljs-comment"># Run specific test by name pattern
pytest -k <span class="hljs-string">"test_article and not slow"
<span class="hljs-comment"># Verbose output
pytest -vBeyond Unit Tests: Continuous Monitoring
pytest-django and factory_boy give you fast, deterministic tests that run in CI. But your Django application running in production faces conditions no test suite fully simulates: real browser quirks, CDN caching, third-party script failures, session edge cases.
HelpMeTest complements your pytest suite by running browser-level scenarios against your live application on a schedule. When your Django app's critical flows — user registration, login, checkout — break in production, HelpMeTest catches it before your users do. Your pytest suite proves your logic is correct; HelpMeTest proves your deployed system is working.
Summary
pytest-django gives you:
@pytest.mark.django_db— opt-in database access with automatic rollback- Built-in fixtures —
client,admin_client,rf,settings,mailoutbox - Custom fixtures — dependency injection replacing
setUp/tearDown factory_boy— clean, composable model factories with traits and sequences@pytest.mark.parametrize— test multiple inputs without copy-paste- Custom markers — organize and selectively run test subsets
The combination of pytest's fixture system and factory_boy's model factories is the most productive Django testing setup available. Once you've written a few factories, creating test data becomes a one-liner.