Testing Strategy and QA — Ghasi-eHealth Platform
Status: populated Last updated: 2026-04-18
Cross-reference: Testing Standards · Definition of Done
1. Purpose and Scope
This document defines the platform-wide testing strategy for the Ghasi-eHealth national eHealth platform. All 27 services inherit these defaults. Service-specific deviations are documented in each service's TESTING_STRATEGY.md. This strategy covers:
- Unit, integration, contract, E2E, visual, performance, chaos, and security test types.
- Coverage targets per architectural layer.
- Mandatory per-service integration test files.
- Consumer-driven contract testing via Pact.
- Event schema conformance testing.
- FHIR profile validation.
- Load testing patterns with k6.
- Accessibility testing.
- Test data management.
- CI pipeline gate definitions.
2. Layer-Based Coverage Targets
All Ghasi-eHealth services follow the hexagonal architecture (domain/, application/, infrastructure/, presentation/). Coverage targets are layer-stratified:
| Layer | Minimum coverage | Rationale |
|---|---|---|
| domain/ (aggregates, value objects, domain services) | ≥ 95% line; 100% VO | Business invariants and safety rules must be exhaustively tested |
| application/ (use cases, orchestration, ports) | ≥ 85% line | Orchestration paths have fewer combinatorial branches but all paths must pass |
| infrastructure/ (adapters, DB, NATS, HTTP clients) | ≥ 75% line | Integration tests cover most paths; unit tests cover adapter mapping logic |
| presentation/ (controllers, guards, DTOs) | ≥ 70% line | Contract tests plus controller unit tests cover the key paths |
| Overall service | ≥ 80% line; ≥ 75% branch | Enforced in CI via coverageThreshold in vitest.config.ts |
Safety-critical services (orders-service, medication-service, laboratory-service, identity-service, registration-service): domain layer target is ≥ 95%; application layer ≥ 90%.
// vitest.config.ts — standard service threshold
coverageThreshold: {
global: { branches: 75, functions: 80, lines: 80, statements: 80 },
'./src/domain/**': { branches: 90, functions: 95, lines: 95, statements: 95 },
'./src/application/**': { branches: 80, functions: 85, lines: 85, statements: 85 },
},
3. Test Types
3.1 Unit Tests
Framework: Vitest (Jest-compatible API) Scope: domain layer (aggregates, value objects, domain events, invariants) + application layer (use case logic with mocked ports)
Mandatory per service:
- Every aggregate state machine transition.
- Every invariant violation (assert throws correct domain error).
- Every value object construction (valid + invalid).
- Every use case happy path and each documented error path.
- Every guard (RBAC, module entitlement).
Rules:
- Mock all ports (repositories, NATS, HTTP clients, CDS). Test the SUT, not its dependencies.
- Reference Business Rule IDs in test descriptions:
it('BR-ORD-003: cancelling a completed order throws ORDER_TERMINAL_STATE'). - No real DB, real NATS, or real HTTP in unit tests.
3.2 Integration Tests
Framework: Vitest + Testcontainers (PostgreSQL 16, NATS 2.10)
Scope: Infrastructure layer + cross-layer workflows through real DB and NATS.
Mandatory per-service files (all three must be green before merge):
| File | What it tests |
|---|---|
test/integration/tenant-isolation.spec.ts | All repository read/write operations are tenant-scoped. Data created for tenant_A is invisible to tenant_B under RLS. Insert cross-tenant IDs via raw SQL, confirm RLS blocks them from service queries. |
test/integration/outbox.spec.ts | Domain event is written to outbox table in same transaction as aggregate write. Relay worker picks up and publishes to NATS. Event is idempotent on re-delivery (inbox dedupe on consumer side). |
test/integration/inbox.spec.ts | Inbound NATS event is de-duplicated (message_id idempotency key). Processing is applied once even if event is delivered twice. |
Additional integration test areas:
- DB migrations apply cleanly on a fresh schema.
- Repository CRUD — happy path and not-found paths.
- FHIR adapter mapping round-trip (internal entity → FHIR resource → back).
- Optimistic lock conflict on concurrent writes.
Idempotency-Keyheader returns cached response for 24 h.
Test isolation: Each test suite runs in a DB transaction rolled back after the suite. Testcontainers creates a fresh instance per CI run.
// test/integration/tenant-isolation.spec.ts shape
describe('tenant isolation — orders', () => {
it('orders created for tenantA are not returned for tenantB', async () => {
// Arrange: insert order for tenantA via raw SQL (bypass RLS)
// Act: query via repository with tenantB session variable
// Assert: result is empty
});
it('RLS blocks direct SELECT without session variable', async () => {
// Assert: query without app.tenant_id returns 0 rows
});
});
3.3 Consumer-Driven Contract Tests (Pact)
Framework: Pact (pactflow.io broker or self-hosted Pact Broker) Pattern: Consumer writes the contract; provider verifies it on every PR.
Workflow:
Contract test locations:
test/contract/
├── <event>.schema.spec.ts ← event schema conformance (see 3.4)
└── <endpoint>.pact.spec.ts ← REST consumer-driven contracts
Per-service requirements:
- Every public REST endpoint with an external consumer must have a Pact contract.
- Producer publishes new versions to Pact broker on merge to main.
can-i-deploygate in CD pipeline before staging deploy.- Consumer tests must be green against provider before any API breaking change is merged.
REST contract test template:
// test/contract/orders-activate.pact.spec.ts (consumer side)
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
const provider = new PactV3({ consumer: 'laboratory-service', provider: 'orders-service', ... });
describe('orders-service — order activated event', () => {
it('publishes order.activated with required fields', async () => {
await provider.addInteraction({ /* ... */ }).executeTest(async (mockProvider) => {
// Assert consumer can parse the published event
});
});
});
3.4 Event Schema Conformance Tests
Framework: AJV (JSON Schema validation) against schemas registered in the schema registry.
Pattern: Every event producer registers a JSON schema at event-schemas/{service}/{aggregate}/{event}/v{N}.json. Conformance tests validate that produced events match the registered schema.
// test/contract/order-activated.schema.spec.ts
import schema from '../../event-schemas/orders/order/activated/v1.json';
import Ajv from 'ajv';
it('order.activated.v1 payload matches registered schema', () => {
const ajv = new Ajv({ strict: true });
const validate = ajv.compile(schema);
const event = buildOrderActivatedEvent(); // factory
expect(validate(event)).toBe(true);
});
CI gate: Event schema conformance tests run on every PR that modifies an event producer. Schema registry diff gate prevents field removal without version bump.
3.5 FHIR Profile Validation Tests
Framework: HAPI FHIR Validator (Java CLI via Docker) or fhir-validator-js wrapper.
What is tested:
- Every FHIR resource type produced by the service validates against the declared FHIR profile (US Core 6.1, IPS, AFG-Core where applicable).
CapabilityStatementdeclares all owned resource types.OperationOutcomeformat for all FHIR error responses.
// test/contract/serviceRequest.fhir.schema.spec.ts
import { validateFhirResource } from '@ghasi/fhir-test-utils';
it('maps Order to conformant US Core ServiceRequest', async () => {
const serviceRequest = mapOrderToServiceRequest(buildOrder({ orderType: 'laboratory' }));
const result = await validateFhirResource(serviceRequest, 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-servicerequest');
expect(result.issues.filter(i => i.severity === 'error')).toHaveLength(0);
});
CI gate: FHIR conformance tests block merge if any FHIR resource produces errors (not just warnings) against the declared profile.
3.6 E2E Tests (Playwright)
Framework: Playwright 1.x Scope: Critical user flows from browser through Kong to real services deployed in the test environment.
Critical paths covered:
| Path | Services involved |
|---|---|
| Login → patient chart → create medication order → view CDS alert | identity, registration, orders, cds-engine |
| Login → create lab order → verify lab worklist entry | identity, registration, orders, laboratory |
| Login → create referral → verify scheduling-service receives referral event | identity, registration, orders, scheduling |
| Patient portal: login → view active orders and care plans | identity, patient-portal, orders, care-plan |
| Admin: manage order sets → clinician instantiates order set | identity, orders |
| Claims: register coverage → submit claim | identity, claims |
Test conventions:
- No
page.waitForTimeout— usepage.waitForSelectororexpect(locator).toBeVisible(). - Each test creates isolated tenant data via API setup hook.
- Visual regression screenshots per test at 1440 px and 375 px (mobile).
Accessibility gate: Every E2E test run includes an axe-core scan on each page visited. Any serious or critical axe violation blocks merge.
3.7 Visual Regression Tests
Framework: Playwright screenshot comparison + Percy / Chromatic (CI)
Scope: Key clinical UI surfaces — order entry form, CDS alert banner, referral detail, order set selector.
Breakpoints tested: 375 px (mobile), 768 px (tablet), 1440 px (desktop).
Policy: Unreviewed visual diffs block the PR. Reviewer must approve or reject visual change before merge.
3.8 Performance / Load Tests (k6)
Framework: k6 (Grafana k6 Cloud or self-hosted)
Scenarios per service:
| Scenario | What it tests | Pass criteria |
|---|---|---|
| Baseline — 10 concurrent users for 5 min | Normal operating load | p95 < SLO target; error rate < 0.5% |
| Soak — 30 concurrent users for 60 min | Memory leaks, connection pool exhaustion | p95 stable over duration; no OOM |
| Stress — ramp 5→100 concurrent over 10 min | Breaking point discovery | Identify cliff; note max sustainable load |
| Spike — 0→500 concurrent in 30 s | Elastic scaling behaviour | HPA scales; error rate recovers within 60 s |
Safety-critical service baselines (orders-service, medication-service):
// k6 baseline — order creation with CDS
export const options = {
scenarios: {
order_create_baseline: {
executor: 'constant-vus',
vus: 50,
duration: '5m',
},
},
thresholds: {
'http_req_duration{name:create_order}': ['p(95)<800'],
'http_req_failed': ['rate<0.005'],
},
};
export default function () {
const payload = JSON.stringify(buildOrderPayload());
const res = http.post(`${BASE_URL}/v1/orders`, payload, { headers });
check(res, { 'status is 201': (r) => r.status === 201 });
}
Platform-wide performance targets:
| Endpoint type | p95 target | p99 target |
|---|---|---|
| Simple reads (single resource) | < 200 ms | < 500 ms |
| Complex reads (list with filters) | < 600 ms | < 1 200 ms |
| Writes (with validation) | < 500 ms | < 1 000 ms |
| Writes (with CDS / external call) | < 800 ms | < 2 000 ms |
| FHIR bulk export | < 30 s for 1 000 resources | — |
k6 tests run in CI: Baseline scenario runs on every PR for safety-critical services. Full soak and stress run weekly on the staging environment.
3.9 Chaos Engineering
Framework: Chaos Mesh (Kubernetes-native) / Litmus
Chaos scenarios run quarterly in staging:
| Scenario | Target | Expected behaviour |
|---|---|---|
| Kill 1 of 3 orders-service pods | orders-service | HPA replaces pod; p95 stays < 800 ms |
| Inject 500 ms NATS latency | NATS JetStream | Outbox relay delays; orders still created; events delivered within 60 s |
| Kill PostgreSQL primary | Primary DB node | Standby promotes; service recovers within 60 s |
| Inject DNS resolution failure for CDS engine | CDS endpoint | orders-service enters CDS degraded mode; medication activation blocked; ops alert fires |
| Network partition between regions | Cross-region links | Tenants in unaffected region unimpacted; affected tenants gracefully degraded |
Chaos runbook: infra/runbooks/chaos-playbook.md
3.10 Security Tests (OWASP ZAP)
Framework: OWASP ZAP (automated baseline scan + authenticated active scan)
Run schedule: Weekly against staging environment + before every major release.
Scan profiles:
| Scan type | Frequency | Pass criteria |
|---|---|---|
| ZAP baseline (passive) | Every PR to main | 0 High, 0 Critical findings |
| ZAP authenticated active scan | Weekly (staging) | 0 High, 0 Critical new findings |
Dependency audit (pnpm audit) | Every PR | 0 Critical severity packages |
security-reviewer agent | Every PR touching auth/payment/data | 0 Critical findings |
Mandatory test coverage for security paths:
401on every endpoint without token.403on every endpoint with insufficient role.403 MODULE_NOT_ENTITLEDwhen tenant lacks module license.- RLS cross-tenant queries return empty (not 403).
- No PII in response for unauthorized caller.
4. Mandatory Per-Service Integration Tests
Every service in the platform must have these three files in test/integration/ — all must be green before merge:
test/integration/
├── tenant-isolation.spec.ts ← MANDATORY — blocks merge if failing
├── outbox.spec.ts ← MANDATORY — blocks merge if failing
└── inbox.spec.ts ← MANDATORY — blocks merge if failing
4.1 tenant-isolation.spec.ts Requirements
// Minimum test cases (extend with service-specific reads)
describe('tenant isolation', () => {
it('data written for tenantA is not visible to tenantB');
it('RLS is enforced — no session variable returns 0 rows');
it('cross-tenant FK references are rejected by domain aggregate');
it('list queries with no tenantId filter return empty, not all-tenant data');
});
4.2 outbox.spec.ts Requirements
describe('outbox', () => {
it('domain event is written to outbox in same transaction as aggregate write');
it('relay publishes event to NATS within 5 seconds of commit');
it('relay does not publish duplicate event if outbox row already delivered');
it('relay retries if NATS is temporarily unavailable');
it('relay marks event delivered after ACK from NATS');
});
4.3 inbox.spec.ts Requirements
describe('inbox', () => {
it('processing the same event twice has no duplicate side effect');
it('processed event is recorded in inbox_processed table');
it('event with unknown type is logged and discarded, not thrown');
it('inbox processing is transactional with the aggregate update');
});
5. Test Data Management
5.1 Factories (not raw fixtures)
All test data is produced via factory functions. No hardcoded IDs or raw SQL fixtures outside migration scripts.
// Example factory — domain/__builders__/order.builder.ts
export function buildOrder(overrides: Partial<Order> = {}): Order {
return {
id: `ord_${ulid()}`,
tenantId: overrides.tenantId ?? 'ten_01TEST',
patientId: overrides.patientId ?? `pat_${ulid()}`,
encounterId: overrides.encounterId ?? `enc_${ulid()}`,
orderType: 'laboratory',
status: 'draft',
priority: 'routine',
orderedBy: `usr_${ulid()}`,
orderedAt: new Date().toISOString(),
cdsAlerts: [],
version: 1,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
};
}
5.2 Anonymised Prod-Like Datasets
Integration and performance tests use anonymised prod-like datasets (not real patient data):
| Dataset | Size | Use |
|---|---|---|
seed-small | 100 patients, 500 encounters, 1 000 orders | Default integration tests |
seed-medium | 10 000 patients, 50 000 encounters | Baseline performance tests |
seed-large | 100 000 patients | Soak and stress tests |
Seed datasets generated by scripts/generate-anonymised-seed.ts using Faker.js with medically plausible codes from terminology-service fixtures. No real patient identifiers.
5.3 Seed Scripts
pnpm db:seed # seed-small (default for dev)
pnpm db:seed:medium # seed-medium (for perf tests)
pnpm db:seed:reset # truncate all tables and re-seed
5.4 Isolation Policy
| Environment | Isolation mechanism |
|---|---|
| Unit tests | In-memory mocks; no DB |
| Integration tests | Testcontainers — fresh container per CI run; DB transaction rollback per test file |
| E2E tests | Isolated tenant per test suite; cleanup hook after suite |
| Performance tests | Dedicated performance tenant; seeded data not shared with functional tests |
6. CI Pipeline Gates
6.1 Merge Gate (PR → main)
All of the following must be green before a PR is merged:
| Gate | Tool | Failure action |
|---|---|---|
| Lint + format | ESLint + Prettier | Block merge |
| Typecheck | TypeScript strict | Block merge |
| Unit tests | Vitest | Block merge |
| Coverage thresholds | Vitest coverage | Block merge |
tenant-isolation.spec.ts | Vitest + Testcontainers | Block merge |
outbox.spec.ts | Vitest + Testcontainers | Block merge |
inbox.spec.ts | Vitest + Testcontainers | Block merge |
| Event schema conformance | AJV | Block merge |
| Pact consumer tests | Pact | Block merge (for consumer changes) |
| FHIR conformance (affected services) | HAPI FHIR Validator | Block merge |
| Security: ZAP baseline | OWASP ZAP passive | Block if Critical/High |
Security: pnpm audit | pnpm | Block if Critical |
| OpenAPI diff gate | openapi-diff | Block on breaking change without version bump |
6.2 Deploy Gate (main → staging)
| Gate | Tool | Failure action |
|---|---|---|
| All merge gates | — | Block deploy |
Pact can-i-deploy | Pact Broker | Block deploy |
| Pact provider verification | Pact | Block deploy |
| k6 baseline performance (safety-critical services) | k6 | Block deploy if p95 > SLO |
| ZAP authenticated scan | OWASP ZAP | Block if Critical/High new findings |
| Helm chart dry-run | Helm | Block deploy on template errors |
6.3 Deploy Gate (staging → production)
| Gate | Tool | Failure action |
|---|---|---|
| All staging gates | — | Block |
| Canary deploy (5% / 30 min) | Kong weighted routing | Block full rollout if error rate > 1% |
| Rollback test (manual, quarterly) | Manual + runbook | Document result |
| SLO status green | Grafana SLO | Block if SLO burn rate critical |
SERVICE_READINESS.md signed off | Human review | Block |
7. Accessibility Testing
Standard: WCAG 2.2 Level AA
Framework: axe-core via @axe-core/playwright
Coverage scope:
- Every clinician-facing UI page (EHR, orders, care plans, scheduling).
- Every patient-facing portal page.
- Every admin UI surface.
Integration with E2E tests:
import AxeBuilder from '@axe-core/playwright';
test('order entry form is accessible', async ({ page }) => {
await page.goto('/orders/new');
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag22aa'])
.analyze();
expect(results.violations.filter(v => ['critical', 'serious'].includes(v.impact))).toHaveLength(0);
});
RTL support: All UI surfaces tested in both LTR (English) and RTL (Dari/Pashto) modes. Logical CSS properties (padding-inline, margin-block) enforced — no directional properties in component CSS.
Reduced motion: Animations gated on prefers-reduced-motion: reduce; Playwright tests run with this preference set.
8. Cross-Service Test Coordination
8.1 Pact Broker Configuration
| Attribute | Value |
|---|---|
| Broker URL | https://pact.internal.ghasi-ehealth.af |
| Authentication | Bearer token (Vault-managed) |
| Webhook: on verification published | Notify consumer CI of provider verification result |
| Webhook: can-i-deploy | Called in CD pipeline before staging deploy |
Tag strategy: Each service publishes pacts tagged with branch:{branch-name} and version:{semver}. can-i-deploy checks the main tag of the provider.
8.2 Schema Registry
Event schemas live at:
event-schemas/
└── {service}/
└── {aggregate}/
└── {event}/
└── v{N}.json
Schema registry CI gate: any PR that adds or modifies an event must include the corresponding schema file. Removing a required field without a version bump is blocked.
9. Testing Anti-Patterns (Platform-Wide)
| Anti-pattern | Why banned | Correct approach |
|---|---|---|
synchronize: true in Testcontainers DB | Schema drift from prod migrations | Always run migrations in test DB |
| Shared mutable state between tests | Flaky tests; order-dependent failures | beforeEach cleanup; factory per test |
page.waitForTimeout(3000) in Playwright | Slow and fragile | waitForSelector / expect.toBeVisible() |
| Testing only happy paths in domain | Misses invariant enforcement | Every invariant has a negative unit test |
| Snapshot tests for API response shapes | Brittle; intent hidden | Assert specific fields and types |
Skipping tenant-isolation.spec.ts | Cross-tenant data leak risk | Test is mandatory and blocking |
| Mocking the system under test | Tests prove nothing | Mock dependencies only; test real SUT |
| Hard-coded patient IDs in tests | Tests fail after seed changes | Use builder factories; generate ULIDs |
10. Reporting and Visibility
| Report | Tool | Audience | Frequency |
|---|---|---|---|
| Coverage report | Vitest HTML / Codecov | Engineering | Per PR |
| Test results | JUnit XML → CI dashboard | Engineering | Per CI run |
| Pact verification matrix | Pact Broker UI | Engineering | Continuous |
| k6 performance trends | Grafana k6 dashboard | SRE + Engineering | Per weekly soak |
| Accessibility violations | axe HTML report → CI artifact | Engineering + UX | Per E2E run |
| Security scan results | ZAP HTML report → CI artifact | Security + Engineering | Weekly |
| FHIR conformance report | HAPI FHIR validator output | Engineering + interop team | Per PR (affected services) |
11. Ownership and Escalation
| Issue | Owner | Escalation |
|---|---|---|
| Coverage threshold regression | Service tech lead | Engineering Manager |
tenant-isolation.spec.ts failure | Service tech lead (P0 — same day) | CISO + Engineering Manager |
| Pact verification broken | Consumer + provider tech leads | Shared resolution within 24 h |
| k6 p95 regression > SLO | SRE + tech lead | SRE Director |
| ZAP Critical finding | Security team | CISO — same day |
| Accessibility serious/critical violation | Front-end lead | Accessibility review within sprint |