CtrlK
BlogDocsLog inGet started
Tessl Logo

tessl-labs/django-testing

Write correct Django tests — TestCase vs TransactionTestCase, setUpTestData, factory-boy, assertNumQueries, mock.patch placement, and DRF APITestCase patterns

99

1.33x
Quality

99%

Does it follow best practices?

Impact

99%

1.33x

Average score across 2 eval scenarios

SecuritybySnyk

Passed

No known issues

Overview
Quality
Evals
Security
Files

SKILL.mdskills/django-testing/

name:
django-testing
description:
Write correct Django tests that avoid common pitfalls. Covers TestCase vs TransactionTestCase, setUp vs setUpTestData, factory-boy patterns, assertNumQueries, mock.patch placement, override_settings, file upload testing, management command testing, and DRF APITestCase. Use when writing or reviewing Django test suites.
keywords:
django testing, django testcase, transactiontestcase, setuptestdata, factory-boy, assertnumqueries, override_settings, mock patch, django file upload test, management command test, drf apitestcase, django test client
license:
MIT

Django Testing — Patterns That Agents Get Wrong

1. TestCase vs TransactionTestCase

TestCase wraps each test in a transaction and rolls back. It is fast but the transaction never commits, so on_commit() callbacks and post-save signals that depend on committed data will not fire.

# WRONG -- on_commit callback never fires inside TestCase
from django.test import TestCase

class OrderTest(TestCase):
    def test_confirmation_email_sent(self):
        order = Order.objects.create(status='confirmed')
        # on_commit hook that sends email never executes!
        self.assertEqual(Notification.objects.count(), 1)  # FAILS
# RIGHT -- TransactionTestCase lets transactions commit
from django.test import TransactionTestCase

class OrderSignalTest(TransactionTestCase):
    def setUp(self):
        """Always seed test data in setUp for TransactionTestCase."""
        self.product = Product.objects.create(name='Widget', stock=10)

    def test_confirmation_email_sent(self):
        order = Order.objects.create(
            product=self.product, status='confirmed'
        )
        self.assertEqual(Notification.objects.count(), 1)

    def test_inventory_decremented(self):
        Order.objects.create(product=self.product, status='confirmed')
        self.product.refresh_from_db()
        self.assertEqual(self.product.stock, 9)

    def test_no_signal_for_non_confirmed(self):
        Order.objects.create(product=self.product, status='pending')
        self.assertEqual(Notification.objects.count(), 0)

Rule: Use TestCase by default. Switch to TransactionTestCase only when testing on_commit() hooks, signals that need committed rows, or multi-database routing.


2. setUpTestData vs setUp

setUpTestData runs once per class and shares data across tests (read-only). setUp runs before every test. Use setUpTestData for expensive, read-only fixtures; use setUp for data that tests will mutate.

# WRONG -- recreates identical read-only data for every test (slow)
class ProductAPITest(TestCase):
    def setUp(self):
        self.category = Category.objects.create(name='Electronics')
        self.product = Product.objects.create(
            name='Laptop', category=self.category, price=999
        )

    def test_list(self):
        res = self.client.get('/api/products/')
        self.assertEqual(res.status_code, 200)

    def test_detail(self):
        res = self.client.get(f'/api/products/{self.product.id}/')
        self.assertEqual(res.status_code, 200)
# RIGHT -- shared read-only data created once
class ProductAPITest(TestCase):
    @classmethod
    def setUpTestData(cls):
        cls.category = Category.objects.create(name='Electronics')
        cls.product = Product.objects.create(
            name='Laptop', category=cls.category, price=999
        )

    def test_list(self):
        res = self.client.get('/api/products/')
        self.assertEqual(res.status_code, 200)

    def test_detail(self):
        res = self.client.get(f'/api/products/{self.product.id}/')
        self.assertEqual(res.status_code, 200)

Rule: If no test mutates the data, use setUpTestData. If tests create/update/delete rows, use setUp.


3. Factory-Boy: Define Factories AND Use Them in setUp

Install: pip install factory-boy

Put factories in <app>/factories.py. Every model that appears in tests should have a factory. Use SubFactory for ForeignKey relationships, factory.Faker for realistic field values, and always set class Meta: model = ....

# orders/factories.py
import factory
from orders.models import Order, OrderItem
from products.factories import ProductFactory

class OrderFactory(factory.django.DjangoModelFactory):
    class Meta:
        model = Order

    customer_name = factory.Faker('name')
    status = 'received'
    total_cents = 500

class OrderItemFactory(factory.django.DjangoModelFactory):
    class Meta:
        model = OrderItem

    order = factory.SubFactory(OrderFactory)
    product = factory.SubFactory(ProductFactory)
    quantity = 1
    price_cents = 350
# WRONG -- factories defined but tests still use objects.create()
class OrderTest(TestCase):
    def setUp(self):
        self.order = Order.objects.create(customer_name='Test', status='received')

# RIGHT -- setUp uses the factories
class OrderTest(TestCase):
    def setUp(self):
        self.order = OrderFactory(customer_name='Test')
        self.item = OrderItemFactory(order=self.order)

Rule: If you define factories, actually call them in setUp (or setUpTestData). Do not mix objects.create() and factories in the same test file.


4. assertNumQueries — Catch N+1 Problems

# RIGHT -- verify query count to prevent N+1 regressions
class ProductListTest(TestCase):
    @classmethod
    def setUpTestData(cls):
        category = Category.objects.create(name='Books')
        for i in range(5):
            Product.objects.create(name=f'Book {i}', category=category)

    def test_list_query_count(self):
        with self.assertNumQueries(2):  # 1 for products + 1 for categories
            res = self.client.get('/api/products/')
        self.assertEqual(res.status_code, 200)

Rule: Add assertNumQueries to any list endpoint test. The number should be constant regardless of result count.


5. mock.patch — Patch Where It Is USED, Not Where It Is Defined

# WRONG -- patches the original module, but the view already imported it
from unittest.mock import patch

@patch('services.email.send_email')  # <-- wrong target
def test_order_sends_email(self, mock_send):
    ...

# RIGHT -- patch where the view imports it
@patch('orders.views.send_email')  # <-- correct: patch in the using module
def test_order_sends_email(self, mock_send):
    self.client.post('/api/orders/', data={...}, content_type='application/json')
    mock_send.assert_called_once()

Rule: @patch('module.where.name.is.looked.up.name'). If orders/views.py does from services.email import send_email, patch orders.views.send_email.


6. override_settings

from django.test import TestCase, override_settings

class FeatureFlagTest(TestCase):
    @override_settings(ENABLE_NOTIFICATIONS=False)
    def test_no_notification_when_disabled(self):
        Order.objects.create(status='confirmed')
        self.assertEqual(Notification.objects.count(), 0)

    @override_settings(MAX_ORDER_ITEMS=2)
    def test_order_item_limit(self):
        res = self.client.post('/api/orders/', data={
            'items': [{'id': 1}, {'id': 2}, {'id': 3}]
        }, content_type='application/json')
        self.assertEqual(res.status_code, 400)

Rule: Use @override_settings on methods or classes. Never modify django.conf.settings directly in tests.


7. Testing File Uploads with SimpleUploadedFile

from django.core.files.uploadedfile import SimpleUploadedFile

class AvatarUploadTest(TestCase):
    def test_upload_profile_image(self):
        image = SimpleUploadedFile(
            'avatar.png',
            b'\x89PNG\r\n\x1a\n' + b'\x00' * 64,
            content_type='image/png'
        )
        res = self.client.post('/api/profile/avatar/', {'file': image})
        self.assertEqual(res.status_code, 200)

    def test_rejects_non_image(self):
        bad = SimpleUploadedFile('script.sh', b'#!/bin/bash', content_type='text/plain')
        res = self.client.post('/api/profile/avatar/', {'file': bad})
        self.assertEqual(res.status_code, 400)

Rule: Use SimpleUploadedFile, not file paths. Pass it directly in the POST data dict (not as JSON).


8. Testing Management Commands

from django.core.management import call_command
from io import StringIO

class CleanupCommandTest(TestCase):
    def test_cleanup_removes_stale_orders(self):
        Order.objects.create(status='stale', created_at=timezone.now() - timedelta(days=31))
        out = StringIO()
        call_command('cleanup_stale_orders', stdout=out)
        self.assertEqual(Order.objects.filter(status='stale').count(), 0)
        self.assertIn('Deleted 1', out.getvalue())

Rule: Use call_command(), not subprocess. Capture stdout with StringIO.


9. DRF: APITestCase and Authentication

from rest_framework.test import APITestCase, APIClient
from django.contrib.auth.models import User

class ProtectedEndpointTest(APITestCase):
    def setUp(self):
        self.user = User.objects.create_user('testuser', password='pass123')
        self.client = APIClient()

    def test_unauthenticated_returns_401(self):
        res = self.client.get('/api/orders/')
        self.assertIn(res.status_code, [401, 403])

    def test_authenticated_returns_200(self):
        self.client.force_authenticate(user=self.user)
        res = self.client.get('/api/orders/')
        self.assertEqual(res.status_code, 200)

Rule: DRF tests extend APITestCase. Use force_authenticate() instead of login. Always test both authenticated and unauthenticated paths.


10. Essential Test Patterns for Every API

Every Django API test suite should include:

  1. Happy path -- GET /api/resource/ returns 200 with expected data shape
  2. Validation rejection -- POST with invalid/empty data returns 400
  3. 404 for missing resource -- GET /api/resource/99999/ returns 404
  4. Persistence verification -- Create via POST, then GET the same resource and verify fields match
  5. Error response consistency -- All error responses share the same shape (e.g., {"error": {"message": "..."}})
class ResourceAPITest(TestCase):
    def setUp(self):
        self.resource = ResourceFactory()

    def test_list_returns_items(self):
        res = self.client.get('/api/resources/')
        self.assertEqual(res.status_code, 200)
        self.assertGreater(len(res.json()['results']), 0)

    def test_create_rejects_empty(self):
        res = self.client.post('/api/resources/', data={}, content_type='application/json')
        self.assertEqual(res.status_code, 400)

    def test_missing_returns_404(self):
        res = self.client.get('/api/resources/99999/')
        self.assertEqual(res.status_code, 404)

    def test_create_and_retrieve(self):
        res = self.client.post('/api/resources/', data={
            'name': 'Test'
        }, content_type='application/json')
        self.assertEqual(res.status_code, 201)
        obj_id = res.json()['id']
        detail = self.client.get(f'/api/resources/{obj_id}/')
        self.assertEqual(detail.status_code, 200)
        self.assertEqual(detail.json()['name'], 'Test')

Run with: python manage.py test


Checklist

  • Use TestCase by default; TransactionTestCase only for on_commit/signals
  • Use setUpTestData for read-only fixtures, setUp for mutable data
  • Factory-boy factories defined in <app>/factories.py with DjangoModelFactory, Meta, SubFactory, Faker
  • Factories actually called in setUp/setUpTestData (not objects.create)
  • assertNumQueries on list endpoints to prevent N+1
  • mock.patch targets the import location, not the definition location
  • @override_settings for config-dependent tests (never mutate settings directly)
  • SimpleUploadedFile for file upload tests
  • call_command() + StringIO for management command tests
  • DRF tests use APITestCase + force_authenticate(); test auth and unauth paths
  • Five essential tests: happy path, validation, 404, persistence, error shape
  • content_type='application/json' on POST requests
  • python manage.py test documented as run command
  • No manual tearDown or cleanup (TestCase handles isolation)

References

  • Django TestCase docs
  • TransactionTestCase docs
  • setUpTestData
  • assertNumQueries
  • override_settings
  • factory-boy Django integration
  • DRF Testing
  • unittest.mock.patch

Verifiers

  • django-tests -- Django testing best practices

skills

django-testing

tile.json