Testing TypeORM Components: From Unit to E2E Tests
TypeORM is one of the most popular Object-Relational Mappers (ORMs) in the Node.js ecosystem, especially when paired with TypeScript and frameworks like NestJS. While TypeORM abstracts database interactions beautifully, it also introduces complexity that can lead to subtle bugs — incorrect relations, lazy-loading surprises, migration drift, and query builder mistakes. A robust testing strategy is essential to keep your data layer reliable as your application grows.
This tutorial walks you through testing TypeORM components at every level: pure unit tests for entities and repositories, integration tests against a real database, and end-to-end (E2E) tests that validate the entire request-to-database flow. By the end, you'll have a complete, reusable testing blueprint.
Why Testing TypeORM Matters
ORMs hide SQL behind elegant TypeScript classes, but that abstraction can mask performance issues and logic errors. Without tests, you might ship code that works in development with a few rows but fails catastrophically in production with thousands. Testing your TypeORM components ensures:
- Data integrity: Relations, constraints, and cascades behave as expected.
- Query correctness: Query builders produce the intended SQL, especially with joins and pagination.
- Regression safety: Schema changes and migrations don't silently break existing features.
- Confidence in refactoring: You can swap strategies (e.g., from
findtoQueryBuilder) without fear.
Setting Up the Testing Environment
Before writing tests, you need a solid foundation. We'll use Jest as the test runner, along with sqlite in-memory databases for fast integration tests and a dedicated PostgreSQL instance (via Docker or testcontainers) for E2E tests.
Install the necessary dependencies:
npm install --save-dev jest ts-jest @types/jest supertest
npm install typeorm pg sqlite3
Create a jest.config.js file:
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src', '<rootDir>/test'],
testMatch: ['**/*.spec.ts', '**/*.e2e-spec.ts'],
moduleFileExtensions: ['ts', 'js', 'json'],
setupFilesAfterEnv: ['<rootDir>/test/setup.ts'],
clearMocks: true,
};
Defining a Sample Entity and Repository
To make the examples concrete, let's define a simple User entity and a custom repository. These will be the subjects of our tests throughout the tutorial.
// src/entities/user.entity.ts
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
OneToMany,
} from 'typeorm';
import { Post } from './post.entity';
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column({ unique: true })
email: string;
@Column()
name: string;
@Column({ default: true })
isActive: boolean;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
@OneToMany(() => Post, (post) => post.author)
posts: Post[];
}
// src/repositories/user.repository.ts
import { EntityRepository, Repository } from 'typeorm';
import { User } from '../entities/user.entity';
@EntityRepository(User)
export class UserRepository extends Repository<User> {
findActiveUsers(): Promise<User[]> {
return this.createQueryBuilder('user')
.where('user.isActive = :isActive', { isActive: true })
.orderBy('user.createdAt', 'DESC')
.getMany();
}
findByEmail(email: string): Promise<User | undefined> {
return this.findOne({ where: { email } });
}
}
Unit Testing Entities and Pure Logic
Unit tests focus on isolated pieces of logic. For TypeORM, this means testing entity methods, custom repository logic (with mocked dependencies), and any pure functions that transform data. The goal is speed and precision — no database, no network, no I/O.
Testing Entity Behavior
Entities often contain helper methods or computed properties. These are perfect candidates for unit tests because they don't require a database connection.
// test/unit/user.entity.spec.ts
import { User } from '../../src/entities/user.entity';
describe('User Entity', () => {
it('should create a user with default values', () => {
const user = new User();
user.email = 'test@example.com';
user.name = 'Test User';
expect(user.email).toBe('test@example.com');
expect(user.isActive).toBe(true);
expect(user.id).toBeUndefined();
});
it('should allow deactivating a user', () => {
const user = new User();
user.isActive = false;
expect(user.isActive).toBe(false);
});
it('should initialize posts as undefined before loading', () => {
const user = new User();
expect(user.posts).toBeUndefined();
});
});
Unit Testing Repositories with Mocks
Custom repositories often contain complex query logic. To unit test them, you mock the underlying TypeORM Repository methods. This lets you verify that the correct query is constructed without hitting a database.
// test/unit/user.repository.spec.ts
import { UserRepository } from '../../src/repositories/user.repository';
import { User } from '../../src/entities/user.entity';
describe('UserRepository', () => {
let repository: UserRepository;
let mockQueryBuilder: any;
beforeEach(() => {
repository = new UserRepository();
mockQueryBuilder = {
where: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
getMany: jest.fn(),
};
// Mock the createQueryBuilder inherited from Repository
(repository as any).createQueryBuilder = jest
.fn()
.mockReturnValue(mockQueryBuilder);
});
describe('findActiveUsers', () => {
it('should build a query filtering active users ordered by createdAt desc', async () => {
const expectedUsers = [
{ id: 1, email: 'a@test.com', isActive: true },
];
mockQueryBuilder.getMany.mockResolvedValue(expectedUsers);
const result = await repository.findActiveUsers();
expect(repository.createQueryBuilder).toHaveBeenCalledWith('user');
expect(mockQueryBuilder.where).toHaveBeenCalledWith(
'user.isActive = :isActive',
{ isActive: true }
);
expect(mockQueryBuilder.orderBy).toHaveBeenCalledWith(
'user.createdAt',
'DESC'
);
expect(result).toEqual(expectedUsers);
});
it('should return an empty array when no active users exist', async () => {
mockQueryBuilder.getMany.mockResolvedValue([]);
const result = await repository.findActiveUsers();
expect(result).toEqual([]);
});
});
describe('findByEmail', () => {
it('should call findOne with the correct where clause', async () => {
const mockUser = { id: 1, email: 'find@test.com' };
jest.spyOn(repository, 'findOne').mockResolvedValue(mockUser as any);
const result = await repository.findByEmail('find@test.com');
expect(repository.findOne).toHaveBeenCalledWith({
where: { email: 'find@test.com' },
});
expect(result).toEqual(mockUser);
});
it('should return undefined when user is not found', async () => {
jest.spyOn(repository, 'findOne').mockResolvedValue(undefined);
const result = await repository.findByEmail('missing@test.com');
expect(result).toBeUndefined();
});
});
});
Mocking the query builder chain is verbose but powerful. Each jest.fn().mockReturnThis() ensures the chainable methods return the builder, mimicking TypeORM's fluent API. This approach catches regressions in query construction without the overhead of a real database.
Integration Testing with an In-Memory Database
Unit tests with mocks verify that your code calls TypeORM correctly, but they don't verify that TypeORM itself behaves correctly with your schema. Integration tests bridge this gap by running real queries against a real (but ephemeral) database. SQLite's in-memory mode is ideal here — it's fast, requires no external services, and supports most TypeORM features.
Creating a Test Database Connection
// test/utils/testDb.ts
import { Connection, createConnection, ConnectionOptions } from 'typeorm';
import { User } from '../../src/entities/user.entity';
import { Post } from '../../src/entities/post.entity';
let connection: Connection;
export async function createTestConnection(): Promise<Connection> {
const options: ConnectionOptions = {
type: 'sqlite',
database: ':memory:',
entities: [User, Post],
synchronize: true,
logging: false,
};
connection = await createConnection(options);
return connection;
}
export async function closeTestConnection(): Promise<void> {
if (connection && connection.isConnected) {
await connection.close();
}
}
export async function clearDatabase(): Promise<void> {
const entities = connection.entityMetadatas;
for (const entity of entities) {
const repository = connection.getRepository(entity.name);
await repository.query(`DELETE FROM ${entity.tableName};`);
}
}
Writing Integration Tests
// test/integration/user.repository.integration.spec.ts
import { getCustomRepository } from 'typeorm';
import { createTestConnection, closeTestConnection, clearDatabase } from '../utils/testDb';
import { UserRepository } from '../../src/repositories/user.repository';
import { User } from '../../src/entities/user.entity';
describe('UserRepository (Integration)', () => {
let userRepository: UserRepository;
beforeAll(async () => {
const connection = await createTestConnection();
userRepository = getCustomRepository(UserRepository);
});
afterAll(async () => {
await closeTestConnection();
});
afterEach(async () => {
await clearDatabase();
});
describe('findActiveUsers', () => {
it('should return only active users', async () => {
await userRepository.save([
{ email: 'active@test.com', name: 'Active', isActive: true },
{ email: 'inactive@test.com', name: 'Inactive', isActive: false },
]);
const result = await userRepository.findActiveUsers();
expect(result).toHaveLength(1);
expect(result[0].email).toBe('active@test.com');
});
it('should order users by createdAt descending', async () => {
const older = userRepository.create({
email: 'older@test.com',
name: 'Older',
isActive: true,
});
older.createdAt = new Date('2023-01-01');
await userRepository.save(older);
const newer = userRepository.create({
email: 'newer@test.com',
name: 'Newer',
isActive: true,
});
newer.createdAt = new Date('2024-01-01');
await userRepository.save(newer);
const result = await userRepository.findActiveUsers();
expect(result[0].email).toBe('newer@test.com');
expect(result[1].email).toBe('older@test.com');
});
});
describe('findByEmail', () => {
it('should find a user by email', async () => {
await userRepository.save({
email: 'unique@test.com',
name: 'Unique',
});
const result = await userRepository.findByEmail('unique@test.com');
expect(result).toBeDefined();
expect(result?.name).toBe('Unique');
});
it('should enforce unique email constraint', async () => {
await userRepository.save({
email: 'dup@test.com',
name: 'First',
});
await expect(
userRepository.save({ email: 'dup@test.com', name: 'Second' })
).rejects.toThrow();
});
});
});
Notice how these tests verify real database behavior: the unique constraint actually fires, ordering actually works, and the schema is validated through synchronize: true. This catches issues that mocks never will, such as incorrect column types or missing indices.
Testing Relations and Cascades
Relations are where ORMs add the most value — and the most risk. Testing them thoroughly prevents orphaned records, unexpected N+1 queries, and cascade deletion bugs.
// test/integration/user-post.relation.spec.ts
import { getRepository, getCustomRepository } from 'typeorm';
import { createTestConnection, closeTestConnection, clearDatabase } from '../utils/testDb';
import { User } from '../../src/entities/user.entity';
import { Post } from '../../src/entities/post.entity';
import { UserRepository } from '../../src/repositories/user.repository';
describe('User-Post Relations', () => {
let userRepository: UserRepository;
let postRepository;
beforeAll(async () => {
await createTestConnection();
userRepository = getCustomRepository(UserRepository);
postRepository = getRepository(Post);
});
afterAll(async () => {
await closeTestConnection();
});
afterEach(async () => {
await clearDatabase();
});
it('should load a user with their posts using relations', async () => {
const user = await userRepository.save({
email: 'author@test.com',
name: 'Author',
});
await postRepository.save([
{ title: 'Post 1', content: 'Content 1', author: user },
{ title: 'Post 2', content: 'Content 2', author: user },
]);
const loadedUser = await userRepository.findOne({
where: { id: user.id },
relations: ['posts'],
});
expect(loadedUser?.posts).toHaveLength(2);
expect(loadedUser?.posts[0].title).toBeDefined();
});
it('should not load posts unless explicitly requested', async () => {
const user = await userRepository.save({
email: 'lazy@test.com',
name: 'Lazy',
});
await postRepository.save({
title: 'Lonely Post',
content: 'Content',
author: user,
});
const loadedUser = await userRepository.findOne({
where: { id: user.id },
});
expect(loadedUser?.posts).toBeUndefined();
});
});
End-to-End Testing the Full Stack
E2E tests validate the entire application stack: HTTP request, controller, service, repository, and database. These tests are slower but provide the highest confidence. For E2E tests, you should use a real database engine (PostgreSQL, MySQL) that matches production, because SQLite has subtle behavioral differences.
Setting Up an E2E Test with Express
// src/app.ts
import express from 'express';
import { Connection } from 'typeorm';
import { UserController } from './controllers/user.controller';
export function createApp(connection: Connection) {
const app = express();
app.use(express.json());
const userController = new UserController(connection);
app.post('/users', (req, res) => userController.create(req, res));
app.get('/users/:email', (req, res) => userController.getByEmail(req, res));
app.get('/users', (req, res) => userController.getActiveUsers(req, res));
return app;
}
// src/controllers/user.controller.ts
import { Request, Response } from 'express';
import { Connection } from 'typeorm';
import { UserRepository } from '../repositories/user.repository';
export class UserController {
private userRepository: UserRepository;
constructor(private connection: Connection) {
this.userRepository = connection.getCustomRepository(UserRepository);
}
async create(req: Request, res: Response) {
try {
const user = await this.userRepository.save(req.body);
return res.status(201).json(user);
} catch (err) {
return res.status(400).json({ error: 'Could not create user' });
}
}
async getByEmail(req: Request, res: Response) {
const user = await this.userRepository.findByEmail(req.params.email);
if (!user) return res.status(404).json({ error: 'User not found' });
return res.json(user);
}
async getActiveUsers(_req: Request, res: Response) {
const users = await this.userRepository.findActiveUsers();
return res.json(users);
}
}
Writing the E2E Test
// test/e2e/users.e2e-spec.ts
import request from 'supertest';
import { createConnection, Connection } from 'typeorm';
import { createApp } from '../../src/app';
import { User } from '../../src/entities/user.entity';
import { Post } from '../../src/entities/post.entity';
describe('Users E2E', () => {
let connection: Connection;
let app: Express.Application;
beforeAll(async () => {
connection = await createConnection({
type: 'postgres',
host: process.env.TEST_DB_HOST || 'localhost',
port: 5433,
username: 'test',
password: 'test',
database: 'e2e_test',
entities: [User, Post],
synchronize: true,
dropSchema: true,
logging: false,
});
app = createApp(connection) as any;
});
afterAll(async () => {
await connection.close();
});
afterEach(async () => {
const entities = connection.entityMetadatas;
for (const entity of entities) {
await connection.query(`DELETE FROM "${entity.tableName}";`);
}
});
describe('POST /users', () => {
it('should create a new user and return 201', async () => {
const res = await request(app)
.post('/users')
.send({ email: 'e2e@test.com', name: 'E2E User' })
.expect(201);
expect(res.body.email).toBe('e2e@test.com');
expect(res.body.isActive).toBe(true);
expect(res.body.id).toBeDefined();
});
it('should return 400 for duplicate email', async () => {
await request(app)
.post('/users')
.send({ email: 'dup@test.com', name: 'First' });
const res = await request(app)
.post('/users')
.send({ email: 'dup@test.com', name: 'Second' })
.expect(400);
expect(res.body.error).toBeDefined();
});
});
describe('GET /users/:email', () => {
it('should return a user by email', async () => {
await request(app)
.post('/users')
.send({ email: 'find@test.com', name: 'Find Me' });
const res = await request(app)
.get('/users/find@test.com')
.expect(200);
expect(res.body.email).toBe('find@test.com');
});
it('should return 404 for non-existent user', async () => {
const res = await request(app)
.get('/users/ghost@test.com')
.expect(404);
expect(res.body.error).toBe('User not found');
});
});
describe('GET /users', () => {
it('should return only active users', async () => {
await request(app)
.post('/users')
.send({ email: 'a@test.com', name: 'Active', isActive: true });
await request(app)
.post('/users')
.send({ email: 'b@test.com', name: 'Inactive', isActive: false });
const res = await request(app).get('/users').expect(200);
expect(res.body).toHaveLength(1);
expect(res.body[0].email).toBe('a@test.com');
});
});
});
Testing Migrations
Migrations are the backbone of schema evolution. Testing them ensures that your up and down methods are reversible and don't lose data. A common strategy is to run migrations against a fresh database, then verify the schema, then run the down migration and confirm the schema reverts.
// test/integration/migrations.spec.ts
import { createConnection, Connection, getConnectionOptions } from 'typeorm';
describe('Migrations', () => {
let connection: Connection;
beforeAll(async () => {
const baseOptions = await getConnectionOptions();
connection = await createConnection({
...baseOptions,
type: 'sqlite',
database: ':memory:',
migrationsRun: true,
synchronize: false,
entities: [],
});
});
afterAll(async () => {
await connection.close();
});
it('should have run all migrations', async () => {
const migrations = await connection.query(
'SELECT * FROM migrations ORDER BY timestamp DESC'
);
expect(migrations.length).toBeGreaterThan(0);
});
it('should have created the users table with expected columns', async () => {
const columns = await connection.query('PRAGMA table_info(users)');
const columnNames = columns.map((c: any) => c.name);
expect(columnNames).toContain('id');
expect(columnNames).toContain('email');
expect(columnNames).toContain('name');
expect(columnNames).toContain('isActive');
expect(columnNames).toContain('createdAt');
expect(columnNames).toContain('updatedAt');
});
});
Best Practices for Testing TypeORM
1. Use the Right Database for the Right Test Level
Use SQLite in-memory for unit and integration tests where speed matters. Use the same database engine as production (PostgreSQL, MySQL) for E2E tests where behavioral fidelity matters. SQLite and PostgreSQL handle types, JSON, and constraints differently — testing only against SQLite can hide production-only bugs.
2. Isolate Tests with Fresh Data
Each test should start with a clean slate. Use beforeEach or afterEach hooks to truncate tables. Never rely on test execution order. If tests share state, they become brittle and hard to debug.
// A robust cleanup helper
export async function resetDatabase(connection: Connection) {
const entities = connection.entityMetadatas;
await connection.query('SET FOREIGN_KEY_CHECKS = 0;'); // MySQL
for (const entity of entities) {
const repository = connection.getRepository(entity.name);
await repository.clear();
}
await connection.query('SET FOREIGN_KEY_CHECKS = 1;');
}
3. Avoid synchronize: true in Production
While synchronize: true is convenient in tests, it's dangerous in production because it can silently drop columns. Always test your migrations explicitly, and disable synchronization in production configurations.
4. Mock at the Right Boundary
When unit testing services that use repositories, mock the repository — not TypeORM internals. This keeps your mocks stable across TypeORM version upgrades.
// test/unit/user.service.spec.ts
import { UserService } from '../../src/services/user.service';
describe('UserService', () => {
let service: UserService;
let mockRepo: any;
beforeEach(() => {
mockRepo = {
findByEmail: jest.fn(),
save: jest.fn(),
findActiveUsers: jest.fn(),
};
service = new UserService(mockRepo);
});
it('should throw if user already exists', async () => {
mockRepo.findByEmail.mockResolvedValue({ id: 1, email: 'exists@test.com' });
await expect(
service.createUser({ email: 'exists@test.com', name: 'Dup' })
).rejects.toThrow('User already exists');
});
it('should create a user when email is available', async () => {
mockRepo.findByEmail.mockResolvedValue(undefined);
mockRepo.save.mockResolvedValue({ id: 1, email: 'new@test.com', name: 'New' });
const result = await service.createUser({
email: 'new@test.com',
name: 'New',
});
expect(result.id).toBe(1);
expect(mockRepo.save).toHaveBeenCalled();
});
});
5. Test Edge Cases for Query Builders
Query builders are powerful but error-prone. Test pagination, sorting, filtering, and joins explicitly. Pay special attention to SQL injection vectors — always use parameterized queries (:param syntax) and verify in tests that raw user input never reaches the query string unparameterized.
6. Use Testcontainers for Real Database E2E Tests
If you want true isolation without maintaining a separate test database, use testcontainers to spin up a disposable PostgreSQL instance per test run:
npm install --save-dev testcontainers @testcontainers/postgresql
// test/e2e/testcontainers-setup.ts
import { PostgreSqlContainer } from '@testcontainers/postgresql';
import { createConnection, Connection } from 'typeorm';
import { User } from '../../src/entities/user.entity';
let connection: Connection;
let container;
export async function setupTestDatabase(): Promise<Connection> {
container = await new PostgreSqlContainer('postgres:15')
.withDatabase('test')
.withUsername('test')
.withPassword('test')
.start();
connection = await createConnection({
type: 'postgres',
host: container.getHost(),
port: container.getMappedPort(5432),
username: container.getUsername(),
password: container.getPassword(),
database: container.getDatabase(),
entities: [User],
synchronize: true,
logging: false,
});
return connection;
}
export async function teardownTestDatabase(): Promise<void> {
if (connection?.isConnected) await connection.close();
if (container) await container.stop();
}
7. Snapshot Test Critical Queries
For complex query builders, consider logging the generated SQL and snapshotting it. This catches unintended query changes during refactoring.
it('should generate the expected SQL', async () => {
const sql = userRepository
.createQueryBuilder('user')
.where('user.isActive = :isActive', { isActive: true })
.orderBy('user.createdAt', 'DESC')
.getSql();
expect(sql).toMatchSnapshot();
});
Conclusion
Testing TypeORM components effectively requires a layered approach. Unit tests with mocks give you fast feedback on query construction and service logic. Integration tests against SQLite validate your schema, relations, and constraints. E2E tests against a production-matching database confirm that the entire stack works end to end. By combining these layers with disciplined practices — isolated test data, proper mocking boundaries, migration testing, and parameterized query verification — you build a safety net that catches bugs early and gives you the confidence to evolve your data layer without fear. Start with unit tests for immediate coverage, add integration tests for your most critical repositories, and layer in E2E tests for your highest-risk user flows. The investment pays for itself the first time a test catches a migration bug before it reaches production.