| id | 0082 | ||||
|---|---|---|---|---|---|
| title | Two Test Tiers — Unit (No DB) and Integration (Real D1 via alchemy `Test.make`) | ||||
| status | extended-in-part by [0104](0104-two-mode-integration-test-tier.md) | ||||
| date | 2026-06-17 | ||||
| tags |
|
Supersedes 0040.
0040 defined a four-tier taxonomy (T0/T1/T2/T3) whose load-bearing premise was:
"node:sqlite is the same engine as D1 — hermetically faithful — so domain
correctness belongs in T1/T2." On that premise it blessed an in-process
node:sqlite D1 stand-in (makeSqliteTestDb) as the backing for service- and
app-integration tests across the worker.
The premise is wrong where it matters, and the investigation behind epic #563 established why:
- It is not faithful.
node:sqlite's FTS5 build, tokenizer, and collation are not Cloudflare D1's. A search test that "proves" Turkish diacritic folding againstnode:sqliteproves nothing about D1 — different engine. - It puts domain correctness on a database. A unit test that boots any SQL engine — real or faked — has stopped being a unit test. Logic that can be wrong even if the database behaves perfectly (normalization, clamping, envelope shaping, pagination math, auth gates, empty/cursor-miss branches, topic-key routing) does not need a database to be tested, and reaching for one is a layering violation.
- It gave false confidence. The folding that
search.test.tsspun up a faked engine + the full write→sync→resolver stack to verify is a pure function (features/search/normalize.ts), applied app-side precisely because the engine'sunicode61fold is wrong for Turkish. The DB was never doing the thing the test claimed to check.
A second, separate finding from the same investigation: the local-dev "D1
divergence" that motivated #563 was a ghost. alchemy dev binds the real
remote D1 by design (0032; confirmed in
the alchemy source: the local worker provider binds D1.remote(...), persists
only Durable Object state under .alchemy/local/, and there is no local D1
emulation anywhere in alchemy). The orphaned apps/web/.wrangler/ sqlite is a
dead wrangler-era file the running worker never opens (verified by lsof on the
live dev workerd). The real problem was never two diverging databases — it was
mis-layered tests on a faked engine.
Two test tiers. No middle. No faked engine.
unit— pure logic: no database, no SQL engine, no I/O. The unit under test sits on a seam whose lower layer is substituted directly (theDatabase/Drizzleseam is already mockable — aLayer.succeed(Drizzle, …)with a recording or throwingrun). Marked by the*.unit.test.tsinfix. If a test boots a SQL engine, it is not a unit test.integration— real behavior against real remote Cloudflare D1, via the alchemyTest.makeidiom:beforeAll(deploy(Stack))+afterAll.skipIf(...)(destroy(Stack))+ retry-first-request, asserting over the live worker. D1 is migrated by the existingD1Database({ migrationsDir, migrationsTable: "drizzle_migrations" })resource — the same resourcedeployuses, so there is one migration path and nothing to keep in sync.
node:sqlite is banned outright as a test backing. makeSqliteTestDb /
apps/web/worker/db/sqlite-d1.testing.ts and its self-test are deleted. The
T0/T1/T2/T3 numbering is dropped — unit and integration are the only names.
The ban is enforced, not prose (#631). A GritQL biome plugin
(biome-plugins/no-node-sqlite-test-backing.grit, wired into biome.jsonc
"plugins") reds the existing check CI job when any non-allowlisted file
imports node:sqlite (import … from "node:sqlite" in any clause shape, or
require("node:sqlite")). A deliberate carve-out is an explicit, reason-bearing
// biome-ignore lint/plugin: <reason> on the import — never a blanket directory
exemption — so the next un-blessed re-introduction can't merge silently.
Both carve-outs are removed; zero survivors — the ban is fully clean. The temporary
carve-out #633 recorded — packages/preview-seed/src/sqlite-d1.testing.ts
(makeSeedTestDb(), the fast unit suite's backing) — was removed by #672: that
issue re-tiered the seed's suite off the fake and onto real D1 via the alchemy
Test.make harness (a D1-only stack deployed per-file,
packages/preview-seed/tests/integration/_d1.ts, seeding over the production REST
transport). The fake is deleted and its biome-ignore allowlist entry is gone. The
seed's genuinely pure-logic assertions (fixture content, the REST-wire param
contract) stayed in the unit tier (no DB); the row-landing, FTS5-fold, and
idempotency assertions moved to integration on real D1 — exactly the placement the
litmus below prescribes.
A second node:sqlite carve-out, not tracked when this ADR was written, was
found during #672: packages/moderator-grant/src/sqlite-d1.testing.ts
(makeGrantTestDb), which mirrored the preview-seed carve-out without its own
removal trigger. It was the last allowlisted survivor — removed by #930, which
re-tiered the grant's suite onto real D1 via the same Test.make D1-only harness
(packages/moderator-grant/tests/integration/_d1.ts): the grant statement-building
and REST-wire param contract stayed unit (no DB); the grant/revoke/list-against-a-row
assertions moved to integration on real D1. With both fakes deleted the ban now
reaches zero allowlisted survivors — the enforcing plugin reds CI on any
re-introduction.
Principle — no domain decision welded to SQL execution. Cursor resolution is
a port (a thin DB read); the keyset / cursor-miss decision and the page
envelope are pure and unit-testable. Domain logic that today executes inline
inside run((db) => …) is to be lifted above that seam so it can be tested with
no database. The litmus for tier placement: "Could this be wrong even if the
database behaved perfectly?" — yes → unit; only-wrong-if-the-DB-differs →
integration.
Integration runs all-on-every-push, parallelized via per-file isolated
stages — the simplest shape, maximizing parallelism to minimize wall-clock.
This is the alchemy-native model (Test.make per file, each its own stage), not
a bespoke harness.
ALCHEMY_DEV=1 runs the worker in local workerd for a fast inner loop, but
D1 binds remote in that mode too — so it speeds local iteration only and is not
a CI cost lever. The CI cost lever is the unit/integration split itself (fewer
DB-touching assertions), not dev mode.
Change-scoped selection is a unit-tier and local-loop accelerator, not an
integration-gate one. vitest --changed / related narrows by the resolved
import graph: sound for unit (tests import disjoint modules → a change selects
only the touched tests) and ideal for the local dev loop. It does not narrow
integration — every integration test deploys the whole worker (one worker = one
Stack), so any worker change is in all of their graphs and selects all of them;
and a black-box harness with no import edge to worker source under-selects
outright. So the integration gate runs full, parallelized via isolated
stages — its speed comes from parallelism, not selection. forceRerunTriggers
(migrations, alchemy.run.ts, the harness) backstops the unit tier against the
out-of-band edges the import graph cannot see.
- Banned: calling any test that boots a SQL engine a "unit" test; using
node:sqlite(or any faked engine) as a test backing; asserting pure logic through a database; filing a domain decision welded to SQL execution. - Deleted:
apps/web/worker/db/sqlite-d1.testing.ts+sqlite-d1.testing.test.ts;packages/preview-seed/src/sqlite-d1.testing.ts(removed by #672); andpackages/moderator-grant/src/sqlite-d1.testing.ts(the lastnode:sqlitesurvivor, removed by #930) — zero allowlisted survivors remain (see the enforcement section). - Migration cost (epic execution, tracked under #563, not this ADR): each
makeSqliteTestDbsuite splits — pure-logic assertions move tounit(several are already duplicated bykeyset.unit.test.ts/normalize.unit.test.ts, so they become deletions), genuine DB-fidelity assertions move tointegrationon real D1; the hand-rolledtests/integration/_global-setup.ts/_harness.tsare replaced byTest.make. The 3-site cursor-port extraction (features/sozluk/Sozluk.ts,features/pano/Pano.ts,features/search/Search.ts) is epic work, deliberately out of scope here. - Cost: ≈ $0 in money (D1 test volume sits in the free tier). Wall-clock for
the integration job rises modestly as ~30–40 fate-op assertions move onto
network-backed real D1, offset by per-file-stage parallelism (the existing
suite is forced single-fork to dodge the prior flake; isolated stages remove
that constraint). the shared-deploy race cluster — the
"all fibers interrupted" timeout (#547),
the deterministic-looking redness (#560),
and the shared-deploy timing assertion (#220)
are one root cause: a single-fork shared deploy racing itself. It is a lifecycle
bug in the hand-rolled harness, not an inherent cost of real D1 — the per-file
isolated-stage
Test.makelifecycle removes the class, and the cluster closes when the swap lands. - Deferred lever (not built): per-file isolated stages provision real D1 per run; if Cloudflare D1 create/destroy rate limits bite, the mitigation is a bounded per-fork stage pool (D1 created once and kept, idempotent re-migrate, data reset per file). We start with max parallelism and revisit only if it becomes a problem.
- Irreducible
integrationcore (stays on real D1): batch atomicity rollback; idempotency viameta.changeson composite-PK conflict; keyset execution verticals (collation / NULL / date / string tiebreaks); FTS5 bm25 / prefix / MATCH and the write→sync→read dual-write loop (0080); read-row shaping and aggregate counters; the better-auth session round-trip. - The
vitest.config.tsprojects,package.jsonscripts, and CI job re-tier from the 0040 four-tier model to the two-tier model atomically (epic execution).
Builds on 0029,
0011, 0032,
0080, and the alchemy Test.make testing model.
Supersedes 0040.
Extended-in-part by 0104: the two-tier
names (unit / integration) and the faked-engine ban stand; the integration
tier's per-file-stage shape is superseded by a run-scoped shared stage for the
irreducible real-D1/DO files plus a downward move of the pure-logic files to the
unit tier — the per-file 24× create/destroy surface was the root of the
#1010/#1019/#1020 flake cluster.