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:
| Import | What it provides |
|---|---|
@zeno-lib/test/configs | The defaultConfig and reactConfig presets, plus defineConfig / mergeConfig |
@zeno-lib/test/testing-library | Re-exports @testing-library/react (render, screen, …) |
@zeno-lib/test/user-event | Re-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 vite2. Add the test scripts
The vitest CLI comes from the vitest package you installed:
{
"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:
export { defaultConfig as default } from "@zeno-lib/test/configs"For React packages, use reactConfig instead so component tests run in a DOM environment:
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/:
import { describe, expect, test } from "vitest"
describe("Example", () => {
test("works", () => {
expect(true).toBe(true)
})
})5. Run it
pnpm testChoosing a config
The package ships two presets. Pick the one that matches your package.
defaultConfig
For plain TypeScript packages (no DOM). It:
- Resolves your
tsconfigpath aliases (vite-tsconfig-paths). - Loads environment variables from
.env.testautomatically. - Discovers tests matching
{src,test,tests}/**/*.test.ts. - Enables type testing for
{src,test,tests}/**/*.test-d.tsfiles.
reactConfig
Extends defaultConfig with what React component tests need:
- Runs tests in a
jsdomenvironment so the DOM is available. - Enables the React plugin with the automatic JSX runtime — no
import Reactneeded. - Widens test discovery to
{src,test,tests}/**/*.test.{ts,tsx}so.tsxtests 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:
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 testTesting 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
jsdomDOM 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 vitest — vi.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:
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.tsIn a monorepo, scope the run to one package with Turborepo's --filter:
pnpm turbo run test --filter <package-name>