Speeding Up NestJS Tests with DB Transactions

2 hours ago 1

Anna Kopp

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

  1. QueryRunner Management: Each test gets its own QueryRunner instance, ensuring complete isolation. The QueryRunner is properly cleaned up in the afterEach hook.
  2. Factory Integration: Ensure that all fatories are initialized with transactional DataSource.
  3. 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

  1. Memory Usage: Long-running transactions can consume more memory, but this is typically not an issue for unit tests with focused scopes
  2. 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)!

Read Entire Article