Bridge Training
Contributing

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.ts

The 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

OptionDefaultDescription
environment—Set to "jsdom" for React/DOM tests
setupFiles—Array of setup file paths
include__tests__/**/*.{test,spec}.*Test file glob patterns
coveragev8 providerSet to false to disable, or pass { include, exclude }

File naming

  • Test files: <name>.test.ts or <name>.test.tsx
  • Mirror the source path: src/auction/auction-utils.ts → __tests__/auction/auction-utils.test.ts
  • Utility files: __tests__/test-utils.tsx for 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

  1. The package that owns a type owns its factory. Student is declared in @workspace/training, so makeStudent lives in packages/training/src/factories/, one file per entity, behind a module barrel.
  2. 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 global faker, 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.
  3. Consumers import the owner's factory, never redeclare it. Unit and integration tests of any package, packages/api-mock and scenarios all import from the owner. A test may pin the fields it asserts on through overrides, 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.
  4. /testing keeps test doubles, not factories. In-memory port implementations (createFakeQueue), mocks and other test helpers stay in src/testing/. It does not re-export the factories: consumers import /factories directly.
  5. Production code never imports /factories. Factories pull faker into the bundle. A Biome noRestrictedImports rule rejects @workspace/*/factories everywhere except tests, packages/api-mock and the app dev chunk (apps/app/src/dev/**); inside training, relative imports of factories/ are rejected outside tests, testing/ and factories/ 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/faker a 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.jsonc allows 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 -- --coverage

Best practices

  • Mirror the source tree in __tests__/ — makes files easy to find
  • Use test factories (makeDeal and the @workspace/bridge/testing builders) 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 .only or .skip — the linter will catch it
  • Async tests: use async/await, not done callbacks