← Back to DevBytes

Vitest from Beginner to Expert: A Learning Path

Vitest from Beginner to Expert: A Learning Path

Testing is one of those practices that separates hobby projects from production-grade software. If you have ever worked in a JavaScript or TypeScript codebase, you have probably encountered Jest — the long-reigning champion of frontend testing. But as the ecosystem shifted toward Vite, ES modules, and modern tooling, a new contender emerged: Vitest. This tutorial walks you from absolute beginner to expert, covering what Vitest is, why it matters, how to use it effectively, and the best practices that will keep your test suite fast, maintainable, and trustworthy.

What Is Vitest?

Vitest is a blazing-fast unit testing framework built and powered by Vite. It is designed to be a drop-in replacement for Jest in many cases, while natively supporting ES modules, TypeScript, JSX, and the entire Vite plugin ecosystem. Because it reuses Vite's transform pipeline, your tests run in the same environment your application runs in — no duplicate configuration, no mismatched behavior between dev and test.

In short, Vitest gives you:

Why Vitest Matters

Modern frontend development has moved on from CommonJS and Webpack-centric tooling. Jest, while mature, was built for a different era. Configuring Jest to handle ESM, TypeScript paths, and Vite-specific transforms often feels like fighting the tool. Vitest eliminates that friction by sharing your existing vite.config.ts. If your app builds, your tests run.

The performance story is equally compelling. Vitest's watch mode leverages Vite's dependency graph to rerun only the tests impacted by a file change. In large codebases, this can mean the difference between a 30-second feedback loop and a 300-millisecond one. Faster feedback means more testing, and more testing means fewer bugs in production.

Getting Started: Installation and Setup

Let us begin with a fresh project. If you already have a Vite project, you can skip ahead — Vitest integrates seamlessly into existing setups.

Installing Vitest

Install Vitest as a development dependency. If you are not already using Vite, Vitest will install it for you as a peer dependency.

npm install -D vitest

Add a test script to your package.json:

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

The vitest command starts in watch mode by default. Use vitest run in CI environments where you want a single pass and an exit code.

Your First Test

Create a simple function to test. In src/math.ts:

export function add(a: number, b: number): number {
  return a + b;
}

export function divide(a: number, b: number): number {
  if (b === 0) {
    throw new Error('Cannot divide by zero');
  }
  return a / b;
}

Now create src/math.test.ts:

import { describe, it, expect } from 'vitest';
import { add, divide } from './math';

describe('add', () => {
  it('sums two positive numbers', () => {
    expect(add(2, 3)).toBe(5);
  });

  it('handles negative numbers', () => {
    expect(add(-1, -4)).toBe(-5);
  });
});

describe('divide', () => {
  it('divides correctly', () => {
    expect(divide(10, 2)).toBe(5);
  });

  it('throws when dividing by zero', () => {
    expect(() => divide(1, 0)).toThrow('Cannot divide by zero');
  });
});

Run your tests:

npm test

You should see green checkmarks. Congratulations — you have written your first Vitest test suite.

Core Concepts: Describe, It, and Expect

Vitest follows the familiar BDD-style structure popularized by Jest and Mocha. Understanding these three primitives is essential.

Common Matchers

Vitest ships with a rich set of matchers. Here are the ones you will use most often:

import { describe, it, expect } from 'vitest';

describe('common matchers', () => {
  it('equality checks', () => {
    expect(1 + 1).toBe(2);
    expect({ name: 'Ada' }).toEqual({ name: 'Ada' });
    expect([1, 2, 3]).toContain(2);
  });

  it('truthiness checks', () => {
    expect(true).toBeTruthy();
    expect(null).toBeNull();
    expect(undefined).toBeUndefined();
    expect(0).toBeFalsy();
  });

  it('numeric checks', () => {
    expect(10).toBeGreaterThan(5);
    expect(5).toBeLessThanOrEqual(5);
    expect(0.1 + 0.2).toBeCloseTo(0.3);
  });

  it('string checks', () => {
    expect('hello world').toMatch(/world/);
    expect('hello world').toContain('world');
  });

  it('array and object checks', () => {
    expect([1, 2, 3]).toHaveLength(3);
    expect({ a: 1, b: 2 }).toHaveProperty('a');
    expect({ a: 1, b: 2 }).toHaveProperty('a', 1);
  });
});

Organizing Tests with Describe Blocks

Nesting describe blocks lets you build a readable hierarchy. This is especially useful for testing classes or modules with multiple methods.

import { describe, it, expect } from 'vitest';
import { Stack } from './stack';

describe('Stack', () => {
  describe('push', () => {
    it('adds an item to the top', () => {
      const stack = new Stack();
      stack.push(1);
      expect(stack.size()).toBe(1);
      expect(stack.peek()).toBe(1);
    });
  });

  describe('pop', () => {
    it('removes and returns the top item', () => {
      const stack = new Stack();
      stack.push(1);
      stack.push(2);
      expect(stack.pop()).toBe(2);
      expect(stack.size()).toBe(1);
    });

    it('throws when the stack is empty', () => {
      const stack = new Stack();
      expect(() => stack.pop()).toThrow('Stack is empty');
    });
  });
});

Setup and Teardown Hooks

When tests share setup logic, hooks keep your code DRY. Vitest provides four hooks that mirror Jest's API exactly.

import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll } from 'vitest';
import { Database } from './database';

describe('Database', () => {
  let db: Database;

  beforeAll(async () => {
    // Runs once for the entire describe block
    await Database.migrate();
  });

  afterAll(async () => {
    await Database.drop();
  });

  beforeEach(() => {
    // Fresh instance for every test
    db = new Database();
    db.connect();
  });

  afterEach(() => {
    db.disconnect();
  });

  it('inserts a record', () => {
    db.insert({ id: 1, name: 'Ada' });
    expect(db.count()).toBe(1);
  });

  it('starts empty for each test', () => {
    expect(db.count()).toBe(0);
  });
});

Mocking: Functions, Modules, and Timers

Mocking is where many developers stumble. Vitest provides a powerful vi object that handles all mocking needs.

Mocking Functions

Use vi.fn() to create a spy function you can assert against.

import { describe, it, expect, vi } from 'vitest';

describe('forEach', () => {
  it('calls the callback for each item', () => {
    const callback = vi.fn();
    [1, 2, 3].forEach(callback);

    expect(callback).toHaveBeenCalledTimes(3);
    expect(callback).toHaveBeenCalledWith(1, 0, [1, 2, 3]);
    expect(callback).toHaveBeenLastCalledWith(3, 2, [1, 2, 3]);
  });

  it('can return a mocked value', () => {
    const mockGetUser = vi.fn().mockReturnValue({ id: 1, name: 'Ada' });
    expect(mockGetUser()).toEqual({ id: 1, name: 'Ada' });
  });

  it('can use mockImplementation', () => {
    const mockAdd = vi.fn((a, number) => a + number);
    expect(mockAdd(2, 3)).toBe(5);
  });
});

Mocking Modules

When your code imports a module you want to replace, use vi.mock(). This is essential for testing code that calls external APIs or reads from a database.

// src/userService.ts
import { fetchUser } from './api';

export async function getUserName(id: number): Promise<string> {
  const user = await fetchUser(id);
  return user.name;
}
// src/userService.test.ts
import { describe, it, expect, vi } from 'vitest';
import { getUserName } from './userService';

// Must be called at the top level — Vitest hoists it automatically
vi.mock('./api', () => ({
  fetchUser: vi.fn(),
}));

import { fetchUser } from './api';

describe('getUserName', () => {
  it('returns the user name from the API', async () => {
    vi.mocked(fetchUser).mockResolvedValue({ id: 1, name: 'Ada Lovelace' });

    const name = await getUserName(1);

    expect(name).toBe('Ada Lovelace');
    expect(fetchUser).toHaveBeenCalledWith(1);
  });

  it('propagates API errors', async () => {
    vi.mocked(fetchUser).mockRejectedValue(new Error('Network error'));

    await expect(getUserName(1)).rejects.toThrow('Network error');
  });
});

Note the call to vi.mocked(). This is a type-safe helper that casts a mocked function so TypeScript recognizes mock-specific methods like mockResolvedValue.

Mocking Timers

Tests that depend on setTimeout, setInterval, or Date can be flaky and slow. Vitest lets you control time with vi.useFakeTimers().

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';

describe('debounce', () => {
  beforeEach(() => {
    vi.useFakeTimers();
  });

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

  it('calls the function after the delay', () => {
    const fn = vi.fn();
    const debounced = debounce(fn, 300);

    debounced();
    expect(fn).not.toHaveBeenCalled();

    vi.advanceTimersByTime(299);
    expect(fn).not.toHaveBeenCalled();

    vi.advanceTimersByTime(1);
    expect(fn).toHaveBeenCalledTimes(1);
  });
});

function debounce(fn: () => void, delay: number): () => void {
  let timer: ReturnType<typeof setTimeout>;
  return () => {
    clearTimeout(timer);
    timer = setTimeout(fn, delay);
  };
}

Snapshot Testing

Snapshots capture the serialized output of a value and fail on future changes. They are ideal for testing serializable outputs, rendered components, and configuration objects — but use them sparingly, as overuse leads to "just update the snapshot" workflows that defeat the purpose of testing.

import { describe, it, expect } from 'vitest';
import { formatUser } from './formatter';

describe('formatUser', () => {
  it('matches the snapshot', () => {
    const result = formatUser({
      id: 1,
      name: 'Ada Lovelace',
      email: 'ada@example.com',
      roles: ['admin', 'editor'],
    });

    expect(result).toMatchInlineSnapshot(`
      {
        "email": "ada@example.com",
        "id": 1,
        "label": "Ada Lovelace (admin, editor)",
      }
    `);
  });
});

Inline snapshots embed the expected output directly in the test file, making them easier to review in pull requests. To update snapshots, run vitest -u.

Testing Asynchronous Code

Modern JavaScript is asynchronous by nature. Vitest handles promises, async/await, and rejection assertions cleanly.

import { describe, it, expect } from 'vitest';
import { fetchPosts, fetchPost } from './blog';

describe('async tests', () => {
  it('resolves with posts', async () => {
    const posts = await fetchPosts();
    expect(posts).toHaveLength(10);
  });

  it('resolves using resolves matcher', () => {
    expect(fetchPost(1)).resolves.toHaveProperty('id', 1);
  });

  it('rejects on invalid id', async () => {
    await expect(fetchPost(-1)).rejects.toThrow('Post not found');
  });
});

Configuration: vitest.config.ts

Vitest reads from vite.config.ts by default, but you can provide a dedicated vitest.config.ts for test-specific settings. The test property is where all test configuration lives.

import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    // Run tests in a Node environment by default
    environment: 'node',

    // Use jsdom for DOM-dependent tests
    // environment: 'jsdom',

    // Glob patterns for test files
    include: ['src/**/*.{test,spec}.{js,ts}'],

    // Exclude patterns
    exclude: ['node_modules', 'dist', '**/*.e2e.ts'],

    // Enable global APIs (describe, it, expect) without imports
    globals: true,

    // Coverage configuration
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      thresholds: {
        lines: 80,
        functions: 80,
        branches: 75,
        statements: 80,
      },
    },

    // Setup files run before all tests
    setupFiles: ['./test/setup.ts'],

    // Timeout for each test in milliseconds
    testTimeout: 5000,
  },
});

Choosing a Test Environment

Vitest supports multiple environments. The default node environment is fast and suitable for pure logic. For code that touches the DOM, use jsdom or happy-dom. Install the package first:

npm install -D jsdom

You can also set the environment per-file using a docblock comment at the top of the test:

// @vitest-environment jsdom
import { describe, it, expect } from 'vitest';

describe('DOM test', () => {
  it('can create elements', () => {
    const div = document.createElement('div');
    div.textContent = 'Hello';
    document.body.appendChild(div);
    expect(document.body.textContent).toBe('Hello');
  });
});

Testing React Components

Vitest pairs beautifully with React Testing Library. Install the necessary packages:

npm install -D @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom

Create a setup file at test/setup.ts to register custom matchers:

import '@testing-library/jest-dom/vitest';
import { afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';

afterEach(() => {
  cleanup();
});

Now write a component test:

// src/components/Counter.tsx
import { useState } from 'react';

export function Counter() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <p data-testid="count">{count}</p>
      <button onClick={() => setCount(c => c + 1)}>Increment</button>
    </div>
  );
}
// src/components/Counter.test.tsx
// @vitest-environment jsdom
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Counter } from './Counter';

describe('Counter', () => {
  it('increments when clicked', async () => {
    const user = userEvent.setup();
    render(<Counter />);

    expect(screen.getByTestId('count')).toHaveTextContent('0');

    await user.click(screen.getByText('Increment'));
    await user.click(screen.getByText('Increment'));

    expect(screen.getByTestId('count')).toHaveTextContent('2');
  });
});

Testing Vue Components

For Vue, use @vue/test-utils alongside Vitest:

npm install -D @vue/test-utils jsdom
// src/components/Counter.vue
<script setup lang="ts">
import { ref } from 'vue';
const count = ref(0);
</script>

<template>
  <div>
    <p data-testid="count">{{ count }}</p>
    <button @click="count++">Increment</button>
  </div>
</template>
// src/components/Counter.test.ts
// @vitest-environment jsdom
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import Counter from './Counter.vue';

describe('Counter', () => {
  it('increments when clicked', async () => {
    const wrapper = mount(Counter);
    expect(wrapper.find('[data-testid="count"]').text()).toBe('0');

    await wrapper.find('button').trigger('click');
    await wrapper.find('button').trigger('click');

    expect(wrapper.find('[data-testid="count"]').text()).toBe('2');
  });
});

Code Coverage

Coverage tells you which lines, branches, and functions your tests exercise. Vitest supports both v8 and istanbul coverage providers. V8 is faster and requires no instrumentation, making it the recommended default.

npm install -D @vitest/coverage-v8

Run coverage with:

npm run test:coverage

The output includes a table showing per-file coverage percentages. Setting thresholds in your config ensures coverage never silently drops below an acceptable level — a valuable guardrail for teams.

The Vitest UI

Vitest ships with a beautiful browser-based UI that visualizes your test suite, shows code coverage inline, and lets you filter and debug tests interactively.

npm install -D @vitest/ui
npm run test:ui

The UI opens in your browser and updates in real time as you edit files. It is particularly helpful when onboarding new team members or debugging a failing test in a large suite.

Advanced: Custom Matchers and Extensions

As your test suite grows, you may want domain-specific assertions. Vitest lets you extend expect with custom matchers using expect.extend.

// test/matchers.ts
import { expect } from 'vitest';

expect.extend({
  toBeWithinRange(received: number, floor: number, ceiling: number) {
    const pass = received >= floor && received <= ceiling;
    if (pass) {
      return {
        message: () => `expected ${received} not to be within range ${floor} - ${ceiling}`,
        pass: true,
      };
    }
    return {
      message: () => `expected ${received} to be within range ${floor} - ${ceiling}`,
      pass: false,
    };
  },
});

interface CustomMatchers<R = unknown> {
  toBeWithinRange(floor: number, ceiling: number): R;
}

declare module 'vitest' {
  interface Assertion<T = any> extends CustomMatchers<T> {}
  interface AsymmetricMatchersContaining extends CustomMatchers {}
}

Import this file in your setup, and the matcher becomes available everywhere:

import { describe, it, expect } from 'vitest';
import './test/matchers';

describe('custom matcher', () => {
  it('checks range', () => {
    expect(5).toBeWithinRange(1, 10);
    expect(15).not.toBeWithinRange(1, 10);
  });
});

Advanced: In-Source Testing

Vitest supports a unique pattern called in-source testing, where you write tests inside the same file as your implementation. This is inspired by languages like Rust and Go, and it keeps tests close to the code they verify.

// src/utils.ts
export function clamp(value: number, min: number, max: number): number {
  return Math.min(Math.max(value, min), max);
}

// These tests only run in test mode and are stripped from production builds
if (import.meta.vitest) {
  const { describe, it, expect } = import.meta.vitest;
  describe('clamp', () => {
    it('clamps below min', () => {
      expect(clamp(-5, 0, 10)).toBe(0);
    });
    it('clamps above max', () => {
      expect(clamp(15, 0, 10)).toBe(10);
    });
    it('passes through in-range values', () => {
      expect(clamp(5, 0, 10)).toBe(5);
    });
  });
}

Enable this by setting includeSource in your Vitest config:

test: {
  includeSource: ['src/**/*.{js,ts}'],
}

Advanced: Workspace Projects

For monorepos or projects with multiple environments, Vitest Workspaces let you run different configurations under a single command. Create a vitest.workspace.ts file:

import { defineWorkspace } from 'vitest/config';

export default defineWorkspace([
  {
    extends: './vite.config.ts',
    test: {
      name: 'unit',
      environment: 'node',
      include: ['packages/**/src/**/*.test.ts'],
    },
  },
  {
    extends: './vite.config.ts',
    test: {
      name: 'components',
      environment: 'jsdom',
      include: ['packages/**/src/**/*.test.tsx'],
      setupFiles: ['./test/setup.ts'],
    },
  },
  {
    test: {
      name: 'e2e',
      include: ['tests/e2e/**/*.test.ts'],
      testTimeout: 30000,
    },
  },
]);

Each project runs independently with its own configuration, and the results are aggregated in a single report. This is the cleanest way to handle mixed environments in a large codebase.

Best Practices

1. Test Behavior, Not Implementation

Tests should verify what your code does, not how it does it. Avoid asserting on internal function calls unless those calls are part of the public contract. Implementation-coupled tests break every time you refactor, even when behavior is unchanged.

2. Keep Tests Independent

Each test should set up and tear down its own state. Never rely on test execution order. Use beforeEach and afterEach liberally to guarantee isolation. A test that passes only when run after another test is a bug waiting to surface in CI.

3. Name Tests Meaningfully

Treat test names as documentation. A good test name describes the scenario and the expected outcome. Compare it('works') with it('returns an empty array when the API responds with 404'). The latter tells a future reader exactly what the test guarantees.

4. Avoid Testing Framework Code

Do not write tests that verify Vitest, React, or your ORM behaves correctly. Test your own logic. If you find yourself mocking everything, you may be testing at the wrong level — consider an integration test instead.

5. Use Type-Safe Mocks

Always use vi.mocked() to preserve type safety on mocked functions. Untyped mocks silently accept incorrect arguments and erode the value of TypeScript in your test suite.

6. Run Tests in CI with vitest run

Watch mode hangs indefinitely waiting for file changes. In CI, always use vitest run for a single-pass execution with a proper exit code. Combine it with coverage reporting and threshold enforcement to catch regressions early.

7. Keep the Suite Fast

Slow tests kill developer productivity. If a test takes more than a second, investigate. Common culprits include real network calls, real timers, and heavy database setup. Mock external boundaries and use fake timers to keep the feedback loop tight.

8. Prefer Integration Tests for Critical Paths

Unit tests are fast and precise, but they can miss integration bugs. For critical user flows, write tests that exercise multiple modules together. The Test Pyramid still applies — lots of fast unit tests, fewer slower integration tests, and a handful of end-to-end tests.

Migration from Jest

If you are migrating from Jest, Vitest makes the process smooth. Most APIs are identical. The main changes are:

Vitest also provides a codemod and compatibility globals. If you set globals: true in your config, you can use describe, it, and expect without imports, exactly as in Jest. However, explicit imports are recommended for better IDE support and tree-shaking clarity.

Conclusion

Vitest represents the modern standard for testing in the Vite ecosystem. It combines the familiar ergonomics of Jest with the speed and developer experience of Vite, eliminating the configuration tax that plagued earlier setups. By starting with the basics — describe blocks, matchers, and hooks — and progressing through mocking, snapshots, component testing, coverage, and workspaces, you now have a complete toolkit for building a robust test suite. The real expertise, however, comes from applying these tools with discipline: testing behavior over implementation, keeping tests independent and fast, naming them clearly, and choosing the right level of testing for each scenario. Adopt Vitest incrementally, let your test suite grow alongside your confidence, and you will find that a well-tested codebase is not just safer to change — it is genuinely more enjoyable to work in.

— Ad —

Google AdSense will appear here after approval

← Back to all articles