Mocking Providers

Ways to Replace a Dependency

TechniqueCode
Value mock{ provide: PaymentGateway, useValue: { charge: jest.fn() } }
Fake class{ provide: PaymentGateway, useClass: FakeGateway }
Override in e2e.overrideProvider(PaymentGateway).useValue(fake)
Override a guard.overrideGuard(JwtAuthGuard).useValue({ canActivate: () => true })
Auto-mock everything.useMocker(createMock) (from @golevelup/ts-jest)

src/orders/orders.service.spec.ts

describe('OrdersService.place', () => {
  const payments = { charge: jest.fn() };
  const notifications = { orderPlaced: jest.fn() };
  const repo = { save: jest.fn((o) => ({ id: 'ORD-1', ...o })) };
  let service: OrdersService;

  beforeEach(async () => {
    const module = await Test.createTestingModule({
      providers: [
        OrdersService,
        { provide: PaymentGateway, useValue: payments },
        { provide: NotificationsService, useValue: notifications },
        { provide: OrdersRepository, useValue: repo },
      ],
    }).compile();
    service = module.get(OrdersService);
    jest.clearAllMocks();
  });

  it('does not notify when payment fails', async () => {
    payments.charge.mockRejectedValue(new Error('card declined'));
    await expect(service.place({ productId: 7, quantity: 1 })).rejects.toThrow('card declined');
    expect(notifications.orderPlaced).not.toHaveBeenCalled();
  });
});

What jest.fn() Really Is

Example

A mock function records every call and returns whatever you program it to return. A tiny version, used to test an OrdersService:

type Mock<A extends unknown[], R> = ((...args: A) => R) & { calls: A[]; mockReturnValue(v: R): void };

function fn<A extends unknown[], R>(impl?: (...args: A) => R): Mock<A, R> {
  let fixed: { v: R } | undefined;
  const mock = ((...args: A) => {
    mock.calls.push(args);
    if (fixed) return fixed.v;
    return impl ? impl(...args) : (undefined as R);
  }) as Mock<A, R>;
  mock.calls = [];
  mock.mockReturnValue = (v: R) => { fixed = { v }; };
  return mock;
}

class OrdersService {
  constructor(private payments: { charge(amount: number): boolean }, private sms: { send(phone: string, text: string): void }) {}
  place(amount: number, phone: string) {
    if (!this.payments.charge(amount)) return 'PAYMENT_FAILED';
    this.sms.send(phone, 'Order confirmed');
    return 'PLACED';
  }
}

function test(name: string, body: () => void) {
  try { body(); console.log('  ✓', name); } catch (e) { console.log('  ✗', name, '-', (e as Error).message); }
}
const expectEq = (a: unknown, b: unknown) => { if (JSON.stringify(a) !== JSON.stringify(b)) throw new Error(JSON.stringify(a) + ' !== ' + JSON.stringify(b)); };

console.log('OrdersService');
test('sends SMS after successful payment', () => {
  const charge = fn((_amount: number) => true);
  const send = fn((_phone: string, _text: string) => {});
  expectEq(new OrdersService({ charge }, { send }).place(1299, '+919876543210'), 'PLACED');
  expectEq(send.calls, [['+919876543210', 'Order confirmed']]);
});
test('does not send SMS when payment fails', () => {
  const charge = fn((_amount: number) => true);
  charge.mockReturnValue(false);
  const send = fn((_phone: string, _text: string) => {});
  expectEq(new OrdersService({ charge }, { send }).place(1299, '+919876543210'), 'PAYMENT_FAILED');
  expectEq(send.calls.length, 0);
});

Output

OrdersService
  ✓ sends SMS after successful payment
  ✓ does not send SMS when payment fails