Published on

Node.js Testing in 2026 — Vitest, TestContainers, and Testing Without Mocking Everything

Authors

Introduction

Jest dominates but struggles with ESM, is slower, and encourages over-mocking. Vitest is faster and modern. Combined with real TestContainers databases, MSW for HTTP mocking, and Pact for contracts, you build confidence in actual behavior, not mocked stubs. This post covers testing patterns that catch production bugs.

Vitest Over Jest: ESM, Speed, and Modern Tooling

Vitest is Jest-compatible but faster, supports ESM natively, and has better debugging.

# Install
npm install -D vitest @vitest/ui

# Run
vitest

# UI dashboard
vitest --ui

# Single run
vitest run

# Coverage
vitest run --coverage
// math.test.ts
import { describe, it, expect, beforeEach, afterEach } from 'vitest';

describe('Math operations', () => {
  let state: { value: number };

  beforeEach(() => {
    state = { value: 0 };
  });

  afterEach(() => {
    console.log('Cleanup after test');
  });

  it('should add numbers', () => {
    const result = 2 + 3;
    expect(result).toBe(5);
  });

  it('should handle edge cases', () => {
    expect(Number.isNaN(NaN)).toBe(true);
    expect(1 / 0).toBe(Infinity);
  });

  it('should run async operations', async () => {
    const result = await Promise.resolve(42);
    expect(result).toBe(42);
  });
});

Why Vitest > Jest:

// ESM support (native, not transpiled)
import { add } from './math.js'; // Works with Vitest, slower with Jest

// Speed: Vitest is 5-10x faster
// - Vite's instant module resolution
// - Parallel execution by default
// - Instant feedback in watch mode

// Debugging: native debugger support
// vitest run --inspect-brk

// Module mocking (vi instead of jest)
import { vi, describe, it, expect } from 'vitest';

describe('with mocking', () => {
  it('should mock modules', () => {
    const mockFn = vi.fn().mockReturnValue(42);
    expect(mockFn()).toBe(42);
    expect(mockFn).toHaveBeenCalled();
  });

  it('should mock imports', async () => {
    const mod = await import('./module.js');
    vi.spyOn(mod, 'myFunction').mockReturnValue('mocked');
  });
});

vitest.config.ts:

import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    globals: true, // describe, it, expect without imports
    environment: 'node', // or 'jsdom' for DOM testing
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html', 'json'],
      exclude: ['node_modules/', 'dist/'],
    },
    testTimeout: 10000,
    hookTimeout: 10000,
  },
});

Integration Tests with Real Databases via TestContainers

Don't mock databases—run real ones in Docker containers.

npm install -D @testcontainers/testcontainers
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { GenericContainer, StartedTestContainer } from '@testcontainers/testcontainers';
import pg from 'pg';

describe('User repository with real database', () => {
  let container: StartedTestContainer;
  let pool: pg.Pool;

  beforeAll(async () => {
    // Start PostgreSQL container
    container = await new GenericContainer('postgres:16-alpine')
      .withEnvironment({
        POSTGRES_USER: 'test',
        POSTGRES_PASSWORD: 'test',
        POSTGRES_DB: 'testdb',
      })
      .withExposedPorts(5432)
      .withWaitStrategy({
        strategy: 'HealthCheck',
      })
      .start();

    const host = container.getHost();
    const port = container.getMappedPort(5432);

    // Connect to real database
    pool = new pg.Pool({
      host,
      port,
      user: 'test',
      password: 'test',
      database: 'testdb',
    });

    // Create tables
    await pool.query(`
      CREATE TABLE users (
        id SERIAL PRIMARY KEY,
        email VARCHAR(255) UNIQUE NOT NULL,
        name VARCHAR(255),
        created_at TIMESTAMP DEFAULT NOW()
      )
    `);
  });

  afterAll(async () => {
    await pool.end();
    await container.stop();
  });

  it('should insert and retrieve user', async () => {
    // Insert
    const result = await pool.query(
      'INSERT INTO users (email, name) VALUES ($1, $2) RETURNING *',
      ['alice@example.com', 'Alice']
    );

    const user = result.rows[0];
    expect(user.email).toBe('alice@example.com');
    expect(user.name).toBe('Alice');

    // Retrieve
    const retrieved = await pool.query(
      'SELECT * FROM users WHERE id = $1',
      [user.id]
    );

    expect(retrieved.rows[0]).toEqual(user);
  });

  it('should enforce unique constraint', async () => {
    // Insert first user
    await pool.query(
      'INSERT INTO users (email, name) VALUES ($1, $2)',
      ['bob@example.com', 'Bob']
    );

    // Try duplicate email
    try {
      await pool.query(
        'INSERT INTO users (email, name) VALUES ($1, $2)',
        ['bob@example.com', 'Bob 2']
      );
      expect.fail('Should throw on duplicate email');
    } catch (err: any) {
      expect(err.code).toBe('23505'); // Unique violation
    }
  });

  it('should handle transactions', async () => {
    const client = await pool.connect();

    try {
      await client.query('BEGIN');

      await client.query(
        'INSERT INTO users (email, name) VALUES ($1, $2)',
        ['charlie@example.com', 'Charlie']
      );

      // Rollback for this test
      await client.query('ROLLBACK');

      const result = await pool.query('SELECT * FROM users WHERE email = $1', [
        'charlie@example.com',
      ]);

      expect(result.rows).toHaveLength(0); // No insert due to rollback
    } finally {
      client.release();
    }
  });
});

// Similar patterns for Redis, MySQL, MongoDB
describe('With Redis', () => {
  beforeAll(async () => {
    container = await new GenericContainer('redis:7-alpine')
      .withExposedPorts(6379)
      .start();
  });
});

MSW: Mock Service Worker for HTTP Mocking

Instead of mocking fetch/axios, mock at the network level.

npm install -D msw
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';

// Define handlers
const handlers = [
  http.get('https://api.example.com/users/:id', ({ params }) => {
    const { id } = params;
    return HttpResponse.json({ id, name: `User ${id}` });
  }),

  http.post('https://api.example.com/users', async ({ request }) => {
    const body = await request.json();
    return HttpResponse.json(
      { id: 1, ...body },
      { status: 201 }
    );
  }),

  http.get('https://api.example.com/error', () => {
    return HttpResponse.json(
      { error: 'Internal error' },
      { status: 500 }
    );
  }),
];

const server = setupServer(...handlers);

describe('API client', () => {
  beforeAll(() => {
    server.listen();
  });

  afterEach(() => {
    server.resetHandlers();
  });

  afterAll(() => {
    server.close();
  });

  it('should fetch user', async () => {
    const response = await fetch('https://api.example.com/users/1');
    const data = await response.json();

    expect(data).toEqual({ id: '1', name: 'User 1' });
  });

  it('should create user', async () => {
    const response = await fetch('https://api.example.com/users', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ name: 'Alice', email: 'alice@example.com' }),
    });

    expect(response.status).toBe(201);
    const data = await response.json();
    expect(data.name).toBe('Alice');
  });

  it('should handle errors', async () => {
    const response = await fetch('https://api.example.com/error');
    expect(response.status).toBe(500);
  });

  it('should override handler per-test', async () => {
    // Override for this test only
    server.use(
      http.get('https://api.example.com/users/:id', () => {
        return HttpResponse.json({ error: 'Not found' }, { status: 404 });
      })
    );

    const response = await fetch('https://api.example.com/users/999');
    expect(response.status).toBe(404);
  });
});

Test Isolation: Transaction Rollback and Truncate

Keep tests isolated: each test starts fresh.

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

describe('Isolated tests with rollback', () => {
  let client: pg.PoolClient;

  beforeEach(async () => {
    const pool = new pg.Pool({ /* ... */ });
    client = await pool.connect();
    await client.query('BEGIN');
  });

  afterEach(async () => {
    await client.query('ROLLBACK');
    await client.release();
  });

  it('should isolate first test', async () => {
    await client.query(
      'INSERT INTO users (email) VALUES ($1)',
      ['test1@example.com']
    );

    const result = await client.query('SELECT COUNT(*) FROM users');
    expect(parseInt(result.rows[0].count)).toBe(1);
  });

  it('should isolate second test', async () => {
    // First test's insert is rolled back
    const result = await client.query('SELECT COUNT(*) FROM users');
    expect(parseInt(result.rows[0].count)).toBe(0);

    await client.query(
      'INSERT INTO users (email) VALUES ($1)',
      ['test2@example.com']
    );

    const newResult = await client.query('SELECT COUNT(*) FROM users');
    expect(parseInt(newResult.rows[0].count)).toBe(1);
  });
});

// Alternative: truncate strategy (cleaner for some DBs)
describe('Isolated tests with truncate', () => {
  let pool: pg.Pool;

  beforeEach(async () => {
    pool = new pg.Pool({ /* ... */ });
  });

  afterEach(async () => {
    // Clear all data
    await pool.query('TRUNCATE TABLE users CASCADE');
    await pool.end();
  });

  it('should start clean', async () => {
    const result = await pool.query('SELECT COUNT(*) FROM users');
    expect(parseInt(result.rows[0].count)).toBe(0);
  });
});

Contract Testing with Pact

Test API contracts between services without full integration.

npm install -D @pact-foundation/pact
import { describe, it, expect } from 'vitest';
import { Pact, Matchers } from '@pact-foundation/pact';

const { like, eachLike } = Matchers;

describe('User API contract', () => {
  const provider = new Pact({
    consumer: 'user-service',
    provider: 'api-service',
    port: 8080,
  });

  beforeAll(() => {
    return provider.setup();
  });

  afterEach(() => {
    return provider.verify();
  });

  afterAll(() => {
    return provider.finalize();
  });

  it('should return user by ID', () => {
    return provider
      .addInteraction({
        state: 'user 1 exists',
        uponReceiving: 'a request for user 1',
        withRequest: {
          method: 'GET',
          path: '/users/1',
        },
        willRespondWith: {
          status: 200,
          body: like({
            id: 1,
            email: 'user@example.com',
            name: 'User',
          }),
        },
      })
      .then(() => {
        // Make request to provider (running on 8080)
        return fetch('http://localhost:8080/users/1').then((res) =>
          res.json()
        );
      })
      .then((data) => {
        expect(data.id).toBe(1);
        expect(data.email).toBe('user@example.com');
      });
  });

  it('should list users', () => {
    return provider
      .addInteraction({
        state: 'users exist',
        uponReceiving: 'a request for all users',
        withRequest: {
          method: 'GET',
          path: '/users',
        },
        willRespondWith: {
          status: 200,
          body: eachLike(
            {
              id: like(1),
              email: like('user@example.com'),
              name: like('User'),
            },
            { min: 1 }
          ),
        },
      })
      .then(() => fetch('http://localhost:8080/users').then((r) => r.json()))
      .then((data) => {
        expect(Array.isArray(data)).toBe(true);
        expect(data[0]).toHaveProperty('id');
        expect(data[0]).toHaveProperty('email');
      });
  });
});

Snapshot Testing for API Responses

Capture API response shape once, detect changes automatically.

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

describe('API response snapshots', () => {
  it('should match user response shape', async () => {
    const response = await fetch('https://api.example.com/users/1');
    const data = await response.json();

    // First run: creates snapshot
    // Subsequent runs: compares against snapshot
    expect(data).toMatchSnapshot();
  });

  it('should match list response', async () => {
    const response = await fetch('https://api.example.com/users');
    const data = await response.json();

    expect(data).toMatchSnapshot('user-list');
  });

  // To update snapshots:
  // vitest run -u
});

// Snapshot file (auto-generated)
// api.test.ts.snap
/*
exports[`API response snapshots should match user response shape 1`] = `
Object {
  "id": 1,
  "email": "user@example.com",
  "name": "User",
}
`;
*/

Coverage Thresholds as CI Gates

Enforce minimum coverage to prevent regressions.

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

export default defineConfig({
  test: {
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html', 'json'],
      branches: 80, // 80% branch coverage minimum
      functions: 80,
      lines: 85,
      statements: 85,
      exclude: ['node_modules/', 'dist/'],
    },
  },
});
# Run with coverage
vitest run --coverage

# Fails if below threshold
# FAIL src/index.ts
# Statements: 70/100 (70%) - Required 85%

Checklist

  • ✓ Use Vitest instead of Jest for new projects (faster, ESM native)
  • ✓ Run real databases with TestContainers, not mocks
  • ✓ Mock HTTP with MSW at network level, not fetch/axios
  • ✓ Isolate tests: transaction rollback or truncate between tests
  • ✓ Use Pact for contract testing between services
  • ✓ Capture snapshots for API responses, detect breaking changes
  • ✓ Set coverage thresholds (80%+) and enforce in CI
  • ✓ Test async code: promises, await, callbacks
  • ✓ Avoid over-mocking: mock external services, test logic with real data

Conclusion

Modern testing stops mocking internal logic and instead validates behavior against real databases and services. Vitest's speed enables this—run hundreds of tests in seconds. Combined with TestContainers and MSW, you gain confidence that your code works in production, not just in mock-land.