Skip to content

Testing SerOps

Three layers of tests cover the backend, each with a different job. Know which layer your change belongs to before you write a new file.

Layers

Layer Location Runs against Answers
Unit <app>/tests/ (one folder per Django app) in-memory objects, mocked I/O "Does this function behave?"
View / integration <app>/tests/test_views.py, <app>/test/test_*.py Django test DB, real serialisers, mocked provider I/O "Do the REST endpoints serialise the right data given a normal request?"
End-to-end backend/tests_e2e/ Django test DB, real ORM writes at every hop, only the outbound AI network hop mocked "Does the full chain from an inbound webhook (or scheduled task) to a persisted issue on the read API actually hold together?"

When to write an e2e test

Add one whenever a code change:

  • Wires a new inbound trigger (webhook, ingest key, scheduled Celery task) to a pipeline the frontend eventually reads.
  • Reorders or renames a pipeline stage — the seam is where signature drift silently breaks the chain.
  • Adds a new signal type the AI RCA prompt or the correlation engine consumes.

Do NOT add an e2e test for:

  • Isolated logic bugs — a unit or view test is faster and pinpoints the fault line.
  • Provider adapter behaviour — that lives in ai_model/tests/test_adapters.py.
  • AI response variance — the model provider has its own tests.

Running

# From backend/
make test-e2e          # e2e only
make test-unit         # unit + view tests, skipping e2e
make test              # curated core suite (key view tests + e2e)
make test-fast         # smoke run: e2e + one representative view suite

The Make targets run a curated subset. The whole backend suite — what CI runs — is plain manage.py test:

docker exec -e DJANGO_SETTINGS_MODULE=api.test_settings serops_backend_dev \
  python manage.py test --noinput --keepdb

--keepdb reuses the test database between runs and is safe to repeat: transactional tests subclass Utils.testing.SeedPreservingTransactionTestCase, which restores migration seed data after every flush. New transactional tests must use that base class rather than TransactionTestCase.

Frontend

cd frontend
npm run lint          # must pass — CI gates on it
npm test              # Vitest, one-shot
npm run test:watch    # interactive

UI invariants (Playwright)

Six cross-cutting checks that run against every page rather than testing one feature each. They catch the class of regression that unit tests structurally cannot: a component renders, its props are valid, and it still looks broken to a human.

Invariant Catches
No console or network errors Broken imports, a backend contract that drifted, PropType warnings that become runtime failures
No [object Object] / undefined / NaN in visible text An object leaking into a string coercion
Dark-mode legibility A colour token defined only for the light theme
No horizontal scroll at 390×844 A min-width wider than a phone
No missing translations in FR / AR Copy shipped without translating
No critical axe-core violations Unlabelled controls, keyboard traps

They run against seeded fixture batches, so the backend must be up and those fixtures must exist:

# 1. Seed ~10 stable-id fixture batches (costs a few AI calls)
docker exec serops-backend python -m tests_gauntlet.seed_ui_fixtures

# 2. Run the invariants
cd frontend
npm run test:e2e            # headless
npm run test:e2e:headed     # visible browser, for debugging
npm run test:e2e:ui         # Playwright UI mode

Full detail: frontend/tests/e2e-invariants/README.md.

Real-prod gauntlet

Unit and e2e tests prove the code paths work. The gauntlet proves the analysis reaches the right conclusion on hostile input — and that it does so without leaking data across tenants.

It is 68 synthetic-but-realistic batches across five tiers, covering PII in logs, prompt injection, cross-tenant isolation, Sentinel edge cases, AI provider failure, high-volume noise, multi-hop cascades, misleading CI/CD signals, and log-format chaos.

docker exec serops-backend python -m tests_gauntlet.run_gauntlet --list
docker exec serops-backend python -m tests_gauntlet.run_gauntlet --tier 0
docker exec serops-backend python -m tests_gauntlet.run_gauntlet --batch S1

Tiers, by what a failure means:

Tier Meaning Count
0 Launch blocker — security, tenant isolation, Sentinel gating, provider fallback 8
1 Must survive — core cascade, volume, and red-herring correctness 7
2 Quality bar 17
3 Polish 18
4 Nice to have 18

The gauntlet spends real money

Most batches make a real AI call. A full 68-batch run costs roughly $2 against Claude. Use --tier 0 during development and save the full run for release candidates. Set GAUNTLET_MODEL to pick the provider.

Full detail, including env vars and the generator contract: backend/tests_gauntlet/README.md.

Fake AI provider

E2e tests must not talk to a real Claude/OpenAI/Gemini endpoint — that would burn credits, flake on network, and couple test outcome to model prompt-response variance. Instead, patch the imported reference inside ai_model.tasks:

from unittest.mock import patch
import json
from tests_e2e.base import fake_ai_json

with patch('ai_model.tasks.generate_for_organisation') as gen:
    gen.return_value = (
        json.dumps(fake_ai_json(correlated_issues=[{'component': 'checkout'}])),
        'e2e-fake-model', None, 500, 700,
    )
    from ai_model.tasks import analyze_batch
    analyze_batch.run(batch.batch_id)

fake_ai_json() returns a dict matching the JSON schema the real provider path parses. Customise only what your assertions need — leave the schema alone so the downstream pipeline exercises its production code paths.

Cross-tenant assertions are mandatory

Every e2e that persists a row must also assert that a second-tenant caller can't see it. tests_e2e.base.E2ETestCase provisions the two orgs and users for you — flip via self._auth(self.other_admin) and re-issue the read request; expect 403 / 404 / empty list.

The single most damaging regression in a multi-tenant system is a leaked queryset. E2e is the last checkpoint where that would surface before beta. The unit suite also walks every API route as another organisation (see Permissions).