| name | testing |
| description | Best practices for writing Django tests (updated template) |
Django Testing Best Practices
Overview
This skill outlines testing conventions and best practices for Django applications. Tests should focus on verifying business logic outcomes rather than framework functionality.
When to Use
- Writing new test cases for models, services, and views
- Creating test factories for test data generation
- Reviewing existing test code for quality and compliance
- Setting up test infrastructure for new apps
Key Principles
- Test business logic, not Django/ORM basics
- Use factories for all test data creation
- Maintain test isolation and independence
- Follow consistent naming and organization patterns
Core Concepts
Test File Organization
All tests live in the app's tests/ directory:
app/
company/
models.py
services/
user_service.py
tests/
__init__.py
factories.py
test_models.py
test_services/
__init__.py
test_user_service.py
Critical Rules:
- Never create
tests/ directories inside services/ submodules
- Always place test files in
app/{app_name}/tests/
- Service tests go in
app/{app_name}/tests/test_services/
- Test paths mirror source structure under
tests/
Base Test Case
All tests inherit from django_spire.core.tests.test_cases.BaseTestCase:
from __future__ import annotations
from django_spire.core.tests.test_cases import BaseTestCase
class MyModelTestCase(BaseTestCase):
def setUp(self) -> None:
super().setUp()
self.user = create_super_user()
self.company = create_test_company()
BaseTestCase provides:
- Test client (
self.client)
- Logged-in superuser (
self.super_user)
- Temporary MEDIA_ROOT for file uploads
Factory Naming Conventions
All factory functions MUST use the create_test_ prefix. This distinguishes test data factories from production code.
Correct:
def create_test_company(**kwargs) -> Company:
...
def create_test_location(**kwargs) -> Location:
...
Incorrect:
def create_location(**kwargs) -> Location:
...
def create_company(**kwargs) -> Company:
...
When importing factories from other apps, always verify the exact function name in the source file before using it.
Hybrid Factory Pattern with **kwargs
Factories belong to the app that owns the model. Use optional FK parameters with auto-creation plus **kwargs for flexible field overrides:
from __future__ import annotations
def create_test_company(**kwargs):
defaults = {
'name': 'InMotion Logistics',
'description': 'Supply chain management solutions',
}
defaults.update(kwargs)
return Company.objects.create(**defaults)
from __future__ import annotations
from typing import TYPE_CHECKING
from django.contrib.auth.models import User
from django_spire.auth.user.tests.factories import create_user
from app.company.user.models import CompanyUser
from app.company.tests.factories import create_test_company
if TYPE_CHECKING:
from app.company.models import Company
def create_test_company_user(
company: Company | None = None,
user: User | None = None,
**kwargs
) -> CompanyUser:
if company is None:
company = create_test_company()
if user is None:
user = create_user('test_user')
defaults = {
'company': company,
'user': user,
'is_active': True,
'is_deleted': False,
}
defaults.update(kwargs)
return CompanyUser.objects.create(**defaults)
Factory Rules:
- Each app has its own
tests/factories.py file
- Import factories from the app that owns the model
- Use optional FK parameters with auto-creation
- Use
**kwargs for flexible field overrides
- Set sensible defaults that can be overridden
- Use
create_super_user() for main test user
- Never create factories for models from other apps
- Always use
from __future__ import annotations and proper type hints
Implementation Guide
Step 1: Create Test File Structure
mkdir -p app/{app_name}/tests/test_services
touch app/{app_name}/tests/__init__.py
touch app/{app_name}/tests/factories.py
touch app/{app_name}/tests/test_models.py
touch app/{app_name}/tests/test_services/__init__.py
Step 2: Create Test Factories
from __future__ import annotations
from django_spire.auth.user.tests.factories import create_super_user, create_user
from app.{app_name}.models import {ModelName}
def create_test_{model_lower}(**kwargs):
defaults = {
'name': 'Test {ModelName}',
'description': 'Test description',
}
defaults.update(kwargs)
return {ModelName}.objects.create(**defaults)
Step 3: Write Test Cases
from __future__ import annotations
from django_spire.core.tests.test_cases import BaseTestCase
from app.{app_name}.tests.factories import create_test_{model_lower}
class {ModelName}TestCase(BaseTestCase):
def setUp(self) -> None:
super().setUp()
self.{model_lower} = create_test_{model_lower}()
def test_{action}_returns_expected_result(self):
result = self.{model_lower}.services.processor.process()
assert result
assert result.value == expected_value
Step 4: What to Test
DO Test:
- Business logic outcomes and domain rules
- State changes after method execution
- Edge cases and boundary conditions
- Side effects and downstream actions
- Correct error handling for invalid inputs
DO NOT Test:
- Framework/ORM basics (Django saving FKs correctly)
- Attribute assignment verification
- Return value existence unless it's the logic
- Standard CRUD operations
Assertion Discipline
A test is only as good as its assertions. Two failure modes make an assertion worthless.
Avoid trivially true assertions and tautologies. An assertion that cannot fail tests nothing. Do not assert a literal (assert True), compare a value to itself (assert user.id == user.id), or re-assert what the line above just set:
user.name = 'Alice'
assert user.name == 'Alice'
If swapping the implementation for a no-op stub would leave the assertion green, the assertion is tautological. Delete it, or replace it with one that pins real behavior.
Assert the positive space AND the negative space. The golden rule of assertions is to assert the positive space that you do expect AND to assert the negative space that you do not expect, because where data moves across the valid/invalid boundary between these spaces is where interesting bugs are often found. Asserting only that the right thing happened misses the bugs where the wrong thing also happened: a duplicate row was created, a sibling record was mutated, an error was swallowed.
def test_activate_only_target_user(self):
target = create_test_company_user(is_active=False)
other = create_test_company_user(is_active=False)
CompanyUser.services.factory.activate(target)
target.refresh_from_db()
other.refresh_from_db()
assert target.is_active
assert not other.is_active
assert CompanyUser.objects.filter(is_active=True).count() == 1
Test exhaustively, across the valid/invalid boundary. This is also why tests must test exhaustively, not only with valid data but also with invalid data, and as valid data becomes invalid. Cover the value just inside the boundary, the value on it, and the value just outside it, plus the transition itself (a record going from active to inactive, a quota at, just under, and just over the limit). Bugs cluster at these edges, not in the middle of the valid range.
def test_quantity_boundary(self):
assert OrderLine(quantity=1).is_valid()
assert not OrderLine(quantity=0).is_valid()
assert not OrderLine(quantity=-1).is_valid()
View Integration Tests
For view/page tests, use high-level integration tests that check response status codes. Keep tests simple and resilient to template changes.
Rules:
- Use
reverse() instead of hardcoded URLs
- Test for 200 status codes, not rendered content
- One test class with separate methods per endpoint
- Only create test data when the endpoint requires it
from __future__ import annotations
from django.urls import reverse
from django_spire.core.tests.test_cases import BaseTestCase
from app.location.tests.factories import create_test_location
class LocationPageViewTestCase(BaseTestCase):
def setUp(self) -> None:
super().setUp()
def test_dashboard_returns_200(self):
response = self.client.get(reverse('location:page:dashboard'))
assert response.status_code == 200
def test_list_returns_200(self):
response = self.client.get(reverse('location:page:list'))
assert response.status_code == 200
def test_detail_returns_200(self):
location = create_test_location()
response = self.client.get(reverse('location:page:detail', kwargs={'pk': location.pk}))
assert response.status_code == 200
Why keep view tests simple:
- Template changes will break detailed content assertions
- View logic is minimal; business logic lives in services
- Integration tests catch real problems (500s, broken templates)
- Detailed testing belongs in service/model test suites
Examples
Example 1: Factory Service Test
DON'T test framework basics:
def test_create_new_company_user(self):
company_user = CompanyUser.services.factory.create_or_activate(
company=self.company,
user=self.user
)
assert company_user is not None
assert company_user.company == self.company
assert company_user.user == self.user
DO test business logic:
def test_create_new_company_user(self):
initial_count = CompanyUser.objects.count()
company_user = CompanyUser.services.factory.create_or_activate(
company=self.company,
user=self.user
)
assert CompanyUser.objects.count() == initial_count + 1
def test_activate_existing_company_user(self):
create_test_company_user(
company=self.company,
user=self.user,
is_active=False
)
company_user = CompanyUser.services.factory.create_or_activate(
company=self.company,
user=self.user
)
assert company_user.is_active
assert CompanyUser.objects.count() == 1
Example 2: Transformation Service Test
def test_to_wav_creates_file(self):
self.episode.services.transformation.to_wav()
original_path = self.episode.audio_path
new_file = Path(original_path).with_suffix('.wav')
assert new_file.exists()
new_file.unlink()
Example 3: Factory with FK Parameters and **kwargs
from __future__ import annotations
from typing import TYPE_CHECKING
from django_spire.auth.user.tests.factories import create_user
from app.podcast.models import Podcast
from app.podcast.tests.factories import create_test_podcast
from app.transcription.models import Transcription
from app.transcription.tests.factories import create_test_transcription
if TYPE_CHECKING:
from django.contrib.auth.models import User
def create_test_episode(
podcast: Podcast | None = None,
transcription: Transcription | None = None,
user: User | None = None,
**kwargs
):
if podcast is None:
podcast = create_test_podcast()
if transcription is None:
transcription = create_test_transcription()
if user is None:
user = create_user('test_user')
defaults = {
'podcast': podcast,
'transcription': transcription,
'user': user,
'name': 'Finding Obvious - DK & Wes go on a journey...',
'episode_number': 1,
}
defaults.update(kwargs)
return Episode.objects.create(**defaults)
Usage in tests:
def test_with_existing_podcast(self):
episode = create_test_episode(podcast=self.podcast)
def test_with_auto_created_podcast(self):
episode = create_test_episode(transcription=self.transcription)
def test_inactive_episode(self):
episode = create_test_episode(is_active=False)
def test_custom_episode_number(self):
episode = create_test_episode(episode_number=5, is_featured=True)
Running Tests
Run tests for a specific app:
pytest app/explorer
pytest app/podcast/episode
Run specific test file:
pytest app/podcast/episode/tests/services/test_transformation_service.py
Run specific test case:
pytest app/podcast/episode/tests/services/test_transformation_service.py::EpisodeServiceTransformationTestCase
Run specific test method:
pytest app/podcast/episode/tests/services/test_transformation_service.py::EpisodeServiceTransformationTestCase::test_to_wav
Code Review Checklist
When reviewing test files, verify the following:
- Base Test Case: All test classes inherit from
django_spire.core.tests.test_cases.BaseTestCase
- Factory Pattern: Factories are imported from the correct app directories, not duplicated in test files
- Business Logic Focus: Tests verify business outcomes, not framework basics (ORM saves, attribute assignment)
- Test Isolation: Tests are independent and don't rely on execution order or shared state
- Naming Conventions: Test classes use
{ModuleName}TestCase, methods use test_{action}
- Factory Naming: All factory functions use the
create_test_ prefix (e.g., create_test_location)
- Hybrid Factory Pattern: Factory functions use optional FK parameters with auto-creation plus
**kwargs for flexible field overrides
- File Cleanup: Tests clean up any created files in teardown or at end of test
- Assertion Quality: Assertions check meaningful outcomes, not just "not None" or equality to inputs, and use
pytest's bare assert (e.g. assert x == y), never unittest's assertEqual/assertTrue/assertIsNotNone or any variant
- Realistic Data: Factories use realistic project-based data, not generic placeholders
- User Creation: Tests use
create_super_user() for main test user, create_user() for additional users
- No Inline Creation: Complex object creation uses factories, not inline
objects.create() calls
- Service Testing: Service tests verify business logic outcomes, not that methods "returned something"
- Type Hints: Factory functions use proper type hints with
from __future__ import annotations and TYPE_CHECKING guards
- Sensible Defaults: Factories set sensible defaults that can be overridden via
**kwargs
- View Tests Use reverse(): View tests use
reverse() instead of hardcoded URLs
- View Tests Check Status Codes: View tests check for 200 status codes, not rendered content
- No Tautologies: Every assertion can actually fail — no
assert True, no self-comparisons, no re-asserting a value the test just assigned
- Positive and Negative Space: Tests assert both the expected outcome AND that unexpected side effects did not happen (siblings untouched, no duplicate rows, no swallowed errors)
- Boundary Coverage: Tests exercise valid, invalid, and boundary inputs, including the transition as valid data becomes invalid
Related Skills