Testing TypeORM: Unit vs Integration Testing for Entities, Repositories, and Relations
```tldr TypeORM testing spans two distinct strategies: fast unit tests that mock the DataSource and repositories, and slower but trustworthy integration tests that run against a real database. This post walks through both approaches — from validating entity schemas with in-memory SQLite to spinning up PostgreSQL with TestContainers — and gives you a practical decision framework for choosing between them. ```
```takeaways **Use in-memory SQLite for entity schema validation** — it catches column mismatches, missing decorators, and relation configuration errors without a real database and completes in milliseconds. **Mock repositories at the interface boundary, not the implementation** — stub Repository<T> methods like findOne, save, and find rather than internal TypeORM internals to keep tests resilient to upgrades. **Custom repositories need both unit and integration coverage** — unit tests verify business logic branching; integration tests verify the SQL that actually executes. **Circular relations require careful eager-loading configuration in tests** — relations option arrays must be explicit or you will hit stack overflows during entity serialization in test assertions. **Integration tests with TestContainers are the only reliable way to test migrations and complex queries** — SQLite diverges enough from PostgreSQL that aggregate queries, JSON operators, and full-text search must run against the real engine. ```
```toc ```
Testing database code is one of the most frequently deferred tasks in backend development. TypeORM, with its decorator-heavy entity definitions and flexible repository pattern, adds its own layer of complexity. When a relation breaks or a column mapping silently fails, you often find out in production rather than in CI.
This post covers the full spectrum: lightweight entity validation with in-memory SQLite, repository mocking for unit-speed feedback, and real-database integration tests using TestContainers. By the end you will have a testing strategy that fits teams of any size and CI budget.
Why TypeORM Needs Its Own Testing Strategy
TypeORM sits at the intersection of TypeScript's type system and a database engine's runtime behavior. A perfectly typed entity can still fail at runtime because:
- A column decorator is missing or misconfigured
- A relation's
joinColumnpoints to a non-existent foreign key - A custom repository method constructs a query that PostgreSQL rejects
- A migration changes a column type that entity code still assumes is the old type
Static analysis catches none of these. Only tests that exercise the ORM layer will surface them before they reach users.
Setting Up an In-Memory SQLite DataSource
For entity schema validation and repository unit tests, SQLite running in-memory gives you a real relational engine at near-zero cost. Install the driver alongside TypeORM:
```bash npm install --save-dev better-sqlite3 @types/better-sqlite3 ```
Create a test DataSource factory:
```typescript // test/utils/createTestDataSource.ts import { DataSource, DataSourceOptions } from 'typeorm';
export async function createTestDataSource( entities: DataSourceOptions['entities'] ): Promise { const dataSource = new DataSource({ type: 'better-sqlite3', database: ':memory:', entities, synchronize: true, // auto-creates tables from entity metadata logging: false, });
await dataSource.initialize(); return dataSource; } ```
The synchronize: true flag is safe in tests — it runs CREATE TABLE statements derived directly from your entity decorators, which is exactly what you want to validate.
Testing Entity Schema Validation
Entity schema tests answer a focused question: does the entity's decorator configuration produce the table structure you intend?
```typescript // src/entities/User.ts import { Entity, PrimaryGeneratedColumn, Column, OneToMany } from 'typeorm'; import { Post } from './Post';
@Entity() export class User { @PrimaryGeneratedColumn('uuid') id: string;
@Column({ unique: true }) email: string;
@Column({ nullable: true }) displayName: string | null;
@OneToMany(() => Post, post => post.author) posts: Post[]; } ```
```typescript // test/entities/user.entity.spec.ts import { DataSource } from 'typeorm'; import { createTestDataSource } from '../utils/createTestDataSource'; import { User } from '../../src/entities/User'; import { Post } from '../../src/entities/Post';
describe('User entity schema', () => { let dataSource: DataSource;
beforeAll(async () => { dataSource = await createTestDataSource([User, Post]); });
afterAll(async () => { await dataSource.destroy(); });
it('creates the users table with expected columns', async () => { const columns = await dataSource.query( PRAGMA table_info(user) ); const columnNames = columns.map((c: any) => c.name);
expect(columnNames).toContain('id');
expect(columnNames).toContain('email');
expect(columnNames).toContain('displayName');});
it('enforces unique constraint on email', async () => { const repo = dataSource.getRepository(User); await repo.save({ email: 'a@example.com', displayName: null });
await expect(
repo.save({ email: 'a@example.com', displayName: null })
).rejects.toThrow();});
it('allows null displayName', async () => { const repo = dataSource.getRepository(User); const user = await repo.save({ email: 'b@example.com', displayName: null });
expect(user.displayName).toBeNull();}); }); ```
These tests run in under 100ms and catch the most common decorator mistakes before any integration infrastructure is involved.
Mocking Repositories for Unit Tests
When testing service classes that depend on TypeORM repositories, you want millisecond feedback without any database. The key is to mock the Repository<T> interface rather than TypeORM internals.
```typescript // src/services/UserService.ts import { Repository } from 'typeorm'; import { User } from '../entities/User';
export class UserService { constructor(private readonly userRepo: Repository) {}
async findByEmail(email: string): Promise<User | null> { return this.userRepo.findOne({ where: { email }, relations: ['posts'], }); }
async createUser(email: string, displayName: string): Promise { const existing = await this.userRepo.findOne({ where: { email } }); if (existing) { throw new Error(User with email ${email} already exists); } return this.userRepo.save({ email, displayName }); } } ```
```typescript // test/services/UserService.unit.spec.ts import { Repository } from 'typeorm'; import { UserService } from '../../src/services/UserService'; import { User } from '../../src/entities/User';
function createMockRepo(): jest.Mocked<Repository> { return { findOne: jest.fn(), save: jest.fn(), find: jest.fn(), delete: jest.fn(), } as unknown as jest.Mocked<Repository>; }
describe('UserService (unit)', () => { let service: UserService; let mockRepo: jest.Mocked<Repository>;
beforeEach(() => { mockRepo = createMockRepo(); service = new UserService(mockRepo); });
it('returns null when user is not found', async () => { mockRepo.findOne.mockResolvedValue(null);
const result = await service.findByEmail('missing@example.com');
expect(result).toBeNull();
expect(mockRepo.findOne).toHaveBeenCalledWith({
where: { email: 'missing@example.com' },
relations: ['posts'],
});});
it('throws when creating a duplicate user', async () => { const existingUser = { id: '1', email: 'dup@example.com' } as User; mockRepo.findOne.mockResolvedValue(existingUser);
await expect(
service.createUser('dup@example.com', 'Duplicate')
).rejects.toThrow('already exists');});
it('saves and returns a new user', async () => { mockRepo.findOne.mockResolvedValue(null); const saved = { id: 'new-id', email: 'new@example.com' } as User; mockRepo.save.mockResolvedValue(saved);
const result = await service.createUser('new@example.com', 'New User');
expect(result.id).toBe('new-id');}); }); ```
This suite runs in under 10ms. It tests every branch in UserService without touching a database.
Testing Custom Repositories
Custom repositories extend TypeORM's Repository class with domain-specific query methods. They need both unit tests (for branching logic) and integration tests (for the actual SQL).
```typescript // src/repositories/PostRepository.ts import { DataSource, Repository } from 'typeorm'; import { Post } from '../entities/Post';
export class PostRepository extends Repository { constructor(dataSource: DataSource) { super(Post, dataSource.createEntityManager()); }
async findPublishedByAuthor(authorId: string): Promise<Post[]> { return this.createQueryBuilder('post') .leftJoinAndSelect('post.author', 'author') .where('post.authorId = :authorId', { authorId }) .andWhere('post.published = :published', { published: true }) .orderBy('post.createdAt', 'DESC') .getMany(); } } ```
The createQueryBuilder chain cannot be reliably unit-tested through mocking — the builder is a fluent interface with dozens of internal state mutations. This is exactly where integration tests earn their cost.
Testing Circular Relations
Circular relations — where User has Post[] and Post has User — are a common source of infinite recursion during JSON serialization in tests.
```typescript it('loads user with posts without circular serialization error', async () => { const repo = dataSource.getRepository(User); const user = await repo.save({ email: 'c@example.com', displayName: 'C' });
const postRepo = dataSource.getRepository(Post); await postRepo.save({ title: 'Hello', published: true, author: user });
// Explicitly request only one level of relations const loaded = await repo.findOne({ where: { id: user.id }, relations: ['posts'], // do NOT also load posts.author here });
expect(loaded!.posts).toHaveLength(1); // Avoid JSON.stringify(loaded) — circular reference will throw expect(loaded!.posts[0].title).toBe('Hello'); }); ```
The critical rule: never serialize an entity that has a bidirectional relation loaded on both sides. Assert on specific properties rather than snapshot-serializing the whole object.
Integration Tests with TestContainers
When you need PostgreSQL-specific behavior — JSON operators, ILIKE, RETURNING clauses, full-text search — SQLite is not a faithful substitute. TestContainers spins up a real PostgreSQL container per test suite.
```bash npm install --save-dev testcontainers ```
```typescript // test/utils/createPostgresDataSource.ts import { PostgreSqlContainer, StartedPostgreSqlContainer } from 'testcontainers'; import { DataSource } from 'typeorm'; import { User } from '../../src/entities/User'; import { Post } from '../../src/entities/Post';
export async function startPostgresContainer(): Promise<{ container: StartedPostgreSqlContainer; dataSource: DataSource; }> { const container = await new PostgreSqlContainer('postgres:16-alpine') .withDatabase('testdb') .withUsername('test') .withPassword('test') .start();
const dataSource = new DataSource({ type: 'postgres', host: container.getHost(), port: container.getPort(), database: container.getDatabase(), username: container.getUsername(), password: container.getPassword(), entities: [User, Post], synchronize: true, logging: false, });
await dataSource.initialize(); return { container, dataSource }; } ```
```typescript // test/repositories/PostRepository.integration.spec.ts import { DataSource } from 'typeorm'; import { StartedPostgreSqlContainer } from 'testcontainers'; import { startPostgresContainer } from '../utils/createPostgresDataSource'; import { PostRepository } from '../../src/repositories/PostRepository'; import { User } from '../../src/entities/User'; import { Post } from '../../src/entities/Post';
describe('PostRepository (integration)', () => { let container: StartedPostgreSqlContainer; let dataSource: DataSource; let postRepo: PostRepository; let testUser: User;
beforeAll(async () => { ({ container, dataSource } = await startPostgresContainer()); postRepo = new PostRepository(dataSource);
const userRepo = dataSource.getRepository(User);
testUser = await userRepo.save({ email: 'd@example.com', displayName: 'D' });}, 60_000);
afterAll(async () => { await dataSource.destroy(); await container.stop(); });
beforeEach(async () => { await dataSource.getRepository(Post).delete({}); });
it('returns only published posts for the given author', async () => { await dataSource.getRepository(Post).save([ { title: 'Published', published: true, author: testUser }, { title: 'Draft', published: false, author: testUser }, ]);
const results = await postRepo.findPublishedByAuthor(testUser.id);
expect(results).toHaveLength(1);
expect(results[0].title).toBe('Published');
expect(results[0].author.email).toBe('d@example.com');});
it('returns posts in descending creation order', async () => { await dataSource.getRepository(Post).save([ { title: 'First', published: true, author: testUser }, { title: 'Second', published: true, author: testUser }, ]);
const results = await postRepo.findPublishedByAuthor(testUser.id);
expect(results[0].title).toBe('Second');}); }); ```
Unit vs Integration: The Decision Framework
Neither strategy replaces the other. Use this decision table:
| Scenario | Unit (SQLite/mock) | Integration (TestContainers) |
|---|---|---|
| Entity decorator validation | Yes | Overkill |
| Service business logic | Yes (mock repo) | Not needed |
| Custom query builder methods | No | Yes |
| Migration correctness | No | Yes |
| JSON/array column operators | No | Yes |
| Constraint enforcement | SQLite sufficient | Use Postgres for production parity |
| CI cost sensitivity | Milliseconds | 30-60s container startup |
A practical split for most teams: run unit and SQLite tests on every commit, run TestContainers integration tests on pull requests and nightly.
Common Pitfalls
Forgetting afterAll cleanup — a DataSource left open after tests will hang the Jest process. Always call dataSource.destroy() and container.stop() in afterAll.
Reusing a DataSource across unrelated test files — TypeORM's entity manager holds state. Sharing a DataSource between test files without clearing data leads to cross-test pollution. Use beforeEach truncation or a fresh DataSource per suite.
Testing the wrong layer — testing that save() was called is testing TypeORM, not your code. Mock repositories at the service boundary and assert on the behavior visible to callers, not on the ORM calls made internally.
SQLite type affinity surprises — SQLite stores booleans as integers and has no native UUID type. Unit tests with SQLite will pass for boolean columns even when you accidentally query with the string "true". Always validate boolean and UUID behavior against PostgreSQL in at least one integration test.
Conclusion
TypeORM testing is not a single tool problem. Entity schema validation belongs in fast SQLite tests. Service logic belongs in mock-based unit tests. Complex queries and migrations belong in TestContainers integration tests. Layer these three strategies and you get comprehensive coverage with a CI pipeline that stays fast enough to run on every push.