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.