Testing guide
Four layers of automated testing. All four run in CI and gate merges.
Backend (pytest)
Framework: pytest + pytest-django + factory-boy for fixtures
Config: backend/pytest.ini
Coverage: coverage.py (target ≥ 80%)
DB: ephemeral Postgres (per-test transaction rollback)
Running
# All tests
docker compose exec backend pytest
# One app
docker compose exec backend pytest apps/compliance/
# One file
docker compose exec backend pytest apps/compliance/tests/test_submissions.py
# One test
docker compose exec backend pytest apps/compliance/tests/test_submissions.py::test_officer_submits_evidence
# With coverage
docker compose exec backend pytest --cov=apps --cov-report=term-missing
# Fast (re-run only failures from last run)
docker compose exec backend pytest --lf
Conventions
- Tests live alongside the app:
backend/apps/<name>/tests/test_<feature>.py - Fixtures (factories) in
backend/apps/<name>/tests/factories.py - Use
factory_boyfor model creation; avoid rawModel.objects.create()for complex graphs - API tests use DRF's
APIClient - For tests that need a specific tenant context, wrap in
org_context(org_id):def test_chapter_only_sees_own_data(api_client, org_context):with org_context(org.id):response = api_client.get("/api/chapters/")assert response.json()["count"] == 1
Common patterns
# Permission test
def test_member_cannot_approve_other_chapter(member, other_chapter):
api_client.force_authenticate(member)
resp = api_client.post(f"/api/chapters/{other_chapter.id}/approve/", {...})
assert resp.status_code == 404 # not 403 — don't reveal existence
# Tenant isolation test
def test_org_a_member_cannot_see_org_b_data(member_a, chapter_b):
api_client.force_authenticate(member_a)
resp = api_client.get(f"/api/chapters/{chapter_b.id}/members/")
assert resp.status_code == 404
# Audit log test
def test_refund_creates_audit_entry(officer, invoice):
api_client.force_authenticate(officer)
api_client.post(f"/api/invoices/{invoice.id}/refund/", {...})
log = AuditLog.objects.last()
assert log.action == "UPDATE"
assert log.resource_type == "invoice"
assert log.user_id == officer.id
Coverage targets
| Layer | Target |
|---|---|
apps/common/ (permissions, middleware, audit) | 95% |
| Per-app business logic | 80% |
apps/<name>/serializers.py | 70% |
| Migrations | n/a (not measured) |
The SonarQube quality gate enforces ≥ 80% on new code — if your PR adds 100 lines, at least 80 must be covered.
Frontend (Vitest)
Framework: Vitest + React Testing Library
Config: frontend/vitest.config.ts
Coverage: target ≥ 70%
Running
# All tests, watch mode
docker compose exec frontend npm test
# Single run (CI mode)
docker compose exec frontend npm run test:ci
# Coverage
docker compose exec frontend npm run test:coverage
# UI (visual test runner)
docker compose exec frontend npm run test:ui
Conventions
- Tests live alongside components:
frontend/src/components/Foo/Foo.test.tsx - Use Testing Library's user-centric queries (
getByRole,getByLabelText) - Avoid testing implementation (state, refs); test behavior (clicks, displayed text)
- Mock fetch via MSW (Mock Service Worker) — not
jest.mock
Common patterns
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
it("opens approval modal when Approve clicked", async () => {
render(<ApprovalsList items={[mockApproval]} />);
await userEvent.click(screen.getByRole("button", { name: /approve/i }));
expect(screen.getByRole("dialog")).toBeVisible();
});
E2E (Playwright)
Framework: Playwright
Tests: ~132 end-to-end scenarios
Config: e2e/playwright.config.ts
Running
cd e2e
npm install
# Smoke (CI gate)
npm run test:smoke
# Full suite
npm test
# Headed mode (see browser)
npm run test:headed
# Debug single test
npx playwright test members-directory.spec.ts --debug
Tags
@smoke— runs in CI on every PR; covers critical paths (login, dashboard, approvals)- (untagged) — full suite; runs in nightly + manually
Auth fixture
// e2e/fixtures/auth.ts
test.use({ storageState: "auth/admin.json" });
test("admin can create chapter", async ({ page }) => {
await page.goto("/org/chapters");
// ...
});
The fixture pre-seeds login state, avoiding the cost of logging in on every test.
Page objects
Encapsulate selectors:
// e2e/pages/MembersPage.ts
export class MembersPage {
constructor(private page: Page) {}
async filterByStatus(status: string) {
await this.page.getByLabel("Status").selectOption(status);
}
async openMember(name: string) {
await this.page.getByRole("link", { name }).click();
}
}
Traces + artifacts
On failure, Playwright captures:
- Trace (
trace.zip— full DOM + network + screenshots, viewable innpx playwright show-trace) - Screenshots
- Video
CI uploads these as artifacts. Download from the failed run.
Security tests (ZAP)
Tool: OWASP ZAP Modes: baseline (passive) + active
Baseline (in PR CI)
Spider + passive checks against the frontend. No active payloads. Catches:
- Missing security headers (X-Frame-Options, CSP, HSTS)
- Mixed content
- Cookie flag issues
- Information disclosure in errors
Credentialed API scan (in PR CI)
ZAP runs with a valid JWT against /api/*. Active scan with payloads (SQLi, XSS, IDOR). Catches:
- IDOR (User A accessing User B's data)
- Authentication bypasses
- Missing authorization checks
- Injection in unsanitized input
Full active scan (nightly)
The most aggressive scan. Long-running. Posts to a daily GitHub issue.
→ See e2e/zap/ for ZAP configuration and contexts
Test data
Seed fixtures
backend/fixtures/seed_data.json — minimal sample org, region, chapter, members, sample bulletin. Loaded with:
python manage.py loaddata seed_data
For richer test data:
python manage.py seed_demo --org "Sample Org" --chapters 5 --members-per-chapter 30
(The seed_demo management command is in apps/common/management/commands/.)
Factories (per-test)
Each app has factories.py with factory_boy classes:
# apps/organizations/tests/factories.py
class ChapterFactory(factory.django.DjangoModelFactory):
class Meta:
model = Chapter
name = factory.Faker("company")
organization = factory.SubFactory(OrganizationFactory)
region = factory.SubFactory(RegionFactory)
# In a test
chapter = ChapterFactory.create()
members = MembershipFactory.create_batch(10, chapter=chapter)
Test isolation
- Per-test transaction rollback — pytest-django wraps each test in a transaction that rolls back on completion. Tests don't leak data into each other.
- Shared DB — but cleared between test sessions via
--create-dbif needed. - Celery tasks —
CELERY_TASK_ALWAYS_EAGER=Truein test settings makes tasks run synchronously in-process. Easy to assert on side effects.
Performance testing
Not currently in CI. Manual approach for hot paths:
import django_assert_num_queries
def test_member_list_no_n_plus_one(api_client, chapter):
MembershipFactory.create_batch(50, chapter=chapter)
api_client.force_authenticate(officer)
with django_assert_num_queries(5): # whatever's reasonable
api_client.get(f"/api/chapters/{chapter.id}/members/")
If you find an N+1, fix with .select_related() / .prefetch_related().
Mobile testing
No automated mobile UI tests today. Manual QA via TestFlight + Play Store internal track. Roadmap: Detox or Maestro.
What's missing (roadmap)
- Load testing (k6 or Locust)
- Visual regression (Percy or Chromatic)
- Mutation testing (mutmut for Python)
- Accessibility testing in unit / E2E (axe-core)