Skip to main content
โšก Calmops

Unit Testing Basics โ€” Jest & Vitest Practical Guide

A practical guide to unit testing fundamentals, testing philosophy, and how to apply Jest and Vitest effectively in modern JavaScript and TypeScript projects. This post covers what unit tests are, when and what to test, framework comparisons, setup and configuration, code examples, testing patterns and anti-patterns, and how to integrate testing into your development workflow.


Table of contents

  • Introduction
  • What is unit testing?
  • Testing philosophy: why, when, and what to test
  • Unit vs integration vs E2E tests
  • Choosing a framework: Jest vs Vitest (detailed comparison)
  • Getting started: setup and configuration
    • Jest setup (Node projects & React)
    • Vitest setup (Vite projects & framework-agnostic)
  • Core testing concepts & patterns
    • Arrange-Act-Assert (AAA)
    • Test organization and naming
    • Mocking, stubbing, and spies
    • Snapshot testing
    • Parameterized tests
  • Example test suites
    • Testing plain functions (Node)
    • Testing async code (API fetch mock)
    • Testing React components (Jest + React Testing Library, Vitest + RTL)
    • Mocking in Jest and Vitest
  • Best practices for maintainable tests
  • Common anti-patterns and how to avoid them
  • CI, coverage, and performance tips
  • Sample package.json scripts and CI snippets
  • Further reading and resources

Introduction

Unit testing is a foundational engineering practice that helps ensure code correctness, enable safe refactoring, and provide living documentation. Modern JS ecosystems offer strong tooling โ€” Jest and Vitest are two popular test runners that cover most developers’ needs.

This guide focuses on core principles, practical setup, and actionable examples so you can write meaningful unit tests and integrate them into your workflows.


What is unit testing?

  • Unit test: a small, fast test that verifies a single โ€œunitโ€ of behavior โ€” typically a function, class, or small module in isolation.
  • Goals:
    • Validate logic correctness.
    • Protect against regressions.
    • Improve confidence for refactorings.
    • Document expected behavior.

Characteristics:

  • Fast to run (milliseconds per test).
  • Deterministic (same inputs produce same outputs).
  • Isolated from external systems (databases, network, filesystem) โ€” use mocks for dependencies.

Testing philosophy: why, when, and what to test

  • Why test:

    • Catch defects early.
    • Reduce manual QA time.
    • Enable safer refactoring.
    • Provide system documentation via examples.
  • When to write tests:

    • Prefer “test-driven development (TDD)” for critical business logic: write failing tests, implement code, then refactor.
    • Add tests for bug fixes to prevent regressions.
    • For libraries and shared modules, maintain high unit test coverage.
    • For UI and integration surfaces, combine unit tests with integration/E2E.
  • What to test:

    • Business logic and rules.
    • Edge cases and error handling.
    • Public API of modules (inputs โ†’ outputs).
    • Boundary conditions and input validation.
  • Don’t over-test:

    • Avoid testing trivial one-line getters/setters.
    • Prefer higher-level tests for complex integration scenarios.

Unit vs integration vs end-to-end (E2E) tests

  • Unit tests: isolated, fast, mock dependencies.
  • Integration tests: verify how several units work together; may use in-memory DB or test containers.
  • E2E tests: simulate real user interactions across full stack (e.g., Playwright/Cypress).

A healthy testing pyramid:

  • Many unit tests (fast & cheap)
  • Some integration tests (slower)
  • Few E2E tests (expensive, flaky)

Choosing a framework: Jest vs Vitest

High-level summary:

  • Jest: mature, batteries-included, excellent snapshot support, works well for Node and Create React App projects, strong ecosystem.
  • Vitest: modern, fast (leverages Vite), designed for ESM & Vite ecosystems, great for Vite-based projects and TypeScript, uses native ESM and runs tests in V8 worker threads; API is Jest-like.

Detailed comparison:

  • Performance:

    • Jest: reliable; uses JSDOM for DOM tests, can be slower in cold start; improved with worker threads and caching.
    • Vitest: very fast for Vite projects (leverages Vite dev server, esbuild for transforms), quicker hot-starts, excellent for iterative development.
  • ESM & TypeScript:

    • Jest: supports ESM and TS via transforms (ts-jest) but setup can be more complex.
    • Vitest: first-class ESM + TypeScript support out of the box with Vite.
  • DOM & React testing:

    • Both work well with React Testing Library.
    • Jest uses JSDOM; Vitest also provides a JSDOM-like environment (jsdom or happy-dom support).
  • API & ergonomics:

    • Vitest intentionally mirrors Jest’s API (describe, it, expect) and uses vi instead of jest for mocks/spies.
    • Switching between them is straightforward for most tests.
  • Mocking:

    • Jest: jest.mock, jest.fn, built-in module mocking.
    • Vitest: vi.mock, vi.fn; supports ESM-style mocking and is compatible with Vite aliasing.
  • Snapshots:

    • Jest: built-in snapshot handling and tooling.
    • Vitest: supports snapshots (via vitest’s snapshot feature); compatibility improving, but ecosystem around snapshot management is larger in Jest.
  • Plugins and ecosystem:

    • Jest: rich ecosystem (reporters, mocking add-ons).
    • Vitest: growing ecosystem; excellent integration with Vite plugins and micro-frontend patterns.
  • When to choose:

    • Use Jest if you need mature snapshot tooling, broad ecosystem, or are on non-Vite stacks.
    • Use Vitest if you’re using Vite, want very fast feedback loops, or prefer modern ESM-first tooling.

Getting started: setup and configuration

Below are minimal setup instructions for both frameworks, including TypeScript notes.

Jest setup (Node project)

  1. Install dependencies:
npm install --save-dev jest @types/jest ts-jest
# or using yarn:
# yarn add -D jest @types/jest ts-jest
  1. Initialize config (TypeScript):
npx ts-jest config:init

This creates jest.config.js (or jest.config.ts). Minimal jest.config.js:

// jest.config.js
module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'node', // or 'jsdom' for browser-like tests
  testMatch: ['**/__tests__/**/*.ts?(x)', '**/?(*.)+(spec|test).ts?(x)'],
  clearMocks: true,
  collectCoverage: true,
  collectCoverageFrom: ['src/**/*.{ts,js}', '!src/**/*.d.ts'],
};
  1. Add scripts to package.json:
{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage"
  }
}
  1. Example test:
// src/sum.ts
export function sum(a: number, b: number) {
  return a + b;
}
// src/sum.test.ts
import { sum } from './sum';

test('sum adds numbers', () => {
  expect(sum(2, 3)).toBe(5);
});

Vitest setup (Vite project or standalone)

  1. Install dependencies (Vite project):
npm install --save-dev vitest @testing-library/react @testing-library/jest-dom
# If using Vite + React + tsconfig paths: install vite and related plugins
  1. Configure vite.config.ts:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  test: {
    globals: true,         // use global test APIs (`describe`, `it`, `expect`)
    environment: 'jsdom',  // or 'node'
    coverage: { provider: 'c8' }
  }
});
  1. Add scripts:
{
  "scripts": {
    "test": "vitest",
    "test:ui": "vitest --ui",
    "test:watch": "vitest --watch",
    "test:coverage": "vitest run --coverage"
  }
}
  1. Basic test:
// src/sum.ts
export function sum(a: number, b: number) {
  return a + b;
}
// src/sum.test.ts
import { describe, it, expect } from 'vitest';
import { sum } from './sum';

describe('sum', () => {
  it('adds numbers', () => {
    expect(sum(1, 2)).toBe(3);
  });
});

Core testing concepts & patterns

Arrange-Act-Assert (AAA)

Structure each test as:

  • Arrange: set up the environment and inputs.
  • Act: invoke the function under test.
  • Assert: verify the result.

Example:

// example.test.ts
import { calculate } from './calc';

test('calculate applies discount', () => {
  // Arrange
  const price = 100;
  const discount = 0.2;

  // Act
  const result = calculate(price, discount);

  // Assert
  expect(result.total).toBe(80);
});

Test organization & naming

  • Organize tests near source files: src/module.ts โ†’ src/module.test.ts
  • Use folders for integration/e2e: tests/e2e/
  • Naming conventions:
    • should do X when Y inside it or test
    • Keep test names descriptive and assert behavior, not implementation

Mocking, stubbing, spies

  • Purpose: isolate unit from external dependencies (network, DB, timers).
  • Tools: jest.fn() / vi.fn(), jest.mock() / vi.mock().

Example mocking network request (Jest):

// fetcher.ts
export async function fetchData(api: string) {
  const res = await fetch(api);
  return res.json();
}
// fetcher.test.ts
import { fetchData } from './fetcher';

global.fetch = jest.fn(() =>
  Promise.resolve({ json: () => Promise.resolve({ ok: true }) })
) as jest.Mock;

test('fetchData parses JSON', async () => {
  const data = await fetchData('/api/test');
  expect(data).toEqual({ ok: true });
  expect(fetch).toHaveBeenCalledWith('/api/test');
});

Vitest equivalent:

global.fetch = vi.fn(() =>
  Promise.resolve({ json: () => Promise.resolve({ ok: true }) })
) as unknown as typeof fetch;

Snapshot testing

  • Useful for serializing output (React component tree, generated JSON).
  • Jest:
import { render } from '@testing-library/react';
import MyComponent from './MyComponent';

test('renders correctly', () => {
  const { container } = render(<MyComponent />);
  expect(container).toMatchSnapshot();
});
  • Vitest also supports snapshots; keep snapshot files manageable and review changes carefully.

Parameterized tests

  • Jest:
test.each([
  [1, 2, 3],
  [2, 3, 5],
])('sum(%i, %i) = %i', (a, b, expected) => {
  expect(sum(a, b)).toBe(expected);
});
  • Vitest supports it.each similarly.

Example test suites

Testing plain functions

Jest:

// src/math.ts
export function multiply(a: number, b: number) {
  if (!Number.isFinite(a) || !Number.isFinite(b)) {
    throw new Error('Invalid input');
  }
  return a * b;
}
// src/math.test.ts
import { multiply } from './math';

describe('multiply', () => {
  it('multiplies positive numbers', () => {
    expect(multiply(2, 4)).toBe(8);
  });

  it('throws on invalid input', () => {
    expect(() => multiply(NaN, 2)).toThrow('Invalid input');
  });
});

Testing async code (mocking fetch)

Vitest:

// src/user.ts
export async function getUser(id: string) {
  const res = await fetch(`/api/users/${id}`);
  if (!res.ok) throw new Error('Not found');
  return res.json();
}
// src/user.test.ts
import { vi, describe, it, expect } from 'vitest';
import { getUser } from './user';

describe('getUser', () => {
  it('returns parsed user', async () => {
    vi.stubGlobal('fetch', vi.fn(() =>
      Promise.resolve({ ok: true, json: () => Promise.resolve({ id: 'u1' }) })
    ));

    const user = await getUser('u1');
    expect(user).toEqual({ id: 'u1' });
    expect((fetch as unknown as vi.Mock)).toHaveBeenCalledWith('/api/users/u1');
  });
});

Remember to restore globals if you modify them in shared test suites.

Testing React components

  • With React Testing Library (works with both Jest and Vitest):
// src/components/Button.tsx
import React from 'react';

export default function Button({ onClick, children }: any) {
  return <button onClick={onClick}>{children}</button>;
}
// src/components/Button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import Button from './Button';

describe('Button', () => {
  it('calls onClick', () => {
    const onClick = vi.fn();
    render(<Button onClick={onClick}>Click</Button>);
    fireEvent.click(screen.getByText('Click'));
    expect(onClick).toHaveBeenCalledOnce();
  });
});

Notes:

  • Use @testing-library/react for user-centric testing (avoid implementation details).
  • Prefer queries: getByRole, getByText, over querySelector.

Mocking modules

Jest example:

// logger.ts
export const logger = {
  info: (msg: string) => console.log(msg),
};

// foo.ts
import { logger } from './logger';
export function doWork() {
  logger.info('work');
}

Test:

jest.mock('./logger', () => ({ logger: { info: jest.fn() } }));

import { doWork } from './foo';
import { logger } from './logger';

test('doWork logs', () => {
  doWork();
  expect(logger.info).toHaveBeenCalledWith('work');
});

Vitest:

vi.mock('./logger', () => ({ logger: { info: vi.fn() } }));
import { doWork } from './foo';
import { logger } from './logger';
test('doWork logs', () => {
  doWork();
  expect(logger.info).toHaveBeenCalledWith('work');
});

Best practices for maintainable tests

  • Keep tests fast and focused on behavior.
  • Test public interfaces, not private implementation details.
  • Use descriptive test names.
  • Arrange-Act-Assert structure for readability.
  • Keep test data small and explicit; prefer builders for complex objects.
  • Use test doubles (mocks/stubs) sparingly: prefer real objects in integration tests.
  • Make tests deterministic: avoid reliance on timing, randomness, or network I/O without control.
  • Isolate side effects; restore global state after tests (vi.restoreAllMocks() / jest.restoreAllMocks()).
  • Use fixtures and factories for setup; avoid large shared setup that hides intent.
  • Prefer getByRole/getByLabelText for accessibility-focused UI tests.
  • Document edge cases and rationale in test comments when non-obvious.

Common anti-patterns

  • Testing implementation details: makes tests brittle when refactoring.
  • Over-mocking: hides integration bugs; use integration tests where appropriate.
  • Large tests (asserting many unrelated behaviors): break into focused tests.
  • Flaky tests: rely on sleep/time-based assertions; use fake timers instead.
  • Snapshot overuse: snapshots without assertions become brittle; prefer targeted assertions.
  • Slow tests in unit suites: move slow tests to integration buckets to keep unit suite fast.

CI, coverage, and performance tips

  • Keep unit test runs fast in CI:
    • Use caching for dependencies and test results where supported.
    • Run parallel workers (Jest has --maxWorkers, Vitest supports parallelism).
  • Coverage:
    • Collect coverage but focus on meaningful metrics. High coverage doesn’t guarantee quality.
    • Measure coverage per critical modules and monitor trends.
  • Test flakiness:
    • Mark intermittent tests with test.skip or quarantined suites until fixed.
    • Root cause flakiness (timing, network).
  • Test partitions:
    • For monorepos or large codebases, partition tests and run affected tests only when possible (affected tests vs. full suite).
  • Use --watch or Vitest UI for quick dev feedback loops.

CI snippet (GitHub Actions):

name: CI
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [18.x]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
      - run: npm ci
      - run: npm test --if-present
      - run: npm run test:coverage --if-present
        continue-on-error: true

Sample package.json scripts

Jest:

{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watchAll",
    "test:coverage": "jest --coverage"
  }
}

Vitest:

{
  "scripts": {
    "test": "vitest",
    "test:watch": "vitest --watch",
    "test:ui": "vitest --ui",
    "test:coverage": "vitest run --coverage"
  }
}

Quick checklist before committing tests

  • Tests run locally (npm test) and pass.
  • CI passes with the same Node version as local.
  • No real network or DB calls in unit tests.
  • Global state restored after tests.
  • Coverage thresholds are reasonable (set thresholds where helpful).
  • Test names are descriptive and focused.

Further reading and resources


Conclusion

Unit tests are a vital safety net for modern JavaScript development. Choose the right tool for your stack โ€” Jest for mature, broad use, Vitest for Vite/ESM-first speedy workflows โ€” and follow testing principles: test behavior, keep tests fast, isolated, and maintainable. Integrate tests into CI pipelines, focus on meaningful coverage, and use the testing pyramid to balance unit, integration, and E2E testing.

Comments