Test placement & naming
Tests are files too, and they need a place. This page answers where a test sits, where shared fakes and fixture data live, how a test file is named, and how vitest finds them all.
Colocate each test beside the file it tests
Section titled “Colocate each test beside the file it tests”Impact: HIGH a feature’s tests move and die with the feature
- A test sits beside its subject as
<stem>.test.ts. There is notest/directory inapps/*orpackages/*. Moving or deleting a feature moves or deletes its tests: onegit mvorrm -r. - A colocated test is inside its feature’s folder, so it can import that feature’s private
*-repo.tswithout any lint exemption. - A contract test lives in the package that can build it.
api.contract.test.tsandapi.compat.test.tssit besideapi.tsinpackages/contracts.api.boundary.test.tsneeds the server’s boundary layer, so it sits inapps/server/src/. - Accepted cost: feature folders roughly double in file count, and a missing test is not visible as a gap in a mirrored tree.
❌ Incorrect — a mirrored tree, outside the feature’s folder:
apps/server/ src/labeling/labeling-rules-repo.ts test/labeling/labeling-rules-repo.test.ts ← outside labeling/, so a private import is a lint error✅ Correct — tests beside their subjects:
apps/server/src/labeling/ labeling-rules.ts labeling-rules.test.ts labeling-rules-repo.ts labeling-rules-repo.test.ts http.ts http.test.ts testing.ts ← only when something is sharedSource: notes/01-code-organization/test-placement.md · Decision 1
Put shared test support in the owning feature’s testing.ts
Section titled “Put shared test support in the owning feature’s testing.ts”Impact: MEDIUM a port and its fake change in one folder
- Values and fakes start inline in the test file. A partial
Layer.mockfake moves out at the third copy; any other helper at the second consumer. - When it moves, it goes to
testing.tsin the feature that owns the faked thing, not the feature that happens to use it. One file per feature folder. It exports layers and builders, neveritblocks. TestDatabaseispackages/db/src/testing.ts, imported as@app/db/testing.tsthrough the./*wildcard.- No app-wide
src/test/ortestUtils/bucket, no*.testkit.tsrole suffix, and no test layer as a static on the production class.
❌ Incorrect — an ownerless bucket and a role-suffixed helper:
apps/server/src/test/fakes.ts ← no owner; a port change does not touch itapps/server/src/labeling/labeling-rules.testkit.ts✅ Correct — the owner of the port owns its fake:
// apps/server/src/labeling/testing.ts — imported only by testsexport const rulesReturning = (rules: ReadonlyArray<Rule>) => Layer.mock(LabelingRules.LabelingRules)({ list: () => Effect.succeed(rules) })
// apps/server/src/labeling/labeling-rules-repo.test.tsimport * as TestDatabase from "@app/db/testing.ts"const testLayer = LabelingRulesRepo.layer.pipe(Layer.provide(TestDatabase.layer))Source: notes/01-code-organization/test-placement.md · Decision 2
Keep fixture data beside its test, and test code out of production
Section titled “Keep fixture data beside its test, and test code out of production”Impact: HIGH a fake never ships in a production path
- Fixture data (anything generated, recorded or binary, or a value a script shares with a test)
goes in
fixtures/beside the test that reads it. Another feature’s tests never read it. - A generated fixture is written only by its script, never by hand. The test that reads it names that script in its first comment.
**/testing.tsand**/fixtures/**may be imported only from**/*.test.tsand another**/testing.ts. Ano-restricted-importsrow enforces it.testing.tscounts as test code: wherever a lint rule exempts*.test.ts, it exemptstesting.tstoo.
❌ Incorrect — production code reaching for a fake:
import * as LabelingTesting from "./testing.ts" // lint error✅ Correct — only tests and other test support import it:
import * as LabelingTesting from "../labeling/testing.ts"const testLayer = Charge.layer.pipe(Layer.provide(LabelingTesting.rulesReturning([])))Source: notes/01-code-organization/test-placement.md · Decision 2
Name tests <stem>.test.ts, with an optional aspect and never a kind
Section titled “Name tests <stem>.test.ts, with an optional aspect and never a kind”Impact: LOW one glob finds every test
<stem>is the stem of the file under test. Never.spec.ts: in the lab it means a Playwright browser suite.- One test file per module by default. Split by aspect as
<stem>.<aspect>.test.tswhen the tests fall into groups that share no setup. The aspect is a kebab word naming a behaviour. - No kind suffix (
.unit,.integration,.e2e,.local). Every test runs in the one default suite, so a kind would route nothing, and a suffix that routes nothing drifts. - Accepted cost: you cannot list the slow tests by name.
❌ Incorrect — kind suffixes that no config acts on:
labeling-rules.spec.tslabeling-rules-repo.integration.test.tshttp.unit.test.ts✅ Correct — the stem, plus an aspect where one is needed:
labeling-rules.test.tslabeling-rules-repo.test.tsorchestrator.migration.test.tsapi.compat.test.tsSource: notes/01-code-organization/test-placement.md · Decision 3
Keep one generated previous.json for cross-release contract tests
Section titled “Keep one generated previous.json for cross-release contract tests”Impact: HIGH a rolling deploy mixes release N and N−1
packages/contracts/src/fixtures/compat/holds a hand-writtensamples.ts(one value per public endpoint, typed by the current schemas) and a generatedprevious.json.scripts/compat-snapshot.ts(pnpm compat:snapshot) is the only writer. It encodes the samples and persists each success schema withSchemaRepresentation.toJson(toRepresentation(ast)). Thereleasejob runs it at the deployed SHA and opens abot/compat-snapshotPR only whenendpointschanged. See release & versioning.api.compat.test.tsdecodes the old sample with today’s schema, and decodes today’s encoded sample with the schema revived throughfromJson→fromRepresentation. No old checkout is needed. Only N−1 is kept; git history keeps the rest.- Accepted cost: a custom declaration or check in a public wire schema needs a reviver, and the revived schema does not compare behaviour that lived in a transformation.
❌ Incorrect — the previous release’s contracts vendored as source:
packages/contracts/src/compat/v41/api.ts ← imports today's domain and Effect; stops compiling or quietly stops being the old schema✅ Correct — one generated, pretty-printed, key-sorted file:
// fixtures/compat/previous.json — never edited by hand{ "release": "<SHA of the production deploy it was taken from>", "endpoints": { "labeling.getRule": { "success": { /* toJson(toRepresentation(ast)) */ }, "sample": { /* … */ } } }}Source: notes/01-code-organization/test-placement.md · Decision 4 (amended)
Use one root vitest.config.ts, with a project per workspace package
Section titled “Use one root vitest.config.ts, with a project per workspace package”Impact: MEDIUM retry: 0 cannot be lost in one package
- The project list is derived from the
apps/andpackages/folders, so a new package is tested without editing the config.passWithNoTestskeeps a package with no tests green. - Shared options are merged into every project. A package that needs different options gets an
entry in an
overridesmap, never its own config file. pnpm testat the root runs everything;vitest run --project apps/serverruns one package. No per-packagevitest.config.ts, novitest.workspace.ts.- Accepted cost: a stray folder under
apps/orpackages/becomes a project, and per-package options live far from the package.
❌ Incorrect — a config per package, each repeating (or forgetting) the shared options:
apps/server/vitest.config.ts include, retry: 0packages/db/vitest.config.ts include, retry: 0 ← repeated by hand, one edit from drifting✅ Correct — one root config:
const shared: ViteUserConfig = { test: { include: ["src/**/*.test.ts"], passWithNoTests: true, retry: 0 },}const overrides: Record<string, ViteUserConfig> = {}const roots = ["apps", "packages"].flatMap((dir) => readdirSync(dir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => `${dir}/${e.name}`))
export default defineConfig({ test: { projects: roots.map((root) => mergeConfig(mergeConfig(shared, { root, test: { name: root } }), overrides[root] ?? {})) } })Source: notes/01-code-organization/test-placement.md · Decision 5
Deferred
Section titled “Deferred”- A test-support workspace package — trigger: the first test-support module that must be imported from outside this workspace, or that needs a dependency its owning package must not declare.
- The
*.integration.test.tssuffix and a vitest project that keeps it out ofpnpm test— trigger: a bug passes the embedded database engine and fails on the production engine. Built in the same PR as the integration suite in testing.