Skip to content

Testing (Unit & E2E)

This guide provides comprehensive instructions for writing unit tests and end-to-end (E2E) tests for NestJS microservices using Jest and Supertest. Testing is critical in an enterprise system to ensure reliability, correctness, and maintainability.

To ensure code consistency and compliance with our strict ESLint configuration, all generated code (including tests) MUST adhere to the following rules:

  1. Explicit Types Only:

    • NEVER use any. Always define explicit types or interfaces.
    • ❌ const data: any = ...
    • βœ… const data: UserProfile = ...
  2. Naming Conventions (Strictly Enforced):

    • Variables/Functions: camelCase
    • Classes/Interfaces: PascalCase
    • Enum Members: UPPER_CASE
    • DTO/Entity Properties: snake_case (Critically important for API consistency)
      • ❌ firstName: string;
      • βœ… first_name: string;
    • Boolean Variables: Prefix with verbs like is, has, should (e.g., isValid, hasPermission).
  3. Strict Boolean Checks:

    • Do not rely on implicit truthy/falsy checks for objects, numbers, or strings.
    • ❌ if (users.length) ...
    • βœ… if (users.length > 0) ...
    • ❌ if (user) ...
    • βœ… if (user !== null) ...
  4. Testing Standards:

    • No console.log: usage in tests is prohibited.
    • Mocking: Use createMock<Service>() or dedicated mock factories. Avoid jest.fn() as any.
    • Isolation: Tests must not depend on external state or execution order.
  5. Clean Code:

    • Remove unused variables and imports.
    • Use const by default, let only when necessary.
    • Functions should have explicit return types.

Why We Test:

  • Reliability: Business data must be handled correctly
  • Regression Prevention: Ensure new features don’t break existing functionality
  • Documentation: Tests serve as executable documentation
  • Refactoring Confidence: Safely improve code structure with a safety net
  • Early Bug Detection: Catch issues in isolated components before they integrate

The Testing Pyramid:

/\
/ \ E2E Tests (Few)
/____\ - Test full workflows (HTTP, Pipes, Interceptors)
/ \ - Slower, but prove integration
/ Unit \ Integration Tests (Some)
/ Tests \ - Test module/service interactions
/____________\ Unit Tests (Many)
- Test a single function/class in isolation
- Fast, focused, and predictable

Each microservice follows this standardized testing structure. Note the use of the test/mocks directory to store reusable mock factories.

Terminal window
apps/[SERVICE_NAME]/
β”œβ”€β”€ jest.config.js # Unit test configuration
β”œβ”€β”€ src/
β”‚ └── modules/
β”‚ └── [MODULE]/
β”‚ β”œβ”€β”€ controllers/
β”‚ β”‚ └── [name].controller.ts
β”‚ β”œβ”€β”€ services/
β”‚ β”‚ └── [name].service.ts
β”‚ β”œβ”€β”€ entities/
β”‚ β”‚ └── [name].entity.ts
β”‚ └── dto/
β”‚ β”œβ”€β”€ create-[name].dto.ts
β”‚ └── update-[name].dto.ts
└── test/
β”œβ”€β”€ jest-e2e.json # E2E test configuration
β”œβ”€β”€ mocks/ # (Plural) Reusable mock factories
β”‚ └── mock-[name].ts
β”œβ”€β”€ unit/ # Unit tests (controller & service)
β”‚ β”œβ”€β”€ [name].controller.spec.ts
β”‚ └── [name].service.spec.ts
└── e2e/ # End-to-end tests
└── [name].e2e-spec.ts

To ensure consistency across data-owner-bc, data-consumer-bc, and future services, we follow these patterns:

  1. Mock Factory Pattern (Recommended):

    • Avoid defining large mock objects directly inside .spec.ts files.
    • Create factory functions in test/mocks/mock-[name].ts.
    • Naming: createMock[Name]Service for service mocks, mock[Name]Entity for data.
  2. Shared Testing Utilities:

    • Use createTestingModule and createTestApp from @lib/common/testing.
    • This ensures that Global Pipes, Interceptors, and Filters are automatically loaded (especially critical for E2E tests).
  3. Encapsulated Constants:

    • Export common UUIDs and test values from the mock factory (e.g., MOCK_USER_ID, MOCK_RESOURCE_ID).
    • This makes tests readable and easy to update.
  4. Mock Interaction Verification:

    • Always verify that the service was called with the correct arguments.
    • Example: expect(service.create).toHaveBeenCalledWith(dto, userSession);
  5. Clean State:

    • Always run jest.clearAllMocks() in afterEach() to avoid cross-test pollution.

Terminal window
# Run all tests
npm run test
# Run tests for specific service (watch mode)
npm run test:data-owner-bc # Unit tests
npm run test:data-owner-bc:e2e # E2E tests
# Run with coverage
npm run test:cov
# Run specific test file
npm run test -- engagement-orders.controller.spec.ts

Unit testing tests individual components (e.g., a single Controller or Service) in complete isolation. All external dependencies (other services, databases, APIs) are replaced with mocks.

Key Principles:

  • Isolation: Test one class at a time
  • Fast: No network, database, or file system calls
  • Predictable: Same input always produces same output
  • Independent: Tests don’t affect each other
  • Mock Dependencies: The Service is mocked when testing the Controller. The Repository/EntityManager is mocked when testing the Service.

Every unit test follows the AAA pattern:

it('should do something', async () => {
// 1. ARRANGE - Set up test data and mocks
const input = { name: 'John' };
mockService.method.mockResolvedValue({ id: '123', name: 'John' });
// 2. ACT - Execute the code under test
const result = await controller.method(input);
// 3. ASSERT - Verify the results
expect(result).toEqual({ id: '123', name: 'John' });
expect(mockService.method).toHaveBeenCalledWith(input);
});

Controllers are the HTTP entry point and should delegate business logic to services. Controller unit tests verify only the controller’s logic. They do not test validation pipes or interceptors.

Test Goals:

  1. Does the controller method call the correct service method?
  2. Does it pass the correct arguments (DTO, params, user) to the service?
  3. Does it return the value that the service provides?
  4. Does it handle errors appropriately?

Complete Example:

This template uses our monorepo’s shared createTestingModule helper and a createMock...Service factory.

import { Test, TestingModule } from '@nestjs/testing';
import { type IUserSession } from '@lib/common';
import { createTestingModule } from '@lib/common/testing';
import { createMockEngagementOrdersService } from '@apps/data-owner-bc/test/mocks/mock-engagement-orders'; // 1. Import mock factory
import { EngagementOrdersController } from '../../src/modules/engagement/controllers/engagement-orders.controller';
import { CreateEngagementOrderDTO } from '../../src/modules/engagement/dto/create-engagement-order.dto';
import { EngagementOrder } from '../../src/modules/engagement/entities/engagement-order.entity';
import { EngagementOrdersService } from '../../src/modules/engagement/services/engagement-orders.service';
describe('EngagementOrdersController (Unit)', () => {
let controller: EngagementOrdersController;
let service: EngagementOrdersService;
const mockCurrentUser: IUserSession = {
id: 'user-uuid-123',
username: 'testuser',
email: 'test@example.com',
roles: ['admin'],
permissions: ['engagement:create', 'engagement:update'],
fullname: 'Test User',
};
const mockEngagementOrder: EngagementOrder = {
id: 'order-uuid-123',
engagement_id: 'engagement-uuid-123',
order_type: 'LAB',
status: 'PENDING',
created_at: new Date(),
updated_at: new Date(),
} as EngagementOrder;
// 2. Create the mock service instance
const mockEngagementOrdersService = createMockEngagementOrdersService();
beforeAll(async () => {
// 3. Use the shared createTestingModule helper
const module: TestingModule = await createTestingModule(
[EngagementOrdersController], // Controller(s) under test
[
{
// Provider(s) to be mocked
provide: EngagementOrdersService,
useValue: mockEngagementOrdersService,
},
],
).compile();
// 4. Get the "real" controller and "fake" service
controller = module.get<EngagementOrdersController>(EngagementOrdersController);
service = module.get<EngagementOrdersService>(EngagementOrdersService);
});
afterEach(() => {
jest.clearAllMocks();
});
it('should be defined', () => {
expect(controller).toBeDefined();
expect(service).toBeDefined();
});
describe('createEngagementOrder', () => {
it('should create a new engagement order successfully', async () => {
// ARRANGE
const engagementId = 'engagement-uuid-123';
const createDTO: CreateEngagementOrderDTO = {
order_type: 'LAB',
order_code: 'LAB001',
order_name: 'Complete Blood Count',
quantity: 1,
priority: 'ROUTINE',
} as any;
// Setup the mock's return value
mockEngagementOrdersService.createEngagementOrder.mockResolvedValue(mockEngagementOrder);
// ACT
// Call the controller method directly
const result = await controller.createEngagementOrder(engagementId, createDTO, mockCurrentUser);
// ASSERT
expect(result).toEqual(mockEngagementOrder); // 1. Did it return the service's value?
expect(service.createEngagementOrder).toHaveBeenCalledWith(
// 2. Was the service called correctly?
engagementId,
createDTO,
mockCurrentUser,
);
expect(service.createEngagementOrder).toHaveBeenCalledTimes(1);
});
it('should handle service errors', async () => {
// ARRANGE
const error = new Error('Engagement not found');
mockEngagementOrdersService.createEngagementOrder.mockRejectedValue(error);
// ACT & ASSERT
await expect(
controller.createEngagementOrder('invalid-id', {} as any, mockCurrentUser),
).rejects.toThrow('Engagement not found');
expect(service.createEngagementOrder).toHaveBeenCalledWith(
'invalid-id',
expect.any(Object),
mockCurrentUser,
);
});
});
describe('updateEngagementOrder', () => {
it('should update an existing order successfully', async () => {
const engagementId = 'engagement-uuid-123';
const orderId = 'order-uuid-123';
const updateDTO = {
status: 'COMPLETED',
};
const updatedOrder = { ...mockEngagementOrder, ...updateDTO };
mockEngagementOrdersService.update.mockResolvedValue(updatedOrder);
const result = await controller.updateEngagementOrder(
engagementId,
orderId,
updateDTO,
mockCurrentUser,
);
expect(result).toEqual(updatedOrder);
expect(service.update).toHaveBeenCalledWith(orderId, updateDTO, mockCurrentUser);
expect(service.update).toHaveBeenCalledTimes(1);
});
it('should handle order not found error', async () => {
const error = new Error('Order not found');
mockEngagementOrdersService.update.mockRejectedValue(error);
await expect(
controller.updateEngagementOrder('engagement-id', 'invalid-id', {}, mockCurrentUser),
).rejects.toThrow('Order not found');
});
});
describe('findOne', () => {
it('should return a single order by ID', async () => {
const engagementId = 'engagement-uuid-123';
const orderId = 'order-uuid-123';
mockEngagementOrdersService.findById.mockResolvedValue(mockEngagementOrder);
const result = await controller.findOne(engagementId, orderId);
expect(result).toEqual(mockEngagementOrder);
expect(service.findById).toHaveBeenCalledWith(orderId);
expect(service.findById).toHaveBeenCalledTimes(1);
});
it('should handle order not found', async () => {
const error = new Error('Order not found');
mockEngagementOrdersService.findById.mockRejectedValue(error);
await expect(controller.findOne('engagement-id', 'invalid-id')).rejects.toThrow(
'Order not found',
);
});
});
});

Services contain business logic and should be tested in isolation. We mock the EntityManager or Repository to avoid database side effects.

Standard Pattern:

import { Test, TestingModule } from '@nestjs/testing';
import { EntityManager } from 'typeorm';
import { createTestingModule } from '@lib/common/testing';
import { createMockEngagementOrderDTO } from '@apps/data-owner-bc/test/mocks/mock-engagement-orders';
import { EngagementOrdersService } from '../../src/modules/engagement/services/engagement-orders.service';
describe('EngagementOrdersService (Unit)', () => {
let service: EngagementOrdersService;
let entityManager: EntityManager;
// 1. Define Mock EntityManager
const mockEntityManager = {
save: jest.fn(),
findOne: jest.fn(),
// ... add other necessary methods
};
beforeEach(async () => {
const module: TestingModule = await createTestingModule(
[EngagementOrdersService],
[
{
provide: EntityManager,
useValue: mockEntityManager,
},
],
).compile();
service = module.get<EngagementOrdersService>(EngagementOrdersService);
entityManager = module.get<EntityManager>(EntityManager);
});
afterEach(() => {
jest.clearAllMocks(); // Critical: Clear mocks state
});
describe('createEngagementOrder', () => {
it('should successfully create and return an engagement order', async () => {
// ARRANGE
const engagementId = 'engagement-123';
const dto = createMockEngagementOrderDTO(engagementId);
const expectedResult = { id: 'order-123', ...dto };
mockEntityManager.save.mockResolvedValue(expectedResult);
// ACT
const result = await service.createEngagementOrder(engagementId, dto, {} as any);
// ASSERT
expect(result).toEqual(expectedResult);
expect(entityManager.save).toHaveBeenCalledTimes(1);
});
});
});

E2E testing tests the full application flow, simulating a real HTTP request. This is the only way to test your application’s β€œwiring”:

  • Routing: Does POST /engagements/:id/orders map to the correct controller method?
  • Global Pipes: Does the ValidationPipe correctly catch invalid DTOs and return a 400?
  • Global Interceptors: Does the TransformInterceptor correctly format the response into the JSON:API standard?
  • Global Filters: Does the AllExceptionsFilter correctly catch and format errors?
  • Decorators: Do @Param(), @Body(), and @CurrentUser() inject the correct data?

Note: We mock the Service layer (useValue: mockService) to avoid database calls, but we test everything else (the entire HTTP request/response pipeline).

E2E tests use Supertest to make real HTTP requests to a test application instance.

// Basic E2E test structure
describe('POST /endpoint', () => {
it('should create resource successfully', async () => {
// 1. ARRANGE - Prepare mock data and setup mocks
const dto = {
/* valid data */
};
mockService.method.mockResolvedValue(mockEntity);
// 2. ACT - Make HTTP request using supertest
const response = await request(server).post('/endpoint').send(dto).expect(201);
// 3. ASSERT - Verify service was called and response is correct
expect(service.method).toHaveBeenCalledWith(dto);
expect(response.body.data).toBeDefined();
});
});

This template uses our monorepo’s shared createTestingModule and createTestApp helpers, which automatically include global pipes and interceptors.

import * as http from 'http';
import { INestApplication } from '@nestjs/common';
import request from 'supertest'; // 1. Import supertest
import { createTestApp, createTestingModule } from '@lib/common/testing'; // 2. Import shared helpers
import {
createMockEngagementOrderDTO,
createMockEngagementOrdersService,
MockEngagementOrdersService,
} from '@apps/data-owner-bc/test/mocks/mock-engagement-orders';
// 3. Import mock factories
import { EngagementOrdersController } from '../../src/modules/engagement/controllers/engagement-orders.controller';
import { EngagementOrdersService } from '../../src/modules/engagement/services/engagement-orders.service';
/**
* E2E Tests for EngagementOrders Controller
*
* These tests verify the full HTTP request/response pipeline including:
* - Routing
* - ValidationPipe (DTO validation)
* - TransformInterceptor (JSON:API formatting)
* - AllExceptionsFilter (error handling)
*/
describe('EngagementOrdersController (e2e)', () => {
let app: INestApplication;
let service: MockEngagementOrdersService;
let server: http.Server;
const mockEngagementOrdersService = createMockEngagementOrdersService();
const resourceType = 'engagement-orders'; // For JSON:API assertions
const MOCK_ENGAGEMENT_ID = '550e8400-e29b-41d4-a716-446655440000';
const MOCK_ORDER_ID = '550e8400-e29b-41d4-a716-446655440002';
beforeAll(async () => {
// 4. Create the sandboxed module
const moduleFixture = await createTestingModule(
[EngagementOrdersController],
[
{
provide: EngagementOrdersService,
useValue: mockEngagementOrdersService,
},
],
);
// 5. Create the full Nest app (this runs .init() and registers all global pipes/interceptors)
app = await createTestApp(moduleFixture);
// 6. Get the server instance for supertest
service = moduleFixture.get(EngagementOrdersService) as MockEngagementOrdersService;
server = app.getHttpServer() as http.Server;
});
afterAll(async () => {
await app.close();
});
beforeEach(() => {
jest.clearAllMocks();
});
describe('POST /engagements/:engagementId/orders', () => {
it('should create a new order and return 201 with JSON:API structure', async () => {
// ARRANGE
const createDTO = createMockEngagementOrderDTO(MOCK_ENGAGEMENT_ID);
// Mock service is already set up to return a mock entity
// ACT
const response: request.Response = await request(server)
.post(`/engagements/${MOCK_ENGAGEMENT_ID}/orders`)
.send(createDTO)
.expect(201); // 7. Assert HTTP status
// ASSERT
// 8. Assert service was called correctly by the "real" controller
expect(service.createEngagementOrder).toHaveBeenCalledWith(
MOCK_ENGAGEMENT_ID,
expect.any(Object),
expect.any(Object), // currentUser
);
// 9. Assert the "real" interceptor formatted the response correctly
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
expect(response.body).toEqual(
expect.objectContaining({
data: expect.objectContaining({
type: resourceType,
id: expect.any(String),
attributes: expect.objectContaining({
engagement_id: MOCK_ENGAGEMENT_ID,
}),
}),
status: expect.objectContaining({ code: 201000 }),
links: expect.objectContaining({ self: expect.any(String) }),
}),
);
});
it('should return 400 for missing required fields (ValidationPipe test)', async () => {
// ARRANGE
const invalidDTO = createMockEngagementOrderDTO(MOCK_ENGAGEMENT_ID, {
order_type: undefined, // This is required
});
// ACT
await request(server)
.post(`/engagements/${MOCK_ENGAGEMENT_ID}/orders`)
.send(invalidDTO)
.expect(400); // 10. Assert the "real" ValidationPipe worked
// ASSERT
expect(service.createEngagementOrder).not.toHaveBeenCalled();
});
it('should return 400 for invalid data types', async () => {
const invalidDTO = {
order_type: 12345, // Should be string
order_code: 'LAB001',
};
await request(server)
.post(`/engagements/${MOCK_ENGAGEMENT_ID}/orders`)
.send(invalidDTO)
.expect(400);
expect(service.createEngagementOrder).not.toHaveBeenCalled();
});
it('should strip unknown properties (whitelist)', async () => {
const dtoWithExtra = createMockEngagementOrderDTO(MOCK_ENGAGEMENT_ID, {
unknownField: 'should be removed', // Should be stripped
});
await request(server)
.post(`/engagements/${MOCK_ENGAGEMENT_ID}/orders`)
.send(dtoWithExtra)
.expect(201);
expect(service.createEngagementOrder).toHaveBeenCalledWith(
MOCK_ENGAGEMENT_ID,
expect.not.objectContaining({ unknownField: 'should be removed' }),
expect.any(Object),
);
});
});
describe('PATCH /engagements/:engagementId/orders/:orderId', () => {
it('should update an existing order', async () => {
const updateDTO = {
status: 'COMPLETED',
};
await request(server)
.patch(`/engagements/${MOCK_ENGAGEMENT_ID}/orders/${MOCK_ORDER_ID}`)
.send(updateDTO)
.expect(200);
expect(service.update).toHaveBeenCalledWith(
MOCK_ORDER_ID,
expect.objectContaining(updateDTO),
expect.any(Object),
);
});
it('should handle service errors gracefully', async () => {
mockEngagementOrdersService.update.mockRejectedValueOnce(new Error('Order not found'));
await request(server)
.patch(`/engagements/${MOCK_ENGAGEMENT_ID}/orders/invalid-id`)
.send({ status: 'COMPLETED' })
.expect(500);
});
});
describe('GET /engagements/:engagementId/orders', () => {
it('should return paginated list with JSON:API structure', async () => {
// ARRANGE
// Mock service is already set up to return paginated data
// ACT
const response: request.Response = await request(server)
.get(`/engagements/${MOCK_ENGAGEMENT_ID}/orders`)
.query({ page: 1, limit: 10 })
.expect(200);
// ASSERT
expect(service.findPaginated).toHaveBeenCalled();
// 11. Assert the "real" interceptor formatted the paginated response
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
expect(response.body).toEqual(
expect.objectContaining({
data: expect.any(Array),
meta: expect.objectContaining({
pagination: expect.objectContaining({
page: 1,
page_size: 10,
}),
}),
links: expect.objectContaining({
self: expect.stringContaining('page=1&limit=10'),
}),
}),
);
});
it('should use default pagination when no query params', async () => {
await request(server).get(`/engagements/${MOCK_ENGAGEMENT_ID}/orders`).expect(200);
expect(service.findPaginated).toHaveBeenCalled();
});
});
describe('GET /engagements/:engagementId/orders/:orderId', () => {
it('should return a single order by ID', async () => {
const response: request.Response = await request(server)
.get(`/engagements/${MOCK_ENGAGEMENT_ID}/orders/${MOCK_ORDER_ID}`)
.expect(200);
expect(service.findById).toHaveBeenCalledWith(MOCK_ORDER_ID);
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
expect(response.body).toEqual(
expect.objectContaining({
data: expect.objectContaining({
type: resourceType,
id: MOCK_ORDER_ID,
}),
}),
);
});
it('should return 404 when order not found', async () => {
mockEngagementOrdersService.findById.mockRejectedValueOnce(new Error('Order not found'));
await request(server).get(`/engagements/${MOCK_ENGAGEMENT_ID}/orders/invalid-id`).expect(404);
});
});
describe('DELETE /engagements/:engagementId/orders/:orderId', () => {
it('should soft delete an order', async () => {
await request(server)
.delete(`/engagements/${MOCK_ENGAGEMENT_ID}/orders/${MOCK_ORDER_ID}`)
.expect(204);
expect(service.delete).toHaveBeenCalledWith(
MOCK_ORDER_ID,
true, // soft delete
expect.any(Object),
);
});
});
});