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.