Unit testing NestJS services
After 50 days of building with NestJS, you've likely accumulated a handful of services that orchestrate business logic, talk to databases, call external APIs, and manage state. Without unit tests, each deploy becomes a gamble. Unit testing NestJS services means isolating your service logic, replacing its external dependencies with mocks, and validating behavior in milliseconds—no database, no network, no waiting.
In this post, I'll walk you through the patterns I use on production platforms to keep my test suite fast, readable, and genuinely useful. We'll cover how to build a testing module, assert the right things, mock at the edges, and keep feedback loops tight enough that you can run tests on every keystroke.
Build a testing module
NestJS ships with a Test utility that mirrors the application's module system. Instead of spinning up a full application, you declare exactly which providers your service needs and swap out the heavy ones (database, HTTP clients, external services) with test doubles.
Here's the pattern I use on almost every service test:
import { Test, TestingModule } from '@nestjs/testing';
import { LeadsService } from './leads.service';
import { PrismaService } from '../prisma/prisma.service';
describe('LeadsService', () => {
let module: TestingModule;
let service: LeadsService;
let prisma: PrismaService;
const prismaMock = {
lead: {
create: jest.fn(),
findUnique: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
},
};
beforeEach(async () => {
module = await Test.createTestingModule({
providers: [
LeadsService,
{ provide: PrismaService, useValue: prismaMock },
],
}).compile();
service = module.get(LeadsService);
prisma = module.get(PrismaService);
});
afterEach(() => {
jest.clearAllMocks();
});
});
What happens here: Test.createTestingModule sets up a minimal injector. You list the real service under test, then override its dependencies with useValue, passing a mock object. When LeadsService is instantiated, it receives the mock instead of the real PrismaService. No database connection. No setup overhead.
I keep mocks simple and synchronous for speed. A mock is just a plain object with jest spy functions. You don't need a fancy mocking library—jest.fn() gives you tracking, return values, and argument inspection for free.
If your service has multiple dependencies—say, PrismaService, a logger, an HTTP client, and a queue—add them all to the providers array:
const module = await Test.createTestingModule({
providers: [
LeadsService,
{ provide: PrismaService, useValue: prismaMock },
{ provide: HttpService, useValue: httpMock },
{ provide: QueueService, useValue: queueMock },
{ provide: Logger, useValue: loggerMock },
],
}).compile();
Each dependency is isolated and predictable. No flaky tests waiting for timeouts or wrestling with side effects.
Test behavior—not internal calls
The most common mistake I see: developers test that a mock was called instead of testing what the service actually returns or throws. That's not a unit test; it's a spy on implementation details.
Here's the wrong approach:
it('should create a lead', async () => {
await service.createLead({ email: 'test@example.com', name: 'Alice' });
// Wrong: testing the call, not the behavior
expect(prisma.lead.create).toHaveBeenCalledWith({
data: { email: 'test@example.com', name: 'Alice' },
});
});
This test breaks if you refactor the internal implementation—even if the behavior stays the same. It's brittle and ties you to one specific way of doing things.
Here's the right approach:
it('should create a lead and return its ID', async () => {
prismaMock.lead.create.mockResolvedValue({
id: 'lead-123',
email: 'test@example.com',
name: 'Alice',
createdAt: new Date(),
});
const result = await service.createLead({
email: 'test@example.com',
name: 'Alice',
});
// Right: testing the actual output
expect(result.id).toBe('lead-123');
expect(result.email).toBe('test@example.com');
});
Now the test validates what matters: the service returns the right data. The test survives implementation changes. If you refactor to use a different query builder or caching layer, this test still passes as long as the behavior is preserved.
The same principle applies to exceptions. Test that the service throws the right error under the right conditions:
it('should throw if email already exists', async () => {
prismaMock.lead.create.mockRejectedValue(
new Error('Unique constraint failed on email')
);
await expect(
service.createLead({ email: 'taken@example.com', name: 'Bob' })
).rejects.toThrow('Unique constraint failed');
});
You're asserting behavior: "when the database rejects this operation, the service propagates that rejection." The test doesn't care how the error gets raised—it only cares that it does.
Mock at the edges
A unit test for a service should mock everything at the boundary: databases, HTTP calls, message queues, external APIs, file systems. Everything that crosses a network or disk I/O boundary gets replaced with a synchronous test double. The domain logic itself—validation, calculations, transformations—should be real.
Here's a realistic example from a lead scoring service I built:
describe('LeadScoringService', () => {
let service: LeadScoringService;
let prisma: PrismaService;
let httpClient: HttpService;
const prismaMock = {
lead: { findUnique: jest.fn() },
interaction: { findMany: jest.fn() },
};
const httpMock = {
get: jest.fn(),
};
beforeEach(async () => {
const module = await Test.createTestingModule({
providers: [
LeadScoringService,
{ provide: PrismaService, useValue: prismaMock },
{ provide: HttpService, useValue: httpMock },
],
}).compile();
service = module.get(LeadScoringService);
});
it('should calculate score based on interactions and firmographic data', async () => {
prismaMock.lead.findUnique.mockResolvedValue({
id: 'lead-1',
email: 'alice@acme.com',
companySize: 1000,
});
prismaMock.interaction.findMany.mockResolvedValue([
{ type: 'email_open', weight: 5 },
{ type: 'link_click', weight: 10 },
{ type: 'link_click', weight: 10 },
]);
// HTTP call to external enrichment service
httpMock.get.mockResolvedValue({
data: { industry: 'Technology', revenue: 50000000 },
});
const score = await service.scoreLeadAsync('lead-1');
// Test the business logic: interactions (25) + firmographic bonus (15) = 40
expect(score).toBe(40);
});
it('should return 0 if lead is flagged as unqualified', async () => {
prismaMock.lead.findUnique.mockResolvedValue({
id: 'lead-2',
unqualified: true,
});
const score = await service.scoreLeadAsync('lead-2');
expect(score).toBe(0);
});
});
Notice what's mocked: database queries and an HTTP call. What's not mocked: the scoring algorithm itself. The test validates that the service correctly combines data from multiple sources and applies business rules. If the scoring logic changes (weights shift, new factors are added), the test will catch it immediately. If you swap out the HTTP client library tomorrow, this test keeps working.
Fast feedback
Unit tests should complete in milliseconds. On a typical project, I run 50–150 unit tests in under a second, often completing before I've even finished typing my next line of code.
This speed is only possible when you mock at the edges. A test that hits a real database or makes a real API call is not a unit test—it's an integration test, and it belongs in a different suite that runs less frequently (perhaps in CI, not on save).
To keep tests fast:
- Use in-memory mocks. No file I/O, no network, no database. A jest mock function runs in microseconds.
- Avoid
setTimeoutor delays. If your service sleeps, mock it. If it polls, mock it. Tests should be deterministic and instant. - Batch your mocks in a helper. For large suites, define mock factories to avoid duplication and keep setup fast.
- Run tests on file save. I use Jest in watch mode with
--onlyChangedso only affected tests re-run. Feedback is near-instant.
On my current projects, I run the full unit test suite every time I save a file. It takes 1–2 seconds, and I know within that window whether I broke something. That tight loop means bugs surface immediately, before I move to the next task.
Common mistakes
- Testing implementation details instead of behavior. If your test cares how many times a dependency was called or what exact arguments were passed, you're over-specifying. Test the output and side effects instead.
- Mocking too much. Don't mock the service under test. Don't mock pure helper functions. Only mock the edges—things that talk to the outside world. Keep domain logic real so you catch bugs in the actual business rules.
- Async/await pitfalls. Make sure every mock that returns a Promise uses
mockResolvedValueormockRejectedValue, notmockReturnValue. Forgettingawaiton your service call means the test passes even if the service throws. - Not clearing mocks between tests. Use
jest.clearAllMocks()inafterEachso one test's setup doesn't leak into the next. - Mixing unit and integration tests. A unit test file should not spin up a real database or make real HTTP calls. If you need to test the database integration, write an integration test with a different setup and run it separately.
Key takeaways
- Use
Test.createTestingModuleto build a minimal injector with the service under test and mocks for its dependencies. - Assert behavior (output and exceptions), not implementation details like mock call counts or argument verification.
- Mock at the edges—databases, HTTP clients, queues—but keep domain logic real to catch actual bugs.
- Fast tests (milliseconds per test) come from mocking I/O-bound operations. Run them on every file save for instant feedback.
- A good unit test is readable, focused on one scenario, and survives refactoring as long as the behavior doesn't change.






Comments
Loading comments…