How we took a Kubernetes reconciler from 320 unit tests to 521 tests + 82.62% mutation score + 8 additional testing methods across 3 refactor phases.
Project: Sylphx Platform — Managed Resource Controller Purpose: Provisions + reconciles customer-facing managed infrastructure (Postgres via CNPG, Valkey, Typesense) Stack: TypeScript, Bun, Drizzle, Effect-TS, Kubernetes, Neon Timeline: 2026 Q2, 3 phases over ~6 weeks Team: 1 engineer (full-time dedicated refactor)
Before the refactor began:
| Metric | Value |
|---|---|
| Tests | 320 |
| Testing methods used | 2 (unit + integration) |
| Mutation score | unknown — Stryker not configured |
| Property tests | 0 |
| Type-level tests | 0 |
| E2E tests | 0 |
| Load tests | 0 |
| Chaos experiments | 0 |
| SAST configured | ❌ |
| Canary deploys | ❌ (direct ArgoCD sync) |
| Runbooks | ❌ |
| ADRs | 47 (pre-existing, platform-wide) |
| SLOs defined | ❌ |
| controller.ts size | 815 lines (God class) |
Two critical bugs existed in production:
- Bug #1: Terminating resources were never picked up by the reconcile query (WHERE clause excluded them) — created zombie CNPG clusters
- Bug #2:
updateResourcedidn't trigger a reconcile, so config changes were silently dropped
We didn't know test quality was weak until we started investigating these bugs. Every "covered" line was covered — but the tests didn't ASSERT on the behaviour that was broken.
Goal: Add foundational test quality + type safety without rewriting the controller.
| Method | Tool | Tests added |
|---|---|---|
| Property-based testing | fast-check | 28 properties × 100-500 runs |
| Mutation testing (config) | Stryker | config only, not yet run |
| Type-level testing | expect-type | 15 type-level tests |
| Contract testing | Zod | 45+ schema tests |
| In-memory DB integration | pg-mem | 15 SQL semantic tests |
| Graceful shutdown (AbortController) | custom | 10 shutdown tests |
| Resilience primitives (breaker + limiter) | custom | 22 tests |
| Metric | Before | After P5 |
|---|---|---|
| Tests | 320 | 475 (+155) |
| Testing methods | 2 | 11 (+9) |
| controller.ts size | 815 | 815 |
We looked at what we'd shipped and found:
- Effect-TS was "skin deep" — we added the library but only one
function (
classifyReconcile) used it. The controller still had try/catch in most places. - Circuit breaker was DEAD CODE — we wrote the class and tests but never wired it into any actual K8s call. The tests passed against the primitive, not against real usage.
- pg-mem test used HAND-ROLLED SQL — the test asserted on a copy of the query, not the REAL Drizzle query builder output. Drift possible.
- Stryker was configured but NEVER RAN — we didn't know the mutation score. It could have been 20%.
- Graceful shutdown not wired to SIGTERM — the function existed but no signal handler called it.
- Zod schemas DUPLICATED TypeScript interfaces — two sources of truth
in
src/lib/db/schema/resources.tsandsrc/lib/resources/schemas.ts.
We declared Phase 5 "done" and moved on, then caught ourselves during a review: "this is SOTA on the surface only." We opened Phase 6.
Goal: Close every gap identified in the Phase 5 self-critique.
src/lib/db/effect.ts — wrapped Drizzle in an Effect Layer with
Context.Tag('@sylphx/DB') + tagged errors (DbError, TransactionError).
Tests inject mock DB via makeDbTestLayer instead of mock.module
pollution. 14 tests added.
This is the gap that matters most. Changed serverSideApply() to:
await withK8sResilience(() => client.patch(...))Where withK8sResilience wraps the call in a process-wide singleton
circuit breaker + rate limiter. Every K8s call in the codebase — legacy
Promise callers AND the Effect pipeline — now flows through ONE breaker
and ONE rate limiter. 8 integration tests proving real wiring.
src/controller/managed-resource/effect-pipeline.ts — reconcileResource
returns Effect<ReconcileOutcome, never, K8s>. The never error channel
forces all outcomes into the success channel:
type ReconcileOutcome =
| { kind: 'synced', manifestsApplied, clusterName }
| { kind: 'provisioning', manifestsApplied, clusterName }
| { kind: 'timeout', elapsedMs }
| { kind: 'error', tag, message, retryable, category }The controller's switch (outcome.kind) gets an exhaustive never check
— adding a new variant breaks compilation. 10 tests.
loop.ts registers onShutdown(() => controller.shutdown()). K8s pod
termination now drains in-flight reconciles before exit. 7 tests.
Extracted actionable-query.ts + provider-registry.ts. Controller
shrank from 815 → 784 lines. 10 registry tests.
Deleted 200 lines of duplicated TypeScript interfaces from
db/schema/resources.ts. Re-exported types from
lib/resources/schemas.ts (Zod schemas). Added
createSelectSchema(managedResources) to auto-derive row types from
Drizzle table.
actionable-query-drizzle.pgmem.test.ts — pg-mem's createPg()
adapter wired to Drizzle. Ran the REAL buildActionableWhere(...)
expression against pg-mem. Hit several gotchas:
- Registered
gen_random_uuid()manually (pg-mem doesn't ship pgcrypto) - Proxy-wrapped the Pool to strip Drizzle's
types.getTypeParser - Used
testDb.execute(sql\...`)instead of.select()` to avoid rowMode='array' issues - Used Drizzle's typed
.insert().values()instead of raw SQL for NULL handling
8 tests passing against the REAL query builder output.
otel-sdk.ts — dynamic-import the SDK only when OTEL_ENABLED=true.
Resource attributes (service.name / version / environment) per OTel
semantic conventions. 5 tests.
File | Score | # killed | # survived
effect-reconcile.ts | 100.00% | 41 | 0 ⭐
connection-string.ts | 93.33% | 56 | 4
brands.ts | 88.89% | 32 | 4
backoff.ts | 88.89% | 16 | 2
conditions.ts | 88.61% | 70 | 9
effect-errors.ts | 85.06% | 74 | 13
resilience.ts | 84.81% | 63 | 12
errors.ts | 72.55% | 37 | 14
schemas.ts | 58.33% | 49 | 35
All files | 82.62% | 438 | 93
Schemas.ts was the weakest — adding parseConfigByKind branch tests
for kv/blob/volume/search lifted it from 51.9% → 62.03%.
infra/addons/monitoring/managed-resource-alerts/vmrule-managed-resource.yaml
— 5 SLO groups with 11 alert rules. Runbook URLs on every alert.
Grafana dashboard ConfigMap with 7 panels: SLO stat gauges, latency histograms, queue depth, retry rates, throughput.
| Metric | Before P5 | After P5 | After P6 |
|---|---|---|---|
| Tests | 320 | 475 | 521 |
| Mutation score | ? | ? | 82.62% |
effect-reconcile.ts mutation |
— | — | 100% |
| controller.ts size | 815 | 815 | 784 |
| Testing methods | 2 | 11 | 14 |
| SLOs defined | 0 | 0 | 5 |
Goal: Close the remaining gaps — docs, canary, chaos, and the last pieces of Effect integration.
The pipeline existed (P6.3) but reconcileOne still used Promise code.
Rewrote to use Effect.runPromise(reconcileResource(...).pipe(Effect.provide(K8sLive)))
with an exhaustive switch (outcome.kind).
Every subclass of BaseProvider now inherits:
reconcileEffect(resource): Effect<ReconcileResult, ReconcileError> {
return Effect.tryPromise({
try: () => this.reconcile(resource),
catch: fromLegacyError,
})
}Existing providers (CNPG, Valkey, Typesense) get Effect-native variants
for free. Pipeline prefers reconcileEffect when present. 10 tests.
src/controller/managed-resource/status-writer.ts — all DB state writes
(markSynced, markFailed, markProvisioning, markTeardownFailed,
hardDelete, applyReconcileResult). Controller shrank: 784 → 665 lines.
Three new ADRs:
- ADR-048: Effect-TS + Layer-based DI (context, decision, alternatives, consequences)
- ADR-049: drizzle-zod SSOT
- ADR-050: Managed-resource SLO definitions
docs/runbooks/ — 7-section runbooks for each critical alert:
managed-resource-slo-burn.mdteardown-failure.mddecryption-error.md(Sev-1 security runbook)
Each includes: symptoms, immediate triage, common patterns + remediation, escalation path, recovery verification, post-incident actions.
apps/sylphx-platform/manifests/canary.yaml — Flagger Canary CRD with
SLO-gated promotion. MetricTemplates query VictoriaMetrics for
reconcile-success-rate and p99-latency. Auto-rollback on breach.
infra/addons/chaos-mesh/managed-resource-experiments/ — composite
workflow with:
- Pod kill (verify HA failover)
- Network delay to kube-apiserver (verify circuit breaker opens)
- CPU stress (verify reconcile rate holds)
- Final SLO gate (strict 99.5% after stabilization)
| Metric | Before P5 | After P6 | After P7 |
|---|---|---|---|
| Tests | 320 | 521 | 521 (+0 new, refactors only) |
| Mutation score | ? | 82.62% | 82.62% |
| Testing methods | 2 | 14 | 18 (+4: canary, chaos, ADR, runbooks) |
| controller.ts size | 815 | 784 | 665 (−18%) |
| Metric | Value |
|---|---|
| Tests | 521 passing, 0 failing |
| Mutation score | 82.62% overall, 100% on effect-reconcile.ts |
| Testing methods in use | 18 distinct methods |
Code in controller.ts |
665 lines (down from 815) |
| Modules extracted | 8 focused modules |
| SLOs defined | 5 (with 11 alert rules) |
| ADRs documenting decisions | 3 new (+47 pre-existing) |
| Runbooks | 3 (SLO burn, teardown failure, decryption) |
| Canary deploys | ✅ Flagger with SLO gates |
| Chaos experiments | 4 (composite workflow + 3 standalone) |
| Pre-existing bugs fixed | 2 (zombie terminating, updateResource) |
| # | Method | Status |
|---|---|---|
| 1 | Unit testing | ✅ 521 tests |
| 2 | Type-level testing | ✅ 15 expect-type tests |
| 3 | Integration testing | ✅ Lifecycle integration suite |
| 4 | Property-based testing | ✅ 28 properties × 100-500 runs |
| 5 | Contract testing (Zod) | ✅ drizzle-zod SSOT |
| 6 | Mutation testing | ✅ 82.62% score |
| 9 | Chaos engineering | ✅ 4 experiments |
| 10 | SAST (CodeQL) | |
| 11 | Canary SLO gates | ✅ Flagger |
| 13 | In-memory DB | ✅ pg-mem + real Drizzle |
| 15 | Migration testing | ✅ Atlas migrate lint |
| 16 | Snapshot testing | 🟡 partial |
| 18 | Accessibility | |
| 20 | Fuzzing | ❌ future work |
| 21 | Benchmark regression | ❌ future work |
| 26 | Fault injection (code) | ✅ makeMockK8sService({ applyBehaviour }) |
| 32 | BDD | ❌ (intentionally skipped) |
| 33 | Smoke testing | ✅ via /healthz |
We declared Phase 5 done and were wrong. The self-critique caught 7 gaps. Always re-audit before shipping. Better: have someone NOT on the refactor do the audit.
We had 80%+ line coverage on schemas.ts before running Stryker. The
mutation score was 52%. Half the "coverage" was weak assertions. Line
coverage is a floor, mutation score is the truth.
Real case: our maskConnectionString function passed all example tests
we wrote. Then fast-check generated a password that happened to be "po"
— and the masked URL postgres://... still contained "po". We'd have
NEVER written that test case by hand.
Adding 15 expect-type tests took ~2 hours. The invariant they
enforce — branded IDs cannot be confused — would have caught 3 prior
bugs if it had existed earlier.
P5 had a circuit breaker. P6 actually USED it. The difference between "written" and "wired" is the difference between performance theatre and real reliability.
We didn't rewrite everything to Effect. We added an Effect-native interface + default implementations, so existing Promise code keeps working AND new code can go all-in. This is the pragmatic SOTA path.
ADRs + runbooks aren't glamorous, but they prevent "how did we decide this?" questions 6 months later and "what do I do at 3am when this fires?" questions during incidents. Both save hours per incident.
A chaos experiment without an SLO gate is just noise. You have to know what "passing" looks like before you start breaking things.
48760e01a feat: phase 7b — BaseProvider Effect-native defaults (P7.2)
be8154e9b feat: phase 7a — SOTA finishing touches (P7.1, 3, 4, 5, 6, 7)
a78b5766b feat: phase 6c — full Effect reconcile pipeline (P6.3)
1c3d4380e feat: phase 6b — drizzle-zod SSOT + split + pg-mem + OTel (P6.5–8)
a347416d4 feat: phase 6a — real Effect integration + SLOs + mutation (P6.1,2,4,9,10)
178d24427 feat: phase 5c — OpenTelemetry spans + Prometheus /metrics
aee70de81 feat: phase 5b — Effect-TS migration (P5.5-7)
23c64d020 feat: phase 5a — type safety + resilience + property tests
If starting fresh with this playbook:
- Start at Level 2, not Level 1. Set up property testing + mutation testing in week 1, not month 3. The ROI is extreme.
- Write ADRs AS you make decisions, not retroactively. Future you won't remember why you chose Effect-TS over fp-ts.
- Set up Stryker + mutation gate FIRST. Before writing 100 unit tests, write 10 and verify they actually kill mutants.
- Write runbooks with the alert, not after the first incident.
- Wire circuit breakers + rate limiters as you build, not after. "Dead code" primitives become real maintenance debt.
- Resist the urge to declare "done." Always do a self-critique before moving on.
| Method | Link to code |
|---|---|
| Unit | src/controller/managed-resource/__tests__/*.test.ts |
| Integration | src/controller/managed-resource/__tests__/lifecycle.integration.test.ts |
| Property | src/controller/managed-resource/__tests__/*.property.test.ts |
| Type-level | src/lib/ids/__tests__/brands.types.test.ts |
| Contract (Zod) | src/lib/resources/schemas.ts |
| Mutation | apps/sylphx/stryker.conf.json |
| pg-mem | src/controller/managed-resource/__tests__/actionable-query-drizzle.pgmem.test.ts |
| OTel | src/controller/managed-resource/otel-sdk.ts |
| SLO VMRule | infra/addons/monitoring/managed-resource-alerts/vmrule-managed-resource.yaml |
| Grafana dashboard | infra/addons/monitoring/managed-resource-alerts/grafana-dashboard.yaml |
| Canary | apps/sylphx-platform/manifests/canary.yaml |
| Chaos | infra/addons/chaos-mesh/managed-resource-experiments/ |
| ADRs | docs/adr/ADR-048-*.md, ADR-049-*.md, ADR-050-*.md |
| Runbooks | docs/runbooks/managed-resource-slo-burn.md et al. |