Skip to content

Unit Testing Services

Aman Kumar Singh8 min read
Part 36 of 40From the NestJS Production Guide series
Unit Testing Services — article by Aman Kumar Singh

Last time in the NestJS Production Guide, I covered gRPC Services in NestJS, which is mostly about wiring one service to talk to another over a fast, typed transport. None of that wiring matters much if the business logic behind it is wrong. The cheapest place to catch that is a unit test that runs in milliseconds against a service in isolation.

Unit testing services is one of those topics every NestJS project claims to do and few do well. Teams either skip it because "the E2E tests cover it," or they write tests so heavily mocked that they verify nothing except that Jest can call a mock function. Both outcomes come from not being deliberate about what a unit test is actually for.

I want to walk through how to structure service tests that catch real bugs, how NestJS's testing module makes dependency injection work in your favor here, and where teams waste effort testing the wrong layer.

What a unit test on a service is actually for

A NestJS service holds your business logic: validation rules, calculations, orchestration across repositories and other services. A unit test on that service exists to answer one question: does this logic produce the correct output for a given input, independent of the database, the network, or any other service actually running?

That "independent of" part is the whole point. If your SubscriptionsService.upgradeTo() test spins up a real PostgreSQL connection, you've stopped testing the service in isolation. Now you're testing the service, the database, your Docker setup, and your migrations, all at once. That's a valuable thing to test, but it's an integration test, not a unit test, and it belongs in a different suite with a different speed budget. Unit tests should run in the low hundreds of milliseconds for a whole file, no network calls, no filesystem, no real timers unless you're explicitly testing timing logic.

The failure mode I see most often is services with no unit tests at all, only E2E tests hitting real HTTP endpoints against a real test database. E2E suites are essential for verifying wiring, but they're slow to run, hard to debug when they fail (was it the controller, the guard, the service, or the database?), and painful to keep green as a codebase grows. A service with solid unit tests around its logic, backed by a smaller number of E2E tests confirming the wiring, catches more bugs faster than a pile of E2E tests trying to do both jobs at once. I'll go deeper on structuring that E2E layer in the next article.

Isolating a service with Nest's testing module

NestJS ships @nestjs/testing, which gives you a Test.createTestingModule() builder that mirrors the real @Module() decorator but lets you override any provider with a mock. This is the mechanism that makes dependency injection worth having in the first place: because your service depends on an injected SubscriptionsRepository rather than importing one directly, you can swap that dependency for a fake in tests without touching the service's source.

Here's a service worth testing, and the suite around it.

// src/modules/subscriptions/subscriptions.service.ts
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { SubscriptionsRepository } from './subscriptions.repository';
import { BillingEventsService } from './billing-events.service';

export type PlanTier = 'free' | 'pro' | 'enterprise';

const PLAN_ORDER: PlanTier[] = ['free', 'pro', 'enterprise'];

@Injectable()
export class SubscriptionsService {
  constructor(
    private readonly repository: SubscriptionsRepository,
    private readonly billingEvents: BillingEventsService,
  ) {}

  async upgradeTo(subscriptionId: string, newPlan: PlanTier): Promise<void> {
    const subscription = await this.repository.findById(subscriptionId);
    if (!subscription) {
      throw new NotFoundException('Subscription not found');
    }

    const currentIndex = PLAN_ORDER.indexOf(subscription.plan);
    const newIndex = PLAN_ORDER.indexOf(newPlan);

    if (newIndex <= currentIndex) {
      throw new BadRequestException('New plan must be a higher tier than the current plan');
    }

    subscription.plan = newPlan;
    await this.repository.save(subscription);
    await this.billingEvents.recordUpgrade(subscriptionId, newPlan);
  }
}
// src/modules/subscriptions/subscriptions.service.spec.ts
import { Test } from '@nestjs/testing';
import { NotFoundException, BadRequestException } from '@nestjs/common';
import { SubscriptionsService } from './subscriptions.service';
import { SubscriptionsRepository } from './subscriptions.repository';
import { BillingEventsService } from './billing-events.service';

describe('SubscriptionsService', () => {
  let service: SubscriptionsService;
  let repository: jest.Mocked<SubscriptionsRepository>;
  let billingEvents: jest.Mocked<BillingEventsService>;

  beforeEach(async () => {
    const moduleRef = await Test.createTestingModule({
      providers: [
        SubscriptionsService,
        {
          provide: SubscriptionsRepository,
          useValue: {
            findById: jest.fn(),
            save: jest.fn(),
          },
        },
        {
          provide: BillingEventsService,
          useValue: {
            recordUpgrade: jest.fn(),
          },
        },
      ],
    }).compile();

    service = moduleRef.get(SubscriptionsService);
    repository = moduleRef.get(SubscriptionsRepository);
    billingEvents = moduleRef.get(BillingEventsService);
  });

  it('throws NotFoundException when the subscription does not exist', async () => {
    repository.findById.mockResolvedValue(null);

    await expect(service.upgradeTo('sub_1', 'pro')).rejects.toThrow(NotFoundException);
    expect(repository.save).not.toHaveBeenCalled();
  });

  it('throws BadRequestException when downgrading through upgradeTo', async () => {
    repository.findById.mockResolvedValue({ id: 'sub_1', plan: 'pro' } as any);

    await expect(service.upgradeTo('sub_1', 'free')).rejects.toThrow(BadRequestException);
  });

  it('saves the new plan and records a billing event on a valid upgrade', async () => {
    const subscription = { id: 'sub_1', plan: 'free' as const };
    repository.findById.mockResolvedValue(subscription as any);

    await service.upgradeTo('sub_1', 'pro');

    expect(subscription.plan).toBe('pro');
    expect(repository.save).toHaveBeenCalledWith(subscription);
    expect(billingEvents.recordUpgrade).toHaveBeenCalledWith('sub_1', 'pro');
  });
});

Three things worth noticing. First, moduleRef.get() pulls the real service instance out of the compiled module, wired up with the mocked providers, exactly the way Nest would wire it in production, minus the real implementations. Second, the mocks are plain objects with jest.fn() on each method Nest actually calls, not full reimplementations of the repository. Third, each test asserts on behavior (was save called with the right object, did the right exception get thrown) rather than on implementation details of how the service is written internally.

Mocking dependencies without overdoing it

The useValue override above works well for small dependencies. Once a service has four or five injected collaborators, hand-rolling useValue objects for each one gets repetitive, and it's easy to forget a method and get a confusing "is not a function" error mid-test.

A pattern that scales better is a small factory function per dependency, kept next to the test file or in a shared test/mocks folder:

// src/modules/subscriptions/test/mock-subscriptions-repository.ts
import { SubscriptionsRepository } from '../subscriptions.repository';

export function createMockSubscriptionsRepository(): jest.Mocked<SubscriptionsRepository> {
  return {
    findById: jest.fn(),
    save: jest.fn(),
    findByOrgId: jest.fn(),
    delete: jest.fn(),
  } as unknown as jest.Mocked<SubscriptionsRepository>;
}

This buys you two things. Every test file that needs a fake SubscriptionsRepository gets the same shape, so a method added to the real repository only needs one factory update, not an update in every spec file that touches it. And because the factory function returns a fresh object each call, beforeEach naturally resets state between tests without extra jest.clearAllMocks() bookkeeping, as long as you call the factory inside beforeEach rather than at module scope.

Where teams overdo it is mocking so aggressively that the test stops verifying real logic. A service under test can have every branch pre-scripted through mock return values until the test essentially checks whether the mock returns what it was told to return. A test that passes by rearranging the mock setup without touching the service's source only exercises the plumbing around the service. It tells you nothing about the service's actual decision-making. Keep the service's real logic (the if statements, the calculations, the validation) unmocked and exercised; mock only the I/O boundaries, database, HTTP clients, queues, external SDKs.

Testing async errors, edge cases, and time-dependent logic

Services that call external systems need tests for the failure path as much as the happy path. A common gap: the test suite covers what happens when repository.save() resolves, but never what happens when it rejects.

it('propagates a repository failure without swallowing it', async () => {
  repository.findById.mockResolvedValue({ id: 'sub_1', plan: 'free' } as any);
  repository.save.mockRejectedValue(new Error('connection reset'));

  await expect(service.upgradeTo('sub_1', 'pro')).rejects.toThrow('connection reset');
  expect(billingEvents.recordUpgrade).not.toHaveBeenCalled();
});

That last assertion matters as much as the exception check. It confirms the service does not call recordUpgrade after a failed save, which is exactly the kind of ordering bug that's easy to introduce during a refactor and easy to miss without an explicit test for it.

For logic that depends on the current time, don't call new Date() directly inside the code you're testing if you can avoid it. Inject a clock, or use Jest's fake timers (jest.useFakeTimers().setSystemTime(...)), so a test for "trial expires after 14 days" doesn't become flaky depending on when it happens to run.

Production pitfalls worth knowing before they bite

Testing through Test.createTestingModule() every time is more setup than a plain new SubscriptionsService(mockRepo, mockBillingEvents) call, and for simple services with two or three constructor arguments, instantiating the class directly is a perfectly valid shortcut that skips Nest's DI container entirely. Reach for createTestingModule when the service or its dependencies use Nest-specific features (custom providers, @Inject() tokens, module-scoped configuration) that a bare constructor call can't replicate.

Watch for tests that assert on internal method calls rather than observable outcomes. A test that checks expect(service['calculateProration']).toHaveBeenCalled() is coupled to an implementation detail that has nothing to do with correctness, and it breaks the moment someone renames or inlines that private method during a refactor, even if the behavior is unchanged. Assert on what the service returns or what it calls on its dependencies, not on its own private structure.

Coverage percentage as a target causes its own problems. A service can hit 100 percent line coverage with tests that call every branch once and assert almost nothing meaningful, while a genuinely important edge case (concurrent upgrade requests, a plan that doesn't exist in PLAN_ORDER) goes untested because it happens to sit on an already-covered line. Treat coverage as a signal for finding untested code, not as a quality bar in itself.

Finally, keep test data realistic. Constructing a subscription object with just { id, plan } works for the example above, but real entities often carry required fields, foreign keys, timestamps, that TypeScript's structural typing will let you skip with an as any cast. That's fine for narrow unit tests focused on one piece of logic, but it hides bugs that only show up when a service reads a field you didn't bother to fake. Build a small test-fixture factory for your core entities early, and it pays for itself the first time a new required field gets added to a Subscription and every test using the shortcut cast quietly stops testing that field.

Key takeaways

  • A unit test on a service verifies business logic in isolation, no real database, network, or timers, and should run in milliseconds.
  • `Test.createTestingModule()` lets you override any injected provider with a mock, which is what dependency injection buys you when it's time to test.
  • Mock only the I/O boundaries (repositories, HTTP clients, external SDKs); leave the service's actual logic unmocked so the test exercises real decision-making.
  • Test failure paths and call ordering explicitly, not just the happy path, since ordering bugs are easy to introduce during a refactor and easy to miss otherwise.
  • Assert on observable behavior (return values, calls to dependencies) rather than on private methods or implementation details.
  • Treat coverage percentage as a way to find untested code, not as proof the important edge cases are actually covered.

Frequently asked questions

Do I need Test.createTestingModule() for every service test?

No. For a service with a small, simple constructor and no Nest-specific dependency injection features, calling the constructor directly with mock arguments works fine and skips the DI container overhead. Reach for `createTestingModule` when the service relies on custom providers, injection tokens, or module-scoped configuration that a bare constructor call can't reproduce.

Should I mock the database in a service unit test?

Yes. Mock the repository layer that talks to the database, not the database itself. A unit test that touches a real database, even a test database, is an integration test with a different speed and reliability profile, and belongs in a separate suite.

How do I test a service method that throws different exceptions for different inputs?

Write one test per exception path, asserting with `expect(promise).rejects.toThrow(SpecificExceptionClass)`, and also assert that no downstream calls happened after the failure point. That second assertion catches ordering bugs a bare exception check misses.

What's the difference between unit tests and E2E tests for a NestJS service?

Unit tests isolate the service from its dependencies using mocks and verify logic quickly. E2E tests exercise the real HTTP layer, guards, and often a real test database to verify the whole request actually resolves the way you expect. Both matter, but they answer different questions and should live in different suites with different speed budgets.

How much should test coverage matter for services?

Use it to find code paths nobody has written a test for, not as a target to hit. A service can reach high line coverage while missing important edge cases like concurrent state changes or invalid enum values, if the tests only exercise the common path through already-covered lines.

Where should mock factories for repositories live?

Next to the module they belong to, in a `test/` subfolder, shared across every spec file that needs a fake for that dependency. A single factory function keeps the mock's shape in one place, so adding a method to the real repository only requires one update instead of hunting through every spec file that constructs a `useValue` object by hand.

Related articles

CQRS in NestJS — Aman Kumar Singh
Exception Filters and Error Handling — Aman Kumar Singh
Interceptors Explained — Aman Kumar Singh
Aman Kumar Singh

About the author

I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.