Skip to main content

Testing

Why test?

Automated tests give you confidence to change code. Without tests, every refactor or new feature risks silently breaking existing behavior. Tests also serve as living documentation -- they show how your code is supposed to be used and what edge cases matter.

Testing pyramid

LevelWhat it testsSpeedConfidenceTools
UnitSingle function or class in isolationFastLower (isolated)Vitest
ComponentOne UI component with its template/logicMediumMediumVue Test Utils, Testing Library, TestBed
IntegrationMultiple units working togetherMediumHigherVitest + DOM environment
E2EFull app from the user's perspectiveSlowHighestPlaywright, Cypress

Write many unit tests, fewer integration tests, and a handful of e2e tests. Unit tests are cheap to write and fast to run. E2e tests are expensive but catch real user-facing bugs.

info

The "testing trophy" (Kent C. Dodds) suggests focusing more on integration tests than strict unit tests for UI code, because isolated component tests often miss the bugs that actually matter -- the ones that happen when components interact.

Vitest

Vitest is a Vite-native test runner. It reuses your Vite config (transforms, resolve aliases, plugins), so there is zero extra configuration for most projects.

Why Vitest over Jest:

  • Native ESM -- no transform hacks for import/export
  • Vite-powered -- instant HMR-like watch mode
  • Jest-compatible API -- same describe, it, expect you already know
  • TypeScript out of the box -- no ts-jest needed
  • In-source testing -- optional, tests can live inside source files

Setup

npm install -D vitest

Add a test script to package.json:

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

If you already have a vite.config.ts, Vitest picks it up automatically. You can add test-specific config under the test key:

// vite.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
test: {
globals: true, // no need to import describe/it/expect
environment: 'jsdom', // DOM environment for component tests
},
});
info

Setting globals: true makes describe, it, expect, vi available everywhere without imports. Add "types": ["vitest/globals"] to your tsconfig.json compilerOptions for TypeScript support.

Running tests

npx vitest          # watch mode (re-runs on file change)
npx vitest run # single run (CI)
npx vitest run src/utils/math.test.ts # run a specific file

Test anatomy

// math.ts
export function sum(a: number, b: number): number {
return a + b;
}
// math.test.ts
import { describe, it, expect } from 'vitest';
import { sum } from './math';

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

it('handles negative numbers', () => {
expect(sum(-1, -2)).toBe(-3);
});

it('returns 0 for zero inputs', () => {
expect(sum(0, 0)).toBe(0);
});
});

Common matchers

// Equality
expect(value).toBe(3); // strict ===
expect(obj).toEqual({ a: 1 }); // deep equality
expect(obj).toStrictEqual({ a: 1 }); // deep + type checking

// Truthiness
expect(value).toBeTruthy();
expect(value).toBeFalsy();
expect(value).toBeNull();
expect(value).toBeDefined();

// Numbers
expect(value).toBeGreaterThan(3);
expect(value).toBeCloseTo(0.3, 5); // floating point

// Strings
expect(str).toContain('hello');
expect(str).toMatch(/pattern/);

// Arrays / Objects
expect(arr).toContain(item);
expect(arr).toHaveLength(3);
expect(obj).toHaveProperty('key');

// Exceptions
expect(() => fn()).toThrow();
expect(() => fn()).toThrow('specific message');

// Functions (mocks)
expect(fn).toHaveBeenCalled();
expect(fn).toHaveBeenCalledWith(arg1, arg2);
expect(fn).toHaveBeenCalledTimes(2);

Testing pure JS/TS

Unit testing plain functions is the simplest case -- no DOM, no framework, just input and output.

Utility function

// formatCurrency.ts
export function formatCurrency(cents: number, currency = 'EUR'): string {
return new Intl.NumberFormat('en', {
style: 'currency',
currency,
}).format(cents / 100);
}
// formatCurrency.test.ts
import { describe, it, expect } from 'vitest';
import { formatCurrency } from './formatCurrency';

describe('formatCurrency', () => {
it('formats cents to EUR by default', () => {
expect(formatCurrency(1299)).toBe('€12.99');
});

it('formats zero', () => {
expect(formatCurrency(0)).toBe('€0.00');
});

it('accepts a different currency', () => {
expect(formatCurrency(500, 'USD')).toBe('$5.00');
});
});

Async functions

// api.ts
export async function fetchUser(id: number): Promise<{ name: string }> {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error('User not found');
return res.json();
}
// api.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { fetchUser } from './api';

describe('fetchUser', () => {
beforeEach(() => {
vi.restoreAllMocks();
});

it('returns user data on success', async () => {
vi.spyOn(global, 'fetch').mockResolvedValue(
new Response(JSON.stringify({ name: 'Alice' }), { status: 200 })
);

const user = await fetchUser(1);
expect(user).toEqual({ name: 'Alice' });
expect(fetch).toHaveBeenCalledWith('/api/users/1');
});

it('throws on non-ok response', async () => {
vi.spyOn(global, 'fetch').mockResolvedValue(
new Response(null, { status: 404 })
);

await expect(fetchUser(999)).rejects.toThrow('User not found');
});
});

Testing error cases

// validate.ts
export function validateAge(age: unknown): number {
if (typeof age !== 'number') throw new TypeError('Age must be a number');
if (age < 0 || age > 150) throw new RangeError('Age out of range');
return age;
}
// validate.test.ts
import { describe, it, expect } from 'vitest';
import { validateAge } from './validate';

describe('validateAge', () => {
it('returns valid age', () => {
expect(validateAge(25)).toBe(25);
});

it('throws TypeError for non-number', () => {
expect(() => validateAge('25')).toThrow(TypeError);
});

it('throws RangeError for out-of-range', () => {
expect(() => validateAge(-1)).toThrow(RangeError);
expect(() => validateAge(200)).toThrow(RangeError);
});
});

Mocking

Mocking replaces real implementations with controlled substitutes so you can test units in isolation.

vi.fn() — mock functions

const handler = vi.fn();
handler('hello');

expect(handler).toHaveBeenCalledWith('hello');
expect(handler).toHaveBeenCalledTimes(1);

// Return values
const getId = vi.fn().mockReturnValue(42);
const fetchData = vi.fn().mockResolvedValue({ ok: true });

vi.spyOn() — spy on existing methods

import * as mathModule from './math';

const spy = vi.spyOn(mathModule, 'sum');
mathModule.sum(1, 2);

expect(spy).toHaveBeenCalledWith(1, 2);
spy.mockRestore(); // restore original implementation

vi.mock() — mock entire modules

// Replace the entire module with auto-mocked version
vi.mock('./api', () => ({
fetchUser: vi.fn().mockResolvedValue({ name: 'Mock User' }),
}));

import { fetchUser } from './api'; // this is now the mock

Mocking fetch globally

beforeEach(() => {
vi.stubGlobal('fetch', vi.fn());
});

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

it('calls the API', async () => {
vi.mocked(fetch).mockResolvedValue(
new Response(JSON.stringify({ data: 'test' }))
);

// ... your test
expect(fetch).toHaveBeenCalledWith('/api/endpoint');
});

Setup and teardown

describe('with database', () => {
beforeAll(async () => {
// runs once before all tests in this describe
await seedDatabase();
});

beforeEach(() => {
// runs before each test
vi.clearAllMocks();
});

afterEach(() => {
// runs after each test
});

afterAll(async () => {
// runs once after all tests
await cleanDatabase();
});
});

Testing Vue components

Install @vue/test-utils alongside Vitest:

npm install -D @vue/test-utils @vitejs/plugin-vue jsdom

Configure the environment:

// vite.config.ts
import { defineConfig } from 'vitest/config';
import vue from '@vitejs/plugin-vue';

export default defineConfig({
plugins: [vue()],
test: {
globals: true,
environment: 'jsdom',
},
});

mount vs shallowMount

  • mount -- renders the component and all its children. Use for integration-style tests.
  • shallowMount -- renders the component but stubs all child components. Use for isolated unit tests.

Basic component test

<!-- Counter.vue -->
<script setup lang="ts">
import { ref } from 'vue';

const count = ref(0);
const increment = () => count.value++;
</script>

<template>
<div>
<span data-testid="count">{{ count }}</span>
<button @click="increment">+1</button>
</div>
</template>
// Counter.test.ts
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import Counter from './Counter.vue';

describe('Counter', () => {
it('renders initial count of 0', () => {
const wrapper = mount(Counter);
expect(wrapper.find('[data-testid="count"]').text()).toBe('0');
});

it('increments when button is clicked', async () => {
const wrapper = mount(Counter);
await wrapper.find('button').trigger('click');
expect(wrapper.find('[data-testid="count"]').text()).toBe('1');
});
});

Testing props and emits

<!-- TodoItem.vue -->
<script setup lang="ts">
defineProps<{ title: string; done: boolean }>();
const emit = defineEmits<{ toggle: [] }>();
</script>

<template>
<li :class="{ done }" @click="emit('toggle')">{{ title }}</li>
</template>
// TodoItem.test.ts
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import TodoItem from './TodoItem.vue';

describe('TodoItem', () => {
it('renders the title', () => {
const wrapper = mount(TodoItem, {
props: { title: 'Buy milk', done: false },
});
expect(wrapper.text()).toContain('Buy milk');
});

it('applies done class when done', () => {
const wrapper = mount(TodoItem, {
props: { title: 'Buy milk', done: true },
});
expect(wrapper.find('li').classes()).toContain('done');
});

it('emits toggle on click', async () => {
const wrapper = mount(TodoItem, {
props: { title: 'Buy milk', done: false },
});
await wrapper.find('li').trigger('click');
expect(wrapper.emitted('toggle')).toHaveLength(1);
});
});

Testing with Pinia

import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import { createTestingPinia } from '@pinia/testing';
import CartSummary from './CartSummary.vue';
import { useCartStore } from '@/stores/cart';

describe('CartSummary', () => {
it('displays item count from store', () => {
const wrapper = mount(CartSummary, {
global: {
plugins: [
createTestingPinia({
initialState: {
cart: { items: [{ id: 1, name: 'Widget', qty: 2 }] },
},
}),
],
},
});

const store = useCartStore();
expect(wrapper.text()).toContain('1 item');
});
});
React comparison

Vue Test Utils uses wrapper.find() and wrapper.trigger() to interact with elements. React Testing Library takes a different philosophy -- it queries by accessible roles and visible text (screen.getByRole, screen.getByText) to encourage tests that resemble how users interact with your app.

Testing React components

Install Testing Library:

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

Configure Vitest:

// vite.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';

export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
},
});
// src/test/setup.ts
import '@testing-library/jest-dom/vitest';

This adds matchers like toBeInTheDocument(), toBeVisible(), toHaveTextContent().

Basic component test

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

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

describe('Counter', () => {
it('renders initial count of 0', () => {
render(<Counter />);
expect(screen.getByTestId('count')).toHaveTextContent('0');
});

it('increments when button is clicked', () => {
render(<Counter />);
fireEvent.click(screen.getByRole('button', { name: '+1' }));
expect(screen.getByTestId('count')).toHaveTextContent('1');
});
});

Querying elements

Testing Library encourages querying by accessible role rather than test IDs:

screen.getByRole('button', { name: 'Submit' }); // <button>Submit</button>
screen.getByRole('textbox', { name: 'Email' }); // <input aria-label="Email">
screen.getByText('Welcome'); // any element with this text
screen.getByLabelText('Password'); // input associated with label
screen.getByPlaceholderText('Search...');

Queries come in three variants:

PrefixBehavior
getByThrows if not found or multiple matches
queryByReturns null if not found (use for asserting absence)
findByReturns a promise, waits for element to appear (async)

User events (more realistic than fireEvent)

import userEvent from '@testing-library/user-event';

it('submits the form', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<LoginForm onSubmit={onSubmit} />);

await user.type(screen.getByLabelText('Email'), 'user@test.com');
await user.type(screen.getByLabelText('Password'), 'secret');
await user.click(screen.getByRole('button', { name: 'Log in' }));

expect(onSubmit).toHaveBeenCalledWith({
email: 'user@test.com',
password: 'secret',
});
});

Testing async behavior

it('loads and displays user data', async () => {
vi.spyOn(global, 'fetch').mockResolvedValue(
new Response(JSON.stringify({ name: 'Alice' }))
);

render(<UserProfile userId={1} />);

// Wait for the async content to appear
expect(await screen.findByText('Alice')).toBeInTheDocument();
});

Testing with providers (Context, Router)

import { MemoryRouter } from 'react-router-dom';
import { ThemeProvider } from './ThemeContext';

function renderWithProviders(ui: React.ReactElement) {
return render(
<ThemeProvider>
<MemoryRouter initialEntries={['/dashboard']}>
{ui}
</MemoryRouter>
</ThemeProvider>
);
}

it('renders dashboard page', () => {
renderWithProviders(<App />);
expect(screen.getByRole('heading', { name: 'Dashboard' })).toBeInTheDocument();
});
Vue comparison

In Vue, you wrap with global.plugins and global.stubs in the mount options. In React, you wrap the component in provider components. The pattern is different but the idea is the same -- provide the dependencies your component expects.

Testing Angular components

Angular has built-in testing utilities. While Karma was the default test runner historically, it is now deprecated. Modern Angular projects use Jest or increasingly Vitest with @analogjs/vitest-angular.

npm install -D @analogjs/vitest-angular jsdom
// vite.config.ts
import { defineConfig } from 'vitest/config';
import angular from '@analogjs/vitest-angular';

export default defineConfig({
plugins: [angular()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['src/test-setup.ts'],
include: ['src/**/*.spec.ts'],
},
});
// src/test-setup.ts
import '@analogjs/vitest-angular/setup-zone';
info

Angular requires Zone.js for change detection in tests. The setup file ensures Zone.js is loaded before tests run. Newer Angular versions with signal-based components may reduce this dependency.

Testing a component with TestBed

// counter.component.ts
import { Component, signal } from '@angular/core';

@Component({
selector: 'app-counter',
standalone: true,
template: `
<span data-testid="count">{{ count() }}</span>
<button (click)="increment()">+1</button>
`,
})
export class CounterComponent {
count = signal(0);
increment() {
this.count.update(c => c + 1);
}
}
// counter.component.spec.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { CounterComponent } from './counter.component';

describe('CounterComponent', () => {
let fixture: ComponentFixture<CounterComponent>;
let component: CounterComponent;

beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [CounterComponent],
}).compileComponents();

fixture = TestBed.createComponent(CounterComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});

it('renders initial count of 0', () => {
const el: HTMLElement = fixture.nativeElement;
expect(el.querySelector('[data-testid="count"]')?.textContent).toBe('0');
});

it('increments when button is clicked', () => {
const button = fixture.nativeElement.querySelector('button');
button.click();
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('[data-testid="count"]')?.textContent).toBe('1');
});
});

Testing services with dependency injection

// user.service.ts
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';

@Injectable({ providedIn: 'root' })
export class UserService {
private http = inject(HttpClient);

getUser(id: number): Observable<{ name: string }> {
return this.http.get<{ name: string }>(`/api/users/${id}`);
}
}
// user.service.spec.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { TestBed } from '@angular/core/testing';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { provideHttpClient } from '@angular/common/http';
import { UserService } from './user.service';

describe('UserService', () => {
let service: UserService;
let httpMock: HttpTestingController;

beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
service = TestBed.inject(UserService);
httpMock = TestBed.inject(HttpTestingController);
});

it('fetches user by id', () => {
service.getUser(1).subscribe(user => {
expect(user.name).toBe('Alice');
});

const req = httpMock.expectOne('/api/users/1');
expect(req.request.method).toBe('GET');
req.flush({ name: 'Alice' });
});
});

Testing a component with injected service

// user-profile.component.spec.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { UserProfileComponent } from './user-profile.component';
import { UserService } from './user.service';
import { of } from 'rxjs';

describe('UserProfileComponent', () => {
let fixture: ComponentFixture<UserProfileComponent>;

const mockUserService = {
getUser: () => of({ name: 'Alice' }),
};

beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [UserProfileComponent],
providers: [
{ provide: UserService, useValue: mockUserService },
],
}).compileComponents();

fixture = TestBed.createComponent(UserProfileComponent);
fixture.detectChanges();
});

it('displays the user name', () => {
expect(fixture.nativeElement.textContent).toContain('Alice');
});
});
Framework comparison
AspectVueReactAngular
Test utility@vue/test-utils@testing-library/reactTestBed
Rendermount(Comp)render(<Comp />)TestBed.createComponent(Comp)
DOM accesswrapper.find()screen.getByRole()fixture.nativeElement.querySelector()
Update cycleawait wrapper.trigger()automatic (mostly)fixture.detectChanges()
DI/mockingglobal.pluginsProvider wrappersproviders: [{ provide, useValue }]

Code coverage

Vitest supports coverage via @vitest/coverage-v8 (V8-based, fast) or @vitest/coverage-istanbul:

npm install -D @vitest/coverage-v8
// vite.config.ts
export default defineConfig({
test: {
coverage: {
provider: 'v8',
reporter: ['text', 'html'], // terminal + HTML report
include: ['src/**/*.{ts,tsx,vue}'],
exclude: ['src/**/*.test.*', 'src/**/*.spec.*', 'src/test/**'],
},
},
});

Run coverage:

npx vitest run --coverage

Coverage metrics

MetricWhat it measures
Statements% of statements executed
Branches% of if/else/switch/?? branches taken
Functions% of functions called
Lines% of lines executed
info

100% coverage does not mean your code is bug-free. Coverage tells you what code ran, not whether the assertions were meaningful. A test with no expect statements still counts toward coverage. Focus on testing behavior, not chasing numbers.


Self preparation QA

Be prepared to explain topics like these:

  1. What is the testing pyramid, and why should you write more unit tests than e2e tests? — The testing pyramid suggests many fast unit tests at the base, fewer integration tests in the middle, and a small number of slow e2e tests at the top. Unit tests are cheap to write, fast to run, and easy to debug when they fail. E2e tests are expensive to maintain and slow to execute, but they catch bugs that only appear when the full system is assembled. The pyramid shape reflects the trade-off between speed and confidence.
  2. Why use Vitest instead of Jest for a Vite-based project? What practical advantages does it provide? — Vitest reuses the same Vite pipeline your app uses (plugins, resolve aliases, transforms), so you do not need a parallel configuration for tests. It supports native ESM without transform hacks, TypeScript works out of the box without ts-jest, and its watch mode is faster because it leverages Vite's module graph. The API is intentionally Jest-compatible, so migration is straightforward.
  3. What is mocking, and when should you use vi.fn() vs vi.spyOn() vs vi.mock()? — Mocking replaces a real dependency with a controlled substitute so you can test a unit in isolation. vi.fn() creates a standalone mock function (useful for callbacks and event handlers). vi.spyOn() wraps an existing method so you can track calls while keeping the original implementation (or override it). vi.mock() replaces an entire module, which is useful when you need to mock something the component imports at the top level.
  4. How does @testing-library/react differ from @vue/test-utils in philosophy and API design? — Testing Library encourages querying by accessible roles and visible text (getByRole, getByText) to write tests that resemble how users interact with the app. Vue Test Utils provides lower-level access to the component wrapper (find, trigger, emitted), giving you more control but coupling tests more tightly to implementation details. Both achieve the same goal of testing components in a DOM environment.
  5. In Angular, why do you call fixture.detectChanges() after triggering a user action, and what happens if you forget? — Angular uses Zone.js to batch change detection. In tests, change detection does not run automatically after you interact with the DOM. Calling detectChanges() tells Angular to run its change detection cycle, which updates the template bindings. If you forget, the DOM still reflects the old state and your assertions will fail even though the component's internal state has changed.
  6. What does code coverage actually tell you, and what are its limitations? — Coverage measures which lines, branches, functions, and statements were executed during tests. It helps find untested code paths. However, coverage does not measure the quality of assertions -- a test that runs code but never asserts anything still counts as covered. You can have 100% coverage and still miss bugs if your assertions do not verify the right behavior. Use coverage as a guide to find gaps, not as a quality metric.
  7. Why is it important to clean up mocks between tests (e.g., vi.restoreAllMocks() in beforeEach)? What can go wrong if you don't? — Mocks persist across tests within the same file. If you do not restore them, a mock set up in one test leaks into the next test, causing false positives (tests pass because they still use old mock data) or false negatives (tests fail because a previous mock interferes). vi.restoreAllMocks() resets all spies to their original implementations, and vi.clearAllMocks() resets call history. Using beforeEach ensures each test starts with a clean state.
  8. When testing a React component that depends on React Router and a Context provider, how do you set up the test environment? — You wrap the component in the required providers during render. Create a helper function (e.g., renderWithProviders) that wraps your component in <MemoryRouter> (for routing) and your Context providers with test values. MemoryRouter accepts initialEntries to simulate navigation to a specific route. This pattern keeps individual tests clean and ensures the component has all the dependencies it expects.