Introduction

“Our test coverage is at 80% — why are we still finding bugs in production?”

This is a common challenge CAW has frequently seen teams struggle with. More often than not, the issue isn’t the lack of testing, but rather the absence of a robust testing strategy.

With years of experience architecting test automation strategies for various NestJS projects, CAW has found that the framework is particularly well-suited for testing. This article will explore how to architect testable systems and write effective unit tests in NestJS based on real-world experience.

Let’s begin by understanding the basics of unit testing and NestJS.

What Is Unit Testing?

Unit testing is a software testing technique where individual components or “units” of code are tested in isolation to ensure they work as expected. A “unit” is typically the smallest part of a program, like a function, method, or procedure, that performs a single task.

The purpose of unit testing is to validate that each unit of the software performs its intended function independently of other parts. Developers usually write unit tests as they code, creating test cases to check different inputs and conditions for each unit. These tests help catch bugs early in the development process, which can save time and make the code more reliable.

In practice, unit tests are often automated, allowing developers to run them frequently to ensure that any new code changes don’t break existing functionality. If each unit passes its tests, it contributes to a stable foundation for the entire program.

What Is NestJS?

NestJS is a framework for building server-side applications with Node.js. It’s designed to help developers create scalable and maintainable applications using JavaScript or TypeScript.

NestJS is built around a modular structure, meaning you can organise your code into separate, reusable parts called “modules.”

In simple terms, NestJS makes it easier to build complex, production-ready applications by providing a solid foundation, structure, and tools that developers can rely on.

Scenario-First Strategy for Building Strong Tests

Before diving into implementation, let’s outline common scenarios we need to test in a typical NestJS application. Doing so helps ensure that each major operation behaves as expected and that edge cases are addressed early on.

1. User Management Module

// User Registration

- Create a new user with valid data to ensure data integrity.

- Hash password before saving to secure sensitive information.

- Validate email uniqueness to prevent duplicate registrations.

- Throw errors for invalid data.

- Send a welcome email upon successful registration.

// User Authentication

- Authenticate users with valid credentials.

- Return a JWT token upon successful login.

- Throw errors for invalid credentials.

- Process password reset requests.

2. Product Management Module

// Product Creation

- Create a product with valid data.

- Validate product uniqueness.

- Handle image uploads.

- Create product variants.

// Product Queries

- Filter products by category.

- Search products by name.

- Paginate results.

- Include category details in response.

Setting Up a Testable Architecture

First, let’s establish our testing infrastructure:

// test/setup.ts

import { Test, TestingModule } from '@nestjs/testing';

import { ConfigModule } from '@nestjs/config';

import { validate } from './env.validation';

export const createTestingModule = async (imports: any[], providers: any[]) => {

 return Test.createTestingModule({

   imports: [

     ConfigModule.forRoot({

       validate,

       isGlobal: true,

       envFilePath: '.env.test',

     }),

     ...imports,

   ],

   providers: [...providers],

 }).compile();

};

Key Points:

Creating standardised test modules with test/setup.ts:

Provides a reusable function to configure test modules.

– Automatically includes the ConfigModule with test environment setup (.env.test).

– Supports custom imports and providers, allowing tailored test setups.

– Validates environment variables through custom validation.

– Ensures global availability of configurations across all tests.

Implementing an Enhanced Repository Factory Pattern

Effective mocking is the foundation of clean tests. Here’s CAW’s enhanced repository factory pattern:

// test/factories/base.repository.factory.ts

export class BaseRepositoryFactory<T> {

 private entity: any;

 private customMethods: Record<string, jest.Mock> = {};

 constructor(entity: any) {

   this.entity = entity;

 }

 createMockRepository(): MockRepository<T> {

   return {

     create: jest.fn().mockImplementation(dto => this.entity.create(dto)),

     save: jest.fn().mockImplementation(entity => Promise.resolve(entity)),

     findOne: jest.fn().mockImplementation(() => Promise.resolve(null)),

     find: jest.fn().mockImplementation(() => Promise.resolve([])),

     update: jest.fn().mockImplementation(() => Promise.resolve(true)),

     delete: jest.fn().mockImplementation(() => Promise.resolve(true)),

     createQueryBuilder: jest.fn(() => ({

       where: jest.fn().mockReturnThis(),

       andWhere: jest.fn().mockReturnThis(),

       leftJoinAndSelect: jest.fn().mockReturnThis(),

       orderBy: jest.fn().mockReturnThis(),

       skip: jest.fn().mockReturnThis(),

       take: jest.fn().mockReturnThis(),

       getOne: jest.fn(),

       getMany: jest.fn(),

       getManyAndCount: jest.fn(),

     })),

     ...this.customMethods,

   };

 }

 addCustomMethod(name: string, implementation: jest.Mock) {

   this.customMethods[name] = implementation;

   return this;

 }

}

// Example Usage for User Repository

export const createUserRepositoryMock = () => {

 return new BaseRepositoryFactory<User>(User)

   .addCustomMethod('findByEmail', jest.fn())

   .addCustomMethod('findActiveUsers', jest.fn())

   .createMockRepository();

};

Key Features:

– Generic type <T> allows creating mock repositories for any entity.

– Provides standard TypeORM repository methods (e.g., create, save, findOne).

– All methods are Jest mock functions with default implementations.

– Includes a mock QueryBuilder with chainable methods.

– Supports adding custom repository methods.

Default Mocked Methods:

create: Returns an entity with the provided DTO.

save: Resolves with the provided entity.

findOne: Resolves with null.

find: Resolves with an empty array.

update: Resolves with true.

delete: Resolves with true.

createQueryBuilder: Returns a chainable query builder mock.

Writing Clean Tests to Implement Test Scenarios

Let’s implement our test scenarios following clean architecture principles:

1. User Registration Test Implementation

// user/tests/user.service.spec.ts

describe('UserService', () => {

 let service: UserService;

 let userRepo: MockRepository<User>;

 let emailService: MockType<EmailService>;

 let eventEmitter: MockType<EventEmitter2>;

 beforeEach(async () => {

   const module = await createTestingModule(

     [UserModule],

     [

       {

         provide: getRepositoryToken(User),

         useFactory: createUserRepositoryMock,

       },

       {

         provide: EmailService,

         useFactory: createMockService(['sendWelcomeEmail']),

       },

       {

         provide: EventEmitter2,

         useFactory: createMockEventEmitter,

       },

     ],

   );

   service = module.get(UserService);

   userRepo = module.get(getRepositoryToken(User));

   emailService = module.get(EmailService);

   eventEmitter = module.get(EventEmitter2);

 });

 describe('register', () => {

   // Arrange

   const registerDto: RegisterUserDto = {

     email: 'test@example.com',

     password: 'StrongPass123!',

     name: 'Test User',

   };

   it('should successfully register a new user', async () => {

     // Arrange

     const hashedPassword = 'hashedPassword123';

     jest.spyOn(bcrypt, 'hash').mockResolvedValueOnce(hashedPassword);

     userRepo.findByEmail.mockResolvedValueOnce(null);

        const expectedUser = {

       ...registerDto,

       password: hashedPassword,

       id: 1,

     };

     userRepo.save.mockResolvedValueOnce(expectedUser);

     // Act

     const result = await service.register(registerDto);

     // Assert

     expect(result).toBeDefined();

     expect(result.password).not.toBe(registerDto.password);

     expect(emailService.sendWelcomeEmail).toHaveBeenCalledWith(

       registerDto.email,

       registerDto.name,

     );

     expect(eventEmitter.emit).toHaveBeenCalledWith(

       'user.registered',

       expect.objectContaining({ userId: result.id }),

     );

   });

   it('should throw ConflictException for duplicate email', async () => {

     // Arrange

     userRepo.findByEmail.mockResolvedValueOnce({ id: 1 });

     // Act & Assert

     await expect(service.register(registerDto))

       .rejects

       .toThrow(ConflictException);

   });

 });

});

Setup Phase:

– Creates a test module with mocked dependencies (e.g., User Repository, Email Service, Event Emitter).

– Uses dependency injection to get service and mock dependency instances.

Key Testing Patterns Used:

– Arrange-Act-Assert pattern

– Mock responses for external dependencies

– Spy on service methods

– Expects for verification

– Error case handling

2. Testing Guards and Interceptors

// auth/tests/jwt-auth.guard.spec.ts

describe('JwtAuthGuard', () => {

 let guard: JwtAuthGuard;

 let reflector: MockType<Reflector>;

 beforeEach(async () => {

   const module = await Test.createTestingModule({

     providers: [

       JwtAuthGuard,

       {

         provide: Reflector,

         useFactory: createMockReflector,

       },

     ],

   }).compile();

   guard = module.get<JwtAuthGuard>(JwtAuthGuard);

   reflector = module.get(Reflector);

 });

 it('should allow public routes', async () => {

   // Arrange

   const context = createMockExecutionContext();

   reflector.getAllAndOverride.mockReturnValue(true);

   // Act

   const result = await guard.canActivate(context);

   // Assert

   expect(result).toBe(true);

 });

});

Testing Complex Business Logic

For complex business logic, we recommend using the Command Pattern with dedicated test files:

// orders/commands/create-order.command.ts

export class CreateOrderCommand {

 constructor(

   public readonly userId: number,

   public readonly items: OrderItemDto[],

 ) {}

}

// orders/commands/tests/create-order.handler.spec.ts

describe('CreateOrderHandler', () => {

 let handler: CreateOrderHandler;

 let orderRepo: MockRepository<Order>;

 let productService: MockType<ProductService>;

 let transactionManager: MockType<EntityManager>;

 beforeEach(async () => {

   const module = await createTestingModule(

     [OrdersModule],

     [

       {

         provide: getRepositoryToken(Order),

         useFactory: createOrderRepositoryMock,

       },

       {

         provide: ProductService,

         useFactory: createMockService(['checkAvailability', 'updateStock']),

       },

       {

         provide: EntityManager,

         useFactory: createMockEntityManager,

       },

     ],

   );

   handler = module.get(CreateOrderHandler);

   orderRepo = module.get(getRepositoryToken(Order));

   productService = module.get(ProductService);

   transactionManager = module.get(EntityManager);

 });

 it('should create order with valid items', async () => {

   // Arrange

   const command = new CreateOrderCommand(1, [

     { productId: 1, quantity: 2 },

   ]);

   productService.checkAvailability.mockResolvedValueOnce(true);

   transactionManager.save.mockResolvedValueOnce({ id: 1 });

   // Act

   const result = await handler.execute(command);

   // Assert

   expect(result).toBeDefined();

   expect(productService.updateStock).toHaveBeenCalled();

 });

});

Best Practices and Patterns

1. Use Test Categories

Organising tests into categories helps maintain clarity and focus. Here’s an example structure:

describe('UserService', () => {

 // Happy Path Tests

 describe('Happy Path', () => {

   it('should create user successfully', async () => {

     // test implementation

   });

 });

 // Error Cases

 describe('Error Cases', () => {

   it('should handle validation errors', async () => {

     // test implementation

   });

 });

 // Edge Cases

 describe('Edge Cases', () => {

   it('should handle concurrent requests', async () => {

     // test implementation

   });

 });

});

– Happy Path: Valid inputs with expected outcomes.

– Error Cases: Handling of incorrect inputs or edge-case scenarios.

– Edge Cases: Stress testing for high loads or rare cases.

2. Implement Custom Test Decorators

Create custom decorators for reusable test setup logic, such as database initialisation and cleanup, to simplify your test suite. 

// test/decorators/integration-test.decorator.ts

export const IntegrationTest = () => {

 return (target: Function) => {

   return class extends target {

     beforeAll = async () => {

       await setupTestDatabase();

     };




     afterAll = async () => {

       await cleanupTestDatabase();

     };

   };

 };

};

3. Create Test Data Builders

Test data builders help create flexible and maintainable test data.

// test/builders/user.builder.ts

export class UserBuilder {

 private user: Partial<User> = {

   email: 'test@example.com',

   password: 'hashedPassword',

   isActive: true,

 };

 withEmail(email: string): this {

   this.user.email = email;

   return this;

 }

 withRole(role: UserRole): this {

   this.user.role = role;

   return this;

 }

 build(): User {

   return this.user as User;

 }

 async persist(repo: Repository<User>): Promise<User> {

   return await repo.save(this.build());

 }

}

Purpose:

– Creates test user data with default values.

– Provides a fluent interface for customisation.

– Supports both in-memory and persisted user creation, making test data creation modular and maintainable.

Example:

// Create default user

const user = new UserBuilder().build();

// Custom user

const customUser = new UserBuilder()

 .withEmail('custom@example.com')

 .withRole(UserRole.ADMIN)

 .build();

// With Repository Persistance

describe('UserService', () => {

 let userRepo: Repository<User>;

 it('should create admin user', async () => {

   const adminUser = await new UserBuilder()

     .withRole(UserRole.ADMIN)

     .persist(userRepo);

   expect(adminUser.id).toBeDefined();

   expect(adminUser.role).toBe(UserRole.ADMIN);

 });

});

// Multiple Users with Different States

const users = [

 new UserBuilder()

   .withRole(UserRole.USER)

   .build(),

 new UserBuilder()

   .withEmail('admin@example.com')

   .withRole(UserRole.ADMIN)

   .build(),

 new UserBuilder()

   .withEmail('inactive@example.com')

   .withRole(UserRole.USER)

   .build()

];

4. Metrics and Coverage

Always set up coverage thresholds to ensure that your code is thoroughly tested.

// jest.config.js

module.exports = {

 coverageThreshold: {

   global: {

     branches: 80,

     functions: 80,

     lines: 85,

     statements: 85,

   },

 },

 collectCoverageFrom: [

   'src/**/*.ts',

   '!src/**/*.module.ts',

   '!src/**/main.ts',

   '!src/**/*.dto.ts',

 ],

};

Common Pitfalls to Avoid in Unit Testing

1. Don’t Test Configurations

Don’t write tests for configurations or module definitions, as they don’t provide meaningful validation of functionality. Instead, focus on testing the behaviour that relies on these configurations.

// Don't do this

describe('AppModule', () => {

 it('should be defined', () => {

   expect(appModule).toBeDefined();

 });

});

2. Avoid Testing Private Methods

If a private method needs to be tested, consider extracting its logic into a separate service. This keeps your tests focused on public interfaces and improves code modularity.

3. Don’t Over-Mock Dependencies

Excessive mocking can lead to false test results, increased maintenance, and reduced coverage of real behaviour. Use real instances for simple dependencies to keep tests reliable and focused.

// Don't mock simple value objects

const mockDto = createMock<UserDto>(); // ❌

// Do use real DTOs

const dto = new UserDto();

dto.email = 'test@example.com'; // ✅

Continuous Integration Setup

Ensure tests are included in your CI pipeline:

# .github/workflows/test.yml

name: Tests

on: [push, pull_request]

jobs:

 test:

   runs-on: ubuntu-latest

   steps:

     - uses: actions/checkout@v4

     - name: Use Node.js

       uses: actions/setup-node@v4

       with:

         node-version: '20'

     - name: Install dependencies

       run: npm ci

     - name: Run tests

       run: npm run test:cov

     - name: Upload coverage

       uses: actions/upload-artifact@v4

       with:

         name: coverage

         path: coverage

What to Do When You Have to Test Private Methods?

While testing private methods directly is generally considered a code smell, there are cases—such as when inheriting legacy code or facing situations where refactoring isn’t immediately feasible—where it becomes necessary. Here’s how to approach it:

// user.service.ts

@Injectable()

export class UserService {

 constructor(

   private readonly userRepo: Repository<User>,

   private readonly configService: ConfigService

 ) {}

 private async validateUserData(userData: CreateUserDto): Promise<boolean> {

   if (!userData.email || !userData.password) {

     return false;

   }

   const existingUser = await this.userRepo.findOne({

     where: { email: userData.email }

   });

   return !existingUser;

 }

 async createUser(userData: CreateUserDto): Promise<User> {

   const isValid = await this.validateUserData(userData);

   if (!isValid) {

     throw new BadRequestException('Invalid user data');

   }

   return this.userRepo.save(userData);

 }

}

Approach 1: TypeScript’s Type Casting

describe('UserService private methods', () => {

 let service: UserService;

 let userRepo: MockRepository<User>;

 beforeEach(async () => {

   const module = await Test.createTestingModule({

     providers: [

       UserService,

       {

         provide: getRepositoryToken(User),

         useFactory: createUserRepositoryMock,

       },

       {

         provide: ConfigService,

         useFactory: createMockConfigService,

       },

     ],

   }).compile();

   service = module.get(UserService);

   userRepo = module.get(getRepositoryToken(User));

 });

 describe('validateUserData', () => {

   it('should validate user data correctly', async () => {

     // Access private method using type casting

     const validateUserData = (service as any).validateUserData.bind(service);

     // Arrange

     const userData = { email: 'test@example.com', password: 'password123' };

     userRepo.findOne.mockResolvedValueOnce(null);

     // Act

     const result = await validateUserData(userData);

     // Assert

     expect(result).toBe(true);

     expect(userRepo.findOne).toHaveBeenCalledWith({

       where: { email: userData.email }

     });

   });

 });

});

Approach 2: Reflection (More Elegant)

// test/utils/expose-private-method.ts

export function exposePrivateMethod<T, R>(

 instance: T,

 methodName: string

): (...args: any[]) => R {

 return (...args: any[]) => {

   const method = Reflect.get(instance, methodName);

   return method.apply(instance, args);

 };

}

// In your test file

describe('UserService private methods using reflection', () => {

 it('should validate user data correctly', async () => {

   // Get private method using reflection

   const validateUserData = exposePrivateMethod<UserService, Promise<boolean>>(

     service,

     'validateUserData'

   );

   const result = await validateUserData({

     email: 'test@example.com',

     password: 'password123'

   });

   expect(result).toBe(true);

 });

});

Spy vs Mock: Making the Right Choice

In testing, choosing between spies and mocks can be crucial for ensuring effective test coverage. Here’s when to use each, along with practical examples.

When to Use Spies:

– Observe method calls without changing behaviour.

– Test event emissions.

– Verify interactions with existing methods.

– Ensure the original implementation is preserved.

Example: Using Spy for Analytics Service

describe('OrderService with Analytics', () => {

 let service: OrderService;

 let analyticsService: AnalyticsService;

 beforeEach(async () => {

   const module = await Test.createTestingModule({

     providers: [

       OrderService,

       AnalyticsService,

       // other providers...

     ],

   }).compile();

   service = module.get(OrderService);

   analyticsService = module.get(AnalyticsService);

 });

 it('should track order creation', async () => {

   // Spy on the trackEvent method

   const trackEventSpy = jest.spyOn(analyticsService, 'trackEvent');

   // Create an order

   await service.createOrder({ items: [{ productId: 1, quantity: 1 }] });

   // Verify the analytics event was tracked

   expect(trackEventSpy).toHaveBeenCalledWith(

     'order_created',

     expect.any(Object)

   );

   // The real trackEvent method was still called!

 });

});

When to Use Mocks:

– Control method returns.

– Test error conditions.

– Test interactions with complex or side-effect-prone implementations.

– Test external service interactions.

Example: Using Mock for Payment Service

describe('CheckoutService with PaymentProvider', () => {

 let service: CheckoutService;

 let paymentProvider: MockType<PaymentProvider>;

 beforeEach(async () => {

   const module = await Test.createTestingModule({

     providers: [

       CheckoutService,

       {

         provide: PaymentProvider,

         useFactory: () => ({

           processPayment: jest.fn(),

           refundPayment: jest.fn(),

         }),

       },

     ],

   }).compile();

   service = module.get(CheckoutService);

   paymentProvider = module.get(PaymentProvider);

 });

 it('should handle payment failure', async () => {

   // Mock the payment to fail

   paymentProvider.processPayment.mockRejectedValueOnce(

     new Error('Insufficient funds')

   );

   await expect(

     service.processCheckout({ amount: 100, cardToken: 'token' })

   ).rejects.toThrow('Payment failed');

 });

});

Combined Usage: Real-World Example

Sometimes, you’ll need to use both spies and mocks in the same test for comprehensive coverage:

describe('OrderProcessingService', () => {

 let service: OrderProcessingService;

 let paymentProvider: MockType<PaymentProvider>;

 let emailService: MockType<EmailService>;

 let logger: Logger;

 beforeEach(async () => {

   const module = await Test.createTestingModule({

     providers: [

       OrderProcessingService,

       {

         provide: PaymentProvider,

         useFactory: () => ({

           processPayment: jest.fn(),

         }),

       },

       {

         provide: EmailService,

         useFactory: () => ({

           sendOrderConfirmation: jest.fn(),

         }),

       },

       Logger,

     ],

   }).compile();

   service = module.get(OrderProcessingService);

   paymentProvider = module.get(PaymentProvider);

   emailService = module.get(EmailService);

   logger = module.get(Logger);

 });

 it('should process order with proper logging and notifications', async () => {

   // Mock payment provider (we don't want real payments in tests)

   paymentProvider.processPayment.mockResolvedValueOnce({ id: 'payment_123' });

   // Mock email service

   emailService.sendOrderConfirmation.mockResolvedValueOnce(true);

   // Spy on logger (we want to verify logging but keep the real logging logic)

   const loggerSpy = jest.spyOn(logger, 'log');

   // Act

   await service.processOrder({

     userId: 1,

     amount: 100,

     items: [{ productId: 1, quantity: 1 }]

   });

   // Assert

   expect(paymentProvider.processPayment).toHaveBeenCalledWith(

     expect.objectContaining({ amount: 100 })

   );

   expect(emailService.sendOrderConfirmation).toHaveBeenCalled();

   expect(loggerSpy).toHaveBeenCalledWith(

     'Order processed successfully',

     expect.any(String)

   );

 });

 it('should handle payment failure gracefully', async () => {

   // Mock payment failure

   paymentProvider.processPayment.mockRejectedValueOnce(

     new Error('Payment declined')

   );

   // Spy on logger error method

   const loggerErrorSpy = jest.spyOn(logger, 'error');

   // Act & Assert

   await expect(

     service.processOrder({

       userId: 1,

       amount: 100,

       items: [{ productId: 1, quantity: 1 }]

     })

   ).rejects.toThrow('Order processing failed');

   // Verify error was logged

   expect(loggerErrorSpy).toHaveBeenCalledWith(

     'Payment processing failed',

     expect.any(Error)

   );

   // Verify no confirmation email was sent

   expect(emailService.sendOrderConfirmation).not.toHaveBeenCalled();

 });

});

Quick Decision Guide for Spy vs Mock

Use a Spy When:

– jest.spyOn(service, ‘method’) (Observing real method calls)

– jest.spyOn(eventEmitter, ’emit’) (Tracking events)

– jest.spyOn(logger, ‘log’) (Verifying logging)

– jest.spyOn(cache, ‘get’) (Monitoring cache access)

Use a Mock When:

– jest.mock(‘./external-service’) (Mocking external services)

– jest.mock(‘./database-connection’) (Mocking database operations)

– jest.mock(‘./file-system’) (Mocking file system operations)

– jest.mock(‘./payment-gateway’) (Mocking payment processing)

Key Differences:

– Spies: Let you observe real method calls while maintaining the original implementation, making them suitable for verification without modification.

– Mocks: Completely replace the method implementation, making them useful for controlling external dependencies.

Conclusion

At CAW, these patterns have helped us maintain high test coverage while keeping the test suite maintainable and meaningful. Here are some key principles to keep in mind:

– Begin with clear, well-defined test scenarios.

– Use factory patterns for a cleaner setup.

– Keep tests focused, organised, and easy to understand.

– Avoid excessive mocking to keep tests grounded in real behaviour.

– Use builders to manage complex test data.

– Set and maintain comprehensive coverage thresholds for quality assurance.

Looking for automation testing support? CAW can take over your entire testing suite, allowing you to ship confidently without worrying about regression bugs. Schedule a call to find out how we can reduce your QA cycles to weeks rather than months.

FAQs

What is unit testing, and why is it important?

Unit testing is a method of testing individual components of a codebase in isolation to verify their functionality. It’s essential for catching bugs early, ensuring reliability, and keeping code maintainable.

What makes NestJS a suitable framework for unit testing?

NestJS provides a modular structure, making it easy to create testable, reusable components. Its architecture is well-suited for implementing scalable, maintainable unit tests.

When should you use a mock vs. a spy in tests?

Use spies when you need to observe real method calls without changing their behaviour, and mocks when you need to control method outputs or simulate external dependencies.

What are the common pitfalls to avoid in unit testing?

Avoid testing configurations, directly testing private methods, and excessive mocking. Focus on creating realistic tests that validate actual behaviour, rather than just ensuring test cases pass.