
A Deep Dive into Testing with NodeJS/NestJS/Jest
Introduction
In modern application development, testing is not just a safety net—it’s an integral part of building robust and maintainable software. In this deep dive, I’ll explore testing with Node.js, NestJS, and Jest. From unit tests to integration tests, end-to-end (E2E) tests, and even more complex scenarios, I’ll also discuss various testing ideologies along the way.
Whether you’re a veteran developer or just starting out, this guide offers detailed examples and best practices to help you integrate testing into your workflow.
Understanding Testing Types
Testing can be broadly classified into three primary types:
- Unit Tests: Focus on individual components or functions in complete isolation.
- Integration Tests: Ensure that multiple parts of your application work together as intended.
- End-to-End (E2E) Tests: Simulate real-world user scenarios to validate complete workflows.
Each type plays a vital role in ensuring your application behaves correctly under different conditions.
Unit Testing with Jest and NestJS
Unit tests verify the functionality of individual components or functions. With Jest and NestJS, you can easily set up tests that run in isolation.
For example, consider a simple service:
// src/example/example.service.ts
import { Injectable } from '@nestjs/common';
@Injectable()
export class ExampleService {
getData(): string {
return 'Expected Data';
}
}
Here’s how you might write a unit test for this service:
// src/example/example.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { ExampleService } from './example.service';
describe('ExampleService', () => {
let service: ExampleService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [ExampleService],
}).compile();
service = module.get<ExampleService>(ExampleService);
});
it('should return "Expected Data"', () => {
expect(service.getData()).toBe('Expected Data');
});
});
This test isolates ExampleService
and verifies that its getData
method returns the correct output.
Integration Testing with NestJS
Integration tests validate that multiple modules or services work together correctly. With NestJS’s TestingModule
, you can simulate real module interactions.
Consider a controller that depends on a service:
// src/users/users.controller.ts
import { Controller, Get } from '@nestjs/common';
import { UsersService } from './users.service';
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Get()
async findAll() {
return this.usersService.findAll();
}
}
The corresponding integration test may look like this:
// src/users/users.controller.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
describe('UsersController Integration', () => {
let usersController: UsersController;
let usersService: UsersService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [UsersController],
providers: [UsersService],
}).compile();
usersController = module.get<UsersController>(UsersController);
usersService = module.get<UsersService>(UsersService);
});
it('should return an array of users', async () => {
const result = [{ id: 1, name: 'John Doe' }];
jest.spyOn(usersService, 'findAll').mockResolvedValue(result);
expect(await usersController.findAll()).toBe(result);
});
});
This test verifies both the controller’s behavior and its interaction with the UsersService
.
End-to-End (E2E) Testing with NestJS and Jest
E2E tests simulate real user scenarios by bootstrapping the entire application and performing HTTP requests. This ensures that all parts of your system work together as expected.
Below is an example of an E2E test for a simple endpoint:
// test/app.e2e-spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from '../src/app.module';
describe('App E2E Test', () => {
let app: INestApplication;
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
it('should return "Hello, NestJS!" on GET /hello', async () => {
const response = await request(app.getHttpServer())
.get('/hello')
.expect(200);
expect(response.text).toBe('Hello, NestJS!');
});
afterAll(async () => {
await app.close();
});
});
A Complex Testing Example: Order Processing Module
Sometimes, your business logic involves coordinating multiple services. In this example, I’ll simulate an order processing module that interacts with both a payment service and an inventory service.
Imagine an OrderService
that must:
- Check inventory availability
- Process a payment
- Create the order only if both operations succeed
Below is the implementation of the service:
// src/orders/order.service.ts
import { Injectable, BadRequestException } from '@nestjs/common';
import { PaymentService } from './payment.service';
import { InventoryService } from './inventory.service';
@Injectable()
export class OrderService {
constructor(
private paymentService: PaymentService,
private inventoryService: InventoryService,
) {}
async createOrder(orderDto: { itemId: number; quantity: number; amount: number }): Promise<string> {
const isAvailable = await this.inventoryService.checkAvailability(orderDto.itemId, orderDto.quantity);
if (!isAvailable) {
throw new BadRequestException('Item not available in requested quantity.');
}
const paymentResult = await this.paymentService.processPayment(orderDto.amount);
if (!paymentResult.success) {
throw new BadRequestException('Payment failed.');
}
// Assume order creation logic happens here.
return 'Order Created Successfully';
}
}
And here is the comprehensive test suite that covers various scenarios:
// src/orders/order.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { OrderService } from './order.service';
import { PaymentService } from './payment.service';
import { InventoryService } from './inventory.service';
import { BadRequestException } from '@nestjs/common';
describe('OrderService Complex Integration', () => {
let orderService: OrderService;
let paymentService: PaymentService;
let inventoryService: InventoryService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
OrderService,
{
provide: PaymentService,
useValue: { processPayment: jest.fn() },
},
{
provide: InventoryService,
useValue: { checkAvailability: jest.fn() },
},
],
}).compile();
orderService = module.get<OrderService>(OrderService);
paymentService = module.get<PaymentService>(PaymentService);
inventoryService = module.get<InventoryService>(InventoryService);
});
it('should create order successfully when inventory is available and payment succeeds', async () => {
jest.spyOn(inventoryService, 'checkAvailability').mockResolvedValue(true);
jest.spyOn(paymentService, 'processPayment').mockResolvedValue({ success: true });
const result = await orderService.createOrder({ itemId: 1, quantity: 2, amount: 100 });
expect(result).toBe('Order Created Successfully');
});
it('should throw error when inventory is insufficient', async () => {
jest.spyOn(inventoryService, 'checkAvailability').mockResolvedValue(false);
await expect(
orderService.createOrder({ itemId: 1, quantity: 10, amount: 100 })
).rejects.toThrow(BadRequestException);
});
it('should throw error when payment fails', async () => {
jest.spyOn(inventoryService, 'checkAvailability').mockResolvedValue(true);
jest.spyOn(paymentService, 'processPayment').mockResolvedValue({ success: false });
await expect(
orderService.createOrder({ itemId: 1, quantity: 2, amount: 100 })
).rejects.toThrow(BadRequestException);
});
});
This example demonstrates a complex integration scenario, where multiple dependencies and conditional logic are validated through thorough testing.
Deep Dive into "DeepMockProxy"
One of the coolest tools in the Jest ecosystem is the DeepMockProxy provided by libraries like jest-mock-extended
. It allows you to create proxy objects that deeply mock nested methods and properties automatically, making your test code simpler and more powerful.
Understanding Proxies vs. Other Mock Types
In JavaScript, a Proxy is an object that wraps another object, intercepting operations that would normally be sent to the target. Unlike simpler mocks or stubs, which often require you to explicitly define every behavior, a deep proxy mock can automatically mock nested functions and properties, reducing boilerplate in your tests.
- Manual Mocks or Stubs: You typically have to specify each method you want to mock. This can grow unwieldy for large, nested objects.
- Partial Mocks: You mock some parts of an object while leaving others intact, but you still have to outline the actual parts being mocked.
- Proxy Mocks (DeepMockProxy): The entire object hierarchy is automatically mocked, letting you configure only the specific methods you actually care about.
This approach is really awesome because it drastically cuts down on the amount of repetitive code you’d otherwise write. You can simply focus on the methods you need to mock, while the rest is automatically handled under the hood.
Setting Up DeepMockProxy
To start using DeepMockProxy
, you’ll need to install jest-mock-extended
:
npm install --save-dev jest-mock-extended
Once installed, you can import and create a deep mock for any TypeScript class or interface:
import { mockDeep, DeepMockProxy } from 'jest-mock-extended';
interface PaymentService {
processPayment(amount: number): Promise<{ success: boolean }>;
}
const paymentServiceMock: DeepMockProxy<PaymentService> = mockDeep<PaymentService>();
test('should handle payment success', async () => {
paymentServiceMock.processPayment.mockResolvedValue({ success: true });
const result = await paymentServiceMock.processPayment(100);
expect(result.success).toBe(true);
});
Notice that I only defined the behavior of processPayment
. If I had nested objects or additional methods, they’d be automatically mocked by the proxy—no extra setup required.
DeepMockProxy in a NestJS Service
Deep mocks shine even more when you incorporate complex NestJS services with multiple dependencies. Suppose you have a service that does the following:
- Checks some nested configuration settings
- Makes multiple API calls through subordinate services
- Filters or processes responses deeply
Here’s how you might integrate DeepMockProxy
in a NestJS test:
// advanced.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { AdvancedService } from './advanced.service';
import { SomeDependency } from './some-dependency';
import { mockDeep, DeepMockProxy } from 'jest-mock-extended';
describe('AdvancedService with DeepMockProxy', () => {
let service: AdvancedService;
let dependencyMock: DeepMockProxy<SomeDependency>;
beforeEach(async () => {
dependencyMock = mockDeep<SomeDependency>();
const module: TestingModule = await Test.createTestingModule({
providers: [
AdvancedService,
{
provide: SomeDependency,
useValue: dependencyMock,
},
],
}).compile();
service = module.get<AdvancedService>(AdvancedService);
});
it('should handle nested methods without extra boilerplate', async () => {
dependencyMock.nestedObject.deepMethod.mockResolvedValue('Nested Success');
const result = await service.doAdvancedWork();
expect(result).toBe('Work done with Nested Success');
});
});
In the snippet above, SomeDependency
might contain one or more nested objects or methods. With DeepMockProxy
, all of those are ready to be mocked instantly. You only configure what you need, when you need it.
Why This Is Really Awesome
By utilizing a deep proxy mock, you immediately get:
- Cleaner Test Code: No more excessive boilerplate when creating mock objects.
- Type Safety: Because it’s built with TypeScript in mind, you’ll get full IntelliSense and type checks.
- Scalability: As your objects grow in complexity, your tests remain easy to maintain.
If you’re serious about testing in NestJS—or any Node.js application—consider adding DeepMockProxy
to your toolkit. It’s a game-changer for writing concise, flexible, and type-safe tests without writing out reams of mock definitions.
Exploring Testing Ideologies
Beyond simply writing tests, it’s important to consider the broader philosophies behind testing. Let’s dive deeper into some key testing ideologies and see how they can be implemented in code.
1. Test-Driven Development (TDD)
Philosophy: TDD follows the “Red–Green–Refactor” cycle. You start by writing a failing test (red), then write the minimal code to pass the test (green), and finally refactor the code for clarity and efficiency.
Code Example: Imagine implementing a simple sum function:
// tests/sum.spec.ts
import { sum } from '../src/sum';
describe('sum', () => {
it('should return 5 when summing 2 and 3', () => {
expect(sum(2, 3)).toBe(5);
});
});
// src/sum.ts
export function sum(a: number, b: number): number {
return a + b;
}
Insights: TDD provides early feedback, encourages better modular design, and the tests act as living documentation of your code’s behavior.
2. Behavior-Driven Development (BDD)
Philosophy: BDD focuses on the behavior of the application from the end-user’s perspective. It uses human-readable scenarios to describe desired outcomes.
Code Example (Using Cucumber with Jest):
# features/login.feature
Feature: User Login
Scenario: Successful login with valid credentials
Given a user with username "john" and password "secret"
When the user attempts to login
Then the login should be successful
// features/steps/login.steps.ts
import { Given, When, Then } from '@cucumber/cucumber';
import { expect } from '@jest/globals';
import { login } from '../../src/authService';
let username: string;
let password: string;
let loginResult: boolean;
Given('a user with username {string} and password {string}', (user: string, pass: string) => {
username = user;
password = pass;
});
When('the user attempts to login', async () => {
loginResult = await login(username, password);
});
Then('the login should be successful', () => {
expect(loginResult).toBe(true);
});
Insights: BDD helps define clear and understandable requirements while fostering better collaboration between technical and non-technical team members. The feature files serve as living documentation.
3. Acceptance Test-Driven Development (ATDD)
Philosophy: ATDD emphasizes writing tests based on acceptance criteria agreed upon by developers, testers, and business stakeholders before development begins.
Code Example (API Endpoint):
// tests/acceptance-status.spec.ts
import request from 'supertest';
import { app } from '../src/app';
describe('GET /status (ATDD)', () => {
it('should return server status as OK', async () => {
const res = await request(app).get('/status');
expect(res.status).toBe(200);
expect(res.body.status).toBe('OK');
});
});
// src/app.ts
import express from 'express';
const app = express();
app.get('/status', (req, res) => {
res.status(200).json({ status: 'OK' });
});
export { app };
Insights: ATDD ensures that all stakeholders have a shared understanding of what “done” means, reducing rework and clarifying expectations early in the process.
4. Shift-Left Testing
Philosophy: Shift-Left Testing advocates for integrating testing early in the development cycle to detect and fix issues sooner.
Code Example (CI Configuration with GitHub Actions):
# .github/workflows/ci.yml
name: CI Pipeline
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '16'
- name: Install Dependencies
run: npm install
- name: Run Tests
run: npm test
Insights: By catching issues early, Shift-Left Testing not only reduces the cost of fixing bugs but also promotes continuous improvement with faster feedback loops.
Conclusion
Testing is a cornerstone of modern software development. By leveraging the combined power of Node.js, NestJS, and Jest, you can implement a robust testing strategy that spans unit tests, integration tests, and end-to-end tests.
With detailed examples—including a complex scenario for order processing—a deep dive into DeepMockProxy
, and an exploration of testing ideologies like TDD and BDD, this guide aims to equip you with the knowledge and tools to build high-quality applications.
Embrace testing as an integral part of your development process, and watch your code quality soar!
Happy testing and coding!