Database Mocking for Integration Tests: Complete Guide

Master database mocking for integration tests. Use in-memory databases, factories, and Testcontainers to build fast, reliable, isolated tests.

Why Mock Databases?

Integration tests that hit real databases are slow and fragile. Database mocking gives you speed, isolation, and reliability.

Real database integration tests:

  • Are slow (seconds per test)
  • Leave data behind that affects other tests
  • Require database setup and teardown
  • Run sequentially, not in parallel
  • Fail if the database is unavailable

Database mocking:

  • Runs in memory (milliseconds per test)
  • Isolated per test
  • No setup needed
  • Can run in parallel
  • Always available

In-Memory Database Approach

Use an in-memory database like H2 (Java), SQLite (any), or similar:

Setup with SQLite

const sqlite3 = require('sqlite3').verbose();

beforeEach(() => {
  db = new sqlite3.Database(':memory:');
  createSchema();
});

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

function createSchema() {
  db.serialize(() => {
    db.run(`
      CREATE TABLE users (
        id INTEGER PRIMARY KEY,
        name TEXT NOT NULL,
        email TEXT UNIQUE
      )
    `);
  });
}

Using Test Fixtures

Define reusable test data:

class UserFixture {
  static defaultUser() {
    return {
      id: 1,
      name: 'John Doe',
      email: 'john@example.com'
    };
  }
  
  static adminUser() {
    return {
      ...this.defaultUser(),
      id: 2,
      name: 'Admin User',
      isAdmin: true
    };
  }
}

it('finds user by email', () => {
  const user = UserFixture.defaultUser();
  db.insert('users', user);
  
  const found = userService.findByEmail('john@example.com');
  expect(found).toEqual(user);
});

Database Factories

Factories generate test data with sensible defaults:

class UserFactory {
  static create(overrides = {}) {
    return {
      id: faker.datatype.number(),
      name: faker.name.fullName(),
      email: faker.internet.email(),
      ...overrides
    };
  }
  
  static createMany(count, overrides = {}) {
    return Array.from({ length: count }, () =>
      this.create(overrides)
    );
  }
}

it('paginates users', () => {
  const users = UserFactory.createMany(100);
  insertUsers(users);
  
  const page1 = userService.paginate(1, 10);
  expect(page1).toHaveLength(10);
});

Testcontainers: Real Databases in Docker

For more realistic testing, use Testcontainers to spin up real databases in Docker:

const { GenericContainer } = require('testcontainers');

let container;

beforeAll(async () => {
  container = await new GenericContainer('postgres:14')
    .withEnvironment('POSTGRES_PASSWORD', 'test')
    .withExposedPorts(5432)
    .start();
});

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

it('tests against real PostgreSQL', async () => {
  const port = container.getMappedPort(5432);
  const db = await connect(`postgres://localhost:${port}`);
  
  // Real database test
});

Transaction Management

Wrap each test in a transaction and rollback:

beforeEach(() => {
  db.beginTransaction();
});

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

it('deletes user', () => {
  const user = UserFactory.create();
  insertUser(user);
  
  userService.delete(user.id);
  
  const found = userService.findById(user.id);
  expect(found).toBeNull();
});

Performance Considerations

Database mocking is fast, but:

  • Index creation takes time (do it once in beforeAll, not beforeEach)
  • Large datasets slow tests
  • Complex queries might expose real database issues

Strike a balance:

  • Use in-memory for most tests (fast)
  • Use real database containers for critical paths (realistic)
  • Use mocks for presentation logic (very fast)

Conclusion

Database mocking is essential for fast, reliable integration tests. Start with in-memory databases and factories, add Testcontainers for realistic scenarios, and always isolate test data. Your test suite will be faster and more maintainable.