Zeno

Unit Testing

Unit testing with Vitest

Zeno uses Vitest for fast, modern unit testing with native TypeScript support. The @zeno-lib/test package provides shared, opinionated configs and Testing Library re-exports so every package tests the same way — install it alongside vitest and vite.

What you get

Adding @zeno-lib/test (with vitest and vite) gives you shared configs and re-exports for writing and running tests. Import Vitest runtime helpers (describe, test, expect, vi, …) directly from vitest:

ImportWhat it provides
@zeno-lib/test/configsThe defaultConfig and reactConfig presets, plus defineConfig / mergeConfig
@zeno-lib/test/testing-libraryRe-exports @testing-library/react (render, screen, …)
@zeno-lib/test/user-eventRe-exports @testing-library/user-event for simulating real user interactions

Installation

In the package you want to test, follow these steps.

1. Install the packages

Install @zeno-lib/test together with its peer dependencies vitest and vite:

npm install --save-dev @zeno-lib/test vitest vite

2. Add the test scripts

The vitest CLI comes from the vitest package you installed:

package.json
{
  "scripts": {
    "test": "vitest --run",
    "test:watch": "vitest watch"
  }
}

3. Create a Vitest config

Create a vitest.config.ts at the root of your package and re-export one config — pick the preset that matches your package (see Choosing a config for the differences).

For plain TypeScript packages, use defaultConfig:

vitest.config.ts
export { defaultConfig as default } from "@zeno-lib/test/configs"

For React packages, use reactConfig instead so component tests run in a DOM environment:

vitest.config.ts
export { reactConfig as default } from "@zeno-lib/test/configs"

4. Write your first test

Create a *.test.ts file (or *.test.tsx for React) under src/, test/, or tests/:

src/example.test.ts
import { describe, expect, test } from "vitest"

describe("Example", () => {
  test("works", () => {
    expect(true).toBe(true)
  })
})

5. Run it

pnpm test

Choosing a config

The package ships two presets. Pick the one that matches your package.

defaultConfig

For plain TypeScript packages (no DOM). It:

  • Resolves your tsconfig path aliases (vite-tsconfig-paths).
  • Loads environment variables from .env.test automatically.
  • Discovers tests matching {src,test,tests}/**/*.test.ts.
  • Enables type testing for {src,test,tests}/**/*.test-d.ts files.

reactConfig

Extends defaultConfig with what React component tests need:

  • Runs tests in a jsdom environment so the DOM is available.
  • Enables the React plugin with the automatic JSX runtime — no import React needed.
  • Widens test discovery to {src,test,tests}/**/*.test.{ts,tsx} so .tsx tests are included.

Both configs set globals: false. This means Vitest helpers are not injected into the global scope — you must import describe, test, expect, etc. from vitest in every test file. This keeps test files explicit and type-safe.

Extending the config

To override shared defaults, merge a preset with package-specific options:

vitest.config.ts
import { defineConfig, mergeConfig, reactConfig } from "@zeno-lib/test/configs"

export default mergeConfig(
  reactConfig,
  defineConfig({
    test: {
      // package-specific overrides
    },
  }),
)

Writing tests

Import the helpers you need from vitest (remember: there are no globals):

import { describe, expect, test } from "vitest"

describe("Calculator", () => {
  test("adds two numbers", () => {
    expect(1 + 2).toBe(3)
  })

  test("handles edge cases", () => {
    expect(0 + 0).toBe(0)
    expect(-1 + 1).toBe(0)
  })
})

File location and naming

Tests live alongside your source in src/ and must end in .test.ts or .test.tsx to be picked up:

src/
├── calculator.ts
├── calculator.test.ts      # unit test
├── button.tsx
└── button.test.tsx         # React component test

Testing React components

Use @zeno-lib/test/testing-library for rendering and querying, and @zeno-lib/test/user-event to simulate interactions. Both require the reactConfig preset.

@zeno-lib/test/testing-library is React Testing Library and @zeno-lib/test/user-event is User Event, re-exported without changes. Refer to their official documentation for the complete API.

These libraries let you test components the way a user actually experiences them, rather than reaching into implementation details:

  • React Testing Library mounts a component into the jsdom DOM and exposes queries (getByRole, getByText, …) that find elements by their accessible role and visible text. Asserting on what's on screen — instead of on internal state or props — keeps tests resilient to refactors.
  • User Event dispatches realistic interaction sequences (a click fires pointerdown, mousedown, focus, click, …), so tests exercise the same event flow a real user triggers, catching bugs that a single synthetic event would miss.
import { describe, expect, test, vi } from "vitest"
import { render, screen } from "@zeno-lib/test/testing-library"
import userEvent from "@zeno-lib/test/user-event"
import { Button } from "@zeno-lib/ui/button"

describe("Button", () => {
  test("renders with text", () => {
    render(<Button>Click me</Button>)
    expect(screen.getByRole("button")).toHaveTextContent("Click me")
  })

  test("calls onClick when pressed", async () => {
    const user = userEvent.setup()
    const onClick = vi.fn()

    render(<Button onClick={onClick}>Click me</Button>)
    await user.click(screen.getByRole("button"))

    expect(onClick).toHaveBeenCalledOnce()
  })
})

Mocking

Import vi and other helpers from vitest. Vitest's mock hoister only recognizes vi from the "vitest" specifier, so always import mocking utilities from vitestvi.fn(), vi.mock(), vi.hoisted(), and the rest. See the Mocking guide for the full range of techniques.

import { describe, expect, test, vi } from "vitest"

const mockFetch = vi.fn()

describe("API", () => {
  test("fetches data", async () => {
    mockFetch.mockResolvedValue({ data: "test" })
    const result = await mockFetch()
    expect(result.data).toBe("test")
  })
})
import { describe, expect, test, vi } from "vitest"

vi.mock("./api", () => ({
  fetchData: vi.fn(),
}))

import { fetchData } from "./api"

describe("API client", () => {
  test("calls fetchData", async () => {
    vi.mocked(fetchData).mockResolvedValue({ data: "test" })
    const result = await fetchData()
    expect(result.data).toBe("test")
  })
})

Type testing

Both presets also type-check files ending in .test-d.ts. Use expectTypeOf to assert on types without running any runtime code. See Vitest's Testing Types guide for the full API:

src/types.test-d.ts
import { expectTypeOf, test } from "vitest"

test("greet returns a string", () => {
  expectTypeOf(greet("world")).toBeString()
})

Environment variables

Tests run with NODE_ENV=test. Both configs load .env.test automatically — create a .env.test file in your package for test-specific variables, and they'll be available via process.env during the run.

Commands

# Run tests once
pnpm test

# Watch mode
pnpm test:watch

# Run a single test file
pnpm test src/example.test.ts

In a monorepo, scope the run to one package with Turborepo's --filter:

pnpm turbo run test --filter <package-name>