Skip to content

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 no test/ directory in apps/* or packages/*. Moving or deleting a feature moves or deletes its tests: one git mv or rm -r.
  • A colocated test is inside its feature’s folder, so it can import that feature’s private *-repo.ts without any lint exemption.
  • A contract test lives in the package that can build it. api.contract.test.ts and api.compat.test.ts sit beside api.ts in packages/contracts. api.boundary.test.ts needs the server’s boundary layer, so it sits in apps/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 shared

Source: 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.mock fake moves out at the third copy; any other helper at the second consumer.
  • When it moves, it goes to testing.ts in 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, never it blocks.
  • TestDatabase is packages/db/src/testing.ts, imported as @app/db/testing.ts through the ./* wildcard.
  • No app-wide src/test/ or testUtils/ bucket, no *.testkit.ts role 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 it
apps/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 tests
export const rulesReturning = (rules: ReadonlyArray<Rule>) =>
Layer.mock(LabelingRules.LabelingRules)({ list: () => Effect.succeed(rules) })
// apps/server/src/labeling/labeling-rules-repo.test.ts
import * 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.ts and **/fixtures/** may be imported only from **/*.test.ts and another **/testing.ts. A no-restricted-imports row enforces it.
  • testing.ts counts as test code: wherever a lint rule exempts *.test.ts, it exempts testing.ts too.

❌ Incorrect — production code reaching for a fake:

labeling/labeling-rules.ts
import * as LabelingTesting from "./testing.ts" // lint error

✅ Correct — only tests and other test support import it:

billing/charge.test.ts
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.ts when 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.ts
labeling-rules-repo.integration.test.ts
http.unit.test.ts

✅ Correct — the stem, plus an aspect where one is needed:

labeling-rules.test.ts
labeling-rules-repo.test.ts
orchestrator.migration.test.ts
api.compat.test.ts

Source: 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-written samples.ts (one value per public endpoint, typed by the current schemas) and a generated previous.json.
  • scripts/compat-snapshot.ts (pnpm compat:snapshot) is the only writer. It encodes the samples and persists each success schema with SchemaRepresentation.toJson(toRepresentation(ast)). The release job runs it at the deployed SHA and opens a bot/compat-snapshot PR only when endpoints changed. See release & versioning.
  • api.compat.test.ts decodes the old sample with today’s schema, and decodes today’s encoded sample with the schema revived through fromJson → 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/ and packages/ folders, so a new package is tested without editing the config. passWithNoTests keeps a package with no tests green.
  • Shared options are merged into every project. A package that needs different options gets an entry in an overrides map, never its own config file.
  • pnpm test at the root runs everything; vitest run --project apps/server runs one package. No per-package vitest.config.ts, no vitest.workspace.ts.
  • Accepted cost: a stray folder under apps/ or packages/ 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: 0
packages/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

  • 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.ts suffix and a vitest project that keeps it out of pnpm 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.