Testing
How to write and organize tests across the monorepo.
Overview
All packages use Vitest as the test runner. Tests live in a __tests__/ directory at the root of each package, mirroring the src/ folder structure.
packages/bridge/
├── src/
│ ├── auction/
│ ├── hands/
│ └── play/
└── __tests__/
├── auction/
│ ├── auction.test.ts
│ └── auction-utils.test.ts
├── hands/
│ └── hands.test.ts
└── play/
└── play-utils.test.tsThe same convention applies to apps/app, packages/ui-bridge, and all other testable packages.
Running tests
# From the root — run all tests across the monorepo
pnpm test
# From a specific package
cd packages/bridge
pnpm test # All tests
pnpm test -- __tests__/auction/auction.test.ts # Single file
pnpm test:watch # Watch mode
pnpm test:ui # Vitest UI (browser)Vitest configuration
Packages re-export a preset from @workspace/config as their whole vitest.config.ts:
// React packages: jsdom, React plugin, jest-dom matchers, jsdom polyfills
export { default } from "@workspace/config/vitest/react";// Pure TypeScript packages
export { default } from "@workspace/config/vitest/node";Both presets alias @/ to the package's src and run __tests__/**. The React preset's setup file registers the jest-dom matchers and polyfills the browser APIs jsdom lacks (scrollIntoView, pointer capture, ResizeObserver, matchMedia), required for Radix UI components. A package that needs something the presets don't cover builds its own config from the helpers in @workspace/config/vitest:
import { createSrcAlias, createTestConfig } from "@workspace/config/vitest";
import { defineConfig } from "vitest/config";
export default defineConfig({
test: createTestConfig({ coverage: false }),
resolve: { alias: createSrcAlias(import.meta.dirname) },
});TypeScript configuration
@workspace/typescript-config owns the tsconfig presets. A React package's tsconfig.package.json (source only, run by check-types) and tsconfig.test.json (source and tests, with the @/ alias and the jest-dom matcher types, run by check-types:test) are one line each:
{ "extends": "@workspace/typescript-config/react-package.json" }{ "extends": "@workspace/typescript-config/react-test.json" }A pure TypeScript package uses the same split: its tsconfig.json extends @workspace/typescript-config/pure-package.json (source only) and its tsconfig.test.json extends @workspace/typescript-config/pure-test.json (source and __tests__, with the @/ alias). A package whose tests live under src/__tests__ and do not import through @/ only needs pure-package.json. The presets resolve src and __tests__ against the extending package through ${configDir}. Imports of the package's own name (@workspace/ui/components/button) resolve through its exports map, so no paths entry is needed.
createTestConfig options
| Option | Default | Description |
|---|---|---|
environment | — | Set to "jsdom" for React/DOM tests |
setupFiles | — | Array of setup file paths |
include | __tests__/**/*.{test,spec}.* | Test file glob patterns |
coverage | v8 provider | Set to false to disable, or pass { include, exclude } |
File naming
- Test files:
<name>.test.tsor<name>.test.tsx - Mirror the source path:
src/auction/auction-utils.ts→__tests__/auction/auction-utils.test.ts - Utility files:
__tests__/test-utils.tsxfor shared render helpers
Imports
All packages alias @/ to src/. Use @/ in test files instead of relative paths:
// Good
import { calculateContract } from "@/auction";
import { AuctionBuilder } from "@/testing";
// Avoid
import { calculateContract } from "../../src/auction";The alias comes from the vitest presets (or createSrcAlias(import.meta.dirname) in a hand-written config).
Writing tests
Pure logic (bridge)
Import from vitest directly. Use the builders from packages/bridge/src/testing to build domain objects:
import { describe, expect, it } from "vitest";
import { calculateContract } from "@/auction";
import { AuctionBuilder } from "@/testing";
describe("calculateContract", () => {
it("derives the final contract and declarer", () => {
const auction = new AuctionBuilder()
.fromCompact("N / 1H pass 4H AP")
.build();
const result = calculateContract(auction);
expect(result?.type).toBe("confirmed");
expect(result?.value).toEqual({
level: 4,
suit: "H",
trump: "H",
declarer: "N",
doubled: "none",
});
});
});React components (ui-bridge)
Use the custom render from __tests__/test-utils — it wraps components in BridgeProvider and BridgeLabelsProvider with English defaults:
import { fireEvent, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { Hand } from "@/components/hand/hand";
import { render } from "../test-utils";
describe("Hand", () => {
it("renders player label", () => {
render(<Hand cards={defaultCards} player="N" />);
expect(screen.getByText("North")).toBeInTheDocument();
});
it("calls onCardsChange on blur", () => {
const handleCardsChange = vi.fn();
render(<Hand cards={defaultCards} onCardsChange={handleCardsChange} player="N" />);
const input = screen.getAllByRole("textbox")[0];
fireEvent.focus(input);
fireEvent.change(input, { target: { value: "AKQJ" } });
fireEvent.blur(input);
expect(handleCardsChange).toHaveBeenCalledWith("S", ["A", "K", "Q", "J"]);
});
});You can customize the context via render(ui, { context, labels }):
render(<MyComponent />, {
context: { teachingMode: "petit-bridge", locale: "fr" },
});App-level tests
Tests in apps/app follow the same pattern. Domain test factories are available from @workspace/training/factories (e.g., makeBoard(faker)).
The /factories and /testing exports
A factory builds a valid, random instance of a type for tests, API mocks and dev scenarios. Every type has one factory, and it lives in the package that owns the type. Test doubles (in-memory port implementations, mocks) and other test helpers go through a ./testing export. Consumers import both without reaching into internal paths:
import { faker } from "@faker-js/faker";
import { AuctionBuilder, makeDeal } from "@workspace/bridge/testing";
import { makeBoard, makeGroup } from "@workspace/training/factories";
import { createFakeQueue } from "@workspace/training/testing";
import { richText, fromString } from "@workspace/rich-text/testing";
const board = makeBoard(faker, { metadata: { level: 2 } });Rules
- The package that owns a type owns its factory.
Studentis declared in@workspace/training, somakeStudentlives inpackages/training/src/factories/, one file per entity, behind a module barrel. - Exposed by
@workspace/<package>/factories, signature(faker, overrides). The faker is always a parameter, never the global one imported inside the factory: tests pass the globalfaker, the scenario runner passes a seeded instance so a scenario replays identically. A factory that delegates to builders or to other factories hands them the same faker. - Consumers import the owner's factory, never redeclare it. Unit and integration tests of any package,
packages/api-mockand scenarios all import from the owner. A test may pin the fields it asserts on throughoverrides, or wrap the factory in a local helper that does; a factory declared inside a test is allowed only for a type that exists only in that test. /testingkeeps test doubles, not factories. In-memory port implementations (createFakeQueue), mocks and other test helpers stay insrc/testing/. It does not re-export the factories: consumers import/factoriesdirectly.- Production code never imports
/factories. Factories pull faker into the bundle. A BiomenoRestrictedImportsrule rejects@workspace/*/factorieseverywhere except tests,packages/api-mockand the app dev chunk (apps/app/src/dev/**); insidetraining, relative imports offactories/are rejected outside tests,testing/andfactories/themselves.
// packages/training/src/factories/student.ts
export function makeStudent(
faker: Faker,
overrides: Partial<Student> = {}
): Student {
return {
id: faker.number.int({ min: 1, max: 10_000 }),
firstName: faker.person.firstName(),
// …
...overrides,
};
}The catalogue of the training factories is on Training: Factories.
DTO factories
@workspace/api-contract owns the wire DTOs, so it owns their factories, exposed by @workspace/api-contract/factories. A DTO with a domain mapper is built from the owner's domain factory passed through that mapper, so a fixture is exactly what the server sends:
export function makeStudentDto(
faker: Faker,
overrides: Partial<StudentDto> = {}
): StudentDto {
return {
...toStudentDto(makeStudentWithGroupCount(faker), { canManage: true }),
...overrides,
};
}A view whose mapper still lives server-side gets a direct factory. What only the mocks need, such as the DTO a mock returns for a request body, stays in packages/api-mock. Inside api-contract, src/factories/, src/testing/ and tests may import factories; the rest of its code may not.
@workspace/bridge/factories predates this convention: it holds production builders (createHands, createBoardGame) that parse compact notation, not test factories, and is exempt from rule 5.
Adding a factory
- Put it next to its siblings in the owner's
src/factories/, export it from the barrel, and keep@faker-js/fakera type-only import (import type { Faker }). - Defaults are random; a literal default is only for a field whose value is the type's neutral state (
isArchived: false,review: []). - A package whose tests start consuming factories and that has its own
biome.jsoncallows them in its__tests__/**override; one without a nested config is listed in the root override.
The @workspace/bridge/testing builders deal random hands; the bridge and training packages' test setups print the faker seed of a failing test, and FAKER_SEED=<seed> pnpm test replays it.
Coverage
Coverage uses the v8 provider with text, json, and html reporters. Source files in src/**/* are included, .d.ts files excluded.
cd packages/bridge
pnpm test -- --coverageBest practices
- Mirror the source tree in
__tests__/— makes files easy to find - Use test factories (
makeDealand the@workspace/bridge/testingbuilders) instead of manually constructing domain objects - One assertion per test when possible — descriptive
it("...")names replace comments - Use
vi.fn()for spies and mocks, avoid mocking internal modules when you can test through the public API - Don't commit
.onlyor.skip— the linter will catch it - Async tests: use
async/await, not done callbacks