ProgrUmar Logo
Module 9: Testing

Unit Testing with Vitest

Duration: 18 mins

Vitest Setup

Vitest is Jest-compatible but dramatically faster thanks to Vite's module graph. Set it up alongside Next.js with a simple vitest.config.ts that maps the same module aliases as your tsconfig.json.

Unit Testing with Vitest

Maintaining code quality as a product features list grows requires automated verification. Unit testing isolates individual software functions—such as date formatters, price calculators, or Server Actions—and tests their responses against set conditions. While Jest was the historical standard for Node.js, Vitest is the modern standard for Next.js 15. Vitest integrates directly with Vite-based build pipelines, parses TypeScript out of the box, and provides multi-threaded testing runs with fast hot-reload feedback. In this lesson, we will cover how to configure Vitest, write unit tests, mock external APIs, and validate Server Actions.


1. What is Vitest and Why Do We Prefer It?

Vitest is a Vite-native test runner. It provides a Jest-compatible assertion API (describe, test, expect, vi) but resolves file imports through Vite's module graph, making it significantly faster than Jest which relies on heavy Babel transpilation.

Primary Advantages in Next.js:

  • Instant Hot Reload: Only re-runs tests that import files you modified.
  • Path Mapping Support: Automatically imports path mappings from your tsconfig.json.
  • Edge Compatibility Mocking: Exposes mock setups for Edge global APIs.

2. Installing Vitest and React Plugin Adapters

Install the test runner and react helper extensions:

npm install -D vitest @vitejs/plugin-react jsdom

Here:

  • vitest is the runner client.
  • @vitejs/plugin-react enables JSX parsing.
  • jsdom provides a browser-like DOM environment inside Node.js.

3. Configuring vitest.config.ts

Create a configuration file in your project root to handle loaders and path alias structures:

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

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom', // Mock DOM environment for testing components
    globals: true, // Auto-expose global APIs (describe, expect, etc.)
  },
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src'), // Maps import path aliases
    },
  },
});

4. Writing a Simple Unit Test

Let's write a unit test for a utility function that formats prices. Create a test file alongside your code named format.test.ts:

// utils/format.ts
export function formatCurrency(amount: number, currency = 'USD'): string {
  if (isNaN(amount)) return '$0.00';
  return new Intl.NumberFormat('en-US', {
    style: 'currency',
    currency,
  }).format(amount);
}

Now write the test validations:

// utils/format.test.ts
import { describe, it, expect } from 'vitest';
import { formatCurrency } from './format';

describe('formatCurrency utility tests', () => {
  it('formats positive numbers correctly', () => {
    expect(formatCurrency(10.5)).toBe('$10.50');
  });

  it('handles zero values cleanly', () => {
    expect(formatCurrency(0)).toBe('$0.00');
  });

  it('returns default fallback on NaN', () => {
    expect(formatCurrency(NaN)).toBe('$0.00');
  });

  it('supports custom currencies', () => {
    expect(formatCurrency(10, 'EUR')).toContain('10.00');
  });
});

5. Mocking Third-Party Modules and APIs

Often, utility functions import database clients or network services (like Stripe or fetch requests). You must mock these dependencies inside your unit tests to isolate your function logic from external network checks.

// utils/api.ts
export async function fetchUserEmail(userId: string): Promise<string> {
  const res = await fetch('https://api.progrumar.com/users/' + userId);
  if (!res.ok) throw new Error('User not found');
  const data = await res.json();
  return data.email;
}

Here is how to mock the global fetch function inside Vitest:

// utils/api.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { fetchUserEmail } from './api';

describe('fetchUserEmail mock tests', () => {
  beforeEach(() => {
    vi.restoreAllMocks(); // Clear mock tracking states before each test run
  });

  it('resolves email on successful HTTP responses', async () => {
    // Mock the global fetch function
    const mockFetch = vi.fn().mockResolvedValue({
      ok: true,
      json: async () => ({ email: 'test@progrumar.com' }),
    });
    vi.stubGlobal('fetch', mockFetch);

    const email = await fetchUserEmail('123');
    expect(email).toBe('test@progrumar.com');
    expect(mockFetch).toHaveBeenCalledWith('https://api.progrumar.com/users/123');
  });

  it('throws error when server responds with 404', async () => {
    const mockFetch = vi.fn().mockResolvedValue({
      ok: false,
    });
    vi.stubGlobal('fetch', mockFetch);

    await expect(fetchUserEmail('404')).rejects.toThrow('User not found');
  });
});

6. Testing Server Actions

Server Actions are simply async functions. You can test them like standard Javascript promises, mocking database calls to ensure they validate parameters:

// app/actions/user.ts
'use server';

import { db } from '@/db';
import { users } from '@/db/schema';
import { eq } from 'drizzle-orm';

export async function deleteUser(id: number) {
  if (id <= 0) return { error: 'Invalid ID' };
  
  await db.delete(users).where(eq(users.id, id));
  return { success: true };
}

Mock the database client inside your test runner:

// app/actions/user.test.ts
import { describe, it, expect, vi } from 'vitest';
import { deleteUser } from './user';

// Mock database client module path
vi.mock('@/db', () => ({
  db: {
    delete: vi.fn().mockReturnThis(),
    where: vi.fn().mockResolvedValue({}),
  },
}));

describe('deleteUser Server Action tests', () => {
  it('rejects execution on invalid user IDs', async () => {
    const res = await deleteUser(-1);
    expect(res.error).toBe('Invalid ID');
  });

  it('runs database delete query on valid IDs', async () => {
    const res = await deleteUser(5);
    expect(res.success).toBe(true);
  });
});

7. CLI commands and Watch Mode

By default, running Vitest initializes watch mode, keeping the test runner running in your terminal to re-evaluate code changes on save:

npx vitest

To execute a one-time test check (useful in CI/CD pipeline deployments), use the run flag:

npx vitest run

8. Common Gotchas

  • Forgetting to Clear Stubbed Globals: Stubbing global variables (like vi.stubGlobal('fetch', ...)) persists across tests. If you forget to call vi.restoreAllMocks() inside beforeEach, other test suites might fail due to mock contamination.
  • Mocking Node-specific APIs inside Browser Environments: If your config sets environment: 'jsdom', calling specific Node APIs (like raw file systems fs) inside unit tests might throw errors unless you mock them out.

Key Takeaways

  • Vitest operates on Vite's module loader, making it significantly faster than Jest.
  • Configure Vitest using vitest.config.ts at the project root.
  • Mock API calls using vi.stubGlobal or module level mocks (vi.mock).
  • Test Server Actions as standard async JS promises.
  • Use npx vitest run in deployment pipelines to run tests once.

Unit tests validate isolated logical functions, but applications also require user interface testing. In the next lesson, we will cover Component Testing with React Testing Library, learning how to mock React hooks and render components to test client-side clicks and inputs.

Chat with us