Testing TypeScript React Code with Vitest

Learn to structure, write, and run type-safe tests for TypeScript React code using a modern runner (Vitest): test structure, assertions, setup and teardown, mocking, spies, async tests, coverage, type-safe test utilities, and TypeScript-specific type-testing techniques including @ts-expect-error and why @ts-ignore should be avoided.

Module CW gave you the vocabulary — unit, integration, E2E, mocks, stubs, spies, fixtures. This lesson makes that vocabulary concrete in TypeScript using Vitest, a fast, Vite-native test runner with first-class TypeScript support and a Jest-compatible API. You will also learn something TypeScript testing adds on top of plain JavaScript testing: verifying that your *types* behave correctly, not just your runtime values.

The Test Runner

A test runner is the program that discovers test files, executes them, collects results, and reports pass/fail output. Vitest is a strong default for TypeScript and React projects built with Vite because it shares Vite's configuration and transform pipeline, understands TypeScript and JSX out of the box with no separate compile step, and runs tests in parallel worker processes for speed.

Vitest's API is intentionally close to Jest's — describe, it/test, expect, beforeEach, vi for mocking — so skills transfer between the two, and most Jest-based tutorials translate almost line for line. For React component tests specifically, Vitest is paired with React Testing Library, which renders components into a simulated DOM (via jsdom or happy-dom) and lets you query and interact with them the way a user would.

A minimal project setup installs vitest, @testing-library/react, @testing-library/jest-dom (for extra DOM-oriented matchers like toBeInTheDocument), and configures a test environment of jsdom in vite.config.ts.

Test Structure

Tests are organized into files, typically named *.test.ts or *.test.tsx, colocated next to the code they test. Inside a file, describe groups related tests under a shared label, and it (or its alias test) defines an individual test case with a description of the behavior being verified and a function body that performs the check.

The classic shape of an individual test follows the Arrange-Act-Assert pattern: arrange the inputs and any test doubles, act by calling the function or interacting with the rendered component, and assert that the result matches what you expect. Keeping this structure explicit — even with blank lines separating the three parts — makes tests far easier to read months later.

Assertions

An assertion is a statement that must hold true for the test to pass; if it does not, the test fails with a message describing the mismatch. In Vitest, assertions are written with expect(actualValue).matcher(expectedValue), where the matcher — toBe, toEqual, toContain, toThrow, toHaveBeenCalledWith, and dozens more — expresses the kind of comparison being made.

toBe uses Object.is for strict equality and is correct for primitives; toEqual performs a deep, structural comparison and is correct for objects and arrays, since two distinct objects with identical contents are toBe-unequal but toEqual-equal. Choosing the wrong matcher is a common source of confusing test failures, so matching the matcher to the shape of the value being compared matters.

Setup and Teardown

Setup and teardown hooks run shared code before or after tests, so that each test starts from a known, clean state without repeating that setup code in every single test body. beforeEach runs before every test in its scope; afterEach runs after every test; beforeAll and afterAll run once for the whole file or describe block, useful for expensive setup like starting an in-memory server.

A very common and important teardown pattern in TypeScript/React testing is resetting mocks between tests with vi.clearAllMocks() or vi.restoreAllMocks() inside afterEach, so that a mock's recorded calls from one test never leak into and contaminate the next test's assertions.

Mocking, Spies, and Async Tests

Vitest's vi object is the mocking toolkit: vi.fn() creates a bare mock function you can configure with .mockReturnValue(), .mockResolvedValue(), or a custom implementation; vi.spyOn(object, 'method') wraps an existing method to record calls while optionally preserving or overriding its behavior; vi.mock('module-path') replaces an entire imported module with a mock version, which is how you isolate a component from a real API client or a third-party library.

Because so much of real React code involves asynchronous behavior — data fetching, timers, debounced input — Vitest tests can be async functions, and you await any promise-returning code under test. React Testing Library provides waitFor and findBy* queries specifically for asserting on UI that updates after an asynchronous action, such as text that appears once a fetch resolves.

For code that depends on timers (setTimeout, setInterval, debounce logic), vi.useFakeTimers() lets a test advance simulated time instantly with vi.advanceTimersByTime(ms) instead of the test actually waiting in real time, keeping the suite fast and deterministic.

Coverage

Code coverage measures what percentage of your source code's lines, branches, functions, and statements were executed while the test suite ran. Vitest generates a coverage report (via the @vitest/coverage-v8 or @vitest/coverage-istanbul provider) with a single flag: vitest run --coverage.

Coverage is a useful diagnostic for finding completely untested code, but it is not itself a measure of test quality — a test can execute every line of a function while asserting nothing meaningful about its behavior, achieving 100% coverage while catching zero bugs. Treat coverage numbers as a map of where to look for gaps, not as a target to game.

Type-Safe Test Utilities

Because the codebase is TypeScript, test helpers should be typed just as carefully as production code. A factory function that builds fixture objects should be generic enough to accept partial overrides while still returning a fully-typed object, so that a test that only cares about one field can override just that field without losing type safety on the rest.

Custom render helpers for React Testing Library — for example a renderWithProviders function that wraps a component in a theme provider and a router before rendering — should be typed to accept the same props React's own render accepts, plus any extra options your app needs, so callers get full autocomplete and type checking rather than an untyped escape hatch.

Type-Testing Strategies: @ts-expect-error and Why @ts-ignore Is Different

Testing types is a TypeScript-specific concern that has no equivalent in plain JavaScript testing: you sometimes want to assert not just that a value is correct at runtime, but that a piece of code is (or is not) assignable to a given type at compile time. Libraries frequently ship dedicated type tests — files that never run, but that the compiler type-checks — using utilities like expectTypeOf (bundled with Vitest) or the standalone tsd tool.

@ts-expect-error is a directive comment you place on the line above a statement that you expect to fail type checking. If the following line genuinely has a type error, the directive suppresses it and the file compiles cleanly. Critically, if the following line does NOT have a type error — for example because a fix or a dependency upgrade accidentally made it valid — TypeScript raises an error on the @ts-expect-error comment itself, saying the directive was unused. This makes it a live, self-checking assertion: exactly what you want when testing that your public API correctly rejects invalid usage.

@ts-ignore also suppresses the type error on the following line, but it does so unconditionally and silently: if the line stops having an error, @ts-ignore says nothing and the now-useless suppression sits there forever, hiding the fact that the code could be written correctly and type-checked normally. Because of this asymmetry, @ts-expect-error is preferred everywhere @ts-ignore might be used, and @ts-ignore is generally reserved only for rare cases (such as a genuinely broken third-party type definition) where you cannot express the expectation any other way.

• @ts-expect-error — 'the next line must fail to type-check'; errors out loud if it stops failing • @ts-ignore — 'silence any error on the next line'; says nothing if the error disappears, hiding dead suppressions • expectTypeOf(value).toEqualTypeOf<Expected>() — asserts a value's inferred type matches exactly, checked at compile time • Type tests live in normal test files but are checked by the compiler, not executed at runtime

Anatomy of a Vitest + React Testing Library test file

describe groups tests; it/test defines one case; beforeEach/afterEach handle setup/teardown. Arrange-Act-Assert is a convention, not a language feature — but following it consistently makes tests far more readable. await userEvent.click(...) is asynchronous because user-event simulates realistic event sequences (pointer down, focus, pointer up) using real timers under the hood.

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ComponentUnderTest } from "./ComponentUnderTest";

describe("ComponentUnderTest", () => {
  beforeEach(() => {
    // Arrange: shared setup that runs before EVERY test in this block
  });

  afterEach(() => {
    vi.clearAllMocks(); // Teardown: never let mocks leak between tests
  });

  it("describes the expected behavior in plain language", async () => {
    // Arrange
    render(<ComponentUnderTest />);

    // Act
    await userEvent.click(screen.getByRole("button", { name: "Submit" }));

    // Assert
    await waitFor(() => {
      expect(screen.getByText("Success")).toBeInTheDocument();
    });
  });
});

Code Examples

This example demonstrates the full arc in one place: mockApi is a type-safe stand-in that satisfies the UserApi interface, so TypeScript would reject a mock missing fetchUser or with the wrong signature. The first test asserts on the synchronous loading state immediately, then uses waitFor to assert on state that only appears after the mocked promise resolves — this is what makes it an async test. afterEach(() => vi.clearAllMocks()) is the teardown hook that guarantees the second test's mock starts fresh rather than inheriting call history from the first. Expected result: Both tests pass: PASS UserProfile.test.tsx (2 tests) await waitFor(() => { expect(screen.getByRole("heading", { name: "Ada Lovelace" })).toBeInTheDocument(); });

makeUser is a type-safe test utility: Partial<User> means a caller can pass just { role: "admin" } and get back a complete, correctly-typed User, with TypeScript still catching a typo like { roll: "admin" } at compile time. This pattern scales — as User grows new required fields, every existing test using makeUser() keeps compiling and passing, because only the factory needs updating.

export function makeUser(overrides: Partial<User> = {}): User {

expectTypeOf runs entirely at compile time — it never executes any comparison at runtime, only the type checker evaluates it. The two @ts-expect-error cases assert that calling applyDiscount with the wrong argument types is a compile error; because @ts-expect-error demands that an error actually occur on the next line, these assertions are self-checking: if a future change accidentally makes the call valid, the build breaks with 'Unused @ts-expect-error directive' instead of silently letting an API regression through. @ts-ignore would not offer this protection.

// @ts-expect-error - price must be a number, not a string Common mistake: Using @ts-ignore to silence a genuine, fixable type error just to make a build pass quickly — this hides real bugs from the type checker permanently, since @ts-ignore gives no warning when the suppressed line's error condition disappears (meaning it could have been left off) or, worse, when the underlying code changes in a way that introduces a *different* error that also gets silently swallowed.

// --- UserProfile.tsx ----------------------------------------------------
import { useEffect, useState } from "react";

export interface User {
  id: string;
  name: string;
}

export interface UserApi {
  fetchUser(id: string): Promise<User>;
}

export function UserProfile({ id, api }: { id: string; api: UserApi }) {
  const [user, setUser] = useState<User | null>(null);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    let cancelled = false;
    api
      .fetchUser(id)
      .then((u) => { if (!cancelled) setUser(u); })
      .catch(() => { if (!cancelled) setError("Failed to load user"); });
    return () => { cancelled = true; };
  }, [id, api]);

  if (error) return <p role="alert">{error}</p>;
  if (!user) return <p>Loading…</p>;
  return <h1>{user.name}</h1>;
}

// --- UserProfile.test.tsx -------------------------------------------------
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { UserProfile, type UserApi } from "./UserProfile";

afterEach(() => {
  vi.clearAllMocks();
});

describe("UserProfile", () => {
  it("shows the user's name once the API
…
// --- test-utils/userFactory.ts -------------------------------------------
export interface User {
  id: string;
  name: string;
  role: "admin" | "member";
  email: string;
}

// Generic-free but fully typed: callers can override any subset of fields
// while the rest keep sensible, always-valid defaults.
export function makeUser(overrides: Partial<User> = {}): User {
  return {
    id: "u_default",
    name: "Test User",
    role: "member",
    email: "test.user@example.com",
    ...overrides,
  };
}

// --- permissions.test.ts ---------------------------------------------------
import { describe, it, expect } from "vitest";
import { canDeletePost } from "./permissions";
import { makeUser } from "./test-utils/userFactory";

describe("canDeletePost", () => {
  it("allows admins", () => {
    const admin = makeUser({ role: "admin" });
    expect(canDeletePost(admin)).toBe(true);
  });

  it("forbids members", () => {
    const member = makeUser({ role: "member" });
    expect(canDeletePost(member)).toBe(false);
  });
});
// --- discount.ts --------------------------------------------------------
export function applyDiscount(price: number, percentOff: number): number {
  return price * (1 - percentOff / 100);
}

// --- discount.type-test.ts ------------------------------------------------
// This file is checked by the TYPE CHECKER; expectTypeOf assertions never
// run at runtime — they exist purely to fail `tsc`/`vitest typecheck` if the
// function's type signature drifts from what callers rely on.
import { describe, it, expectTypeOf } from "vitest";
import { applyDiscount } from "./discount";

describe("applyDiscount types", () => {
  it("returns a number", () => {
    expectTypeOf(applyDiscount(100, 10)).toEqualTypeOf<number>();
  });

  it("rejects a string price at compile time", () => {
    // @ts-expect-error - price must be a number, not a string
    applyDiscount("100", 10);
  });

  it("rejects a missing second argument at compile time", () => {
    // @ts-expect-error - percentOff is required
    applyDiscount(100);
  });
});

// --- WHY NOT @ts-ignore -----------------------------------------------
// If applyDiscount's signature is later loosened to accept `string | number`,
// the li
…

Important Rules

Name test files *.test.ts/*.test.tsx and colocate them next to the source they test.

Follow Arrange-Act-Assert inside each test body for readability.

Reset mocks in afterEach with vi.clearAllMocks() (or vi.restoreAllMocks()) so tests stay independent.

Prefer toEqual for objects/arrays and toBe for primitives; using the wrong one produces confusing failures.

Await async actions and use waitFor/findBy* for UI that updates after a promise resolves — never assert on async UI synchronously.

Treat coverage percentage as a map of untested code, not a proxy for test quality.

Type test utilities (fixture factories, custom render helpers) as carefully as production code — Partial<T> overrides, explicit return types, no any.

Prefer @ts-expect-error over @ts-ignore for every suppression that is meant to assert 'this should fail to type-check.'

Type tests catching real API drift

Open-source TypeScript libraries commonly ship a dedicated *.test-d.ts or expectTypeOf-based suite alongside their runtime tests specifically to catch accidental breaking changes to public type signatures — for example, a maintainer widening a parameter's type to fix one caller can silently break the guarantees other callers relied on. Because a @ts-expect-error directive fails the build the moment its target line stops erroring, these type tests turn 'the type signature quietly changed' from a silent breaking release into a loud, CI-blocking build failure caught before publish.

// @ts-ignore const total = applyDiscount("100", 10);

// @ts-expect-error - price must be a number, not a string const total = applyDiscount("100", 10);

@ts-ignore suppresses the error unconditionally and gives no feedback if the error later disappears, so a signature change can silently invalidate the test's intent. @ts-expect-error fails loudly ('Unused directive') the moment the line stops being an error, keeping the assertion honest.

it("loads the user", () => { render(<UserProfile id="1" api={mockApi} />); expect(screen.getByRole("heading", { name: "Ada Lovelace" })).toBeInTheDocument(); });

it("loads the user", async () => { render(<UserProfile id="1" api={mockApi} />); await waitFor(() => { expect(screen.getByRole("heading", { name: "Ada Lovelace" })).toBeInTheDocument(); }); });

Because mockApi.fetchUser resolves asynchronously, the heading is not present on the first synchronous render — it appears after the promise resolves and React re-renders. Asserting synchronously produces a flaky or outright failing test; waitFor polls until the assertion passes or times out.

[concept] Why does Vitest suit TypeScript React projects built with Vite particularly well? A. It is the only test runner that supports JSX B. It shares Vite's config and transform pipeline and understands TypeScript/JSX natively, with a Jest-compatible API C. It replaces the need for React Testing Library D. It only works with class components

B. It shares Vite's config and transform pipeline and understands TypeScript/JSX natively, with a Jest-compatible API — Vitest reuses Vite's own build pipeline, so TypeScript and JSX are handled without a separate compile step, while its API closely mirrors Jest's, making existing testing knowledge transfer directly.

What is the key behavioral difference between @ts-expect-error and @ts-ignore? A. They behave identically; the names are just stylistic preference B. @ts-ignore only works in .tsx files, @ts-expect-error only works in .ts files C. @ts-expect-error errors out if the next line stops having a type error, while @ts-ignore silently does nothing in that case D. @ts-expect-error suppresses runtime errors, while @ts-ignore suppresses type errors

C. @ts-expect-error errors out if the next line stops having a type error, while @ts-ignore silently does nothing in that case — @ts-expect-error asserts that the following line must fail to type-check; if it no longer does, TypeScript flags the directive itself as unused. @ts-ignore silently suppresses whatever error is there (if any) and gives no signal when there stops being one, which is why it is discouraged for anything but truly unavoidable cases.

In a Vitest React Testing Library test, why must a test asserting on UI that appears after a mocked async API call be written with await waitFor(...) instead of a plain synchronous expect? A. waitFor is required syntax for all Vitest tests, synchronous or not B. Because the UI update happens after the mocked promise resolves and React re-renders, which does not happen within the same synchronous tick as the initial render C. Because React Testing Library does not support synchronous assertions at all D. waitFor is only needed when testing class components

B. Because the UI update happens after the mocked promise resolves and React re-renders, which does not happen within the same synchronous tick as the initial render — The component renders its loading state synchronously, but the update that shows fetched data only happens after the mocked promise resolves and a subsequent re-render occurs; waitFor polls the DOM until the assertion passes (or times out), matching the test to the component's real asynchronous behavior.

What does code coverage actually measure, and why is 100% coverage not the same as a well-tested codebase? A. Coverage measures how many bugs a suite has caught; 100% means zero bugs remain B. Coverage measures which lines/branches executed during the run; a test can execute code while asserting nothing meaningful, so full coverage doesn't guarantee good assertions C. Coverage measures how fast the test suite runs D. Coverage and test quality are always the same thing by definition

B. Coverage measures which lines/branches executed during the run; a test can execute code while asserting nothing meaningful, so full coverage doesn't guarantee good assertions — Coverage only tracks execution, not verification quality — a test can call every line of a function with no expect() at all and still count as covering it, so coverage percentage should be used to locate untested code, not as a proxy for how well-verified that code is.

Write a Vitest test file for a useCounter hook that exposes { count, increment, reset }. Include: a setup/teardown pair, one synchronous assertion on the initial state, one assertion after calling increment twice, and one @ts-expect-error line proving that increment takes no arguments.

import { describe, it, expect, afterEach, vi } from "vitest"; import { renderHook, act } from "@testing-library/react"; import { useCounter } from "./useCounter"; afterEach(() => { vi.clearAllMocks(); }); describe("useCounter", () => { it("starts at zero", () => { const { result } = renderHook(() => useCounter()); expect(result.current.count).toBe(0); }); it("increments twice to reach two", () => { const { result } = renderHook(() => useCounter()); act(() => { result.current.increment(); result.current.increment(); }); expect(result.current.count).toBe(2); }); it("increment takes no arguments", () => { const { result } = renderHook(() => useCounter()); // @ts-expect-error - increment takes zero arguments result.current.increment(5); }); });

Use renderHook from @testing-library/react and wrap state-changing calls in act(...). For the type-test line, call result.current.increment(5) and mark it with @ts-expect-error since increment should take zero arguments.