Skip to content

Testing Guide

Quick Start

Running Tests

# Run all tests
cd app && python manage.py test --verbosity=2 --keepdb

# Run a specific app's tests
cd app && python manage.py test jobtracker.tests --verbosity=2 --keepdb

# Run a specific test file
cd app && python manage.py test jobtracker.tests.test_qualification_models --verbosity=2 --keepdb

# Run a specific test class
cd app && python manage.py test jobtracker.tests.test_qualification_models.QualificationTests --verbosity=2 --keepdb

# Run a single test method
cd app && python manage.py test jobtracker.tests.test_qualification_models.QualificationTests.test_qualification_creation --verbosity=2 --keepdb

The --keepdb flag reuses the test database between runs, significantly speeding up test execution.

Project Conventions

Framework

CHAOTICA uses Django's built-in TestCase framework. Do not use pytest.

File Layout

Tests live in a tests/ package within each Django app:

app/<appname>/tests/
├── __init__.py              # Required — makes it a Python package
├── test_models.py           # Model tests
├── test_forms.py            # Form tests
├── test_views_<area>.py     # View tests (split by area)
└── test_tasks.py            # Celery task tests

Naming Conventions

  • Files: test_<system>_<layer>.py (e.g. test_qualification_models.py, test_skill_models.py)
  • Classes: One test class per model, form, or view group (e.g. QualificationTests, AwardingBodyTests)
  • Methods: test_<what_is_being_tested> — be descriptive (e.g. test_validity_period_display_years_months_days)

Critical: Custom SessionMiddleware

Most Common Gotcha

CHAOTICA has a custom SessionMiddleware (chaotica_utils/middleware/session.py) that validates the HTTP_HOST header on every request. Django's test client does not set this header by default, causing all view test requests to return HTTP 400.

The Fix

Always pass HTTP_HOST="testserver" when creating the test client:

from django.test import TestCase, Client as TestClient

class MyViewTestCase(TestCase):
    """Base class for all view tests."""
    def setUp(self):
        super().setUp()
        self.client = TestClient(HTTP_HOST="testserver")

Note

Model-only tests do not make HTTP requests and do not need this.

Authentication Patterns

Login URL

The login URL is /auth/login/ (not /login/). When testing redirects for unauthenticated users:

def test_requires_login(self):
    resp = self.client.get(reverse("my_view"))
    self.assertEqual(resp.status_code, 302)
    self.assertIn("/auth/login/", resp.url)

Creating Test Users

from chaotica_utils.models import User

# In setUp():
self.user = User.objects.create_user(
    email="user@test.com",
    password="testpass123"
)

Logging In

self.client.login(email="user@test.com", password="testpass123")

Permission-Gated Views (django-guardian)

Views that use PermissionRequiredMixin with return_403=True return HTTP 403 for anonymous users, not a 302 redirect.

Assigning Permissions

from guardian.shortcuts import assign_perm

# Global permission
assign_perm("jobtracker.view_qualification", self.user)

# Object-level permission
assign_perm("can_approve_leave_requests", self.user, org_unit)

Testing Permission Denied

def test_requires_permission(self):
    resp = self.client.get(reverse("protected_view"))
    # Could be 302 (LoginRequired) or 403 (PermissionRequired)
    self.assertIn(resp.status_code, [302, 403])

Testing Models

Standard Model Test Pattern

class MyModelTests(TestCase):
    def setUp(self):
        # Create dependencies
        self.user = User.objects.create_user(
            email="user@test.com", password="testpass123"
        )

    def test_creation(self):
        obj = MyModel.objects.create(name="Test", user=self.user)
        self.assertEqual(obj.name, "Test")

    def test_str(self):
        obj = MyModel.objects.create(name="Test", user=self.user)
        self.assertEqual(str(obj), "Test")

    def test_auto_slug(self):
        obj = MyModel.objects.create(name="Test", user=self.user)
        self.assertTrue(obj.slug)

    def test_get_absolute_url(self):
        obj = MyModel.objects.create(name="Test", user=self.user)
        url = obj.get_absolute_url()
        self.assertIn(obj.slug, url)

Testing Validation

from django.core.exceptions import ValidationError

def test_clean_raises_on_invalid(self):
    obj = MyModel(invalid_field=-1)
    with self.assertRaises(ValidationError):
        obj.clean()

Testing State Transitions

def test_authorise_sets_fields(self):
    request = LeaveRequest.objects.create(...)
    request.authorise(self.manager)
    self.assertTrue(request.authorised)
    self.assertEqual(request.authorised_by, self.manager)

Testing Forms

Basic Form Tests

def test_form_valid(self):
    form = MyForm(data={"name": "Test", "value": 42})
    self.assertTrue(form.is_valid(), form.errors)

def test_form_fields(self):
    form = MyForm()
    self.assertIn("name", form.fields)

Crispy Forms Layout Inspection

Warning

form.as_p() renders all model fields regardless of the crispy layout. To check which fields are actually shown in the form, walk the layout tree:

def _get_layout_field_names(self, form):
    """Extract field names from the crispy forms layout."""
    fields = []
    def _walk(layout_obj):
        if hasattr(layout_obj, 'fields'):
            for f in layout_obj.fields:
                if isinstance(f, str):
                    fields.append(f)
                else:
                    _walk(f)
    _walk(form.helper.layout)
    return fields

def test_form_hides_field(self):
    form = MyForm(instance=record)
    layout_fields = self._get_layout_field_names(form)
    self.assertNotIn("hidden_field", layout_fields)

Testing Views

AJAX / JSON Views

Many CHAOTICA views return JSON for modal forms:

def test_add_returns_json(self):
    resp = self.client.get(reverse("add_thing"))
    self.assertEqual(resp.status_code, 200)
    data = resp.json()
    self.assertIn("html_form", data)

def test_add_post_valid(self):
    resp = self.client.post(
        reverse("add_thing"),
        {"name": "Test", "value": 42},
    )
    data = resp.json()
    self.assertTrue(data["form_is_valid"])

POST-Only Views

def test_get_not_allowed(self):
    resp = self.client.get(reverse("action_endpoint", args=[record.pk]))
    self.assertEqual(resp.status_code, 405)

Testing Record Isolation

Ensure users can only access their own records:

def test_other_users_record_404(self):
    other_record = Record.objects.create(user=self.other_user, ...)
    resp = self.client.get(
        reverse("update_record", args=[other_record.pk])
    )
    self.assertEqual(resp.status_code, 404)

Manager / Acting Manager Patterns

def setUp(self):
    super().setUp()
    self.manager = User.objects.create_user(
        email="manager@test.com", password="testpass123"
    )
    self.user = User.objects.create_user(
        email="user@test.com", password="testpass123"
    )
    self.user.manager = self.manager
    self.user.save()

def test_acting_manager_access(self):
    self.user.manager = None
    self.user.acting_manager = self.manager
    self.user.save()
    # ... test that acting_manager has same access as manager

Common Pitfalls

Pitfall Symptom Fix
Missing HTTP_HOST All view requests return 400 Use Client(HTTP_HOST="testserver")
Wrong login URL Redirect assertions fail Check for /auth/login/, not /login/
PermissionRequiredMixin Expected 302, got 403 Use assertIn(status, [302, 403])
Crispy layout vs as_p() Field appears present when hidden Walk form.helper.layout tree
Missing super().setUp() Parent fixtures not created Always call super().setUp() in subclass
auto_now_add fields Can't set timestamp in test Use Model.objects.filter().update() after create

Existing Test Coverage

App File Tests Coverage
jobtracker test_qualification_models.py 30 QualificationTag, AwardingBody, Qualification, QualificationRecord
jobtracker test_qualification_forms.py 14 OwnQualificationRecordForm, QualificationForm, AwardingBodyForm
jobtracker test_qualification_views_own.py 27 Own qualification CRUD, transitions, date validation
jobtracker test_qualification_views_team.py 17 Team list, verify/unverify, manager access
jobtracker test_qualification_views_catalogue.py 8 Permission-gated list/detail views
jobtracker test_qualification_tasks.py 5 Expiry check cron task
jobtracker test_skill_models.py 25 SkillCategory, Skill, UserSkill, learning paths
jobtracker test_client_models.py 25 Client, Contact, ClientOnboarding, FrameworkAgreement
chaotica_utils test_utils.py 30 Utility functions (slug, percentage, dates, etc.)
chaotica_utils test_job_levels.py 15 JobLevel, UserJobLevel
chaotica_utils test_leave.py 20 LeaveRequest state machine
notifications test_models.py 20 Notification, Subscription, OptOut, Category

Use the qualification tests as a comprehensive reference example — they demonstrate all the patterns described above.