All posts
TestingMay 20, 2026·7 min read

Unit Testing Node.js with Vitest

Vitest is the fastest Node.js test runner available today. Setup, writing tests, mocking modules, coverage configuration, and how to test async code correctly.

Why Vitest over Jest in 2026

Jest dominated Node.js testing for years, but Vitest has become the better default for new TypeScript projects. The advantages are practical: Vitest shares your existing TypeScript and path alias configuration (no separate babel or ts-jest setup), runs tests in parallel worker threads by default, starts 3–10x faster on cold runs, and has first-class ESM support. The API is intentionally compatible with Jest, so describe, it, expect, vi (instead of jest) all work the same way — migration is usually a search-and-replace.

Project setup

npm install -D vitest @vitest/coverage-v8 @vitest/ui
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import path from 'path';

export default defineConfig({
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src'),
    },
  },
  test: {
    globals: true,          // No need to import describe, it, expect
    environment: 'node',
    setupFiles: ['./src/test/setup.ts'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'lcov', 'html'],
      exclude: [
        'node_modules/**',
        'dist/**',
        '**/*.config.*',
        '**/*.test.*',
        'src/test/**',
      ],
      thresholds: {
        lines: 80,
        functions: 80,
        branches: 70,
      },
    },
  },
});
// src/test/setup.ts — runs before every test file
import { beforeAll, afterAll, beforeEach, vi } from 'vitest';
import { prisma } from '@/lib/db';

// Reset all mocks between tests
beforeEach(() => {
  vi.clearAllMocks();
});

// Clean up database connection after all tests
afterAll(async () => {
  await prisma.$disconnect();
});

What to test and what not to

Unit tests are most valuable for business logic: validation rules, calculations, data transformations, branching conditions. They're least valuable for code that's mostly framework glue or database queries (integration tests handle those better).

Test thoroughly: Service layer business logic, utility functions, data validation, error conditions, edge cases

Test lightly: HTTP controllers (just check they call the right service), repository methods (just check they construct the right query)

Don't bother testing: Third-party library usage, trivial getters/setters, generated code

Writing good unit tests

// src/features/projects/projects.service.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { ProjectService } from './projects.service';
import type { ProjectRepository, UserRepository } from './projects.types';

describe('ProjectService', () => {
  let service: ProjectService;
  let projectRepo: ProjectRepository;
  let userRepo: UserRepository;

  beforeEach(() => {
    // Create fresh mocks before each test — prevents state leakage
    projectRepo = {
      create: vi.fn(),
      findById: vi.fn(),
      findByUserId: vi.fn(),
      delete: vi.fn(),
    };
    userRepo = {
      findById: vi.fn(),
    };
    service = new ProjectService(projectRepo, userRepo);
  });

  describe('createProject', () => {
    it('creates a project for an existing user', async () => {
      const mockUser = { id: 'usr_123', email: '[email protected]', plan: 'pro' };
      const mockProject = { id: 'proj_456', name: 'My App', userId: 'usr_123' };

      vi.mocked(userRepo.findById).mockResolvedValue(mockUser);
      vi.mocked(projectRepo.create).mockResolvedValue(mockProject);

      const result = await service.createProject('usr_123', { name: 'My App' });

      expect(result).toEqual(mockProject);
      expect(projectRepo.create).toHaveBeenCalledWith({
        name: 'My App',
        userId: 'usr_123',
      });
    });

    it('throws NotFoundError when user does not exist', async () => {
      vi.mocked(userRepo.findById).mockResolvedValue(null);

      await expect(
        service.createProject('nonexistent', { name: 'App' })
      ).rejects.toThrow('User not found');

      // Ensure create was never called
      expect(projectRepo.create).not.toHaveBeenCalled();
    });

    it('throws AuthorizationError when free plan user exceeds project limit', async () => {
      const freeUser = { id: 'usr_123', plan: 'free' };
      vi.mocked(userRepo.findById).mockResolvedValue(freeUser);
      vi.mocked(projectRepo.findByUserId).mockResolvedValue(
        Array(3).fill({ id: 'proj' }) // Already has 3 projects
      );

      await expect(
        service.createProject('usr_123', { name: 'App' })
      ).rejects.toThrow('Project limit reached for free plan');
    });
  });
});

Mocking dependencies effectively

// Mock an entire module at the top of the file
vi.mock('@/lib/db', () => ({
  prisma: {
    user: {
      findUnique: vi.fn(),
      findMany: vi.fn(),
      create: vi.fn(),
      update: vi.fn(),
      delete: vi.fn(),
    },
    project: {
      findUnique: vi.fn(),
      create: vi.fn(),
    },
  },
}));

// Get the mocked instance in tests
import { prisma } from '@/lib/db';

describe('UserRepository', () => {
  it('finds user by email', async () => {
    const mockUser = { id: '1', email: '[email protected]' };
    vi.mocked(prisma.user.findUnique).mockResolvedValue(mockUser);

    const result = await userRepo.findByEmail('[email protected]');

    expect(prisma.user.findUnique).toHaveBeenCalledWith({
      where: { email: '[email protected]' },
    });
    expect(result).toEqual(mockUser);
  });
});

// Mock only part of a module (partial mock)
vi.mock('./email.service', async (importOriginal) => {
  const actual = await importOriginal<typeof import('./email.service')>();
  return {
    ...actual,
    sendEmail: vi.fn().mockResolvedValue({ messageId: 'test-msg-id' }),
  };
});

Testing async patterns

// Testing resolved values
it('returns the created user', async () => {
  const result = await userService.create({ email: '[email protected]' });
  expect(result).toMatchObject({ email: '[email protected]', id: expect.any(String) });
});

// Testing rejected promises
it('rejects with duplicate email error', async () => {
  vi.mocked(prisma.user.create).mockRejectedValue(
    new Error('Unique constraint failed on email')
  );

  await expect(
    userService.create({ email: '[email protected]' })
  ).rejects.toThrow('Email already exists'); // Service should translate DB errors
});

// Testing event emissions
it('emits user.created event after successful creation', async () => {
  const emit = vi.spyOn(eventBus, 'emit');
  await userService.create({ email: '[email protected]' });

  expect(emit).toHaveBeenCalledWith(
    'user.created',
    expect.objectContaining({ id: expect.any(String), email: '[email protected]' })
  );
});

// Testing with fake timers
it('rate limits after 5 attempts', async () => {
  vi.useFakeTimers();

  for (let i = 0; i < 5; i++) {
    await service.login('[email protected]', 'wrong');
  }
  await expect(service.login('[email protected]', 'wrong'))
    .rejects.toThrow('Too many attempts');

  // After 15 minutes, should work again
  vi.advanceTimersByTime(15 * 60 * 1000);
  // Should not throw rate limit error anymore
  vi.useRealTimers();
});

Snapshot testing for complex output

// Useful for complex data transformations where you want to catch unexpected changes
it('transforms raw API response to domain model', () => {
  const rawResponse = { /* complex nested API response */ };
  const result = transformer.transform(rawResponse);

  // Creates a snapshot file on first run, compares on subsequent runs
  expect(result).toMatchSnapshot();
});

// For inline snapshots (visible in the test file):
expect(result).toMatchInlineSnapshot(`
  {
    "id": "proj_123",
    "name": "My Project",
    "status": "active",
  }
`);

Running tests in CI

// package.json
{
  "scripts": {
    "test": "vitest run",
    "test:watch": "vitest",
    "test:coverage": "vitest run --coverage",
    "test:ui": "vitest --ui"
  }
}
# GitHub Actions
- name: Run tests
  run: npm run test:coverage

- name: Upload coverage to Codecov
  uses: codecov/codecov-action@v4
  with:
    file: ./coverage/lcov.info

Coverage as a guideline, not a goal

Coverage percentage is a proxy metric. 90% coverage that only tests happy paths is worse than 70% coverage that tests every error condition and edge case in your critical business logic. Set thresholds that enforce coverage on business-critical code but don't chase the number at the expense of test quality.

A test that imports a function, calls it with valid inputs, and checks that it returns without error is adding coverage numbers without adding confidence. Tests should assert on behavior, not just execution.

Ready to put this into practice?

Deploy your Node.js app to production in minutes — zero YAML, automatic CI/CD, and HTTPS included.