|$ curl https://forge-ai.dev/api/markdown?path=docs/testing/unit
$cat docs/unit-testing-(vitest/jest).md
updated Recently·35 min read·published

Unit Testing (Vitest/Jest)

TestingVitestJestBeginner to Advanced
Introduction

Unit testing verifies individual units of code — functions, methods, or components — in isolation. Vitest and Jest are the two dominant test runners in the JavaScript ecosystem, sharing a compatible API while offering different performance characteristics.

This guide covers setup, writing tests, assertions, mocking, spying, coverage, snapshot testing, and the TDD workflow using both Vitest and Jest.

Setup & Configuration

Both Vitest and Jest require minimal configuration. Vitest is designed as a drop-in replacement for Jest with native ESM support, Vite-powered transforms, and faster watch mode.

terminal
Bash
1# Vitest (recommended for new projects)
2npm install -D vitest
3
4# Jest
5npm install -D jest @types/jest ts-jest
6
7# Optional: jest-environment-jsdom for DOM tests
8npm install -D jest-environment-jsdom

Configuration files:

vitest.config.ts
TypeScript
1// vitest.config.ts
2import { defineConfig } from "vitest/config";
3
4export default defineConfig({
5 test: {
6 globals: true,
7 environment: "jsdom",
8 setupFiles: ["./src/test-setup.ts"],
9 coverage: {
10 provider: "v8",
11 reporter: ["text", "json", "html"],
12 include: ["src/**/*.{ts,tsx}"],
13 exclude: ["src/**/*.test.*", "src/**/*.spec.*"],
14 thresholds: {
15 branches: 80,
16 functions: 80,
17 lines: 80,
18 statements: 80,
19 },
20 },
21 },
22});
jest.config.js
JavaScript
1// jest.config.js
2module.exports = {
3 preset: "ts-jest",
4 testEnvironment: "jsdom",
5 setupFilesAfterSetup: ["./src/test-setup.ts"],
6 moduleNameMapper: {
7 "^@/(.*)$": "<rootDir>/src/$1",
8 },
9 collectCoverageFrom: [
10 "src/**/*.{ts,tsx}",
11 "!src/**/*.test.*",
12 "!src/**/*.spec.*",
13 ],
14 coverageThreshold: {
15 global: {
16 branches: 80,
17 functions: 80,
18 lines: 80,
19 statements: 80,
20 },
21 },
22};

info

Vitest uses Vite's transform pipeline, which means it handles TypeScript, JSX, and ESM out of the box with no additional configuration. Jest requires ts-jest or @swc/jest for TypeScript support.
Test Structure

Tests are organized using describe, it, and test blocks. describe groups related tests, while it and test are interchangeable aliases for individual test cases.

math.test.ts
TypeScript
1import { describe, it, expect } from "vitest";
2// import { describe, it, expect } from "@jest/globals";
3
4// Grouping tests with describe
5describe("MathUtils", () => {
6 // Individual test case
7 it("should add two numbers correctly", () => {
8 const result = add(2, 3);
9 expect(result).toBe(5);
10 });
11
12 // test() is an alias for it()
13 test("should subtract two numbers correctly", () => {
14 expect(subtract(10, 4)).toBe(6);
15 });
16
17 // Nested describe blocks
18 describe("multiply", () => {
19 it("should multiply positive numbers", () => {
20 expect(multiply(3, 4)).toBe(12);
21 });
22
23 it("should handle zero", () => {
24 expect(multiply(5, 0)).toBe(0);
25 });
26 });
27});

Lifecycle hooks for setup and teardown:

database.test.ts
TypeScript
1describe("Database", () => {
2 // Runs once before all tests in this describe block
3 beforeAll(async () => {
4 await db.connect();
5 });
6
7 // Runs before each test case
8 beforeEach(() => {
9 db.reset();
10 });
11
12 // Runs after each test case
13 afterEach(() => {
14 db.clearLogs();
15 });
16
17 // Runs once after all tests
18 afterAll(async () => {
19 await db.disconnect();
20 });
21
22 it("should insert a record", async () => {
23 const result = await db.insert({ name: "test" });
24 expect(result.id).toBeDefined();
25 });
26
27 it("should find a record", async () => {
28 await db.insert({ name: "test" });
29 const found = await db.find({ name: "test" });
30 expect(found).not.toBeNull();
31 });
32});

best practice

Keep tests independent. Each test should set up its own state and not depend on the execution order of other tests. Use beforeEach to reset shared state rather than beforeAll for mutable resources.
Assertions (Matchers)

Assertions use expect with matchers to verify values. Vitest and Jest share nearly identical matcher APIs.

MatcherPurposeExample
toBeStrict equality (===)expect(x).toBe(5)
toEqualDeep equalityexpect(obj).toEqual({ a: 1 })
toStrictEqualDeep equality + type checksexpect(arr).toStrictEqual([1, 2])
toContainArray/string inclusionexpect(arr).toContain(3)
toHaveLengthLength checkexpect(arr).toHaveLength(3)
toThrowError assertionexpect(fn).toThrow()
toBeTruthy/toBeFalsyTruthiness checkexpect(val).toBeTruthy()
toBeNull/toBeUndefinedNull/undefined checksexpect(val).toBeNull()
toMatchObjectPartial object matchexpect(obj).toMatchObject({ a: 1 })
toHaveBeenCalledSpy/Mock call checkexpect(spy).toHaveBeenCalled()
assertions.test.ts
TypeScript
1import { describe, it, expect } from "vitest";
2
3describe("Common Assertions", () => {
4 const user = { id: 1, name: "Alice", roles: ["admin", "editor"] };
5 const date = new Date("2026-01-15");
6
7 // Primitive equality
8 it("toBe for primitives", () => {
9 expect(2 + 2).toBe(4);
10 expect("hello").toBe("hello");
11 expect(true).toBe(true);
12 });
13
14 // Object equality (deep comparison)
15 it("toEqual for objects", () => {
16 expect(user).toEqual({ id: 1, name: "Alice", roles: ["admin", "editor"] });
17 });
18
19 // Strict equality (checks undefined properties, types)
20 it("toStrictEqual for strict checking", () => {
21 expect({ a: 1, b: undefined }).toStrictEqual({ a: 1 });
22 });
23
24 // Array/string containment
25 it("toContain for inclusion", () => {
26 expect(user.roles).toContain("admin");
27 expect("Hello, World!").toContain("World");
28 });
29
30 // Length checks
31 it("toHaveLength", () => {
32 expect([1, 2, 3]).toHaveLength(3);
33 expect("abc").toHaveLength(3);
34 });
35
36 // Error throwing
37 it("toThrow for errors", () => {
38 expect(() => JSON.parse("invalid")).toThrow();
39 expect(() => { throw new Error("fail"); }).toThrow("fail");
40 });
41
42 // Negation using .not
43 it("not modifier", () => {
44 expect(2 + 2).not.toBe(5);
45 expect([1, 2, 3]).not.toContain(4);
46 });
47
48 // Partial object matching
49 it("toMatchObject", () => {
50 expect(user).toMatchObject({ name: "Alice" });
51 });
52
53 // Number comparisons
54 it("number matchers", () => {
55 expect(0.1 + 0.2).toBeCloseTo(0.3);
56 expect(10).toBeGreaterThan(5);
57 expect(3).toBeLessThanOrEqual(3);
58 });
59
60 // Type checks
61 it("type matchers", () => {
62 expect(date).toBeInstanceOf(Date);
63 expect([].length).toBeDefined();
64 expect(undefined).toBeUndefined();
65 });
66});

info

Use toBe for primitives (numbers, strings, booleans) and toEqual or toStrictEqual for objects and arrays. toBe uses Object.is (like ===), while toEqual performs recursive deep comparison.
Mocks & Spies

Mocks replace real implementations with controlled test doubles. Spies wrap existing functions to track calls without replacing the implementation. Vitest and Jest provide equivalent APIs.

Creating Mocks

mocks.test.ts
TypeScript
1import { vi, describe, it, expect } from "vitest";
2// import { jest, describe, it, expect } from "@jest/globals";
3
4// Mock a module
5vi.mock("../services/api");
6// jest.mock("../services/api");
7
8// Mock a default export
9vi.mock("../utils/logger", () => ({
10 default: {
11 info: vi.fn(),
12 error: vi.fn(),
13 warn: vi.fn(),
14 },
15}));
16
17// Create a standalone mock function
18const mockFn = vi.fn();
19// const mockFn = jest.fn();
20
21mockFn("hello", 42);
22mockFn({ key: "value" });
23
24expect(mockFn).toHaveBeenCalledTimes(2);
25expect(mockFn).toHaveBeenCalledWith("hello", 42);
26expect(mockFn).toHaveBeenLastCalledWith({ key: "value" });
27expect(mockFn.mock.calls[0][0]).toBe("hello");
28
29// Mock return values
30const compute = vi.fn();
31compute.mockReturnValue(42);
32compute.mockReturnValueOnce(100);
33compute.mockReturnValueOnce(200);
34
35expect(compute()).toBe(100); // first call
36expect(compute()).toBe(200); // second call
37expect(compute()).toBe(42); // fallback
38
39// Mock implementations
40const filter = vi.fn((items: number[]) =>
41 items.filter((n) => n > 10)
42);
43expect(filter([5, 15, 8, 20])).toEqual([15, 20]);
44
45// Resolving promises
46const fetchData = vi.fn().mockResolvedValue({ id: 1, name: "test" });
47const result = await fetchData();
48expect(result).toEqual({ id: 1, name: "test" });

Spies

spies.test.ts
TypeScript
1import { vi, describe, it, expect } from "vitest";
2
3// Spy on an existing object method
4const calculator = {
5 add(a: number, b: number) {
6 return a + b;
7 },
8 subtract(a: number, b: number) {
9 return a - b;
10 },
11};
12
13const addSpy = vi.spyOn(calculator, "add");
14// jest.spyOn(calculator, "add");
15
16const result = calculator.add(3, 4);
17
18expect(addSpy).toHaveBeenCalled();
19expect(addSpy).toHaveBeenCalledWith(3, 4);
20expect(result).toBe(7);
21
22// Restore original after testing
23addSpy.mockRestore();
24
25// Spy with mock implementation
26const subSpy = vi.spyOn(calculator, "subtract").mockImplementation(
27 (a, b) => b - a // intentionally reverse
28);
29
30expect(calculator.subtract(3, 7)).toBe(4); // 7 - 3, not 3 - 7
31subSpy.mockRestore();
32
33// Spy on a class constructor
34class UserService {
35 async getUsers() {
36 return fetch("/api/users").then((r) => r.json());
37 }
38}
39
40const getUsersSpy = vi
41 .spyOn(UserService.prototype, "getUsers")
42 .mockResolvedValue([{ id: 1, name: "Alice" }]);
43
44const service = new UserService();
45const users = await service.getUsers();
46expect(users).toHaveLength(1);
47expect(users[0].name).toBe("Alice");

warning

Always call mockRestore() or use vi.restoreAllMocks() in afterEach to prevent leaked mocks from affecting other tests. Mock leaks are one of the most common sources of flaky tests.
Code Coverage

Code coverage measures which parts of your codebase are executed during testing. The two primary coverage providers are v8 (built into Node.js) and istanbul (instrumentation-based).

MetricDefinitionTarget
LinesPercentage of executable lines hit80%+
BranchesPercentage of control flow branches (if/else, switch)80%+
FunctionsPercentage of functions/methods called80%+
StatementsPercentage of statements executed80%+
terminal
Bash
1# Vitest coverage
2npx vitest run --coverage
3
4# Jest coverage
5npx jest --coverage
6
7# View HTML report
8open coverage/index.html
9
10# CI mode (fail if thresholds not met)
11npx vitest run --coverage --coverage.thresholds.lines=80

best practice

Coverage is a metric, not a goal. 100% coverage does not mean bug-free code. Focus on testing behavior and edge cases, not on chasing green squares. Use coverage to find untested code paths, not as a performance target.
discount.test.ts
TypeScript
1// Example: code paths that need coverage
2export function calculateDiscount(
3 price: number,
4 userType: "standard" | "premium" | "vip"
5): number {
6 if (price <= 0) {
7 throw new Error("Price must be positive");
8 }
9
10 let discount = 0;
11
12 if (userType === "premium") {
13 discount = 0.1;
14 } else if (userType === "vip") {
15 discount = 0.2;
16 }
17
18 // Edge case: max discount cap
19 const finalDiscount = Math.min(discount, 0.25);
20 return price * (1 - finalDiscount);
21}
22
23// Test covering all branches
24describe("calculateDiscount", () => {
25 it("throws for invalid price", () => {
26 expect(() => calculateDiscount(-1, "standard")).toThrow();
27 });
28
29 it("applies no discount for standard users", () => {
30 expect(calculateDiscount(100, "standard")).toBe(100);
31 });
32
33 it("applies 10% for premium users", () => {
34 expect(calculateDiscount(100, "premium")).toBe(90);
35 });
36
37 it("applies 20% for vip users", () => {
38 expect(calculateDiscount(100, "vip")).toBe(80);
39 });
40
41 it("caps discount at 25%", () => {
42 // If future logic adds a 50% tier, it gets capped
43 expect(calculateDiscount(100, "vip")).toBeGreaterThanOrEqual(75);
44 });
45});
Snapshot Testing

Snapshot tests capture the output of a function or component and compare it against a stored reference file. They are useful for detecting unexpected changes in output structure.

snapshot.test.ts
TypeScript
1import { describe, it, expect } from "vitest";
2
3// Snapshot test for a configuration object
4describe("appConfig", () => {
5 it("should match the production config snapshot", () => {
6 const config = {
7 apiUrl: "https://api.example.com",
8 timeout: 5000,
9 retries: 3,
10 features: {
11 darkMode: true,
12 analytics: false,
13 experimental: false,
14 },
15 logging: {
16 level: "info",
17 format: "json",
18 },
19 };
20
21 expect(config).toMatchSnapshot();
22 });
23});
24
25// Inline snapshots (snapshot stored in the test file)
26describe("formatUser", () => {
27 function formatUser(user: { id: number; name: string }) {
28 return `User(${user.id}): ${user.name}`;
29 }
30
31 it("should format user string", () => {
32 expect(formatUser({ id: 1, name: "Alice" })).toMatchInlineSnapshot(
33 '"User(1): Alice"'
34 );
35 });
36});
37
38// Updating snapshots
39// npx vitest --update
40// npx jest --updateSnapshot

warning

Snapshots can produce false positives when large objects change incidentally. Review snapshot diffs carefully in PRs. For React components, prefer specific assertions over snapshots — they break less often and provide clearer intent.
TDD Cycle

Test-Driven Development (TDD) follows the Red-Green-Refactor cycle: write a failing test first, then implement the minimum code to pass it, then clean up.

fizzbuzz.test.ts
TypeScript
1// Step 1: RED — Write a failing test
2
3describe("FizzBuzz", () => {
4 it("should return 'Fizz' for multiples of 3", () => {
5 expect(fizzBuzz(3)).toBe("Fizz");
6 expect(fizzBuzz(6)).toBe("Fizz");
7 expect(fizzBuzz(9)).toBe("Fizz");
8 });
9
10 it("should return 'Buzz' for multiples of 5", () => {
11 expect(fizzBuzz(5)).toBe("Buzz");
12 expect(fizzBuzz(10)).toBe("Buzz");
13 });
14
15 it("should return 'FizzBuzz' for multiples of 3 and 5", () => {
16 expect(fizzBuzz(15)).toBe("FizzBuzz");
17 expect(fizzBuzz(30)).toBe("FizzBuzz");
18 });
19
20 it("should return the number as string otherwise", () => {
21 expect(fizzBuzz(1)).toBe("1");
22 expect(fizzBuzz(2)).toBe("2");
23 expect(fizzBuzz(7)).toBe("7");
24 });
25});
26
27// Step 2: GREEN — Write minimal implementation to pass
28
29function fizzBuzz(n: number): string {
30 if (n % 15 === 0) return "FizzBuzz";
31 if (n % 3 === 0) return "Fizz";
32 if (n % 5 === 0) return "Buzz";
33 return String(n);
34}
35
36// Step 3: REFACTOR — Clean up without changing behavior
37// (tests confirm refactoring didn't break anything)

best practice

The TDD cycle enforces design by forcing you to think about the API before the implementation. Benefits include: near-100% test coverage by default, simpler interfaces, and living documentation. Run tests in watch mode (vitest --watch) for instant feedback.
CLI & CI Usage

Common command-line patterns for running tests in development and CI.

terminal
Bash
1# Run all tests once
2npx vitest run
3npx jest
4
5# Watch mode (development)
6npx vitest
7npx jest --watch
8
9# Run specific test file
10npx vitest run src/utils/__tests__/math.test.ts
11
12# Run tests matching a pattern
13npx vitest run -t "should add"
14npx jest -t "should add"
15
16# Run tests in a specific directory
17npx vitest run src/utils
18
19# CI mode with coverage
20npx vitest run --coverage --reporter=junit
21npx jest --ci --coverage --reporters=jest-junit
22
23# Update snapshots
24npx vitest run --update
25npx jest --updateSnapshot

info

Use vitest --reporter=verbose for detailed output during debugging, and --reporter=junit for CI pipeline integration with tools like GitHub Actions, GitLab CI, or Jenkins.
$Blueprint — Engineering Documentation·Section ID: UT-01·Revision: 1.0