Skip to content
ExponaBLOG
NESTJS · DAY 051 / 100

Mocking providers cleanly: a NestJS guide for developers

Good mocks make tests readable; bad ones make them brittle.

Expona Admin8 min read

Mocking providers cleanly

I've watched plenty of test suites collapse under their own weight because mocks weren't structured properly. Mocking providers cleanly means building tests that stay maintainable as your codebase grows, which is exactly what separates a test suite you'll actually run from one you'll dread touching six months from now. In this article, I'll walk you through the strategies I use to keep mocks readable, type-safe, and resilient to refactoring.

Why mocking matters in NestJS

When you're testing a NestJS service that depends on ten other providers—databases, caches, external APIs, message queues—you need a way to isolate what you're actually testing. Without clean mocking, you end up either testing the entire system (slow, brittle) or fighting with confusing mock setups for hours. The cost of bad mocks isn't just wasted time during test writing; it's the constant pain of maintaining them as code evolves.

A well-mocked provider tells you exactly what it does, requires minimal setup, and breaks loudly when expectations change. That's what we're aiming for.

Using overrideProvider for integration tests

overrideProvider is the NestJS testing module's answer to selective dependency injection during tests. Instead of swapping out every provider, you override only what you need, keeping the rest of your application graph intact. This is invaluable for integration tests where you want to test real interactions between multiple services but stub out external dependencies.

Here's a practical scenario: you have a PaymentService that depends on a StripeClient and a NotificationService. You want to test that PaymentService correctly calls both, but you can't actually charge credit cards during tests.

typescript
// payment.service.ts
@Injectable()
export class PaymentService {
  constructor(
    private stripe: StripeClient,
    private notifications: NotificationService,
  ) {}

  async processPayment(userId: string, amount: number) {
    const charge = await this.stripe.charge(userId, amount);
    await this.notifications.sendReceipt(userId, charge.id);
    return charge;
  }
}

// payment.service.spec.ts
describe('PaymentService', () => {
  let service: PaymentService;
  let stripeClient: StripeClient;
  let notificationService: NotificationService;

  beforeEach(async () => {
    const stripeClientMock = {
      charge: jest.fn().mockResolvedValue({ id: 'charge_123', amount: 5000 }),
    };

    const notificationServiceMock = {
      sendReceipt: jest.fn().mockResolvedValue(undefined),
    };

    const module = await Test.createTestingModule({
      providers: [PaymentService],
    })
      .overrideProvider(StripeClient)
      .useValue(stripeClientMock)
      .overrideProvider(NotificationService)
      .useValue(notificationServiceMock)
      .compile();

    service = module.get(PaymentService);
    stripeClient = module.get(StripeClient);
    notificationService = module.get(NotificationService);
  });

  it('should charge and notify', async () => {
    await service.processPayment('user_1', 5000);

    expect(stripeClient.charge).toHaveBeenCalledWith('user_1', 5000);
    expect(notificationService.sendReceipt).toHaveBeenCalledWith('user_1', 'charge_123');
  });
});

Notice how we're not instantiating anything manually—overrideProvider integrates the mock directly into NestJS's dependency container. When PaymentService is created, it receives the mocked dependencies automatically. This keeps your test setup declarative and closely mirrors how your application actually works.

You can also chain multiple overrides for complex scenarios, and they compose cleanly with .compile(), making it easy to read what's being replaced.

jest-mock-extended for typed deep mocks

Plain jest.fn() mocks get the job done, but they don't give you type safety. If you refactor a provider's interface, your mocks won't complain—you'll discover the breakage when tests run. That's where jest-mock-extended enters the picture. It provides DeepMockProxy, which wraps your types and gives you compile-time feedback.

Install it first:

typescript
npm install --save-dev jest-mock-extended

Now imagine a UserRepository with multiple methods. Using DeepMockProxy ensures you're calling real methods with the right signatures:

typescript
// user.repository.ts
@Injectable()
export class UserRepository {
  constructor(private db: Database) {}

  async findById(id: string): Promise {
    return this.db.query('SELECT * FROM users WHERE id = ?', [id]);
  }

  async findByEmail(email: string): Promise {
    return this.db.query('SELECT * FROM users WHERE email = ?', [email]);
  }

  async update(id: string, data: Partial): Promise {
    return this.db.query('UPDATE users SET ? WHERE id = ?', [data, id]);
  }
}

// user.service.spec.ts
import { mockDeep, DeepMockProxy } from 'jest-mock-extended';

describe('UserService', () => {
  let service: UserService;
  let userRepository: DeepMockProxy;

  beforeEach(async () => {
    userRepository = mockDeep();

    const module = await Test.createTestingModule({
      providers: [UserService],
    })
      .overrideProvider(UserRepository)
      .useValue(userRepository)
      .compile();

    service = module.get(UserService);
  });

  it('should update user email', async () => {
    const user = { id: '1', name: 'Alice', email: 'alice@example.com' };
    userRepository.findById.mockResolvedValue(user);
    userRepository.update.mockResolvedValue({ ...user, email: 'alice.new@example.com' });

    const result = await service.updateEmail('1', 'alice.new@example.com');

    expect(result.email).toBe('alice.new@example.com');
    expect(userRepository.findById).toHaveBeenCalledWith('1');
    expect(userRepository.update).toHaveBeenCalledWith('1', { email: 'alice.new@example.com' });
  });
});

The beauty here is that TypeScript knows about every method on UserRepository. If someone removes findByEmail, you'll get a compile error in your test. If they change the return type, you'll catch it immediately. This transforms your tests from "run them to find bugs" into "don't even compile if they're wrong."

For deeply nested objects, DeepMockProxy recursively mocks all properties, so you can spy on chained calls without extra setup.

Mock interfaces you own, not libraries

Here's a hard-won principle: mock the abstractions your code owns, never third-party libraries directly. If you need to mock Stripe, create a StripeAdapter that your code depends on, then mock the adapter. This keeps your tests decoupled from library internals and makes refactoring painless.

Bad approach:

typescript
// Mocking the library directly—brittle and tightly coupled
jest.mock('stripe', () => ({
  Stripe: jest.fn().mockImplementation(() => ({
    charges: {
      create: jest.fn().mockResolvedValue({ id: 'charge_123' }),
    },
  })),
}));

Good approach:

typescript
// stripe.adapter.ts
export interface PaymentGateway {
  charge(userId: string, amountCents: number): Promise<{ transactionId: string }>;
  refund(transactionId: string): Promise;
}

@Injectable()
export class StripeAdapter implements PaymentGateway {
  private stripe = new Stripe(process.env.STRIPE_SECRET);

  async charge(userId: string, amountCents: number) {
    const result = await this.stripe.charges.create({
      amount: amountCents,
      currency: 'usd',
      customer: userId,
    });
    return { transactionId: result.id };
  }

  async refund(transactionId: string) {
    await this.stripe.refunds.create({ charge: transactionId });
  }
}

// payment.service.ts
@Injectable()
export class PaymentService {
  constructor(private gateway: PaymentGateway) {}

  async processPayment(userId: string, amount: number) {
    return this.gateway.charge(userId, amount);
  }
}

// payment.service.spec.ts
describe('PaymentService', () => {
  let service: PaymentService;
  let gateway: DeepMockProxy;

  beforeEach(async () => {
    gateway = mockDeep();

    const module = await Test.createTestingModule({
      providers: [PaymentService],
    })
      .overrideProvider(PaymentGateway)
      .useValue(gateway)
      .compile();

    service = module.get(PaymentService);
  });

  it('should call gateway.charge', async () => {
    gateway.charge.mockResolvedValue({ transactionId: 'tx_456' });

    const result = await service.processPayment('user_1', 5000);

    expect(result.transactionId).toBe('tx_456');
  });
});

Now your tests depend on PaymentGateway, an interface you control. You can swap Stripe for Square tomorrow without touching a single test. The adapter absorbs Stripe's API details, leaving your domain logic clean and easily testable.

Reset mocks between tests

Mock state leaking from one test to the next is a silent killer. You run tests individually and they pass; run them all together and they fail randomly. The culprit is usually a mock that wasn't cleared between tests.

Use clearAllMocks() in your afterEach hook:

typescript
describe('UserService', () => {
  let service: UserService;
  let repository: DeepMockProxy;

  beforeEach(async () => {
    repository = mockDeep();
    // ... module setup
  });

  afterEach(() => {
    jest.clearAllMocks();
  });

  it('test one', async () => {
    repository.findById.mockResolvedValue({ id: '1', name: 'Alice' });
    await service.getUser('1');
    expect(repository.findById).toHaveBeenCalled();
  });

  it('test two', async () => {
    // If you didn't clear, the mock from test one would still have its call count
    repository.findById.mockResolvedValue(null);
    await service.getUser('2');
    // This will pass because the mock was reset
    expect(repository.findById).toHaveBeenCalledTimes(1);
  });
});

If you're using jest-mock-extended's mockReset(), it clears call history while preserving the mock structure. That's often cleaner than clearAllMocks() for specific mocks:

typescript
afterEach(() => {
  repository.mockReset();
});

Pick one approach per test file and stick with it. Consistency prevents surprises.

Common mistakes

Over-mocking the dependency tree: If you mock everything, you're not testing your service anymore; you're testing your mocks. Mock only external dependencies (databases, APIs, message queues), not internal services you want to verify actually work together.

Using real instances in mocks: Avoid .useClass(RealService) when you mean to mock. It defeats the purpose and slows tests. Use .useValue() with an object or mockDeep().

Forgetting to reset mock state: This causes tests to pass in isolation but fail in a suite. Always reset in afterEach.

Mocking too loosely: A mock with no expectations is a wasted test. Assert that mocked functions were called with the right arguments. That's where the real value lies.

Not versioning mock interfaces: When you change a provider's interface, your mocks must change too. Use TypeScript to enforce this. If jest complains about type mismatches, fix them; don't ignore them.

Key takeaways

  • overrideProvider integrates mocks into NestJS's test container cleanly, keeping your dependency graph intact while stubbing external dependencies.
  • jest-mock-extended's DeepMockProxy gives you type safety so TypeScript catches mock mismatches at compile time, not test runtime.
  • Mock the interfaces you own (PaymentGateway, UserRepository), never third-party libraries directly (Stripe, Redis). This decouples tests from library internals and makes refactoring safe.
  • Always reset mocks in afterEach to prevent state from leaking between tests and causing random failures in suites.
  • Mocking providers cleanly isn't about having the most sophisticated setup; it's about clarity, maintainability, and confidence that your tests will still pass when you refactor tomorrow.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles