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.

Supercharge Your NestJS Unit Tests with Dependency Injection - Andy Primawan
Supercharge your NestJS Unit Tests with Dependency Injection - Andy Primawan

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:

  1. Ensure a Redis server is available for our test runner.
  2. Mock the entire ioredis library using complex jest.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:

dojotek-ai-chatbot-backend/src/caches/caches.service.ts at main · dojotek/dojotek-ai-chatbot-backend
Dojotek AI Chatbot backend (Nest.js, LangChain, LangGraph) - dojotek/dojotek-ai-chatbot-backend

Real life sample code of Dependency Injection on NestJS