../

Testing

Unit, component and end-to-end testing for TypeScript: the four main runners, matchers, mocks and fake timers, type tests, Testing Library, MSW and Playwright. Examples use bun test (what this site runs) and Vitest side by side; the APIs are Jest-compatible, so most lines port directly.

Runners compared

RunnerTypeScriptSpeedWatchMockingDOM envCoverage
Vitestnative, via Vite transform (no type-check)fast, parallel workersdefault in a terminalvi.fn, vi.spyOn, hoisted vi.mock, timersjsdom, happy-dom, or real browser (Browser Mode)--coverage, v8 or istanbul
bun testnative TS and JSX, zero configfastest; one process--watchmock, spyOn, mock.module, timers (1.3.4+)happy-dom via --preload--coverage, built in
Jestbabel-jest (strips), ts-jest (checks), @swc/jestslowest to start--watch (git-aware)jest.fn, jest.spyOn, hoisted jest.mockjest-environment-jsdom package--coverage, babel or v8
node:testtype stripping (Node 22.18+), erasable syntax onlyfast, no transformnode --test --watchmock.fn, mock.method, mock.timers; mock.module behind a flagnone built in--experimental-test-coverage
PickWhen
VitestVite or framework apps, Browser Mode, the richest ecosystem outside Jest
bun testthe project already runs on Bun; fastest feedback loop
Jestexisting Jest codebases, React Native
node:testlibraries and CLIs that want zero dependencies

None of them type-check your tests while running. Run tsc --noEmit separately (see Testing types).

bun test
# all *.test.ts / *.spec.ts files
bun test cart --watch
# filter by path, re-run on save
bun test -t "totals"          # filter by test name
bunx vitest
# watch mode; `vitest run` for CI
node --test "**/*.test.ts"    # Node 22+ glob

Anatomy

cart.test.ts
import { beforeEach, describe, expect, it } from "bun:test";
import { Cart } from "./cart";
 
describe("Cart", () => {
  let cart: Cart;
  beforeEach(() => {
    cart = new Cart();
  });
 
  it("totals line items", () => {
    cart.add({ sku: "tea", price: 4.5, qty: 2 }); // Arrange
    const total = cart.total();                   // Act
    expect(total).toBe(9);                        // Assert
  });
 
  it.todo("applies discount codes", () => {});
});

Arrange, Act, Assert: set up state, do one thing, check the outcome. One behavior per test; name it as a sentence (it("rejects an expired token")). it and test are aliases.

Where tests live
repo/bunfig.toml              # preloads dom.ts + setup.tsplaywright.config.ts     # testDir: "tests/e2e"src/cart.tscart.test.ts         # unit test next to its codecomponents/Counter.tsxCounter.test.tsxtests/dom.ts               # preload: happy-dom globalssetup.ts             # preload: jest-dom, cleanupmatchers.d.ts        # types for those matchersmocks/server.ts        # MSW handlerse2e/sign-in.spec.ts  # Playwright, not bun test

bun test also picks up *.spec.ts, so keep Playwright's folder out of its way: run bun test src or list the folder in pathIgnorePatterns under [test] in bunfig.toml.

HookRunsTypical use
beforeAllonce before the tests in its scopestart a server, open a DB
beforeEachbefore every test in scopefresh fixtures, reset state
afterEachafter every test, even failed onescleanup, restore mocks, cleanup()
afterAllonce after the scope finishesclose connections

Hooks in a describe apply to that block only; outer beforeEach runs before inner. Hooks in a preload or setup file apply to every test file.

ModifierEffect
it.only / describe.onlyrun only these (Vitest fails on .only in CI by default)
it.skip / describe.skipskip, reported as skipped
it.todo("…")placeholder; Bun's types want a body, run with --todo
it.skipIf(cond) / it.runIf(cond)conditional skip (Vitest); Bun: test.skipIf, test.if
it.failingpasses only if the body fails (Bun, Jest)
it.concurrentrun in parallel with siblings (Vitest, Bun)
it(name, fn, 10_000)per-test timeout in ms (default 5 s; none in node:test)

Matchers

MatcherPasses when
toBe(v)Object.is(actual, v): same primitive or same reference
toEqual(v)deep equality; ignores undefined props and class identity
toStrictEqual(v)deep equality plus undefined props, class, sparse slots
toMatchObject(subset)object contains at least these props (recursively)
toContain(item)array includes item (===), or string includes substring
toContainEqual(item)array includes a deep-equal item
toHaveLength(n).length === n
toHaveProperty("a.b", v?)property path exists (and equals v)
toBeTruthy() / toBeFalsy()truthy / falsy
toBeNull() / toBeUndefined() / toBeDefined()as named
toBeGreaterThan(n) / toBeLessThanOrEqual(n)numeric comparison
toBeCloseTo(n, digits?)float equality (0.1 + 0.2)
toMatch(/re/ | "s")string matches
toThrow(msg? | /re/ | Class)wrapped function throws: expect(() => f()).toThrow()
resolves / rejectsunwrap a promise: await expect(p).rejects.toThrow("x")
toHaveBeenCalled()mock was called at least once
toHaveBeenCalledTimes(n)called exactly n times
toHaveBeenCalledWith(...args)some call had these args (deep equality)
toHaveBeenLastCalledWith(...args)the last call had these args
toHaveReturnedWith(v)some call returned v
toMatchSnapshot() / toMatchInlineSnapshot()output equals the stored snapshot
.not.…negates any matcher

Asymmetric matchers go inside toEqual, toHaveBeenCalledWith and friends:

MatcherMatches
expect.anything()anything except null and undefined
expect.any(Number)any value of that constructor or primitive
expect.objectContaining({ … })object with at least these props
expect.arrayContaining([ … ])array containing these items, any order
expect.stringContaining("s")string with substring
expect.stringMatching(/re/)string matching regex
expect.closeTo(n, digits?)number close to n
import { expect, it } from "bun:test";
 
it("creates a user", () => {
  const user = { id: 7, name: "Ada", createdAt: new Date() };
  expect(user).toEqual({
    id: expect.any(Number),
    name: "Ada",
    createdAt: expect.any(Date),
  });
  const loose: { a?: number; b: number } = {
    a: undefined,
    b: 2,
  };
  expect(loose).toEqual({ b: 2 });           // ✅
  expect(loose).not.toStrictEqual({ b: 2 }); // ✅
  expect(() => JSON.parse("{")).toThrow(SyntaxError);
});

Mocks, spies & fake timers

Toolbun testVitest
mock functionmock(impl?) or jest.fnvi.fn(impl?)
typed, no implmock<(id: number) => string>()vi.fn<(id: number) => string>()
spy on a methodspyOn(obj, "method")vi.spyOn(obj, "method")
mock a modulemock.module(path, factory)vi.mock(import(path), factory)
mock typeMock<typeof fn>Mock<typeof fn>
clear all historymock.clearAllMocks()vi.clearAllMocks()
restore all spiesmock.restore()vi.restoreAllMocks()
Per-mock methodDoes
mockReturnValue(v)always return v (…Once for the next call only)
mockResolvedValue(v)return Promise.resolve(v)
mockRejectedValue(e)return Promise.reject(e)
mockImplementation(fn)replace the body
mockClear()forget calls and results
mockReset()mockClear plus drop the implementation
mockRestore()mockReset plus put the original back (spies)
.mock.calls, .mock.resultscall arguments and return values, for manual checks
import {
  afterEach,
  expect,
  it,
  mock,
  spyOn,
} from "bun:test";
 
type Notify = (
  userId: number,
  text: string,
) => Promise<void>;
 
afterEach(() => mock.restore());
 
it("notifies once per user", async () => {
  const notify = mock<Notify>(async () => {});
  await Promise.all([notify(1, "hi"), notify(2, "hi")]);
  expect(notify).toHaveBeenCalledTimes(2);
  expect(notify).toHaveBeenCalledWith(2, "hi");
});
 
it("silences expected errors", () => {
  const err = spyOn(console, "error")
    .mockImplementation(() => {});
  console.error("boom");
  expect(err).toHaveBeenCalledWith("boom");
});

Module mocks

import { expect, it, mock } from "bun:test";
 
mock.module("./email", () => ({
  sendEmail: mock(async () => ({ id: "msg_1" })),
}));
 
it("sends a welcome email", async () => {
  const { signUp } = await import("./signup");
  const { sendEmail } = await import("./email");
  await signUp("ada@example.com");
  expect(sendEmail).toHaveBeenCalledTimes(1);
});

mock.module is not hoisted: it rewrites live bindings of modules already loaded, but their top-level side effects have run. Put such mocks in a --preload file. mock.restore() does not undo mock.module. In Vitest, vi.mock(import("…")) type-checks the factory against the real module, and importOriginal lets you keep the rest of it.

Fake timers

import { afterEach, expect, it, jest, mock } from "bun:test";
 
afterEach(() => jest.useRealTimers());
 
function debounce(fn: () => void, ms: number) {
  let t: ReturnType<typeof setTimeout> | undefined;
  return () => {
    clearTimeout(t);
    t = setTimeout(fn, ms);
  };
}
 
it("debounces", () => {
  jest.useFakeTimers({ now: new Date("2026-01-01") });
  const fn = mock(() => {});
  const run = debounce(fn, 300);
  run(); run(); run();
  jest.advanceTimersByTime(299);
  expect(fn).not.toHaveBeenCalled();
  jest.advanceTimersByTime(1);
  expect(fn).toHaveBeenCalledTimes(1);
  expect(Date.now()).toBe(Date.parse("2026-01-01") + 300);
});
Actionbun test (jest.*)Vitest (vi.*)
enable / disableuseFakeTimers(), useRealTimers()same names
advanceadvanceTimersByTime(ms)same, plus advanceTimersByTimeAsync
run everythingrunAllTimers(), runOnlyPendingTimers()same, plus …Async
next timeradvanceTimersToNextTimer()same
set the clocksetSystemTime(date)vi.setSystemTime(date)

Always restore real timers in afterEach, or later tests hang. Vitest 5 also fakes Temporal.Now.

Async tests

import { expect, it } from "bun:test";
 
async function load(id: number) {
  if (id < 0) throw new RangeError("bad id");
  return { id };
}
 
it("awaits", async () => {
  expect(await load(1)).toEqual({ id: 1 });
});
 
it("unwraps with resolves/rejects", async () => {
  await expect(load(1)).resolves.toEqual({ id: 1 });
  await expect(load(-1)).rejects.toThrow(RangeError);
});
 
it("counts assertions in callbacks", async () => {
  expect.assertions(1);
  await load(-1).catch((e) => {
    expect(e).toBeInstanceOf(RangeError);
  });
});
MistakeSymptomFix
expect(p).rejects… without awaitpasses even when it shouldn'tawait or return it
forgetting async on the testassertions run after it endsmake the callback async
real setTimeout in the code under testslow or flaky testsfake timers or inject a clock
unhandled rejection in a helpererror blamed on another testawait everything you start

Promises plus timers

With fake timers, a timer callback that awaits something needs the microtask queue flushed between steps. Vitest's …Async variants do that:

import { expect, it, vi } from "vitest";
 
async function retry<T>(fn: () => Promise<T>, tries = 3) {
  for (let i = 0; ; i++) {
    try {
      return await fn();
    } catch (e) {
      if (i >= tries - 1) throw e;
      await new Promise((r) => setTimeout(r, 1000 * 2 ** i));
    }
  }
}
 
it("backs off between attempts", async () => {
  vi.useFakeTimers();
  const fn = vi.fn<() => Promise<string>>()
    .mockRejectedValueOnce(new Error("503"))
    .mockResolvedValueOnce("ok");
  const p = retry(fn);
  await vi.advanceTimersByTimeAsync(1000);
  await expect(p).resolves.toBe("ok");
  expect(fn).toHaveBeenCalledTimes(2);
  vi.useRealTimers();
});

Bun has no …Async timer helpers; inject the delay (sleep: (ms: number) => Promise<void>) and pass async () => {} in the test instead.

Table-driven tests & factories

import { describe, expect, test } from "bun:test";
 
const slugify = (s: string) =>
  s.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-")
    .replace(/^-|-$/g, "");
 
describe("slugify", () => {
  test.each<[input: string, expected: string]>([
    ["Hello World", "hello-world"],
    ["  trim me  ", "trim-me"],
    ["a--b__c", "a-b-c"],
    ["", ""],
  ])("slugify(%p) -> %p", (input, expected) => {
    expect(slugify(input)).toBe(expected);
  });
});
PlaceholderPrints
%s / %d / %istring / number / integer
%p / %j / %opretty-format / JSON / object
%#row index
$nameproperty of an object row (Vitest, Jest)

Vitest also has test.for(rows), which doesn't spread array rows and passes the test context as a second argument (needed for expect in concurrent tests).

Test data builders

factories.ts
type User = {
  id: number;
  name: string;
  email: string;
  role: "admin" | "member";
};
 
let seq = 0;
export function buildUser(
  overrides: Partial<User> = {},
): User {
  seq += 1;
  return {
    id: seq,
    name: `User ${seq}`,
    email: `user${seq}@example.com`,
    role: "member",
    ...overrides,
  };
}
 
const admin = buildUser({ role: "admin" });
// only what matters

Builders keep each test focused on the fields it cares about, and a new required field is added in one place. Unique values (seq) avoid accidental collisions in maps and databases.

Testing types

Type-level behavior (generics, overloads, inference) needs its own assertions; nothing fails at runtime when a type widens to any.

import { expectTypeOf, test } from "bun:test"; // or "vitest"
 
declare function parse<T extends "int" | "bool">(
  kind: T,
  s: string,
): T extends "int" ? number : boolean;
 
test("parse return types", () => {
  expectTypeOf(parse("int", "1")).toEqualTypeOf<number>();
  expectTypeOf(parse("bool", "1")).toEqualTypeOf<boolean>();
  expectTypeOf(parse).parameter(1).toBeString();
  // @ts-expect-error: "date" is not a valid kind
  parse("date", "2026-01-01");
});
ToolHow it runs
expectTypeOf (Vitest, Bun)checked by the compiler; no-op at runtime
vitest --typecheckruns tsc over *.test-d.ts and reports failures as tests
// @ts-expect-errorline must error, else tsc fails: tests "this must not compile"
tsd / expectType<T>(v)separate CLI for .test-d.ts files; common in libraries
tsc --noEmit in CIthe baseline: catches every type error the runners ignore

A zero-dependency equality check, from the type-challenges project:

type Equal<X, Y> =
  (<T>() => T extends X ? 1 : 2) extends
  (<T>() => T extends Y ? 1 : 2) ? true : false;
type Expect<T extends true> = T;
 
type Cases = [
  Expect<Equal<ReturnType<typeof JSON.parse>, any>>,
  // @ts-expect-error: string is not number
  Expect<Equal<string, number>>,
];

With bun test, expectTypeOf does nothing unless tsc runs, so keep tsc --noEmit in CI (this site: bun run typecheck). See Fundamentals.

Testing React

Testing Library renders components into a DOM (happy-dom or jsdom) and queries them the way a user would: by role, label and text.

PriorityQueryUse for
1getByRolealmost everything: getByRole("button", { name: /save/i })
2getByLabelTextform fields
3getByPlaceholderTextinputs with no label (fix the label instead)
4getByTextnon-interactive text
5getByDisplayValuecurrent value of a filled field
6getByAltTextimg, area, input type="image"
7getByTitletitle attribute; unreliable for screen readers
8getByTestIdlast resort: data-testid
Variant0 matches1 match2+ matchesAsync
getBy…throwselementthrowsno
queryBy…nullelementthrowsno: assert absence
findBy…rejectselementrejectsretries, 1000 ms default
…AllBy…throws / []arrayarrayas above
counter.test.tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { expect, it } from "bun:test";
import { Counter } from "./Counter";
 
it("increments and saves", async () => {
  const user = userEvent.setup();
  render(<Counter initial={1} />);
 
  await user.click(
    screen.getByRole("button", { name: "+1" }),
  );
  expect(screen.getByRole("status")).toHaveTextContent("2");
 
  const save = screen.getByRole("button", { name: /save/i });
  await user.click(save);
  expect(
    await screen.findByText("Saved"),
  ).toBeInTheDocument();
  expect(
    screen.queryByRole("alert"),
  ).not.toBeInTheDocument();
});
HelperWhy
userEvent.setup() then await user.…real event sequences (focus, keydown, input, click)
fireEvent.click(el)one synthetic event; fine for simple cases
await screen.findBy…wait for something to appear
await waitFor(() => expect(…))retry an assertion until it passes
renderHook(() => useThing())test a custom hook via result.current
screen.debug()print the current DOM

jest-dom adds DOM matchers: toBeInTheDocument, toBeVisible, toBeDisabled, toHaveTextContent, toHaveValue, toBeChecked, toHaveAttribute, toHaveClass, toHaveAccessibleName.

Setup with bun test + happy-dom

bunfig.toml
[test]
preload = ["./tests/dom.ts", "./tests/setup.ts"]
tests/dom.ts
import {
  GlobalRegistrator,
} from "@happy-dom/global-registrator";
 
// must run before anything imports @testing-library/*
GlobalRegistrator.register({
  url: "http://localhost:3000/",
});
tests/setup.ts
import * as matchers
  from "@testing-library/jest-dom/matchers";
import { cleanup } from "@testing-library/react";
import { afterEach, expect } from "bun:test";
 
expect.extend(matchers);
afterEach(() => cleanup());
tests/matchers.d.ts
import type {
  TestingLibraryMatchers,
} from "@testing-library/jest-dom/matchers";
import type { expect } from "bun:test";
 
type Any = typeof expect.stringContaining;
declare module "bun:test" {
  interface Matchers<T>
    extends TestingLibraryMatchers<Any, T> {}
}

In Vitest: test: { environment: "happy-dom", setupFiles: ["./setup.ts"] } and import "@testing-library/jest-dom/vitest". More on components in TypeScript + React.

Mocking the network

MSW (Mock Service Worker) intercepts real fetch calls at the network layer, so the code under test is unchanged. The same handlers work in Node tests, the browser and Storybook.

mocks/server.ts
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
 
const API = "https://api.example.com";
 
export const handlers = [
  http.get(`${API}/users/:id`, ({ params }) =>
    HttpResponse.json({
      id: Number(params.id),
      name: "Ada",
    }),
  ),
  http.post(`${API}/users`, async ({ request }) => {
    const body = (await request.json()) as { name: string };
    const user = { id: 2, ...body };
    return HttpResponse.json(user, { status: 201 });
  }),
];
 
export const server = setupServer(...handlers);
users.test.ts
import {
  afterAll, afterEach, beforeAll, expect, it,
} from "bun:test";
import { http, HttpResponse } from "msw";
import { server } from "./mocks/server";
import { getUser } from "./users";
 
beforeAll(() =>
  server.listen({ onUnhandledRequest: "error" }),
);
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
 
it("surfaces a 500", async () => {
  server.use(
    http.get("https://api.example.com/users/:id", () =>
      new HttpResponse(null, { status: 500 }),
    ),
  );
  await expect(getUser(1)).rejects.toThrow("HTTP 500");
});
MSW v2 APIDoes
http.get/post/put/delete/allmatch method and URL (:param, * wildcards)
HttpResponse.json(body, init?)JSON response
HttpResponse.text / .htmlother body types
HttpResponse.error()simulate a network failure (TypeError in fetch)
server.use(...handlers)per-test override, removed by resetHandlers()
onUnhandledRequest: "error"fail on any request you didn't mock
setupWorker (msw/browser)same handlers in the browser via a service worker

Or inject fetch

import { expect, it, mock } from "bun:test";
 
const API = "https://api.example.com";
 
type Fetch = (input: string | URL, init?: RequestInit) =>
  Promise<Response>;
 
function makeUsers(fetchFn: Fetch = fetch) {
  return async (id: number): Promise<unknown> => {
    const res = await fetchFn(`${API}/users/${id}`);
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    return res.json();
  };
}
 
it("calls the right URL", async () => {
  const fake = mock<Fetch>(async () =>
    Response.json({ id: 1 }),
  );
  const getUser = makeUsers(fake);
  expect(await getUser(1)).toEqual({ id: 1 });
  expect(fake).toHaveBeenCalledWith(`${API}/users/1`);
});

Injection is simplest for unit tests; MSW is better for integration tests where the request goes through your real client code. See Fetch API.

E2E with Playwright

Playwright drives Chromium, Firefox and WebKit against the running app.

tests/e2e/sign-in.spec.ts
import { expect, test } from "@playwright/test";
 
test("signs in and sees the dashboard", async ({ page }) => {
  await page.goto("/login");
  await page.getByLabel("Email").fill("ada@example.com");
  await page.getByLabel("Password").fill("correct horse");
  await page
    .getByRole("button", { name: "Sign in" })
    .click();
 
  await expect(page).toHaveURL(/\/dashboard$/);
  await expect(page.getByRole("heading", { level: 1 }))
    .toHaveText("Welcome, Ada");
  await expect(page.getByRole("alert")).toHaveCount(0);
});
ConceptWhat to know
locatorsgetByRole, getByLabel, getByText, getByTestId; lazy, re-queried each use
strictnessan action on a locator matching 2+ elements throws; narrow it or use .first()
auto-waitingactions wait until the element is attached, visible, stable, enabled
web-first assertionsawait expect(locator).toBeVisible() retries until timeout (5 s)
no manual sleepsnever waitForTimeout; assert on the state you're waiting for
isolationeach test gets a fresh browser context (cookies, storage)
fixturespage, context, request, browser; add your own with test.extend
authlog in once in a setup project, reuse storageState
trace viewertrace: "on-first-retry", then bunx playwright show-trace trace.zip
UI mode / codegenplaywright test --ui, playwright codegen <url>
fixtures.ts
import { test as base, type Page } from "@playwright/test";
 
type Fixtures = { adminPage: Page };
 
export const test = base.extend<Fixtures>({
  adminPage: async ({ browser }, use) => {
    const context = await browser.newContext({
      storageState: "playwright/.auth/admin.json",
    });
    await use(await context.newPage());
    await context.close();
  },
});
export { expect } from "@playwright/test";
playwright.config.ts
import { defineConfig, devices } from "@playwright/test";
 
const CI = !!process.env.CI;
 
export default defineConfig({
  testDir: "tests/e2e",
  fullyParallel: true,
  forbidOnly: CI,
  retries: CI ? 2 : 0,
  use: {
    baseURL: "http://localhost:3000",
    trace: "on-first-retry",
  },
  projects: [
    {
      name: "chromium",
      use: { ...devices["Desktop Chrome"] },
    },
    {
      name: "webkit",
      use: { ...devices["Desktop Safari"] },
    },
  ],
  webServer: {
    command: "bun run build && bun run start",
    url: "http://localhost:3000",
    reuseExistingServer: !CI,
  },
});

Coverage & TDD

Red, green, refactor: write a failing test for the next small behavior (red), write the least code that passes (green), then clean up with the tests as a safety net (refactor). Repeat in minutes, not hours.

RunnerRunThreshold
bun testbun test --coveragecoverageThreshold = 0.8 in bunfig.toml [test]
Vitestvitest run --coveragecoverage: { thresholds: { lines: 80, branches: 80 } }
Jestjest --coveragecoverageThreshold: { global: { lines: 80 } }
node:testnode --test --experimental-test-coverage--test-coverage-lines=80

What coverage doesn't tell you:

  • a line ran, not that anything checked its result (a test with no expect still "covers")
  • whether the behavior is correct, or whether a requirement is missing entirely
  • branch coverage misses combinations of conditions and short-circuit paths
  • 100% can be gamed; aim for a floor (e.g. 80%) and review what's uncovered
  • mutation testing (Stryker) measures whether tests catch deliberately injected bugs
ModelShapeEmphasis
Test pyramidmany unit, fewer integration, few E2Efast, isolated unit tests
Testing trophystatic types and lint, some unit, most integration, few E2Etests that resemble real use, best confidence per cost

Either way: types and lint catch the cheapest bugs, E2E covers the few flows that must never break (sign-up, checkout, search), and everything else sits in between.

Recipes

Test a route without a server

When the handler is a Request to Response function (Hono, Next.js route handlers, Bun fetch): call it directly, with no port and no network.

app.test.ts
import { expect, it } from "bun:test";
import { Hono } from "hono";
 
const app = new Hono()
  .get("/health", (c) => c.json({ ok: true }))
  .post("/echo", async (c) => c.json(await c.req.json()));
 
it("GET /health", async () => {
  const res = await app.request("/health");
  expect(res.status).toBe(200);
  expect(await res.json()).toEqual({ ok: true });
});
 
it("POST /echo", async () => {
  const res = await app.request("/echo", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ n: 1 }),
  });
  expect(await res.json()).toEqual({ n: 1 });
});
// plain (req) => Response: handler(new Request("http://x/"))

Real server on a random port

When the request must go through real HTTP (middleware, headers, streaming); port: 0 lets the OS pick a free port.

server.test.ts
import { afterAll, beforeAll, expect, it } from "bun:test";
 
let server: Bun.Server<undefined>;
 
beforeAll(() => {
  server = Bun.serve({
    port: 0, // any free port: parallel runs never clash
    routes: { "/ping": new Response("pong") },
    fetch: () => new Response("Not found", { status: 404 }),
  });
});
afterAll(() => server.stop(true)); // also close keep-alives
 
it("answers over real HTTP", async () => {
  const res = await fetch(new URL("/ping", server.url));
  expect(res.status).toBe(200);
  expect(await res.text()).toBe("pong");
});

Temp directory per test

When code reads or writes files: every test gets a fresh folder, removed afterward.

report.test.ts
import { afterEach, beforeEach, expect, it } from "bun:test";
import { mkdtemp, readFile, rm, writeFile }
  from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
 
let dir: string;
beforeEach(async () => {
  dir = await mkdtemp(path.join(tmpdir(), "app-test-"));
});
afterEach(() => rm(dir, { recursive: true, force: true }));
 
async function saveReport(out: string, rows: string[]) {
  const file = path.join(out, "report.csv");
  await writeFile(file, rows.join("\n"));
}
 
it("writes report.csv", async () => {
  await saveReport(dir, ["a,1", "b,2"]);
  const file = path.join(dir, "report.csv");
  const text = await readFile(file, "utf8");
  expect(text.split("\n")).toHaveLength(2);
});

Stub environment variables

When code reads process.env and the originals must come back after each test.

config.test.ts
import { afterEach, expect, it } from "bun:test";
 
type Env = Record<string, string | undefined>;
const saved = new Map<string, string | undefined>();
 
function put(key: string, value: string | undefined) {
  if (value === undefined) delete process.env[key];
  else process.env[key] = value;
}
function setEnv(vars: Env): void {
  for (const [key, value] of Object.entries(vars)) {
    if (!saved.has(key)) saved.set(key, process.env[key]);
    put(key, value);
  }
}
afterEach(() => {
  for (const [key, value] of saved) put(key, value);
  saved.clear();
});
 
const port = () => Number(process.env.PORT ?? 3000);
it("reads PORT", () => {
  setEnv({ PORT: "8080", DEBUG: undefined });
  expect(port()).toBe(8080);
});

Vitest has this built in: vi.stubEnv(name, value) and vi.unstubAllEnvs().

Assert on an error's fields

When the type and data of a failure matter, not only its message.

users.test.ts
import { expect, it } from "bun:test";
 
class HttpError extends Error {
  override name = "HttpError";
  constructor(
    readonly status: number,
    readonly url: string,
  ) {
    super(`HTTP ${status} ${url}`);
  }
}
 
async function getUser(id: number): Promise<{ id: number }> {
  if (id <= 0) throw new HttpError(404, `/users/${id}`);
  return { id };
}
 
it("rejects with a typed 404", async () => {
  const err = await getUser(0).catch((e: unknown) => e);
  expect(err).toBeInstanceOf(HttpError);
  expect(err).toMatchObject({
    status: 404,
    url: "/users/0",
  });
});

References