Skip to main content

Billing Service — Testing Strategy

Status: populated Owner: TBD Last updated: 2026-04-17 Companion: Service Template · Testing standards

1. Coverage targets

LayerStatement / branchNotes
Domain≥ 95 %Pure logic — no excuses
Application≥ 90 %Includes sagas
Infrastructure adapters≥ 80 %
Presentation (controllers)≥ 80 %Plus Pact
Overall service≥ 85 %CI gate

2. Test types & mandatory suites

SuiteLocationMandatory?
Unit — domaintest/unit/domain/*.spec.tsyes
Unit — applicationtest/unit/application/*.spec.tsyes
Integration — repositorytest/integration/repositories/*.integration.spec.tsyes
Integration — tenant isolationtest/integration/tenant-isolation.integration.spec.tsmandatory
Integration — outboxtest/integration/outbox.integration.spec.tsmandatory
Integration — inboxtest/integration/inbox.integration.spec.tsmandatory
Integration — ledger immutabilitytest/integration/ledger-immutability.integration.spec.tsmandatory
Integration — idempotencytest/integration/idempotency.integration.spec.tsmandatory
Contract — OpenAPI Pacttest/contract/*.pact.spec.tsyes (claims-service, portal BFF consumers)
Contract — event schematest/contract/*.schema.spec.tsyes
E2E — charge → invoice → pay → statementtest/e2e/revenue-cycle.e2e.spec.tsyes
Property-based — ledger mathtest/unit/domain/ledger.property.spec.tsyes

3. Critical test scenarios

3.1 Ledger integrity

  • Append-only: any attempt to UPDATE or DELETE ledger_entries raises.
  • Balance invariant: for 10 000 random charge/payment sequences, sum(ledger.amount) == account.balance.
  • Reversal: reverse charge creates a matching negative entry; original remains.

3.2 Idempotency

  • Replay same Idempotency-Key + same body → 200/201 with original response.
  • Same key + different body → 409 IDEMPOTENCY_CONFLICT.
  • TTL expiry: after expires_at, same key is usable again.

3.3 Tenant isolation

  • set_config('app.tenant_id', 'ten_A', true) then attempt to SELECT rows for ten_B → empty.
  • API JWT for tenant A attempting to reference invoice inv_… of tenant B → 403 CROSS_TENANT_REFERENCE.

3.4 Refund approval

  • Request above threshold without billing:refund:approve scope → pending_approval + 403 on /approve from unprivileged user.
  • Approval + post → ledger entry + billing.refund.issued.v1.

3.5 Currency safety

  • Payment currency ≠ account currency → MONEY_CURRENCY_MISMATCH.
  • Arithmetic with minor_units never round-trips through float.

3.6 Price resolution

  • No active price row for (facility, code, date)PRICE_NOT_FOUND.
  • Effective-dating: service date inside price window is selected; outside → next fallback.

3.7 Consumer flows

  • registration.encounter.discharged.v1 drives charge capture; dedup on CloudEvents id.
  • claims.remittance.posted.v1 posts PAYER_REMITTANCE payment + CONTRACTUAL adjustment.

3.8 Statement run

  • Job partitioned by facility; failure in one account doesn't fail the run.
  • Artifact stored at tenant-prefixed object key; link expires per signed URL TTL.

4. Fixtures & builders

  • __builders__/moneyBuilder.ts, accountBuilder.ts, chargeBuilder.ts, etc.
  • Seed a baseline tenant with facility + price list + tax rule in test/integration/_setup/seed.ts.

5. Containers

  • Testcontainers-node: Postgres 16, NATS JetStream, MinIO (for statement PDFs).
  • Running tests requires docker-compose test stack up (pnpm test:integration:up).

6. CI gates

  • Unit + integration green.
  • Coverage ≥ 85 %.
  • OpenAPI Pact: no breaking change vs consumer contracts.
  • Event schema conformance vs schema registry.
  • npm audit --production no HIGH/CRITICAL.
  • Trivy image scan no HIGH/CRITICAL.
  • ESLint domain import-restriction pass.

7. Performance tests

  • k6 load profile: 50 rps POST /charges, 20 rps POST /payments sustained 10 min — target p95 latency SLOs.
  • Run in staging pre-release.

8. Chaos tests

  • NATS partition: outbox drains after connection restored; no duplicates at consumers.
  • DB failover: in-flight transactions roll back; outbox still consistent.
  • Clock skew on node: idempotency TTL remains correct via UTC.