Skip to content

Testing

Vitest configuration, Effect boundaries, browser control, file placement, and commands

Vitest 5 is the test runner for every unit, integration, and end-to-end test in this repository. Each test workspace has a local configuration file. An app uses one configuration with named projects. Vite apps put those projects in vite.config.ts. Other apps use vitest.config.ts.

Shared configuration

Use the shared presets from @concourse/vitest-config:

ExportUseDefault files
/nodeParallel unit tests in the Node environmenttests/**/*.test.{ts,tsx}, not in tests/integration/
/integrationSerial files that share SQLite or MySQL resourcestests/integration/**/*.test.ts
/e2eSerial browser files, HTML reports, and global setuptests/e2e/**/*.spec.ts

App configurations use nodeProject, integrationProject, and e2eProject from these exports. Name each project unit, integration, or e2e, and include only suites that the app owns. Put aliases and transforms at the configuration root. Put environment setup and service startup in the project that needs them. Use nodeRun() for root cleanup limits. With --mode e2e, use e2eRun() for browser cleanup limits and the HTML reporter. Reporters and teardown timeouts are process-wide options; do not put them inside projects. Package configurations can keep the existing node, integration, and e2e presets.

App scripts select projects with --project unit, --project integration, or --project e2e --mode e2e. Keep unit, integration, and E2E scripts as separate Turbo tasks with their own dependencies and cache outputs. An unfiltered Vitest command selects every project. Use the package scripts for routine runs. Use Vitest's runner flag and Vite mode to exclude application plugins during tests. Do not use NODE_ENV=test alone: acceptance builds and servers also use that environment.

Component tests run with /node. A test that needs JSDOM starts with a // @vitest-environment jsdom comment on line 1.

The shared configuration uses fork workers, isolated files, explicit imports, and cleared mocks. Each Node suite uses at most two workers. Root verification runs at most two Turbo tasks at once to bound competition between builds and browser assertions. Focused commands keep their own scheduling. Do not enable Vitest globals. Do not share mutable state between parallel unit files.

Every shared preset sets finite test, hook, and teardown timeouts. The shared configuration package owns the timeout values. When CI=true, unit and integration tests get 30 seconds and browser tests get 60 seconds. Hooks and teardown also get longer limits in CI. Local limits stay unchanged. Turbo includes CI in each test task's environment and cache key.

If a focused run exceeds a default, add the smallest timeout to that test. Do not increase a workspace timeout for one slow test. Explicit test and workspace timeouts override the shared defaults in both environments.

Effect tests

Use this section when the operation under test returns an Effect. React, Next.js, Promise SDK, browser, and framework harness tests can use ordinary async and Promise control flow. Import Vitest APIs from @effect/vitest when an Effect is the subject of the test. In that file, import every Vitest API from @effect/vitest, including assert, expect, and vi. The one exception is a file that calls vi.mock or vi.hoisted. That file imports vi from vitest, because Vitest hoists these calls only for that import. Return the Effect to the runner. Do not call a synchronous Effect runner from a test, and do not call a Promise runner only to bridge an Effect back into Vitest.

  • Use it.effect whenever the operation under test returns an Effect. It supplies a fresh Scope and test services such as TestClock.
  • Use it.live only when real Effect services such as Clock, Console, or Random are intentionally part of the behavior.
  • Use layer(...) for expensive scoped infrastructure. Share infrastructure, not fixture records, and set concurrent: false for a mutable database.
  • A shared layer(...) also memoizes its default test environment. Do not put a test that mutates TestClock under that shared environment.
  • Keep Effect.runPromise, ManagedRuntime, or Promise assertions only when a framework, process, HTTP adapter, CLI, vendor callback, or runtime lifecycle is itself the boundary under test. Review the purpose of each call; a file name alone cannot prove it is a boundary.
  • Use Effect.exit, Effect.flip, or cause inspection for Effect failures. Use Promise rejection assertions only for Promise boundaries.
  • Use it.prop for generated invariants and it.each for a known finite matrix. Do not use flakyTest to hide an unreliable test.

it.effect and it.live give the body one Scope. They close it after the test, also after a failure, an interruption, or a timeout. That Scope is enough for most tests, so do not wrap the body in Effect.scoped. The test Scope runs finalizers in reverse order. Thus, to remove a database file after its client closes, add the removal finalizer before you open the client.

Effect.provide(layer) closes the Layer when the body ends. A resource that the body acquires stays open until the test Scope closes, and that is after the Layer closes. This order causes a problem only when both of these conditions are true:

  • The provided Layer releases something when it closes. For example, a test database Layer closes its client and removes its files.
  • The body acquires a resource that uses a service from that Layer. Examples are a ManagedRuntime over the test database, an Identity built with Layer.build, and a forked fiber or a server that uses the database.

When both conditions are true, write .pipe(Effect.scoped, Effect.provide(layer)). The resource then closes before the Layer. Effect.scoped after or around Effect.provide does not change this order. When one condition is false, do not add the wrapper. For example, NodeServices.layer releases nothing that a temporary directory or a child process needs. A test under a shared layer(...) also does not need the wrapper, because the shared Layer closes after all of its tests.

Use another inner scope only in these cases:

  • Closing that scope is the behavior under test.
  • A timeout or an interruption must release a resource. Put Effect.scoped before Effect.timeout.
  • A helper acquires a resource, uses it completely, and returns plain data.

Browser operations use the ordinary Vitest API. Effect-based browser setup and lifecycle modules can use @effect/vitest or an Effect runtime at their boundary. Test a Promise adapter through its Promise interface. The SDK Promise tests must cover request-local credentials, tagged refusals, transport failures, and response decoding. Effect client tests use the generated client and Effect error channel.

Some tests exercise Node behavior that Effect does not expose, such as IPC or TTY streams. In Effect-owned code, keep each host fixture in a *.node.ts file with a visible static node: import. Name an Effect-owned test with a direct Node import *.node.test.ts. These suffixes exclude only the conflicting Effect import diagnostic. Keep the other Effect diagnostics and ordinary lint active. The root contract rejects direct Node imports in other Effect-owned files. It rejects process.getBuiltinModule calls in every TypeScript file. Files outside Effect ownership can import Node modules without the suffix. Examples are E2E specs, framework/ adapters, and the files of packages/vitest-config.

Assertions and cleanup

Use Vitest assertions. Use the strict forms, because chai's assert.equal uses ==.

IntentAssertion
Strict scalar equalityassert.strictEqual(actual, expected)
Strict scalar inequalityassert.notStrictEqual(actual, expected)
Deep equalityassert.deepEqual(actual, expected)
Negative regular expressionassert.notMatch(value, expression)
Promise rejectionawait expect(operation).rejects...
Promise resolutionawait expect(operation).resolves...

assert.deepEqual compares enumerable keys and values, but it does not compare prototypes. Chai's assert.deepStrictEqual is an alias for it. When the class or prototype of a value matters, use expect(actual).toStrictEqual(expected).

Match a rejection by its constructor, tag, or predicate, and not only by its message or status. Use onTestFinished for per-test cleanup. Use vi.spyOn for a scoped method spy, and always restore the spy.

File placement

Every workspace puts executable tests in a top-level tests/ directory. Mirror the source or harness directory under it. Name each file after the module or behavior that it tests. Packages put unit tests in tests/ and integration tests in tests/integration/.

packages/<name>/tests/<module>.test.ts
packages/<name>/tests/integration/<module>.sqlite.test.ts
packages/<name>/tests/integration/<module>.mysql.test.ts

App unit tests mirror lib/, scripts/, components/, or the e2e/ harness under tests/. Integration and browser tests use tests/integration/ and tests/e2e/. Browser tests use *.spec.ts. Unit tests of the browser harness use *.test.ts and run with the other unit tests. Keep non-test fixtures and harness modules near the code they support. Test files inherit the Effect or ordinary TypeScript scope of that code.

apps/concourse/tests/lib/**/*.test.ts
apps/concourse/tests/components/**/*.test.tsx
apps/concourse/tests/e2e/acceptance/*.test.ts
apps/concourse/tests/*.test.ts(x)
apps/concourse/tests/integration/*.test.ts
apps/concourse/tests/e2e/*.spec.ts

In an app, a file directly in tests/ is a boundary test. Use it only for framework adapters, root files such as proxy.ts, and Next.js app/ routes, actions, and components. These files get no Effect rules. A test of Effect-owned code mirrors that code under tests/lib/ or tests/scripts/.

The shared exports select test files with globs. Set include in a local configuration only to select a smaller set, such as the integration suites of one package. Do not list individual test files in a runner configuration.

End-to-end tests

Vitest runs end-to-end files in the Node environment. The Playwright library controls Chromium, browser contexts, pages, requests, routes, and additional actors. Import only expect from @playwright/test to retain retrying locator assertions.

The shared harness in @concourse/vitest-config/e2e-harness provides one browser per worker and one context per test file. Each test gets a fresh page.

Before every test, the harness closes the pages of the context and clears cookies, permissions, routes, headers, and same-origin storage. This reset also runs for a test that uses only request. The request fixture is the context request client, so it shares the context cookie jar. A multi-actor test gets each extra actor from the newActor fixture. Each actor has its own traced context, and the harness closes it after the test. Do not create a context from browser in a test.

Global setup starts the application and its required services with @concourse/vitest-config/e2e-services. It fails before it starts a process when another process answers on a service port. It fails at once when a process exits before it is ready, and the error names the process log. It stops every process after success or error. Concourse setup seeds browser fixtures. Turbo selects SQLite integration suites separately from E2E. The SQLite tasks form one serial chain and cache successful isolated test results.

Failed tests keep a screenshot and Playwright trace in test-results/. Each actor from newActor also keeps its own screenshot and trace there. Axe results and service logs also use test-results/. Vitest writes the HTML report to vitest-report/.

Vitest Browser Mode is not the acceptance runner. Its page represents the Vitest iframe. The current acceptance suites need top-level navigation, API requests, multiple contexts, and owned service lifecycle control.

Tests that add no evidence

Do not write a test that asserts a constant equals itself. Do not add empty tests. Do not use a regular expression when structured introspection can prove the same fact. Group one-line helpers into a domain test instead of creating one file per helper.

A package test can import its own lib/ directory. Do not add a public package entry point only for a test.

Commands

CommandRuns
pnpm testAll workspace unit tests and root contract tests
pnpm test:integrationAll SQLite and MySQL suites, serial across workspaces
pnpm --dir packages/rsdb test:integrationRSDB MySQL tests
pnpm rsdb:test:cleanRemove MySQL test state that a killed run left behind
pnpm e2eConcourse acceptance tests
pnpm exec turbo run @concourse/midot#e2eMIDOT build, then acceptance and accessibility tests
pnpm exec turbo run @concourse/docs.concourse#e2eDocumentation build, then the responsive layout check
pnpm exec turbo run @concourse/docs.tredis#e2eTREDIS manual build, then the responsive layout check
pnpm exec turbo run @concourse/us-counties#e2eUS counties build, then the responsive layout check
pnpm exec turbo run @concourse/vitest-config#e2eTests of the shared E2E harness
pnpm verifyThe complete merge gate

The MySQL suites need MySQL 8.0 or 8.4 on your machine. Each fixture creates and removes its own isolated databases, so the suites never change your development databases. Tests that share one fixture, such as a layer(...) suite with concurrent: false, share its databases and run serially. The RSDB checks describe the setup.

Install Chromium with pnpm --dir apps/concourse browsers:install. Run the focused command for the workspace that changed. Run pnpm verify before a merge.

Enforcement

Root contract tests require executable test and spec files to live under a workspace tests/ directory. Each test file must match the globs of exactly one runnable configuration or named project. A package script must select each app project, and each app must use one Vite or Vitest configuration. The contract tests also reject node:test, node --test, Playwright runner imports, and Playwright configuration files. Turbo contract tests make sure that unit, integration, both application E2E tasks, and the harness E2E task remain discoverable. CI keeps the Vitest reports and browser diagnostics.