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% |