Repositories and Testability

Putting an interface and a token between a service and TypeORM so the service can be tested without a database.

Syntax{ provide: ORDER_STORE, useClass: TypeOrmOrderStore } // @Inject(ORDER_STORE) private store: OrderStore

A service that injects Repository<Order> is tied to TypeORM's whole API surface. Testing it means faking find, findOneBy, save, createQueryBuilder and whatever else gets used next month. The fix is a seam: an interface that lists only the operations the service needs, and a token under which some implementation of it is registered.

How the seam is built

Declare the interface and a const token. Write one implementation backed by TypeORM, register it with { provide: ORDER_STORE, useClass: TypeOrmOrderStore }, and export the token from the module. The service then asks for the token with @Inject and never names TypeORM at all.

Shorthand and longhand

Listing a class in providers is shorthand. These two entries mean the same thing:

providers: [OrdersService]

// longhand
providers: [{ provide: OrdersService, useClass: OrdersService }]

Which is why a custom token needs the long form: there is no class to infer it from.

Testing across the seam

In a test, swap the implementation for an in-memory one. Test.createTestingModule({...}).overrideProvider(ORDER_STORE).useValue(fake).compile() builds the same injector with your fake in place. Newly generated NestJS 12 ESM projects use Vitest; CommonJS projects still use Jest. The Nest testing API is identical either way.

What trips people up

Keep the interface in terms of your domain. The moment a method returns a SelectQueryBuilder, the seam leaks and the fake becomes impossible.

Example

Example · typescript
// src/orders/order.store.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Order } from './order.entity.js';

// The seam. Only what the service needs, in domain terms:
// no query builders, no find options, nothing TypeORM-shaped.
export interface OrderStore {
  findById(id: number): Promise<Order | null>;
  findPaidFor(customerId: number, limit: number): Promise<Order[]>;
  save(order: Order): Promise<Order>;
}

// The token. Interfaces vanish when TypeScript compiles, so this symbol
// is what Nest actually looks up in its provider registry.
export const ORDER_STORE = Symbol('ORDER_STORE');

@Injectable()
export class TypeOrmOrderStore implements OrderStore {
  constructor(
    @InjectRepository(Order) private readonly orders: Repository<Order>,
  ) {}

  findById(id: number): Promise<Order | null> {
    return this.orders.findOneBy({ id });
  }

  findPaidFor(customerId: number, limit: number): Promise<Order[]> {
    return this.orders.find({
      where: { customerId, status: 'paid' },
      relations: { items: true },
      order: { createdAt: 'DESC' },
      take: limit,
    });
  }

  save(order: Order): Promise<Order> {
    return this.orders.save(order);
  }
}

When to use it

  • Testing order logic in milliseconds, with no database container in CI.
  • Keeping TypeORM types out of a domain service so a later move to Prisma touches one module.
  • Replacing a repository with a fake in an end-to-end test while leaving the rest of the app real.
  • Giving two implementations, one SQL-backed and one in-memory, to the same service under one token.

More examples

Wiring the token, and the service that uses it

The module is the only file that knows TypeORM is involved. Swapping TypeOrmOrderStore for a Prisma- or SQL-backed class is a one-line change here, and nothing in OrdersService moves. Export the token so other modules inject the same provider instance rather than registering their own.

Example · typescript
// src/orders/orders.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Order } from './order.entity.js';
import { ORDER_STORE, TypeOrmOrderStore } from './order.store.js';
import { OrdersService } from './orders.service.js';

@Module({
  imports: [TypeOrmModule.forFeature([Order])],
  providers: [
    OrdersService, // shorthand for { provide: OrdersService, useClass: OrdersService }
    // A symbol token has no class to infer, so the long form is required.
    { provide: ORDER_STORE, useClass: TypeOrmOrderStore },
  ],
  // Export the token, not the class: importers should not know the implementation.
  exports: [OrdersService, ORDER_STORE],
})
export class OrdersModule {}

// src/orders/orders.service.ts
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
import { ORDER_STORE, OrderStore } from './order.store.js';

@Injectable()
export class OrdersService {
  // @Inject names the token; the OrderStore type is for the compiler only.
  constructor(@Inject(ORDER_STORE) private readonly store: OrderStore) {}

  async markPaid(id: number) {
    const order = await this.store.findById(id);
    if (!order) {
      throw new NotFoundException(`Order ${id} not found`);
    }
    order.status = 'paid';
    return this.store.save(order);
  }
}

A unit test with an in-memory store

No database, no TypeORM, no container beyond the three lines of createTestingModule. The fake is real code rather than a pile of mocked methods, so it can enforce invariants and be reused by every test of this service. This is the payoff for defining the interface narrowly.

Example · typescript
// src/orders/orders.service.spec.ts
import { Test } from '@nestjs/testing';
import { ORDER_STORE, OrderStore } from './order.store.js';
import { OrdersService } from './orders.service.js';
import { Order } from './order.entity.js';

// A complete implementation of the seam, backed by a Map.
class InMemoryOrderStore implements OrderStore {
  readonly rows = new Map<number, Order>();

  async findById(id: number) {
    return this.rows.get(id) ?? null;
  }

  async findPaidFor(customerId: number, limit: number) {
    return [...this.rows.values()]
      .filter((o) => o.customerId === customerId && o.status === 'paid')
      .slice(0, limit);
  }

  async save(order: Order) {
    this.rows.set(order.id, order);
    return order;
  }
}

describe('OrdersService', () => {
  let service: OrdersService;
  let store: InMemoryOrderStore;

  beforeEach(async () => {
    store = new InMemoryOrderStore();

    const moduleRef = await Test.createTestingModule({
      providers: [OrdersService, { provide: ORDER_STORE, useValue: store }],
    }).compile();

    service = moduleRef.get(OrdersService);
  });

  it('marks an existing order paid', async () => {
    store.rows.set(1, { id: 1, customerId: 7, status: 'draft' } as Order);

    await service.markPaid(1);

    expect(store.rows.get(1)?.status).toBe('paid');
  });

  it('404s for an unknown order', async () => {
    await expect(service.markPaid(99)).rejects.toThrow('Order 99 not found');
  });
});

When you keep Repository and override it instead

Not every service needs its own interface. If a service injects Repository directly, getRepositoryToken(Entity) gives you the token to replace, and overrideProvider does it without rewriting the module. The drawback is visible here: the fake has to grow a method every time the service reaches for a new repository call.

Example · typescript
// src/orders/orders.direct.service.spec.ts
import { vi } from 'vitest';
import { Test } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Order } from './order.entity.js';
import { OrdersDirectService } from './orders.direct.service.js';

describe('OrdersDirectService', () => {
  let service: OrdersDirectService;

  // Only the repository methods this service calls.
  const repository = {
    findOneBy: vi.fn(),
    find: vi.fn(),
    save: vi.fn((entity) => Promise.resolve(entity)),
  };

  beforeEach(async () => {
    const moduleRef = await Test.createTestingModule({
      providers: [
        OrdersDirectService,
        {
          // getRepositoryToken(Order) returns the same token
          // @InjectRepository(Order) asks for: "OrderRepository".
          // Pass a data source name as the second argument for a named source.
          provide: getRepositoryToken(Order),
          useValue: repository,
        },
      ],
    }).compile();

    service = moduleRef.get(OrdersDirectService);
  });

  it('returns the order it found', async () => {
    repository.findOneBy.mockResolvedValue({ id: 1 } as Order);

    await expect(service.findOne(1)).resolves.toEqual({ id: 1 });
    expect(repository.findOneBy).toHaveBeenCalledWith({ id: 1 });
  });
});

// An alternative to listing the provider, when the module is already built:
//
//   const moduleRef = await Test.createTestingModule({ imports: [OrdersModule] })
//     .overrideProvider(getRepositoryToken(Order))
//     .useValue(repository)
//     .compile();

Discussion

  • Be the first to comment on this lesson.