Skip to main content

Unit Tests

This guide explains how to write unit tests for services and handlers in the MBC CQRS Serverless framework.

Mocking Dependencies

The framework uses @golevelup/ts-jest for creating mocks. The createMock() function automatically generates mock implementations for any interface or class.

Basic Usage of createMock()

import { createMock } from '@golevelup/ts-jest'
import { Test } from '@nestjs/testing'
import { ConfigService } from '@nestjs/config'

const moduleRef = await Test.createTestingModule({
providers: [MyService],
})
.useMocker(createMock) // Auto-mock all dependencies
.compile()

Custom Mock Implementation

import { createMock } from '@golevelup/ts-jest'
import { ConfigService } from '@nestjs/config'

const config = {
NODE_ENV: 'test',
APP_NAME: 'my-app',
}

const moduleRef = await Test.createTestingModule({
providers: [
MyService,
{
provide: ConfigService,
useValue: createMock<ConfigService>({
get: jest.fn((key) => config[key] ?? 'default'),
}),
},
],
}).compile()

Mocking AWS SDK Clients

Use aws-sdk-client-mock to mock AWS SDK v3 clients:

import { mockClient } from 'aws-sdk-client-mock'
import 'aws-sdk-client-mock-jest'
import { DynamoDBClient, PutItemCommand, GetItemCommand } from '@aws-sdk/client-dynamodb'

describe('MyService', () => {
const dynamoDBMock = mockClient(DynamoDBClient)

beforeEach(() => {
dynamoDBMock.reset()
})

afterEach(() => {
dynamoDBMock.reset()
})

it('should put item to DynamoDB', async () => {
// Arrange: Set up mock response
dynamoDBMock.on(PutItemCommand).resolves({})

// Action: Execute the method
await myService.saveItem({ pk: 'test', sk: 'item' })

// Assert: Verify the mock was called
expect(dynamoDBMock).toHaveReceivedCommandTimes(PutItemCommand, 1)
expect(dynamoDBMock).toHaveReceivedCommandWith(PutItemCommand, {
TableName: 'my-table',
Item: expect.objectContaining({
pk: { S: 'test' },
sk: { S: 'item' },
}),
})
})
})

Complete Test Example

Here is a complete example based on the framework's actual test patterns:

import { createMock } from '@golevelup/ts-jest'
import { Test } from '@nestjs/testing'
import { mockClient } from 'aws-sdk-client-mock'
import 'aws-sdk-client-mock-jest'
import { DynamoDBClient, GetItemCommand, PutItemCommand } from '@aws-sdk/client-dynamodb'
import { ConfigService } from '@nestjs/config'

const config = {
NODE_ENV: 'test',
APP_NAME: 'my-app',
}

describe('CommandService', () => {
let commandService: CommandService
const dynamoDBMock = mockClient(DynamoDBClient)

beforeEach(async () => {
const moduleRef = await Test.createTestingModule({
providers: [
CommandService,
DynamoDbService,
{
provide: ConfigService,
useValue: createMock<ConfigService>({
get: jest.fn((key) => config[key]),
}),
},
],
})
.useMocker(createMock) // Auto-mock remaining dependencies
.compile()

commandService = moduleRef.get<CommandService>(CommandService)
})

afterEach(() => {
jest.clearAllMocks()
dynamoDBMock.reset()
})

describe('getLatestItem', () => {
it('should return the latest item', async () => {
// Arrange
const key = { pk: 'master', sk: 'test' }

// Action
const item = await commandService.getLatestItem(key)

// Assert
expect(item).toBeDefined()
expect(item?.pk).toBe('master')
})

it('should return null when item not found', async () => {
// Arrange
const key = { pk: 'master', sk: 'nonexistent' }

// Action
const item = await commandService.getLatestItem(key)

// Assert
expect(item).toBeNull()
})
})
})

Key Testing Patterns

Test Structure

Each test follows the Arrange-Act-Assert pattern:

  1. Arrange: Set up test data and mock responses
  2. Act: Execute the method being tested
  3. Assert: Verify the results and mock interactions

Using describe and it

Use describe to group related tests and it for individual test cases:

describe('ServiceName', () => {
describe('methodName', () => {
it('should do something when condition is met', async () => {
// test implementation
})

it('should throw error when input is invalid', async () => {
// test implementation
})
})
})

Testing Error Cases

import { BadRequestException } from '@nestjs/common';

it('should throw BadRequestException when item not found', async () => {
const invalidKey = { pk: 'invalid', sk: 'key', version: 1 }

await expect(
commandService.publishPartialUpdateAsync(invalidKey, { invokeContext: {} })
).rejects.toThrow(BadRequestException)
})

Advanced AWS SDK Mock Patterns

Mocking Multiple AWS Services

When testing services that interact with multiple AWS services, set up mocks for each client:

import { mockClient } from 'aws-sdk-client-mock'
import 'aws-sdk-client-mock-jest'
import { DynamoDBClient, PutItemCommand } from '@aws-sdk/client-dynamodb'
import { SNSClient, PublishCommand } from '@aws-sdk/client-sns'
import { SQSClient, SendMessageCommand } from '@aws-sdk/client-sqs'

describe('MultiServiceTest', () => {
const dynamoMock = mockClient(DynamoDBClient)
const snsMock = mockClient(SNSClient)
const sqsMock = mockClient(SQSClient)

beforeEach(() => {
dynamoMock.reset()
snsMock.reset()
sqsMock.reset()
})

it('should publish to SNS after saving to DynamoDB', async () => {
dynamoMock.on(PutItemCommand).resolves({})
snsMock.on(PublishCommand).resolves({ MessageId: 'msg-123' })

await myService.saveAndNotify(data)

expect(dynamoMock).toHaveReceivedCommandTimes(PutItemCommand, 1)
expect(snsMock).toHaveReceivedCommandTimes(PublishCommand, 1)
})
})

Mocking Conditional Responses

Use callsFake to return different responses based on input:

import { marshall } from '@aws-sdk/util-dynamodb'

dynamoMock.on(GetItemCommand).callsFake((input) => {
if (input.Key.pk.S === 'existing-key') {
return {
Item: marshall({
pk: 'existing-key',
sk: 'item',
name: 'Test Item',
}),
}
}
return { Item: undefined } // Item not found
})

Mocking Errors

Test error handling by making mocks reject:

import { ConflictException } from '@nestjs/common'

it('should handle ConditionalCheckFailedException as 409 conflict', async () => {
const error = new Error('The conditional request failed')
error.name = 'ConditionalCheckFailedException'
dynamoMock.on(PutItemCommand).rejects(error)

await expect(myService.saveItem(data)).rejects.toThrow(ConflictException)
})

Using jest.mock() for Module-Level Mocking

For services that instantiate AWS clients internally, use jest.mock():

// Mock at the top of your test file
jest.mock('@aws-sdk/client-dynamodb', () => {
const original = jest.requireActual('@aws-sdk/client-dynamodb')
return {
...original,
DynamoDBClient: jest.fn().mockImplementation(() => ({
send: jest.fn(),
})),
}
})
Best Practice

Prefer aws-sdk-client-mock over jest.mock() when possible, as it provides better type safety and more detailed assertions like toHaveReceivedCommandWith.

Testing DataSyncHandlers

Test the up() and down() methods of an IDataSyncHandler by mocking database clients with createMock().

import { createMock } from '@golevelup/ts-jest'
import { Test } from '@nestjs/testing'
import { CommandModel } from '@mbc-cqrs-serverless/core'

import { OrderDataSyncHandler } from './order.handler'
import { PrismaService } from 'src/prisma.service'

describe('OrderDataSyncHandler', () => {
let handler: OrderDataSyncHandler
let prisma: jest.Mocked<PrismaService>

beforeEach(async () => {
const moduleRef = await Test.createTestingModule({
providers: [OrderDataSyncHandler],
})
.useMocker(createMock) // Auto-mock all dependencies
.compile()

handler = moduleRef.get(OrderDataSyncHandler)
prisma = moduleRef.get(PrismaService)
})

describe('up', () => {
it('should upsert record when command is processed', async () => {
// Arrange: build a representative CommandModel
const cmd = {
pk: 'ORDER#mbc',
sk: 'ORD-001',
tenantCode: 'mbc',
version: 1,
type: 'ORDER',
attributes: { customerId: 'C-001', total: 150 },
} as unknown as CommandModel

prisma.order.upsert.mockResolvedValue({ id: 'ORD-001' } as any)

await handler.up(cmd)

expect(prisma.order.upsert).toHaveBeenCalledWith(
expect.objectContaining({ where: { sk: 'ORD-001' } }),
)
})
})

describe('down', () => {
it('should delete record on rollback', async () => {
const cmd = {
pk: 'ORDER#mbc',
sk: 'ORD-001',
tenantCode: 'mbc',
} as unknown as CommandModel

prisma.order.delete.mockResolvedValue({ id: 'ORD-001' } as any)

await handler.down(cmd)

expect(prisma.order.delete).toHaveBeenCalledWith({
where: { sk: 'ORD-001' },
})
})
})
})

If your handler does not use Prisma (e.g., writes directly to DynamoDB), inject and mock DynamoDbService or DataService from @mbc-cqrs-serverless/core instead.