Write correct Django tests — TestCase vs TransactionTestCase, setUpTestData, factory-boy, assertNumQueries, mock.patch placement, and DRF APITestCase patterns
99
99%
Does it follow best practices?
Impact
99%
1.33xAverage score across 2 eval scenarios
Passed
No known issues
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.
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.
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.
# 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.
# 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.
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.
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).
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.
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.
Every Django API test suite should include:
GET /api/resource/ returns 200 with expected data shapePOST with invalid/empty data returns 400GET /api/resource/99999/ returns 404{"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
TestCase by default; TransactionTestCase only for on_commit/signalssetUpTestData for read-only fixtures, setUp for mutable data<app>/factories.py with DjangoModelFactory, Meta, SubFactory, FakersetUp/setUpTestData (not objects.create)assertNumQueries on list endpoints to prevent N+1mock.patch targets the import location, not the definition location@override_settings for config-dependent tests (never mutate settings directly)SimpleUploadedFile for file upload testscall_command() + StringIO for management command testsAPITestCase + force_authenticate(); test auth and unauth pathscontent_type='application/json' on POST requestspython manage.py test documented as run command