Skip to content

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

  1. Testing Overview
  2. Test Setup & Configuration
  3. Running Tests
  4. Writing Tests
  5. Test Structure
  6. Coverage Requirements
  7. Testing Best Practices
  8. Continuous Integration
  9. 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 in htmlcov/
  • --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

# Install pytest-xdist
pip install pytest-xdist

# Run tests in parallel (4 workers)
pytest -n 4

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%

# Check current coverage
pytest --cov

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):

[tool.coverage.run]
omit = [
    "*/migrations/*",
    "*/tests/*",
    "*/__init__.py",
    "manage.py"
]

Testing Best Practices

✅ Do's

  1. Use fixtures for reusable setup
@pytest.fixture
def user():
    return User.objects.create_user(username='test')
  1. One assertion per test (when possible)
def test_ticket_has_status(ticket):
    assert ticket.status == 'open'
  1. Use descriptive names
def test_ticket_requires_valid_email_for_requester():  # Clear
def test_email(ticket):  # Unclear
  1. Test edge cases
def test_empty_description_rejected():
def test_very_long_description_accepted():
def test_special_characters_in_title():
  1. Use parameterization for multiple cases
    @pytest.mark.parametrize('priority', ['low', 'medium', 'high', 'urgent'])
    def test_all_priorities(priority):
        ticket = Ticket.objects.create(priority=priority)
        assert ticket.priority == priority
    

❌ Don'ts

  1. 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)
  1. 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!
  1. 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):
    # ...
  1. Don't use hardcoded IDs
# Bad:
ticket = Ticket.objects.get(pk=1)

# Good:
ticket = Ticket.objects.first()

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


Happy testing! 🧪