Testing Guide¶
Version: 0.6.0
Last Updated: January 3, 2026
This guide covers testing practices and tools used in Arctyk ITSM, a Django 5.2 application with comprehensive test coverage requirements.
Table of Contents¶
- Testing Overview
- Test Setup & Configuration
- Running Tests
- Writing Tests
- Test Structure
- Coverage Requirements
- Testing Best Practices
- Continuous Integration
- Troubleshooting
Testing Overview¶
Arctyk ITSM uses pytest with Django support for all automated testing. The project maintains a minimum 60% code coverage requirement with a target of 80%+ on critical paths.
Testing Stack¶
| Tool | Purpose | Version |
|---|---|---|
pytest | Test runner | Latest |
pytest-django | Django integration | Latest |
pytest-cov | Coverage reporting | Latest |
factory-boy | Test data factories | Latest |
django-test-plus | Django test utilities | Latest |
Test Organization¶
src/
├── tickets/
│ ├── tests/
│ │ ├── __init__.py
│ │ ├── test_models.py # Model tests
│ │ ├── test_views.py # View and endpoint tests
│ │ ├── test_forms.py # Form validation tests
│ │ ├── test_signals.py # Signal handler tests
│ │ ├── test_comments.py # Comment system tests
│ │ └── test_workflows.py # Workflow engine tests
│ └── ...
├── users/
│ ├── tests/
│ │ ├── test_models.py
│ │ ├── test_views.py
│ │ └── test_auth.py
│ └── ...
└── ... (other apps)
Test Setup & Configuration¶
Configuration File¶
Tests are configured in pytest.ini:
[pytest]
DJANGO_SETTINGS_MODULE = config.settings
python_files = tests.py test_*.py *_tests.py
python_classes = Test*
python_functions = test_*
addopts =
--reuse-db
--nomigrations
--cov=src
--cov-report=html:htmlcov
--cov-report=term-missing
--strict-markers
testpaths = src
markers =
slow: marks tests as slow (deselect with '-m "not slow"')
integration: marks tests as integration tests
unit: marks tests as unit tests
Key Settings¶
--reuse-db: Reuses test database between test runs for speed--nomigrations: Skips migrations, uses Django's schema creation (faster)--cov=src: Measures coverage only on source code, not tests--cov-report=html: Generates HTML coverage report inhtmlcov/--cov-report=term-missing: Shows missing lines in terminal output
Environment Setup¶
Create a test environment file if needed:
# .env.test
DEBUG=False
DJANGO_SECRET_KEY=test-secret-key-not-for-production
POSTGRES_DB=arctyk_test
POSTGRES_USER=arctyk_test
POSTGRES_PASSWORD=testpass
POSTGRES_HOST=localhost
POSTGRES_PORT=5432
Running Tests¶
Basic Test Execution¶
# Run all tests
pytest
# Run with verbose output
pytest -v
# Run specific test file
pytest src/tickets/tests/test_models.py
# Run specific test class
pytest src/tickets/tests/test_models.py::TestTicketModel
# Run specific test function
pytest src/tickets/tests/test_models.py::TestTicketModel::test_ticket_creation
Running Tests by Marker¶
# Run only unit tests
pytest -m unit
# Run only integration tests
pytest -m integration
# Run all except slow tests
pytest -m "not slow"
Coverage Reports¶
# Generate coverage report
pytest --cov
# Generate HTML report (open htmlcov/index.html)
pytest --cov
open htmlcov/index.html
# Show coverage for specific file
pytest --cov=src/tickets --cov-report=term-missing
# Coverage by module
pytest --cov --cov-report=term:skip-covered
Parallel Testing¶
Docker Testing¶
# Run all tests in Docker
docker compose exec web pytest
# Run with coverage in Docker
docker compose exec web pytest --cov
# Run specific test file in Docker
docker compose exec web pytest src/tickets/tests/test_models.py
Writing Tests¶
Test File Structure¶
# src/tickets/tests/test_models.py
import pytest
from django.contrib.auth import get_user_model
from tickets.models import Ticket, Comment
from tickets.workflows import apply_transition, STATUS_OPEN, STATUS_CLOSED
User = get_user_model()
pytestmark = pytest.mark.django_db # All tests in this file use DB
class TestTicketModel:
"""Tests for Ticket model."""
@pytest.fixture
def user(self):
"""Create a test user."""
return User.objects.create_user(
username='testuser',
email='test@example.com',
password='testpass123'
)
@pytest.fixture
def ticket(self, user):
"""Create a test ticket."""
return Ticket.objects.create(
title='Test Ticket',
description='Test description',
requester=user,
priority='medium'
)
def test_ticket_creation(self, ticket):
"""Test that ticket is created correctly."""
assert ticket.pk is not None
assert ticket.title == 'Test Ticket'
assert ticket.status == STATUS_OPEN # Default status
assert ticket.requester is not None
def test_ticket_str_representation(self, ticket):
"""Test ticket string representation includes ticket number."""
assert str(ticket).startswith('TKT-')
def test_ticket_requires_requester(self):
"""Test that ticket creation requires a requester."""
with pytest.raises(ValueError):
Ticket.objects.create(
title='No Requester Ticket',
description='This should fail'
)
class TestCommentSystem:
"""Tests for Comment model and related functionality."""
@pytest.fixture
def user(self):
return User.objects.create_user(username='commenter')
@pytest.fixture
def ticket(self, user):
return Ticket.objects.create(
title='Comment Test Ticket',
requester=user
)
def test_create_public_comment(self, ticket, user):
"""Test creating a public comment."""
comment = Comment.objects.create(
ticket=ticket,
author=user,
body='This is a public comment',
comment_type='public'
)
assert comment.pk is not None
assert comment.is_internal is False
assert comment.created_at is not None
def test_create_internal_note(self, ticket, user):
"""Test creating an internal note (staff only)."""
user.is_staff = True
user.save()
comment = Comment.objects.create(
ticket=ticket,
author=user,
body='Internal note for staff',
comment_type='internal'
)
assert comment.is_internal is True
def test_edit_comment(self, ticket, user):
"""Test editing a comment."""
comment = Comment.objects.create(
ticket=ticket,
author=user,
body='Original text'
)
comment.body = 'Updated text'
comment.save()
assert comment.body == 'Updated text'
assert comment.edited_at is not None
class TestWorkflowTransitions:
"""Tests for ticket workflow state transitions."""
@pytest.fixture
def ticket(self):
user = User.objects.create_user(username='requester')
return Ticket.objects.create(
title='Workflow Test',
requester=user,
assigned_user=User.objects.create_user(username='agent')
)
def test_transition_open_to_in_progress(self, ticket):
"""Test transitioning ticket from Open to In Progress."""
from tickets.workflows import STATUS_IN_PROGRESS
result = apply_transition(ticket, STATUS_IN_PROGRESS)
assert result.status == STATUS_IN_PROGRESS
def test_transition_with_conditions(self, ticket):
"""Test that transitions validate conditions."""
from tickets.workflows import STATUS_CLOSED, apply_transition
# Should fail because ticket has no resolution (description < 50 chars)
with pytest.raises(ValueError):
apply_transition(
ticket,
STATUS_CLOSED,
conditions=['has_resolution']
)
@pytest.mark.slow
def test_workflow_history(self, ticket):
"""Test that workflow transitions create audit trail."""
from changelog.models import ChangeLog
apply_transition(ticket, 'in_progress')
apply_transition(ticket, 'resolved')
changes = ChangeLog.objects.filter(
object_id=ticket.pk,
content_type__model='ticket'
)
assert changes.count() >= 2
Using Fixtures¶
Fixtures provide reusable test data and setup:
import pytest
from django.contrib.auth import get_user_model
User = get_user_model()
@pytest.fixture
def user():
"""Create a basic test user."""
return User.objects.create_user(
username='testuser',
email='test@example.com',
password='testpass'
)
@pytest.fixture
def staff_user():
"""Create a staff user."""
user = User.objects.create_user(
username='staffuser',
password='testpass'
)
user.is_staff = True
user.save()
return user
@pytest.fixture
def authenticated_client(client, user):
"""Return an authenticated test client."""
client.force_login(user)
return client
Testing Views and Endpoints¶
import pytest
from django.urls import reverse
from tickets.models import Ticket
@pytest.mark.django_db
class TestTicketViews:
"""Tests for ticket views."""
def test_ticket_list_requires_login(self, client):
"""Test that ticket list requires authentication."""
response = client.get(reverse('tickets:ticket_list'))
assert response.status_code == 302 # Redirect to login
def test_ticket_list_authenticated(self, authenticated_client, user):
"""Test that authenticated users can view ticket list."""
Ticket.objects.create(
title='Test Ticket',
requester=user
)
response = authenticated_client.get(reverse('tickets:ticket_list'))
assert response.status_code == 200
assert 'Test Ticket' in response.content.decode()
def test_ticket_detail_view(self, authenticated_client, user):
"""Test viewing a specific ticket."""
ticket = Ticket.objects.create(
title='Detail Test',
requester=user
)
url = reverse('tickets:ticket_detail', kwargs={'pk': ticket.pk})
response = authenticated_client.get(url)
assert response.status_code == 200
assert ticket.title in response.content.decode()
def test_create_ticket_post(self, authenticated_client, user):
"""Test creating a ticket via POST."""
response = authenticated_client.post(
reverse('tickets:ticket_create'),
data={
'title': 'New Ticket',
'description': 'Test description',
'priority': 'high',
'category': 'technical'
}
)
assert response.status_code in [200, 302] # Success or redirect
assert Ticket.objects.filter(title='New Ticket').exists()
Testing Forms¶
import pytest
from tickets.forms import TicketForm
@pytest.mark.django_db
class TestTicketForm:
"""Tests for ticket forms."""
def test_valid_form(self):
"""Test form with valid data."""
form_data = {
'title': 'Test Ticket',
'description': 'Valid description',
'priority': 'medium',
'category': 'general'
}
form = TicketForm(data=form_data)
assert form.is_valid()
def test_missing_required_field(self):
"""Test form without required field."""
form_data = {
'description': 'Missing title'
}
form = TicketForm(data=form_data)
assert not form.is_valid()
assert 'title' in form.errors
def test_description_length_validation(self):
"""Test that description must meet minimum length."""
form_data = {
'title': 'Test',
'description': 'Too short' # Less than required minimum
}
form = TicketForm(data=form_data)
assert not form.is_valid()
Testing Signals¶
import pytest
from django.db.models.signals import post_save
from tickets.models import Ticket
from tickets.signals import assign_ticket_number
@pytest.mark.django_db
class TestTicketSignals:
"""Tests for ticket signal handlers."""
def test_ticket_number_assigned_on_creation(self):
"""Test that ticket numbers are auto-assigned."""
user = User.objects.create_user(username='requester')
ticket = Ticket.objects.create(
title='Signal Test',
requester=user
)
assert ticket.ticket_number is not None
assert isinstance(ticket.ticket_number, int)
def test_ticket_numbers_are_sequential(self):
"""Test that ticket numbers increment."""
user = User.objects.create_user(username='requester')
ticket1 = Ticket.objects.create(title='First', requester=user)
ticket2 = Ticket.objects.create(title='Second', requester=user)
assert ticket2.ticket_number > ticket1.ticket_number
Test Structure¶
Naming Conventions¶
| Element | Convention | Example |
|---|---|---|
| Test file | test_*.py or *_tests.py | test_models.py |
| Test class | Test* | TestTicketModel |
| Test method | test_* | test_ticket_creation |
| Fixture | lowercase with underscores | @pytest.fixture def user(): |
Test Categories¶
Unit Tests¶
Test individual functions/methods in isolation:
@pytest.mark.unit
def test_ticket_str_representation(ticket):
"""Single unit - just the __str__ method."""
assert str(ticket).startswith('TKT-')
Integration Tests¶
Test multiple components working together:
@pytest.mark.integration
def test_comment_creates_changelog_entry(ticket, user):
"""Tests comment creation + changelog + signals."""
Comment.objects.create(
ticket=ticket,
author=user,
body='Test'
)
# Verify changelog entry was created
assert ChangeLog.objects.filter(
object_id=ticket.pk
).exists()
Slow Tests¶
Mark tests that take considerable time:
@pytest.mark.slow
def test_large_dataset_processing(create_many_tickets):
"""This test processes 1000+ tickets."""
# ...
Coverage Requirements¶
Minimum Coverage: 60%¶
Target Coverage: 80%+¶
Focus on:
- Core business logic (models, workflows, signals)
- Views (endpoints, permissions, responses)
- Forms (validation, cleaning)
- Critical paths (user-facing features)
Coverage by Module¶
src/
├── config/ 60%+ (settings, urls)
├── users/ 75%+ (auth-critical)
├── tickets/ 85%+ (core feature)
├── projects/ 70%+
├── inventory/ 65%+
├── reports/ 60%+
├── changelog/ 75%+ (audit-critical)
└── dashboard/ 60%+
Excluding from Coverage¶
Files to exclude from coverage (already configured in pyproject.toml):
Testing Best Practices¶
✅ Do's¶
- Use fixtures for reusable setup
- One assertion per test (when possible)
- Use descriptive names
- Test edge cases
def test_empty_description_rejected():
def test_very_long_description_accepted():
def test_special_characters_in_title():
- Use parameterization for multiple cases
❌ Don'ts¶
- Don't test Django/Python internals
# Don't do this - testing Django's queryset
def test_queryset_returns_list():
assert isinstance(Ticket.objects.all(), list)
- Don't make tests depend on each other
# Bad: test2 depends on test1 running first
def test1(db):
global created_ticket
created_ticket = Ticket.objects.create(...)
def test2(db):
assert created_ticket.pk # Fragile!
- Don't hit external services
# Don't actually send emails, APIs, etc.
# Mock them instead:
@mock.patch('tickets.email_utils.send_email')
def test_email_sent(mock_send, ticket):
# ...
- Don't use hardcoded IDs
Continuous Integration¶
GitHub Actions¶
Tests run automatically on:
- Push to dev: Pre-release validation
- Pull requests to dev: Before merge approval
- Push to main: Release validation
CI Configuration¶
See .github/workflows/tests.yml:
- name: Run tests
run: pytest --cov
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
file: ./coverage.xml
Pre-commit Hooks¶
Tests can be run locally before commit:
# Install pre-commit hooks
pre-commit install
# Runs pytest, linting, formatting checks automatically
git commit -m "feat: add comment system"
Troubleshooting¶
Test Database Issues¶
Problem: "Cannot connect to test database"
Solution:
# Ensure PostgreSQL is running
docker compose up -d db
# Or in local development:
postgres -D /usr/local/var/postgres
Migration Errors¶
Problem: "relation does not exist" in tests
Solution: The --nomigrations flag should handle this. If issues persist:
# Run migrations explicitly
pytest --no-nomigrations # Runs migrations
# Or manually:
python src/manage.py migrate --database=test
Fixture Scope Issues¶
Problem: "object has no attribute" in test
Solution: Ensure fixture has correct scope:
# Function scope (per test) - default
@pytest.fixture
def user():
return User.objects.create_user(...)
# Class scope (per test class)
@pytest.fixture(scope='class')
def setup_users():
return [User.objects.create_user(...) for _ in range(3)]
Coverage Not Counting Tests¶
Problem: Coverage report shows lower than expected
Solution: Ensure tests are in configured paths:
# Check configuration
pytest --setup-show # Shows fixture setup
# Verify coverage on specific file
pytest --cov=src/tickets/models.py src/tickets/tests/test_models.py
Quick Reference¶
# Common commands
pytest # Run all tests
pytest -v # Verbose output
pytest --cov # With coverage
pytest src/tickets/ # Specific app
pytest -k "test_name" # By keyword
pytest -m "not slow" # Skip slow tests
# Debugging
pytest -s # Show print statements
pytest -x # Stop on first failure
pytest --pdb # Drop into debugger on failure
pytest -l # Show local variables
# HTML reports
pytest --cov
open htmlcov/index.html # View coverage report
Resources¶
- pytest Documentation
- pytest-django Documentation
- Django Testing Documentation
- Factory Boy Documentation
- Coverage.py Documentation
Happy testing! 🧪