Testing#

Test runners are the JavaScript surface (see Testing); TypeScript adds typed mocks, typed fixtures, and type-only tests that assert on structures at compile time. The 2026 default is vitest, which runs TypeScript directly without a build step.

This page covers the TypeScript-specific testing patterns.

vitest with TypeScript#

vitest runs .test.ts files directly; no tsc pass needed at test time.

$ npm install --save-dev vitest
import {describe, it, expect} from "vitest";
import {trim} from "./string-helpers.js";

describe("trim", () => {
  it("removes trailing whitespace", () => {
    expect(trim("hi   ")).toBe("hi");
  });

  it("throws on null", () => {
    expect(() => trim(null as unknown as string)).toThrow(TypeError);
  });
});

For type-checked tests in CI, run tsc --noEmit alongside vitest run.

Typed mocks#

vi.fn() accepts a generic for the function signature; the mock then enforces the same structure.

import {vi} from "vitest";

const onSend = vi.fn<(msg: string) => void>();
onSend("hi");                              // ok
onSend(42);                                // type error
expect(onSend).toHaveBeenCalledWith("hi");

For full-module mocks, vi.mock with a typed factory preserves the export types.

vi.mock("./network.js", (): typeof import("./network.js") => ({
  fetchUser: vi.fn().mockResolvedValue({id: "1", name: "rk"}),
}));

Type-only tests#

The operator catches type regressions at compile time with expect-type (or tsd).

$ npm install --save-dev expect-type
import {expectTypeOf} from "expect-type";

expectTypeOf<User>().toMatchTypeOf<{id: string; name: string}>();
expectTypeOf<typeof load>().returns.toEqualTypeOf<Promise<User>>();

The assertions evaluate at compile time; a wrong type fails the tsc pass.

Fixtures with types#

vitest’s test.extend produces a typed test function with extra fixtures injected.

import {test as base, expect} from "vitest";

type Fixtures = {server: Server; client: Client};

const test = base.extend<Fixtures>({
  server: async ({}, use) => {
    const s = await Server.start();
    await use(s);
    await s.stop();
  },
  client: async ({server}, use) => {
    await use(new Client(server.url));
  },
});

test("ping", async ({server, client}) => {
  expect(await client.ping()).toBe("pong");
});

Snapshots#

.toMatchSnapshot() writes the value on first run and compares on subsequent runs. Inline snapshots (.toMatchInlineSnapshot) live in the test source.

it("renders the report", () => {
  expect(render(data)).toMatchInlineSnapshot();
});

Coverage#

@vitest/coverage-v8 produces V8 coverage. Configure in vitest.config.ts.

// vitest.config.ts
import {defineConfig} from "vitest/config";

export default defineConfig({
  test: {
    coverage: {
      provider: "v8",
      reporter: ["text", "html"],
    },
  },
});
$ npx vitest run --coverage

Playwright (e2e, typed)#

End-to-end tests in TypeScript. The page and locator APIs are fully typed.

import {test, expect, type Page} from "@playwright/test";

async function login(page: Page, user: string, pass: string) {
  await page.goto("/login");
  await page.getByLabel("Username").fill(user);
  await page.getByLabel("Password").fill(pass);
  await page.getByRole("button", {name: "Sign in"}).click();
}

test("dashboard after login", async ({page}) => {
  await login(page, "op", "***");
  await expect(page).toHaveURL("/dashboard");
});

node:test with TypeScript#

Node’s stdlib runner works with tsx (or --loader).

$ npx tsx --test test/
import {test} from "node:test";
import assert from "node:assert/strict";
import {trim} from "../src/string-helpers.js";

test("trim", () => {
  assert.equal(trim("hi  "), "hi");
});

References#