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

2. Product Management Module

Setting Up a Testable Architecture

First, let’s establish our testing infrastructure:

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:

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

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

Testing Complex Business Logic

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

Best Practices and Patterns

1. Use Test Categories

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

– 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. 

3. Create Test Data Builders

Test data builders help create flexible and maintainable test data.

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:

4. Metrics and Coverage

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

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.

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.

Continuous Integration Setup

Ensure tests are included in your CI pipeline:

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:

Approach 1: TypeScript’s Type Casting

Approach 2: Reflection (More Elegant)

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

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

Combined Usage: Real-World Example

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

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.