Unit Testing (Vitest/Jest)
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.
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.
| 1 | # Vitest (recommended for new projects) |
| 2 | npm install -D vitest |
| 3 | |
| 4 | # Jest |
| 5 | npm install -D jest @types/jest ts-jest |
| 6 | |
| 7 | # Optional: jest-environment-jsdom for DOM tests |
| 8 | npm install -D jest-environment-jsdom |
Configuration files:
| 1 | // vitest.config.ts |
| 2 | import { defineConfig } from "vitest/config"; |
| 3 | |
| 4 | export 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 | }); |
| 1 | // jest.config.js |
| 2 | module.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
Tests are organized using describe, it, and test blocks. describe groups related tests, while it and test are interchangeable aliases for individual test cases.
| 1 | import { describe, it, expect } from "vitest"; |
| 2 | // import { describe, it, expect } from "@jest/globals"; |
| 3 | |
| 4 | // Grouping tests with describe |
| 5 | describe("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:
| 1 | describe("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
Assertions use expect with matchers to verify values. Vitest and Jest share nearly identical matcher APIs.
| Matcher | Purpose | Example |
|---|---|---|
| toBe | Strict equality (===) | expect(x).toBe(5) |
| toEqual | Deep equality | expect(obj).toEqual({ a: 1 }) |
| toStrictEqual | Deep equality + type checks | expect(arr).toStrictEqual([1, 2]) |
| toContain | Array/string inclusion | expect(arr).toContain(3) |
| toHaveLength | Length check | expect(arr).toHaveLength(3) |
| toThrow | Error assertion | expect(fn).toThrow() |
| toBeTruthy/toBeFalsy | Truthiness check | expect(val).toBeTruthy() |
| toBeNull/toBeUndefined | Null/undefined checks | expect(val).toBeNull() |
| toMatchObject | Partial object match | expect(obj).toMatchObject({ a: 1 }) |
| toHaveBeenCalled | Spy/Mock call check | expect(spy).toHaveBeenCalled() |
| 1 | import { describe, it, expect } from "vitest"; |
| 2 | |
| 3 | describe("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
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
| 1 | import { vi, describe, it, expect } from "vitest"; |
| 2 | // import { jest, describe, it, expect } from "@jest/globals"; |
| 3 | |
| 4 | // Mock a module |
| 5 | vi.mock("../services/api"); |
| 6 | // jest.mock("../services/api"); |
| 7 | |
| 8 | // Mock a default export |
| 9 | vi.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 |
| 18 | const mockFn = vi.fn(); |
| 19 | // const mockFn = jest.fn(); |
| 20 | |
| 21 | mockFn("hello", 42); |
| 22 | mockFn({ key: "value" }); |
| 23 | |
| 24 | expect(mockFn).toHaveBeenCalledTimes(2); |
| 25 | expect(mockFn).toHaveBeenCalledWith("hello", 42); |
| 26 | expect(mockFn).toHaveBeenLastCalledWith({ key: "value" }); |
| 27 | expect(mockFn.mock.calls[0][0]).toBe("hello"); |
| 28 | |
| 29 | // Mock return values |
| 30 | const compute = vi.fn(); |
| 31 | compute.mockReturnValue(42); |
| 32 | compute.mockReturnValueOnce(100); |
| 33 | compute.mockReturnValueOnce(200); |
| 34 | |
| 35 | expect(compute()).toBe(100); // first call |
| 36 | expect(compute()).toBe(200); // second call |
| 37 | expect(compute()).toBe(42); // fallback |
| 38 | |
| 39 | // Mock implementations |
| 40 | const filter = vi.fn((items: number[]) => |
| 41 | items.filter((n) => n > 10) |
| 42 | ); |
| 43 | expect(filter([5, 15, 8, 20])).toEqual([15, 20]); |
| 44 | |
| 45 | // Resolving promises |
| 46 | const fetchData = vi.fn().mockResolvedValue({ id: 1, name: "test" }); |
| 47 | const result = await fetchData(); |
| 48 | expect(result).toEqual({ id: 1, name: "test" }); |
Spies
| 1 | import { vi, describe, it, expect } from "vitest"; |
| 2 | |
| 3 | // Spy on an existing object method |
| 4 | const 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 | |
| 13 | const addSpy = vi.spyOn(calculator, "add"); |
| 14 | // jest.spyOn(calculator, "add"); |
| 15 | |
| 16 | const result = calculator.add(3, 4); |
| 17 | |
| 18 | expect(addSpy).toHaveBeenCalled(); |
| 19 | expect(addSpy).toHaveBeenCalledWith(3, 4); |
| 20 | expect(result).toBe(7); |
| 21 | |
| 22 | // Restore original after testing |
| 23 | addSpy.mockRestore(); |
| 24 | |
| 25 | // Spy with mock implementation |
| 26 | const subSpy = vi.spyOn(calculator, "subtract").mockImplementation( |
| 27 | (a, b) => b - a // intentionally reverse |
| 28 | ); |
| 29 | |
| 30 | expect(calculator.subtract(3, 7)).toBe(4); // 7 - 3, not 3 - 7 |
| 31 | subSpy.mockRestore(); |
| 32 | |
| 33 | // Spy on a class constructor |
| 34 | class UserService { |
| 35 | async getUsers() { |
| 36 | return fetch("/api/users").then((r) => r.json()); |
| 37 | } |
| 38 | } |
| 39 | |
| 40 | const getUsersSpy = vi |
| 41 | .spyOn(UserService.prototype, "getUsers") |
| 42 | .mockResolvedValue([{ id: 1, name: "Alice" }]); |
| 43 | |
| 44 | const service = new UserService(); |
| 45 | const users = await service.getUsers(); |
| 46 | expect(users).toHaveLength(1); |
| 47 | expect(users[0].name).toBe("Alice"); |
warning
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).
| Metric | Definition | Target |
|---|---|---|
| Lines | Percentage of executable lines hit | 80%+ |
| Branches | Percentage of control flow branches (if/else, switch) | 80%+ |
| Functions | Percentage of functions/methods called | 80%+ |
| Statements | Percentage of statements executed | 80%+ |
| 1 | # Vitest coverage |
| 2 | npx vitest run --coverage |
| 3 | |
| 4 | # Jest coverage |
| 5 | npx jest --coverage |
| 6 | |
| 7 | # View HTML report |
| 8 | open coverage/index.html |
| 9 | |
| 10 | # CI mode (fail if thresholds not met) |
| 11 | npx vitest run --coverage --coverage.thresholds.lines=80 |
best practice
| 1 | // Example: code paths that need coverage |
| 2 | export 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 |
| 24 | describe("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 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.
| 1 | import { describe, it, expect } from "vitest"; |
| 2 | |
| 3 | // Snapshot test for a configuration object |
| 4 | describe("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) |
| 26 | describe("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
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.
| 1 | // Step 1: RED — Write a failing test |
| 2 | |
| 3 | describe("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 | |
| 29 | function 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
Common command-line patterns for running tests in development and CI.
| 1 | # Run all tests once |
| 2 | npx vitest run |
| 3 | npx jest |
| 4 | |
| 5 | # Watch mode (development) |
| 6 | npx vitest |
| 7 | npx jest --watch |
| 8 | |
| 9 | # Run specific test file |
| 10 | npx vitest run src/utils/__tests__/math.test.ts |
| 11 | |
| 12 | # Run tests matching a pattern |
| 13 | npx vitest run -t "should add" |
| 14 | npx jest -t "should add" |
| 15 | |
| 16 | # Run tests in a specific directory |
| 17 | npx vitest run src/utils |
| 18 | |
| 19 | # CI mode with coverage |
| 20 | npx vitest run --coverage --reporter=junit |
| 21 | npx jest --ci --coverage --reporters=jest-junit |
| 22 | |
| 23 | # Update snapshots |
| 24 | npx vitest run --update |
| 25 | npx jest --updateSnapshot |
info