Testing Guide
=============
Comprehensive guide to testing SATHI application components, features, and integrations.
.. contents:: Table of Contents
:local:
:depth: 3
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
~~~~~~~~~~~~~~~~~~
.. list-table::
:header-rows: 1
:widths: 30 70
* - 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
~~~~~~~~~~~~
.. code-block:: bash
pip install pytest pytest-django pytest-cov
pip install factory-boy faker
pip install selenium webdriver-manager
pip install locust
Configuration
~~~~~~~~~~~~~
**pytest.ini:**
.. code-block:: 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:**
.. code-block:: python
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:**
.. code-block:: python
# 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:**
.. code-block:: python
# 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:**
.. code-block:: python
# 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:**
.. code-block:: python
# 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:**
.. code-block:: python
# 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:**
.. code-block:: python
# 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:**
.. code-block:: text
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:**
.. code-block:: python
# 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:**
.. code-block:: python
# 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:**
.. code-block:: bash
# 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:**
.. list-table::
:header-rows: 1
:widths: 40 30 30
* - 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:**
.. code-block:: python
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:**
.. code-block:: python
# 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:**
.. code-block:: python
@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:**
.. code-block:: python
# 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:**
.. code-block:: python
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
~~~~~~~~~~~~~
.. code-block:: bash
# 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
~~~~~~~~~~~~~~~~~~~
.. code-block:: bash
# 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
~~~~~~~~~~~~~~~~
.. code-block:: bash
# Install pytest-xdist
pip install pytest-xdist
# Run tests in parallel (4 workers)
pytest -n 4
Coverage Reports
~~~~~~~~~~~~~~~~
.. code-block:: bash
# 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:**
.. code-block:: yaml
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
~~~~~~~~~~~~~~~~~~~
.. list-table::
:header-rows: 1
:widths: 40 30 30
* - Component
- Target Coverage
- Minimum Coverage
* - Models
- 90%+
- 80%
* - Views
- 85%+
- 75%
* - Forms
- 90%+
- 80%
* - Utilities
- 95%+
- 85%
* - Overall
- 85%+
- 75%
Resources
---------
- `pytest Documentation `_
- `pytest-django `_
- `factory_boy `_
- `Selenium Documentation `_
- `Locust Documentation `_
- `Django Testing `_