Supercharge Your NestJS Unit Tests with Dependency Injection - Andy Primawan
Struggling with testing code that has hard dependencies? Learn how Nest.JS Dependency Injection lets you easily swap services with mocks in your unit tests. This simple pattern makes your tests faster, more reliable, and focused on logic, not infrastructure.

As full-stack engineers, we wear a lot of hats. We're part architect, part developer, and part detective, especially when it comes to squashing bugs. One of the most powerful tools in our bug-prevention toolkit is the unit test. But let's be honest: writing tests for code that's tangled up with external dependencies like databases, APIs, or caches can be a real pain.
This is where frameworks like NestJS truly shine. If you've worked with Nest, you've undoubtedly encountered its powerful Dependency Injection (DI) system. The framework's core is built around this concept, where the NestJS runtime, often called the "IoC container", manages object creation and their dependencies for you.
While DI has many benefits, like promoting separation of concerns, today I want to focus on my absolute favorite: how it makes unit testing ridiculously easy and effective.
We'll look at a common scenario: adding a caching layer to a service. We'll see how doing it the "quick and dirty" way leads to testing headaches, and then refactor it using DI to create clean, focused, and truly unit tests.
Code Structure
For illustration, the demo project source code structure will be like this:
nestjs-dependency-injection-demo
|
\- src
|- cache
| |- cache.module.ts
| |- cache.service.ts
| \- redis-cache.service.ts
|
|- products
| |- products.controller.ts
| |- products.module.ts
| |- products.service.spec.ts
| \- products.service.ts
|
|- app.controller.ts
|- app.module.ts
\- app.service.ts
The Problem: Tightly Coupled Code
Let's imagine we have a ProductsService
that fetches product data. To improve performance, we want to cache the results using Redis. A quick first approach might look something like this.
We install ioredis
:
npm install ioredis
And then we implement our service by creating a Redis client directly inside it.
products.service.ts
(The "Hard Way")
import { Injectable, NotFoundException } from '@nestjs/common';
import Redis from 'ioredis'; // Direct import
// A mock DB call for demonstration
const mockDatabase = [
{ id: 1, name: 'Super Widget', price: 99.99 },
{ id: 2, name: 'Mega Gadget', price: 149.50 },
];
@Injectable()
export class ProductsService {
private readonly redisClient: Redis;
constructor() {
// Hard-coded instantiation
this.redisClient = new Redis({
host: 'localhost',
port: 6379,
});
}
async getProductById(id: number): Promise<{ id: number; name: string; price: number }> {
const cacheKey = `product:${id}`;
const cachedProduct = await this.redisClient.get(cacheKey);
if (cachedProduct) {
console.log('Serving from cache...');
return JSON.parse(cachedProduct);
}
console.log('Fetching from database...');
const product = mockDatabase.find((p) => p.id === id);
if (!product) {
throw new NotFoundException(`Product with ID ${id} not found`);
}
// Set to cache for 1 hour
await this.redisClient.set(cacheKey, JSON.stringify(product), 'EX', 3600);
return product;
}
}
This code works fine at runtime. But what happens when we try to unit test the getProductById
method?
Our test now has a hard dependency on a running Redis instance. To test the logic, we'd have to:
- Ensure a Redis server is available for our test runner.
- Mock the entire
ioredis
library using complexjest.mock()
features to prevent it from making real network calls.
This is brittle. Our test is no longer a unit test; it's a mini-integration test. We're testing our logic and its connection to an external tool. If we ever decide to swap ioredis
for another client, we have to rewrite our tests. There's a better way.
The Solution: Depend on Abstractions, Not Concretions
The core idea behind testable code is Inversion of Control. Instead of our ProductsService
controlling the creation of its dependencies, we "invert" that control and let the NestJS framework provide them.
Let's refactor.
Step 1: Define a Contract (The Abstraction)
First, we define a "contract" for what a cache service should do, without worrying about the implementation details. An abstract class is perfect for this in TypeScript.
cache.service.ts
export abstract class CacheService {
abstract get<T>(key: string): Promise<T | null>;
abstract set(key: string, value: any, ttlInSeconds?: number): Promise<void>;
}
Our ProductsService
will depend on this abstraction, not a specific Redis client.
Step 2: Create a Concrete Implementation
Next, we create a specific implementation of this contract using ioredis
. This class will be our injectable provider.
redis-cache.service.ts
import { Injectable, OnModuleDestroy } from '@nestjs/common';
import Redis from 'ioredis';
import { CacheService } from './cache.service';
@Injectable()
export class RedisCacheService extends CacheService implements OnModuleDestroy {
private readonly redisClient: Redis;
constructor() {
super();
this.redisClient = new Redis({
host: 'localhost',
port: 6379,
});
}
onModuleDestroy() {
this.redisClient.disconnect();
}
async get<T>(key: string): Promise<T | null> {
const value = await this.redisClient.get(key);
return value ? JSON.parse(value) as T : null;
}
async set(key: string, value: any, ttlInSeconds?: number): Promise<void> {
if (ttlInSeconds) {
await this.redisClient.set(key, JSON.stringify(value), 'EX', ttlInSeconds);
} else {
await this.redisClient.set(key, JSON.stringify(value));
}
}
}
Step 3: Create a Module to Provide the Service
We'll bundle our caching logic into a CacheModule
and tell Nest: "Whenever someone asks for CacheService
, provide them with an instance of RedisCacheService
."
cache.module.ts
import { Module } from '@nestjs/common';
import { CacheService } from './cache.service';
import { RedisCacheService } from './redis-cache.service';
@Module({
providers: [
{
provide: CacheService, // The abstraction (token)
useClass: RedisCacheService, // The concrete implementation
},
],
exports: [CacheService], // Export the abstraction
})
export class CacheModule {}
Step 4: Inject the Dependency
Finally, we refactor our ProductsService
to receive the CacheService
via its constructor.
products.service.ts
(The "Smart Way")
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
import { CacheService } from '../cache/cache.service'; // Import the abstraction
// ... mockDatabase remains the same
@Injectable()
export class ProductsService {
// Inject the abstraction!
constructor(private readonly cacheService: CacheService) {}
async getProductById(id: number): Promise<{ id: number; name: string; price: number }> {
const cacheKey = `product:${id}`;
// The service doesn't know this is Redis. It just knows it's a CacheService.
const cachedProduct = await this.cacheService.get<{ id: number; name: string; price: number }>(cacheKey);
if (cachedProduct) {
console.log('Serving from cache...');
return cachedProduct;
}
console.log('Fetching from database...');
const product = mockDatabase.find((p) => p.id === id);
if (!product) {
throw new NotFoundException(`Product with ID ${id} not found`);
}
await this.cacheService.set(cacheKey, product, 3600);
return product;
}
}
Notice how much cleaner this is. The ProductsService
has no idea that Redis is being used underneath. It just trusts that it's getting something that fulfills the CacheService
contract.
The Payoff: Testing Becomes a Breeze
Now for the magic. Let's write a unit test for our new ProductsService
.
products.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { ProductsService } from './products.service';
import { CacheService } from '../cache/cache.service';
import { NotFoundException } from '@nestjs/common';
const mockProduct = { id: 1, name: 'Super Widget', price: 99.99 };
// Create a fake object that matches the CacheService contract
const mockCacheService = {
get: jest.fn(),
set: jest.fn(),
};
describe('ProductsService', () => {
let service: ProductsService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
ProductsService,
{
provide: CacheService, // When ProductsService asks for CacheService...
useValue: mockCacheService, // ...give it our fake object instead.
},
],
}).compile();
service = module.get<ProductsService>(ProductsService);
jest.clearAllMocks(); // Clear mocks before each test
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('getProductById', () => {
it('should return a product from the cache if it exists', async () => {
// Arrange: mock the cache to return our product
mockCacheService.get.mockResolvedValue(mockProduct);
// Act
const result = await service.getProductById(1);
// Assert
expect(result).toEqual(mockProduct);
expect(mockCacheService.get).toHaveBeenCalledWith('product:1');
expect(mockCacheService.set).not.toHaveBeenCalled(); // Important!
});
it('should fetch from the database and set the cache if the product is not cached', async () => {
// Arrange: mock the cache to return null
mockCacheService.get.mockResolvedValue(null);
// Act
const result = await service.getProductById(1);
// Assert
expect(result).toEqual(mockProduct);
expect(mockCacheService.get).toHaveBeenCalledWith('product:1');
expect(mockCacheService.set).toHaveBeenCalledWith(
'product:1',
mockProduct,
3600,
);
});
it('should throw a NotFoundException for a non-existent product', async () => {
// Arrange: mock the cache to return null
mockCacheService.get.mockResolvedValue(null);
// Act & Assert
await expect(service.getProductById(999)).rejects.toThrow(NotFoundException);
});
});
});
And voilà! Look how clean that is.
We are testing only the business logic within ProductsService
. We can precisely control the behavior of its dependency (CacheService
) for each test case. We don't need a running Redis server, and our tests are fast, reliable, and decoupled from the implementation details.
Conclusion
Dependency Injection isn't just a fancy design pattern; it's a pragmatic tool that directly improves code quality and developer productivity. By letting the NestJS framework manage our dependencies, we gain incredible flexibility. This not only makes our code more modular and maintainable but, as we've seen, it completely transforms the testing experience.
So, the next time you import
a new library to use directly in a service, take a moment to pause. Ask yourself: "Can I abstract this away and inject it instead?" Your future self, staring at a failing CI pipeline at 2 AM, will thank you.
Real Life Code Sample
You want to see snippets of real life code sample? You can check my open source project, Dojotek AI Chatbot on GitHub:
Real life sample code of Dependency Injection on NestJS