ZenoZENO

Unit testing

Unit testing with Vitest

Zeno uses Vitest for unit testing, with native TypeScript support. The @zeno-lib/test package provides shared 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, Testing Library re-exports and a Row Level Security test harness. 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
@zeno-lib/test/supabasecreateRlsTestHarness, signInAs and idsSeen for RLS suites against a local Supabase

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

Use it for plain TypeScript packages that need 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, so you don't need import React.
  • Widens test discovery to {src,test,tests}/**/*.test.{ts,tsx}, so it also picks up .tsx tests.

Both configs set globals: false, so Vitest does not inject its helpers into the global scope. Import describe, test, expect and the rest from vitest in every test file.

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, since 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 for Vitest to find them:

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. See their documentation for the complete API.

These libraries let you test components the way a user uses 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. Assert on what's on screen, not on internal state or props, and a refactor that leaves the output unchanged doesn't break the test.
  • User Event dispatches realistic interaction sequences (a click fires pointerdown, mousedown, focus, click, …), the same events a real user triggers. That catches bugs 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 it from vitest for 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")
  })
})

Testing Row Level Security

@zeno-lib/test/supabase runs a suite as real Supabase users: it creates them through the Auth admin API, signs them in on one publishable-key client, and gives you a database handle bound to that client. It needs @supabase/supabase-js and a running local Supabase. You pass the database client through createDb, typically createAuthClient from @zeno-lib/db.

tests/deals.rls.test.ts
import { createClient } from "@supabase/supabase-js"
import { createAuthClient } from "@zeno-lib/db"
import { createRlsTestHarness, idsSeen, signInAs } from "@zeno-lib/test/supabase"
import { afterAll, expect, it } from "vitest"

const harness = createRlsTestHarness({
  admin: createClient(url, secretKey),
  client: createClient(url, publishableKey),
  createDb: (client) => createAuthClient(client, { relations }),
  // Your domain's setup for each new user, e.g. role rows.
  onCreateUser: async (user, options: { roles?: string[] } | undefined, { admin }) => {
    for (const role of options?.roles ?? []) {
      const { error } = await admin.from("users_roles").insert({ role, user_id: user.id })
      if (error) throw error
    }
  },
})

afterAll(() => harness.cleanUp())

it("anon sees nothing", async () => {
  await signInAs(harness, null)
  expect(await idsSeen(() => harness.db.select().from(deals))).toEqual(new Set())
})

it("admins see every deal", async () => {
  await signInAs(harness, { roles: ["ADMIN"] })
  expect(await idsSeen(() => harness.db.select().from(deals))).toEqual(new Set([1, 2]))
})
  • The harness builds harness.db once, and the handle follows whoever is signed in, so you can capture it anywhere.
  • signInAs always signs in a fresh user, because an access-token hook usually writes claims such as roles into the token at sign-in.
  • Assert the exact set idsSeen returns. An empty set is also what a broken claims setup looks like.
  • cleanUp() deletes every user the harness created, then signs out and closes db. It retries transient Auth errors and skips users that are already gone. Delete rows that reference those users first.

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, so put test-specific variables in a .env.test file in your package and read them from 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>