TypeORM Testing Guide: Entities, Repositories, and Migrations

TypeORM Testing Guide: Entities, Repositories, and Migrations

TypeORM remains one of the most widely deployed ORMs in the Node.js ecosystem, powering everything from enterprise APIs to startup MVPs. Its decorator-based entity system and active record/data mapper duality give you flexibility, but they also create testing challenges that are distinct from newer ORMs like Prisma or Drizzle.

This guide focuses on three areas that TypeORM teams consistently struggle with: testing entity logic in isolation, mocking the repository layer cleanly, and ensuring migrations work correctly in CI.

Understanding TypeORM's Testing Challenges

TypeORM introduces a few patterns that complicate testing:

  • Decorator-heavy entities — entity classes use decorators (@Entity, @Column, @ManyToOne) that require TypeORM's metadata infrastructure to be loaded before any test code runs.
  • Active Record vs. Data Mapper — if you use the active record pattern (BaseEntity), entities have static methods that are harder to mock than injected repositories.
  • DataSource management — TypeORM requires a DataSource to be initialized before queries can run, which affects how you structure test setup and teardown.

The good news: TypeORM's design also makes it relatively straightforward to test if you follow the data mapper pattern with custom repositories.

Setting Up TypeORM for Testing

Start with a test-specific DataSource configuration:

// src/database/test-datasource.ts
import { DataSource } from 'typeorm';
import { User } from '../entities/User';
import { Post } from '../entities/Post';

export const TestDataSource = new DataSource({
  type: 'postgres',
  url: process.env.TEST_DATABASE_URL ??
    'postgresql://postgres:testpass@localhost:5433/typeorm_test',
  entities: [User, Post],
  synchronize: false, // Always use migrations, never synchronize
  dropSchema: false,
  logging: false,
});

export async function initTestDatabase() {
  if (!TestDataSource.isInitialized) {
    await TestDataSource.initialize();
  }
  return TestDataSource;
}

export async function closeTestDatabase() {
  if (TestDataSource.isInitialized) {
    await TestDataSource.destroy();
  }
}

For unit tests, you need to make sure TypeORM's metadata is loaded without initializing a real database connection:

// vitest.setup.ts
import 'reflect-metadata'; // REQUIRED — TypeORM decorators need this

import { vi } from 'vitest';

// Prevent TypeORM from trying to connect during unit tests
vi.mock('../database/datasource', () => ({
  AppDataSource: {
    getRepository: vi.fn(),
    manager: { transaction: vi.fn() },
    isInitialized: true,
  },
}));

Testing Entities in Isolation

TypeORM entities often contain validation logic, computed properties, and lifecycle hooks that are worth testing independently of the database. Start by testing what you can without a database connection:

// src/entities/User.ts
import {
  Entity,
  PrimaryGeneratedColumn,
  Column,
  BeforeInsert,
  BeforeUpdate,
  OneToMany,
  CreateDateColumn,
} from 'typeorm';
import * as bcrypt from 'bcrypt';
import { Post } from './Post';

@Entity('users')
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({ unique: true })
  email: string;

  @Column()
  passwordHash: string;

  @Column({ default: 'active' })
  status: 'active' | 'suspended' | 'deleted';

  @OneToMany(() => Post, (post) => post.author)
  posts: Post[];

  @CreateDateColumn()
  createdAt: Date;

  @BeforeInsert()
  @BeforeUpdate()
  async hashPassword() {
    if (this.passwordHash && !this.passwordHash.startsWith('$2')) {
      this.passwordHash = await bcrypt.hash(this.passwordHash, 10);
    }
  }

  async validatePassword(plaintext: string): Promise<boolean> {
    return bcrypt.compare(plaintext, this.passwordHash);
  }

  get isActive(): boolean {
    return this.status === 'active';
  }
}
// src/entities/User.test.ts
import 'reflect-metadata';
import { describe, it, expect, vi } from 'vitest';
import { User } from './User';
import * as bcrypt from 'bcrypt';

describe('User Entity', () => {
  it('hashes password on insert', async () => {
    const user = new User();
    user.email = 'test@example.com';
    user.passwordHash = 'plaintext-password';

    await user.hashPassword();

    expect(user.passwordHash).not.toBe('plaintext-password');
    expect(user.passwordHash).toMatch(/^\$2/); // bcrypt hash prefix
  });

  it('does not double-hash an already hashed password', async () => {
    const user = new User();
    user.passwordHash = await bcrypt.hash('password', 10);
    const originalHash = user.passwordHash;

    await user.hashPassword();

    expect(user.passwordHash).toBe(originalHash);
  });

  it('validates correct password', async () => {
    const user = new User();
    user.passwordHash = await bcrypt.hash('correct-password', 10);

    expect(await user.validatePassword('correct-password')).toBe(true);
    expect(await user.validatePassword('wrong-password')).toBe(false);
  });

  it('isActive returns true for active status', () => {
    const user = new User();
    user.status = 'active';
    expect(user.isActive).toBe(true);

    user.status = 'suspended';
    expect(user.isActive).toBe(false);
  });
});

These entity-level tests run without any database connection and execute in milliseconds. They are the fastest feedback you can get on entity logic.

Custom Repository Pattern and Mocking

The data mapper pattern with custom repositories is the most testable TypeORM architecture. Instead of calling dataSource.getRepository(User) everywhere, wrap your database operations in typed repository classes:

// src/repositories/user.repository.ts
import { DataSource, Repository } from 'typeorm';
import { User } from '../entities/User';

export class UserRepository {
  private readonly repo: Repository<User>;

  constructor(dataSource: DataSource) {
    this.repo = dataSource.getRepository(User);
  }

  async findByEmail(email: string): Promise<User | null> {
    return this.repo.findOne({ where: { email } });
  }

  async findActiveUsers(): Promise<User[]> {
    return this.repo
      .createQueryBuilder('user')
      .where('user.status = :status', { status: 'active' })
      .orderBy('user.createdAt', 'DESC')
      .getMany();
  }

  async create(data: Partial<User>): Promise<User> {
    const user = this.repo.create(data);
    return this.repo.save(user);
  }

  async softDelete(id: number): Promise<void> {
    await this.repo.update(id, { status: 'deleted' });
  }
}

Now your service layer takes the repository as a dependency:

// src/services/auth.service.ts
export class AuthService {
  constructor(private userRepo: UserRepository) {}

  async login(email: string, password: string) {
    const user = await this.userRepo.findByEmail(email);
    if (!user || !user.isActive) {
      throw new Error('Invalid credentials');
    }
    const valid = await user.validatePassword(password);
    if (!valid) throw new Error('Invalid credentials');
    return user;
  }
}

Mocking the repository in service tests becomes trivial:

// src/services/auth.service.test.ts
import 'reflect-metadata';
import { describe, it, expect, vi } from 'vitest';
import { AuthService } from './auth.service';
import { User } from '../entities/User';
import * as bcrypt from 'bcrypt';

function createMockUserRepository() {
  return {
    findByEmail: vi.fn(),
    findActiveUsers: vi.fn(),
    create: vi.fn(),
    softDelete: vi.fn(),
  };
}

describe('AuthService', () => {
  it('throws on inactive user', async () => {
    const mockRepo = createMockUserRepository();
    const user = new User();
    user.status = 'suspended';
    mockRepo.findByEmail.mockResolvedValue(user);

    const service = new AuthService(mockRepo as any);
    await expect(service.login('test@example.com', 'password')).rejects.toThrow(
      'Invalid credentials'
    );
  });

  it('throws on wrong password', async () => {
    const mockRepo = createMockUserRepository();
    const user = new User();
    user.status = 'active';
    user.passwordHash = await bcrypt.hash('correct', 10);
    mockRepo.findByEmail.mockResolvedValue(user);

    const service = new AuthService(mockRepo as any);
    await expect(service.login('test@example.com', 'wrong')).rejects.toThrow(
      'Invalid credentials'
    );
  });

  it('returns user on successful login', async () => {
    const mockRepo = createMockUserRepository();
    const user = new User();
    user.status = 'active';
    user.email = 'test@example.com';
    user.passwordHash = await bcrypt.hash('correct', 10);
    mockRepo.findByEmail.mockResolvedValue(user);

    const service = new AuthService(mockRepo as any);
    const result = await service.login('test@example.com', 'correct');
    expect(result.email).toBe('test@example.com');
  });
});

Integration Tests with TypeORM

For integration tests that touch the real database, use transaction-based isolation:

// src/repositories/user.repository.integration.test.ts
import 'reflect-metadata';
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
import { TestDataSource, initTestDatabase, closeTestDatabase } from '../database/test-datasource';
import { UserRepository } from './user.repository';
import { runMigrations } from '../database/migrations';

describe('UserRepository Integration', () => {
  beforeAll(async () => {
    await initTestDatabase();
    await runMigrations(TestDataSource);
  });

  afterAll(closeTestDatabase);

  afterEach(async () => {
    // Clean up test data
    await TestDataSource.getRepository('Post').delete({});
    await TestDataSource.getRepository('User').delete({});
  });

  it('finds user by email', async () => {
    const repo = new UserRepository(TestDataSource);

    const created = await repo.create({
      email: 'find@test.com',
      passwordHash: 'hashed',
      status: 'active',
    });

    const found = await repo.findByEmail('find@test.com');
    expect(found?.id).toBe(created.id);
  });

  it('returns only active users', async () => {
    const repo = new UserRepository(TestDataSource);

    await repo.create({ email: 'active@test.com', passwordHash: 'h', status: 'active' });
    await repo.create({ email: 'suspended@test.com', passwordHash: 'h', status: 'suspended' });

    const active = await repo.findActiveUsers();
    expect(active.every((u) => u.status === 'active')).toBe(true);
    expect(active.find((u) => u.email === 'suspended@test.com')).toBeUndefined();
  });
});

Testing Migrations

TypeORM migrations deserve their own test coverage. A broken migration that reaches production is one of the most painful incidents a team can face. Run migration tests in CI:

// src/database/migrations.test.ts
import 'reflect-metadata';
import { describe, it, expect } from 'vitest';
import { DataSource } from 'typeorm';

describe('Database Migrations', () => {
  let dataSource: DataSource;

  beforeEach(async () => {
    dataSource = new DataSource({
      type: 'postgres',
      url: process.env.TEST_DATABASE_URL,
      entities: ['src/entities/**/*.ts'],
      migrations: ['src/database/migrations/**/*.ts'],
      synchronize: false,
    });
    await dataSource.initialize();
  });

  afterEach(async () => {
    await dataSource.dropDatabase();
    await dataSource.destroy();
  });

  it('applies all pending migrations without error', async () => {
    await expect(dataSource.runMigrations()).resolves.not.toThrow();
  });

  it('reverts most recent migration cleanly', async () => {
    await dataSource.runMigrations();
    await expect(dataSource.undoLastMigration()).resolves.not.toThrow();
    
    // Re-apply to verify idempotency
    await expect(dataSource.runMigrations()).resolves.not.toThrow();
  });

  it('schema matches entity definitions after migrations', async () => {
    await dataSource.runMigrations();
    
    // Check for schema drift — if this is not empty, 
    // your entities and migrations are out of sync
    const pendingMigrations = await dataSource.showMigrations();
    expect(pendingMigrations).toBe(false); // false = no pending migrations
  });
});

The final test — checking for schema drift — is the most valuable. If your TypeORM entities were modified and a migration wasn't generated, showMigrations() returns true, catching the problem before it reaches production.

Testing Query Builders

TypeORM's QueryBuilder is powerful but error-prone. Test complex queries with integration tests rather than trying to mock the builder chain:

it('paginates results correctly', async () => {
  const repo = new UserRepository(TestDataSource);
  
  // Create 15 users
  for (let i = 0; i < 15; i++) {
    await repo.create({
      email: `user${i}@test.com`,
      passwordHash: 'h',
      status: 'active',
    });
  }

  const page1 = await repo.paginate({ page: 1, limit: 10 });
  const page2 = await repo.paginate({ page: 2, limit: 10 });

  expect(page1.data).toHaveLength(10);
  expect(page2.data).toHaveLength(5);
  expect(page1.total).toBe(15);
});

Adding E2E Coverage with HelpMeTest

Unit and integration tests cover the data layer thoroughly, but they don't validate that your API endpoints correctly expose that data to real users. HelpMeTest complements your TypeORM test suite by running Playwright-based E2E scenarios that exercise the full request-response cycle — from HTTP endpoint down through the service layer, into TypeORM, and back out as a JSON response.

This is especially valuable for TypeORM apps that use complex relations or query builder logic, because an E2E test will catch serialization bugs (entities sent as circular references, for example) that neither unit nor integration tests would reveal.

Summary

Testing TypeORM applications effectively means separating what you can test cheaply from what requires a real database:

  • Entity tests — pure TypeScript, no database, test validation and lifecycle hooks in milliseconds.
  • Service tests with mocked repositories — inject mock repository objects to test business logic independently.
  • Integration tests — run against a real test database, clean up with afterEach, and test actual query behavior.
  • Migration tests — verify all migrations apply and revert cleanly, and check for schema drift in CI.

The custom repository pattern is the key architectural decision that makes all of this tractable. Once your data access is behind a typed interface, every layer becomes independently testable.

Read more