The Problem: Slow, Sequential Database Tests
When building a robust NestJS application with TypeORM, testing can become a critical bottleneck. Our database testing approach had the following issues:
• Slow test execution due to database setup/teardown overhead
• Test isolation issues where tests can interfere with each other
• Sequential test execution preventing parallelization
The Solution: Run tests in transactions
We recently switched to a new testing pattern that wraps each test in a database transaction. Each transaction then gets rolled back after test completion. This approach provides:
1. Test isolation — each test runs in its own transaction
2. Fast cleanup — rollbacks are near-instantaneous
3. Parallel test execution — no shared state between tests
This pattern has helped us achieve a 73% improvement in test execution speed.
The Core Implementation
Our createTransactionalTypeOrmModule() function creates a TypeORM module that injects transactional behavior into the dependency tree:
export interface TransactionalTestContext {dataSource: DataSource;
queryRunner: QueryRunner;
}
export function createTransactionalTypeOrmModule() {
let dataSource: DataSource;
let queryRunner: QueryRunner;
const module = TypeOrmModule.forRootAsync({
useFactory: async () => {
return {
synchronize: false,
// ...rest of the typeorm configs
}
},
dataSourceFactory: async (options) => {
if (!options) {
// this is the same as the typeorm config from
// useFactory above
throw new Error('DataSource options are required');
}
// This gets the dataSource config that was already created by TypeOrmModule
// in the useFactory above and uses it to create a new dataSource that we
// can override.
const newDataSource = new DataSource(options);
await newDataSource.initialize();
// New dataSource now allows us to create a queryRunner
// which we can use to run transactions.
queryRunner = newDataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
// Override the "newDataSource". We keep it mostly the same
// but update key properties and functions so that the
// transaction connection is injected into the rest of the module
// and any dependencies.
//
// Note: We use Object.assign to preserve methods because splat (...)
// only preserves properties.
const transactionalDataSource = Object.assign(newDataSource, {
manager: queryRunner.manager,
createEntityManager: () => queryRunner.manager,
getRepository: (target: any) =>
queryRunner.manager.getRepository(target),
});
dataSource = transactionalDataSource;
return transactionalDataSource;
},
});
// return the module as well as a "getContext" function so that
// we can refer to the dataSource and queryRunner in our tests
return {
module,
getContext: (): TransactionalTestContext => {
if (!dataSource || !queryRunner) {
throw new Error(
'Context not yet initialized. Make sure the module has been compiled and initialized.',
);
}
return { dataSource, queryRunner };
},
};
}
How It Works
The key insight is in the Object.assign() call that overrides critical DataSource methods using our transaction connection:
• manager: Routes all entity operations through the transactional manager
• createEntityManager(): Ensures any new entity manager uses the transaction
• getRepository(): All repository instances use the transactional connection
This means that every database operation within the test — whether from services, repositories, or direct manager calls — automatically participates in the same transaction.
Here’s how to use this new function in a test:
describe('ExampleService', () => {let service: ExampleService;
let moduleRef: TestingModule;
let dataSource: DataSource;
let queryRunner: QueryRunner;
let testContext: TransactionalTestContext;
beforeEach(async () => {
const { module, getContext } = createTransactionalTypeOrmModule();
moduleRef = await Test.createTestingModule({
imports: [
module, // use the TypeOrmModule with the transaction connection
TypeOrmModule.forFeature([ExampleUserEntity])],
providers: [ExampleService],
}).compile();
service = moduleRef.get<ExampleService>(ExampleService);
testContext = getContext();
dataSource = testContext.dataSource;
queryRunner = testContext.queryRunner;
//...make sure to set up any seeders or factories using the new dataSource
});
afterEach(async () => {
// rollback transactions after every test
await queryRunner.rollbackTransaction();
await dataSource.destroy();
await moduleRef.close();
});
describe('exampleFn', () => {
it('should return user', async () => {
// Create a new "user" using the transaction
// query runner:
const newUserDb = await queryRunner.manager
.getRepository(ExampleUserEntity)
.save({
firstName: 'Test',
lastName: 'User',
email: 'email@example',
});
const user = await service.getOne();
expect(user?.lastName).toBe('User');
});
});
});
Implementation Considerations
- QueryRunner Management: Each test gets its own QueryRunner instance, ensuring complete isolation. The QueryRunner is properly cleaned up in the afterEach hook.
- Factory Integration: Ensure that all fatories are initialized with transactional DataSource.
- Service Integration: Services and repositories automatically use the transaction connection because we override the DataSource’s core methods. No changes to business logic required.
Limitations and Trade-offs
- Memory Usage: Long-running transactions can consume more memory, but this is typically not an issue for unit tests with focused scopes
- Transaction-Specific Features: Some database features that require committed data (like certain triggers or constraints) might behave differently in a transactional context.
Did you find this interesting? We’re hiring software engineers at Mae!
Mae is a digital health solution on a mission to improve the health and quality of life for mothers, babies, and those who love them. Mae has created a space where complete digital care meets culturally responsive, on-the-ground support. We address access gaps and bolster physical and emotional well-being through continuous engagement, risk assessment, early symptom awareness, and a community-led model of support for our users.
If this is something you’d like to be a part of, apply here (and mention this blog post)!