Skip to main content

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_boy for model creation; avoid raw Model.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

LayerTarget
apps/common/ (permissions, middleware, audit)95%
Per-app business logic80%
apps/<name>/serializers.py70%
Migrationsn/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 in npx 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-db if needed.
  • Celery tasksCELERY_TASK_ALWAYS_EAGER=True in 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)