abelcastro.dev

Testing Strategies for a NestJS + Mikro-ORM App with Jest

2024-09-20

TypeScriptNestJSMikro-ORM

When building an application with NestJS and Mikro-ORM in TypeScript, ensuring proper testing is essential to maintain code quality and reliability. In this post, I will cover three main testing strategies for database-related operations, each with its pros and cons.

Option 1: In-Memory Database (SQLite as Driver)

In this approach, you set up an in-memory SQLite database during tests to simulate persistence without interacting with a real database.

Pros:

  • Entities persist, allowing you to perform actual database operations and queries.
  • Tests remain relatively fast because no external DB connection is required.

Cons:

  • SQLite might behave differently from your production database (e.g., PostgreSQL). This can result in misleading tests, especially for complex queries or schema-related features.
  • Mikro-ORM's discussion has discouraged this approach due to potential discrepancies, but the Mikro-ORM repository still uses it in some tests.

Example: Setting up an In-Memory SQLite Database

import { MikroORM } from '@mikro-orm/core';
import { User } from './user.entity'; // example entity
import { SqliteDriver } from '@mikro-orm/sqlite';

describe('User Service - In-Memory DB', () => {
  let orm: MikroORM;

  beforeAll(async () => {
    orm = await MikroORM.init({
      entities: [User],
      dbName: ':memory:',
      type: 'sqlite',
    });

    const generator = orm.getSchemaGenerator();
    await generator.createSchema();
  });

  afterAll(async () => {
    await orm.close(true);
  });

  it('should persist and retrieve a user entity', async () => {
    const userRepo = orm.em.getRepository(User);
    const user = userRepo.create({ name: 'John Doe' });
    
    await userRepo.persistAndFlush(user);
    
    const retrievedUser = await userRepo.findOne({ name: 'John Doe' });
    expect(retrievedUser).toBeDefined();
    expect(retrievedUser.name).toBe('John Doe');
  });
});

This setup is relatively straightforward, but keep in mind the limitations regarding database compatibility. Note also this approach is not recommended by the Mikro-ORM creator but in the Mikro-ORM repo it is used anyway for some tests.

Option 2: Same Driver, No Database Connection (Mock Queries)

Another option is to initialize Mikro-ORM with the same driver you'd use in production but prevent it from connecting to a real database by setting connect: false. This can be a quick setup, especially when you don't need to run real database queries.

Pros:

  • Simple to set up.
  • No real database connection required, meaning no external dependency.

Cons:

  • Since the database isn’t connected, you can’t make real queries.
  • You’ll likely end up mocking database operations, which can lead to less meaningful tests.

Example: Mocking Queries with No DB Connection

import { MikroORM } from '@mikro-orm/core';
import { User } from './user.entity';

describe('User Service - No DB Connection', () => {
  let orm: MikroORM;

  beforeAll(async () => {
    orm = await MikroORM.init({
      entities: [User],
      dbName: 'test-db',
      type: 'postgresql', // same as production
      connect: false, // prevent real connection
    });
  });

  it('should mock user creation and retrieval', async () => {
    const mockUser = { id: 1, name: 'Mock User' };
    
    const userRepo = orm.em.getRepository(User);
    
    jest.spyOn(userRepo, 'persistAndFlush').mockImplementation(async () => mockUser);
    jest.spyOn(userRepo, 'findOne').mockResolvedValue(mockUser);
    
    await userRepo.persistAndFlush(mockUser);
    const foundUser = await userRepo.findOne({ name: 'Mock User' });
    
    expect(foundUser).toBeDefined();
    expect(foundUser.name).toBe('Mock User');
  });
});

This approach works well for unit tests where database interaction is mocked. However, the lack of actual persistence may make your tests less reliable.

Option 3: Mocking Everything

Mocking everything is an approach where you mock both the repository methods and any related services to simulate the behavior of the database without involving the actual ORM operations. See example an example in the nestjs-realworld-example-app here.

Pros:

  • Tests run extremely fast because no real database or ORM is involved.
  • Full control over the behavior of mocked services and repositories.

Cons:

  • Requires significant mocking effort, which can make tests harder to maintain and understand.
  • Mocking too much might lead to tests that are disconnected from reality.

Example: Fully Mocked Service and Repository

import { Test, TestingModule } from '@nestjs/testing';
import { UserService } from './user.service';
import { User } from './user.entity';
import { getRepositoryToken } from '@mikro-orm/nestjs';

describe('User Service - Full Mock', () => {
  let userService: UserService;
  const mockRepository = {
    persistAndFlush: jest.fn(),
    findOne: jest.fn(),
  };

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        UserService,
        { provide: getRepositoryToken(User), useValue: mockRepository },
      ],
    }).compile();

    userService = module.get<UserService>(UserService);
  });

  it('should create and return a user', async () => {
    const mockUser = { id: 1, name: 'Mock User' };
    mockRepository.persistAndFlush.mockResolvedValue(mockUser);
    mockRepository.findOne.mockResolvedValue(mockUser);
    
    const createdUser = await userService.create({ name: 'Mock User' });
    const foundUser = await userService.findOne({ name: 'Mock User' });
    
    expect(createdUser).toEqual(mockUser);
    expect(foundUser).toEqual(mockUser);
  });
});

This is particularly useful in unit tests where the focus is on testing business logic rather than database interaction.

Conclusion

Choosing the right testing strategy depends on the scope and type of your tests:

  • In-Memory DB (Option 1) is great for integration tests that closely mimic production behavior, but be cautious of differences between SQLite and your production DB.
  • No DB Connection (Option 2) simplifies the setup but limits real database operations, which may force you to rely on mocking.
  • Mock Everything (Option 3) provides full control and is the fastest, but the tests might lose touch with actual database behavior, which could cause issues later.

Consider mixing and matching these approaches based on the requirements of your project to balance accuracy, speed, and simplicity.