pytest-django Tutorial: Fixtures, Markers, Database Access, and factory_boy

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 faker

Create 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.ini

Database 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() == 1

Each @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 == 200

settings

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].subject

mailoutbox

@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].subject

rf — 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"] == article

Writing 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 client

Use 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() == 3

Factory 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 == 200

Run 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 True

Running 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 -v

Beyond 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 fixturesclient, 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.

Read more