Testing Guide

Comprehensive guide to testing SATHI application components, features, and integrations.

Overview

SATHI uses multiple testing approaches:

  • Unit Tests: Test individual functions and methods

  • Integration Tests: Test component interactions

  • UI/UX Tests: Test user interface and experience

  • Performance Tests: Test load and response times

  • Security Tests: Test authentication and authorization

Testing Stack

Tools & Frameworks

Tool

Purpose

pytest

Test runner and framework

pytest-django

Django-specific test utilities

factory_boy

Test data generation

coverage.py

Code coverage measurement

Selenium

Browser automation for UI tests

Locust

Load and performance testing

Installation

pip install pytest pytest-django pytest-cov
pip install factory-boy faker
pip install selenium webdriver-manager
pip install locust

Configuration

pytest.ini:

[pytest]
DJANGO_SETTINGS_MODULE = chaviprom.settings
python_files = tests.py test_*.py *_tests.py
python_classes = Test*
python_functions = test_*
addopts = --reuse-db --nomigrations --cov=. --cov-report=html

conftest.py:

import pytest
from django.contrib.auth.models import User, Group, Permission
from patientapp.models import Patient, Institution

@pytest.fixture
def test_user(db):
    """Create a test user."""
    user = User.objects.create_user(
        username='testuser',
        email='test@example.com',
        password='testpass123'
    )
    return user

@pytest.fixture
def test_institution(db):
    """Create a test institution."""
    return Institution.objects.create(
        name='Test Hospital',
        address='123 Test St'
    )

@pytest.fixture
def test_patient(db, test_institution):
    """Create a test patient."""
    return Patient.objects.create(
        name='Test Patient',
        patient_id='P001',
        institution=test_institution,
        date_of_registration='2024-01-01'
    )

Unit Testing

Model Tests

Test Patient Model:

# patientapp/tests/test_models.py
import pytest
from datetime import date
from patientapp.models import Patient, Institution

@pytest.mark.django_db
class TestPatientModel:

    def test_patient_creation(self, test_institution):
        """Test creating a patient."""
        patient = Patient.objects.create(
            name='John Doe',
            patient_id='P12345',
            institution=test_institution,
            date_of_registration=date(2024, 1, 1)
        )

        assert patient.name == 'John Doe'
        assert patient.patient_id == 'P12345'
        assert patient.institution == test_institution

    def test_patient_str(self, test_patient):
        """Test patient string representation."""
        assert str(test_patient) == 'Test Patient (P001)'

    def test_patient_age_calculation(self, test_patient):
        """Test age calculation."""
        test_patient.date_of_birth = date(1990, 1, 1)
        test_patient.save()

        # Age should be calculated correctly
        assert test_patient.age >= 34

View Tests

Test Patient List View:

# patientapp/tests/test_views.py
import pytest
from django.urls import reverse
from django.contrib.auth.models import Permission

@pytest.mark.django_db
class TestPatientListView:

    def test_patient_list_requires_login(self, client):
        """Test that patient list requires authentication."""
        url = reverse('patient_list')
        response = client.get(url)

        # Should redirect to login
        assert response.status_code == 302
        assert '/account/login/' in response.url

    def test_patient_list_requires_permission(self, client, test_user):
        """Test that patient list requires view permission."""
        client.force_login(test_user)
        url = reverse('patient_list')
        response = client.get(url)

        # Should return 403 without permission
        assert response.status_code == 403

    def test_patient_list_with_permission(self, client, test_user, test_patient):
        """Test patient list with proper permission."""
        # Add permission
        permission = Permission.objects.get(codename='view_patient')
        test_user.user_permissions.add(permission)

        client.force_login(test_user)
        url = reverse('patient_list')
        response = client.get(url)

        assert response.status_code == 200
        assert 'patients' in response.context

Form Tests

Test Patient Form:

# patientapp/tests/test_forms.py
import pytest
from datetime import date
from patientapp.forms import PatientForm

@pytest.mark.django_db
class TestPatientForm:

    def test_valid_patient_form(self, test_institution):
        """Test form with valid data."""
        form_data = {
            'name': 'Jane Doe',
            'patient_id': 'P67890',
            'institution': test_institution.id,
            'date_of_registration': date(2024, 1, 15),
            'gender': 'F',
        }

        form = PatientForm(data=form_data)
        assert form.is_valid()

    def test_invalid_patient_form_missing_required(self):
        """Test form with missing required fields."""
        form_data = {
            'name': 'Jane Doe',
            # Missing patient_id
        }

        form = PatientForm(data=form_data)
        assert not form.is_valid()
        assert 'patient_id' in form.errors

Utility Function Tests

Test Date Reference Functions:

# patientapp/tests/test_utils.py
import pytest
from datetime import date
from patientapp.utils import (
    get_patient_available_start_dates,
    get_patient_start_date
)
from patientapp.models import Diagnosis, Treatment

@pytest.mark.django_db
class TestDateReferenceFunctions:

    def test_get_available_start_dates(self, test_patient):
        """Test getting all available start dates."""
        dates = get_patient_available_start_dates(test_patient)

        # Should include registration date
        assert len(dates) >= 1
        assert dates[0][0] == 'date_of_registration'

    def test_get_start_date_registration(self, test_patient):
        """Test getting registration date."""
        start_date = get_patient_start_date(
            test_patient,
            'date_of_registration'
        )

        assert start_date == test_patient.date_of_registration

    def test_get_start_date_invalid_reference(self, test_patient):
        """Test invalid reference falls back to registration."""
        start_date = get_patient_start_date(
            test_patient,
            'invalid_reference'
        )

        assert start_date == test_patient.date_of_registration

Integration Testing

Institution-Based Access Control

Test Provider Access:

# patientapp/tests/test_institution_access.py
import pytest
from django.contrib.auth.models import User, Permission
from providerapp.models import Provider
from patientapp.models import Patient, Institution
from patientapp.utils import (
    filter_patients_by_institution,
    check_patient_access
)

@pytest.mark.django_db
class TestInstitutionAccess:

    @pytest.fixture
    def institution_a(self, db):
        return Institution.objects.create(name='Hospital A')

    @pytest.fixture
    def institution_b(self, db):
        return Institution.objects.create(name='Hospital B')

    @pytest.fixture
    def provider_user(self, db, institution_a):
        user = User.objects.create_user(
            username='provider',
            password='test123'
        )
        Provider.objects.create(
            user=user,
            institution=institution_a
        )
        return user

    def test_provider_sees_only_own_institution_patients(
        self, provider_user, institution_a, institution_b
    ):
        """Test provider can only see their institution's patients."""
        # Create patients in both institutions
        patient_a = Patient.objects.create(
            name='Patient A',
            patient_id='PA001',
            institution=institution_a
        )
        patient_b = Patient.objects.create(
            name='Patient B',
            patient_id='PB001',
            institution=institution_b
        )

        # Filter patients
        all_patients = Patient.objects.all()
        filtered = filter_patients_by_institution(
            all_patients,
            provider_user
        )

        # Should only see institution A patients
        assert patient_a in filtered
        assert patient_b not in filtered

    def test_provider_cannot_access_other_institution_patient(
        self, provider_user, institution_b
    ):
        """Test provider cannot access other institution's patient."""
        patient_b = Patient.objects.create(
            name='Patient B',
            patient_id='PB001',
            institution=institution_b
        )

        # Should not have access
        has_access = check_patient_access(provider_user, patient_b)
        assert not has_access

Questionnaire Workflow

Test Complete Questionnaire Flow:

# promapp/tests/test_questionnaire_workflow.py
import pytest
from promapp.models import (
    Questionnaire,
    Item,
    QuestionnaireItem,
    PatientQuestionnaire,
    QuestionnaireSubmission,
    QuestionnaireItemResponse
)

@pytest.mark.django_db
class TestQuestionnaireWorkflow:

    def test_complete_questionnaire_submission(
        self, test_patient, test_user
    ):
        """Test complete questionnaire submission workflow."""
        # Create questionnaire
        questionnaire = Questionnaire.objects.create(
            name='Test Questionnaire'
        )

        # Create item
        item = Item.objects.create(
            name='How are you feeling?',
            response_type='Text'
        )

        # Add item to questionnaire
        QuestionnaireItem.objects.create(
            questionnaire=questionnaire,
            item=item,
            item_number=1
        )

        # Assign to patient
        pq = PatientQuestionnaire.objects.create(
            patient=test_patient,
            questionnaire=questionnaire
        )

        # Create submission
        submission = QuestionnaireSubmission.objects.create(
            patient_questionnaire=pq
        )

        # Add response
        response = QuestionnaireItemResponse.objects.create(
            questionnaire_submission=submission,
            item=item,
            response_value='Feeling good'
        )

        # Verify workflow
        assert submission.patient_questionnaire == pq
        assert response.questionnaire_submission == submission
        assert response.response_value == 'Feeling good'

UI/UX Testing

Vertical Tabs Testing

Manual Test Checklist:

PROM Review Page - Vertical Tabs

✓ Visual Verification:
  □ Topline Results section appears with red circles
  □ Other Construct Scores section appears with green circles
  □ Composite Construct Scores section appears (if applicable)
  □ First tab is active by default

✓ Tab Interaction:
  □ Clicking tab switches content
  □ Active tab has colored background and left border
  □ Only one tab content visible at a time
  □ Tab switching is instant (no delay)

✓ Content Display:
  □ Construct name displayed
  □ Current score displayed
  □ Trend indicator (arrow) displayed
  □ Bokeh plot renders correctly
  □ Related items section appears
  □ Item plots render correctly

✓ Filter Integration:
  □ Questionnaire filter updates tabs
  □ Time range filter updates plots
  □ Active tab remains selected after filter
  □ All filters work with tabs

✓ Responsive Design:
  □ Desktop (1920px+): 3-column item grid
  □ Tablet (768px-1024px): 2-column item grid
  □ Mobile (320px-767px): 1-column item grid
  □ Sidebar adapts to screen size

✓ Accessibility:
  □ Keyboard navigation works (Tab key)
  □ Enter/Space activates tabs
  □ Screen reader announces tab roles
  □ ARIA attributes present

✓ Performance:
  □ Page loads in < 2 seconds
  □ Plots load progressively
  □ No JavaScript errors in console
  □ No layout shift when switching tabs

Selenium UI Tests

Automated UI Test:

# tests/test_ui.py
import pytest
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC

@pytest.fixture
def browser():
    """Create browser instance."""
    driver = webdriver.Chrome()
    yield driver
    driver.quit()

@pytest.mark.ui
def test_vertical_tabs_switching(browser, live_server, test_patient):
    """Test vertical tabs switch correctly."""
    # Login
    browser.get(f'{live_server.url}/account/login/')
    browser.find_element(By.NAME, 'login').send_keys('testuser')
    browser.find_element(By.NAME, 'password').send_keys('testpass123')
    browser.find_element(By.CSS_SELECTOR, 'button[type="submit"]').click()

    # Navigate to PROM review
    browser.get(
        f'{live_server.url}/patients/{test_patient.id}/prom-review/'
    )

    # Wait for page load
    WebDriverWait(browser, 10).until(
        EC.presence_of_element_located((By.CLASS_NAME, 'vertical-tabs'))
    )

    # Find tab buttons
    tabs = browser.find_elements(By.CSS_SELECTOR, '[role="tab"]')
    assert len(tabs) > 0

    # Click second tab
    if len(tabs) > 1:
        tabs[1].click()

        # Wait for content to switch
        WebDriverWait(browser, 5).until(
            EC.visibility_of_element_located(
                (By.ID, tabs[1].get_attribute('aria-controls'))
            )
        )

        # Verify active state
        assert 'bg-red-500' in tabs[1].get_attribute('class') or \
               'bg-green-500' in tabs[1].get_attribute('class')

Performance Testing

Load Testing with Locust

Basic Load Test:

# locustfile.py
from locust import HttpUser, task, between
from locust import events

class SATHIUser(HttpUser):
    wait_time = between(1, 3)

    def on_start(self):
        """Login before testing."""
        self.client.post('/account/login/', {
            'login': 'testuser',
            'password': 'testpass123'
        })

    @task(3)
    def view_patient_list(self):
        """View patient list (common action)."""
        self.client.get('/patients/')

    @task(5)
    def view_prom_review(self):
        """View PROM review (most common action)."""
        self.client.get('/patients/123e4567-e89b-12d3-a456-426614174000/prom-review/')

    @task(1)
    def view_patient_detail(self):
        """View patient detail (less common)."""
        self.client.get('/patients/123e4567-e89b-12d3-a456-426614174000/')

    @task(2)
    def apply_filters(self):
        """Apply filters on PROM review."""
        self.client.get(
            '/patients/123e4567-e89b-12d3-a456-426614174000/prom-review/',
            params={
                'questionnaire': 'all',
                'time_range': '5',
                'start_date_reference': 'date_of_registration'
            }
        )

Run Load Test:

# Start with 10 users, spawn 2 per second
locust -f locustfile.py --host=http://localhost:8000 \
       --users 10 --spawn-rate 2

# Open web UI at http://localhost:8089

Performance Targets:

Endpoint

Target (95th percentile)

Max Acceptable

Patient List

< 500ms

< 1000ms

PROM Review (cold cache)

< 2000ms

< 5000ms

PROM Review (warm cache)

< 500ms

< 1000ms

Patient Detail

< 300ms

< 600ms

Database Query Testing

Test Query Count:

import pytest
from django.test.utils import override_settings
from django.db import connection
from django.test.utils import CaptureQueriesContext

@pytest.mark.django_db
def test_patient_list_query_count(client, test_user, test_patient):
    """Test patient list doesn't have N+1 queries."""
    # Add permission
    from django.contrib.auth.models import Permission
    permission = Permission.objects.get(codename='view_patient')
    test_user.user_permissions.add(permission)

    client.force_login(test_user)

    # Capture queries
    with CaptureQueriesContext(connection) as context:
        response = client.get('/patients/')

    # Should use select_related/prefetch_related
    # Acceptable query count: ~5-10 queries
    assert len(context.captured_queries) < 15

Security Testing

Permission Tests

Test View Permissions:

# tests/test_security.py
import pytest
from django.urls import reverse

@pytest.mark.django_db
class TestViewPermissions:

    def test_anonymous_cannot_access_patient_list(self, client):
        """Test anonymous users cannot access patient list."""
        response = client.get(reverse('patient_list'))
        assert response.status_code == 302  # Redirect to login

    def test_user_without_permission_cannot_access(
        self, client, test_user
    ):
        """Test users without permission get 403."""
        client.force_login(test_user)
        response = client.get(reverse('patient_list'))
        assert response.status_code == 403

    def test_user_with_permission_can_access(
        self, client, test_user
    ):
        """Test users with permission can access."""
        from django.contrib.auth.models import Permission
        permission = Permission.objects.get(codename='view_patient')
        test_user.user_permissions.add(permission)

        client.force_login(test_user)
        response = client.get(reverse('patient_list'))
        assert response.status_code == 200

CSRF Protection Tests

Test CSRF Token Required:

@pytest.mark.django_db
def test_post_requires_csrf_token(client, test_user):
    """Test POST requests require CSRF token."""
    client.force_login(test_user)

    # POST without CSRF token
    response = client.post('/patients/create/', {
        'name': 'Test Patient',
        'patient_id': 'P001'
    })

    # Should fail with 403
    assert response.status_code == 403

Test Data Factories

Using factory_boy

Define Factories:

# tests/factories.py
import factory
from factory.django import DjangoModelFactory
from patientapp.models import Patient, Institution
from django.contrib.auth.models import User

class InstitutionFactory(DjangoModelFactory):
    class Meta:
        model = Institution

    name = factory.Sequence(lambda n: f'Hospital {n}')
    address = factory.Faker('address')

class PatientFactory(DjangoModelFactory):
    class Meta:
        model = Patient

    name = factory.Faker('name')
    patient_id = factory.Sequence(lambda n: f'P{n:05d}')
    institution = factory.SubFactory(InstitutionFactory)
    date_of_registration = factory.Faker('date_this_year')
    gender = factory.Iterator(['M', 'F', 'O'])

class UserFactory(DjangoModelFactory):
    class Meta:
        model = User

    username = factory.Sequence(lambda n: f'user{n}')
    email = factory.LazyAttribute(lambda obj: f'{obj.username}@example.com')
    password = factory.PostGenerationMethodCall('set_password', 'testpass123')

Use Factories in Tests:

from tests.factories import PatientFactory, InstitutionFactory

@pytest.mark.django_db
def test_with_factory():
    """Test using factory-generated data."""
    # Create 5 patients
    patients = PatientFactory.create_batch(5)

    assert len(patients) == 5
    assert all(p.institution is not None for p in patients)

Running Tests

Run All Tests

# Run all tests
pytest

# Run with coverage
pytest --cov=. --cov-report=html

# Run specific test file
pytest patientapp/tests/test_models.py

# Run specific test class
pytest patientapp/tests/test_models.py::TestPatientModel

# Run specific test
pytest patientapp/tests/test_models.py::TestPatientModel::test_patient_creation

Run Tests by Marker

# Run only unit tests
pytest -m unit

# Run only integration tests
pytest -m integration

# Run only UI tests
pytest -m ui

# Skip slow tests
pytest -m "not slow"

Parallel Testing

# Install pytest-xdist
pip install pytest-xdist

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

Coverage Reports

# Generate HTML coverage report
pytest --cov=. --cov-report=html

# Open report
open htmlcov/index.html

# Generate terminal report
pytest --cov=. --cov-report=term-missing

Continuous Integration

GitHub Actions

.github/workflows/tests.yml:

name: Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest

    services:
      postgres:
        image: postgres:15
        env:
          POSTGRES_PASSWORD: postgres
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    steps:
      - uses: actions/checkout@v3

      - name: Set up Python
        uses: actions/setup-python@v4
        with:
          python-version: '3.11'

      - name: Install dependencies
        run: |
          pip install -r requirements.txt
          pip install pytest pytest-django pytest-cov

      - name: Run tests
        run: pytest --cov=. --cov-report=xml
        env:
          DATABASE_URL: postgresql://postgres:postgres@localhost/test_db

      - name: Upload coverage
        uses: codecov/codecov-action@v3

Best Practices

Test Organization

DO:

  • Organize tests by app (patientapp/tests/)

  • Use descriptive test names

  • Test one thing per test

  • Use fixtures for common setup

  • Mock external dependencies

  • Test edge cases and error conditions

DON’T:

  • Put all tests in one file

  • Test multiple things in one test

  • Use real external APIs in tests

  • Skip error case testing

  • Ignore test failures

Test Coverage Goals

Component

Target Coverage

Minimum Coverage

Models

90%+

80%

Views

85%+

75%

Forms

90%+

80%

Utilities

95%+

85%

Overall

85%+

75%

Resources