Testing Services
Testing Services
Section titled “Testing Services”The Banyan Platform uses Jest for testing with strict 90%+ coverage requirements. Tests focus on message-based communication, not HTTP endpoints.
Use This Guide If…
Section titled “Use This Guide If…”- You’re writing tests for your handlers
- You need to meet the 90%+ coverage requirement
- You’re mocking service clients for unit tests
- You want to write integration tests via message bus
- You’re testing event-sourced aggregates
Quick Example
Section titled “Quick Example”import { CreateUserHandler } from './CreateUserHandler';import { CreateUserCommand } from '../contracts/commands';import type { AuthenticatedUser } from '@banyanai/platform-core';
describe('CreateUserHandler', () => { it('should create user successfully', async () => { // Arrange const handler = new CreateUserHandler(); const command = new CreateUserCommand(); command.email = 'test@example.com'; command.firstName = 'John'; command.lastName = 'Doe';
const user: AuthenticatedUser = { userId: 'admin-123', email: 'admin@example.com', name: 'Admin User', permissions: ['users:create'] };
// Act const result = await handler.handle(command, user);
// Assert expect(result.userId).toBeDefined(); expect(result.email).toBe('test@example.com'); expect(result.createdAt).toBeDefined(); });});Test Framework Setup
Section titled “Test Framework Setup”Jest Configuration
Section titled “Jest Configuration”package.json:
{ "scripts": { "test": "jest", "test:watch": "jest --watch", "test:coverage": "jest --coverage" }, "jest": { "preset": "ts-jest", "testEnvironment": "node", "roots": ["<rootDir>/src"], "testMatch": ["**/__tests__/**/*.test.ts", "**/*.test.ts"], "collectCoverageFrom": [ "src/**/*.ts", "!src/**/*.test.ts", "!src/**/__tests__/**", "!src/**/index.ts" ], "coverageThreshold": { "global": { "branches": 90, "functions": 90, "lines": 90, "statements": 90 } } }}TypeScript Configuration
Section titled “TypeScript Configuration”tsconfig.json:
{ "compilerOptions": { "target": "ES2022", "module": "Node16", "moduleResolution": "Node16", "esModuleInterop": true, "experimentalDecorators": true, "emitDecoratorMetadata": true }}Unit Testing Handlers
Section titled “Unit Testing Handlers”Testing Command Handlers
Section titled “Testing Command Handlers”import { CreateUserHandler } from '../commands/CreateUserHandler';import { CreateUserCommand } from '../contracts/commands';import type { AuthenticatedUser } from '@banyanai/platform-core';
describe('CreateUserHandler', () => { let handler: CreateUserHandler; let mockUser: AuthenticatedUser;
beforeEach(() => { handler = new CreateUserHandler(); mockUser = { userId: 'admin-123', email: 'admin@example.com', name: 'Admin', permissions: ['users:create'] }; });
describe('handle', () => { it('should create user with valid input', async () => { // Arrange const command = new CreateUserCommand(); command.email = 'john@example.com'; command.firstName = 'John'; command.lastName = 'Doe';
// Act const result = await handler.handle(command, mockUser);
// Assert expect(result).toMatchObject({ email: 'john@example.com', userId: expect.any(String), createdAt: expect.any(String) }); });
it('should throw error for invalid email', async () => { // Arrange const command = new CreateUserCommand(); command.email = 'invalid-email'; command.firstName = 'John'; command.lastName = 'Doe';
// Act & Assert await expect(handler.handle(command, mockUser)) .rejects .toThrow('Invalid email format'); }); });});Testing Query Handlers
Section titled “Testing Query Handlers”import { GetUserHandler } from '../queries/GetUserHandler';import { GetUserQuery } from '../contracts/queries';import type { AuthenticatedUser } from '@banyanai/platform-core';
describe('GetUserHandler', () => { let handler: GetUserHandler;
beforeEach(() => { handler = new GetUserHandler(); });
describe('handle', () => { it('should return user by ID', async () => { // Arrange const query = new GetUserQuery(); query.userId = 'user-123';
const user: AuthenticatedUser = { userId: 'admin-123', permissions: ['users:read'] };
// Act const result = await handler.handle(query, user);
// Assert expect(result).toMatchObject({ userId: 'user-123', email: expect.any(String), firstName: expect.any(String), lastName: expect.any(String) }); });
it('should throw error for non-existent user', async () => { // Arrange const query = new GetUserQuery(); query.userId = 'non-existent';
// Act & Assert await expect(handler.handle(query, null)) .rejects .toThrow('User not found'); }); });});Testing Event Handlers
Section titled “Testing Event Handlers”import { UserCreatedHandler } from '../subscriptions/UserCreatedHandler';import { UserCreatedEvent } from '@myorg/user-service-contracts';
describe('UserCreatedHandler', () => { let handler: UserCreatedHandler;
beforeEach(() => { handler = new UserCreatedHandler(); });
describe('handle', () => { it('should process user created event', async () => { // Arrange const event = new UserCreatedEvent(); event.userId = 'user-123'; event.email = 'user@example.com'; event.createdAt = new Date().toISOString();
// Act await handler.handle(event, null);
// Assert - verify side effects // (e.g., check logs, database updates, etc.) });
it('should be idempotent', async () => { // Arrange const event = new UserCreatedEvent(); event.userId = 'user-123'; event.email = 'user@example.com'; event.createdAt = new Date().toISOString();
// Act - handle same event twice await handler.handle(event, null); await handler.handle(event, null);
// Assert - should not cause errors or duplicate effects }); });});Mocking Service Clients
Section titled “Mocking Service Clients”Jest Mocks
Section titled “Jest Mocks”import { NotificationServiceClient } from '../clients/NotificationServiceClient';
// Mock the entire modulejest.mock('../clients/NotificationServiceClient');
describe('CreateUserHandler', () => { let handler: CreateUserHandler; let mockNotifications: jest.Mocked<NotificationServiceClient>;
beforeEach(() => { // Create mock instance mockNotifications = new NotificationServiceClient() as jest.Mocked<NotificationServiceClient>; mockNotifications.sendWelcomeEmail = jest.fn().mockResolvedValue(undefined);
handler = new CreateUserHandler(mockNotifications); });
it('should call notification service', async () => { // Arrange const command = new CreateUserCommand(); command.email = 'test@example.com'; command.firstName = 'John'; command.lastName = 'Doe';
// Act await handler.handle(command, null);
// Assert expect(mockNotifications.sendWelcomeEmail).toHaveBeenCalledWith( expect.any(String), 'test@example.com', 'John' ); });});Manual Mocks
Section titled “Manual Mocks”// Create manual mockconst mockNotifications = { sendWelcomeEmail: jest.fn().mockResolvedValue(undefined), sendPasswordResetEmail: jest.fn().mockResolvedValue(undefined),} as any;
describe('CreateUserHandler', () => { it('should send welcome email', async () => { const handler = new CreateUserHandler(mockNotifications);
await handler.handle(command, user);
expect(mockNotifications.sendWelcomeEmail).toHaveBeenCalledWith( 'user-123', 'test@example.com', 'John' ); });});Testing with Event Sourcing
Section titled “Testing with Event Sourcing”Testing Aggregates
Section titled “Testing Aggregates”import { UserAggregate } from '../domain/UserAggregate';import { UserCreatedEvent, UserUpdatedEvent } from '../domain/events';
describe('UserAggregate', () => { describe('create', () => { it('should create user aggregate', () => { // Arrange const command = new CreateUserCommand(); command.email = 'test@example.com'; command.firstName = 'John'; command.lastName = 'Doe';
// Act const aggregate = UserAggregate.create(command, 'creator-123');
// Assert expect(aggregate.id).toBeDefined(); expect(aggregate.email).toBe('test@example.com');
// Check uncommitted events const events = aggregate.getUncommittedEvents(); expect(events).toHaveLength(1); expect(events[0]).toBeInstanceOf(UserCreatedEvent); }); });
describe('updateProfile', () => { it('should update user profile', () => { // Arrange const aggregate = UserAggregate.create( { email: 'test@example.com', firstName: 'John', lastName: 'Doe' }, 'creator-123' ); aggregate.clearUncommittedEvents();
// Act aggregate.updateProfile('Jane', 'Smith');
// Assert expect(aggregate.firstName).toBe('Jane'); expect(aggregate.lastName).toBe('Smith');
const events = aggregate.getUncommittedEvents(); expect(events).toHaveLength(1); expect(events[0]).toBeInstanceOf(UserUpdatedEvent); }); });});Testing Event Sourced Handlers
Section titled “Testing Event Sourced Handlers”import { CreateUserHandler } from '../commands/CreateUserHandler';import { UserAggregate } from '../domain/UserAggregate';
describe('CreateUserHandler with Event Sourcing', () => { let handler: CreateUserHandler;
beforeEach(() => { handler = new CreateUserHandler();
// Mock save method handler.save = jest.fn().mockResolvedValue(undefined); });
it('should create and save aggregate', async () => { // Arrange const command = new CreateUserCommand(); command.email = 'test@example.com'; command.firstName = 'John'; command.lastName = 'Doe';
const user: AuthenticatedUser = { userId: 'admin-123', permissions: ['users:create'] };
// Act const result = await handler.handle(command, user);
// Assert expect(handler.save).toHaveBeenCalledWith( expect.any(UserAggregate) );
const savedAggregate = (handler.save as jest.Mock).mock.calls[0][0]; expect(savedAggregate.email).toBe('test@example.com'); });});Integration Testing
Section titled “Integration Testing”Message Bus Integration Tests
Section titled “Message Bus Integration Tests”Integration tests validate message-based communication, NOT HTTP:
import { MessageBusClient } from '@banyanai/platform-message-bus-client';import { CreateUserCommand, CreateUserResult } from '../contracts/commands';
describe('User Service Integration', () => { let messageBus: MessageBusClient;
beforeAll(async () => { messageBus = await MessageBusClient.connect({ host: 'localhost', port: 5672 }); });
afterAll(async () => { await messageBus.disconnect(); });
it('should handle create user command via message bus', async () => { // Arrange const command = new CreateUserCommand(); command.email = 'integration@example.com'; command.firstName = 'Integration'; command.lastName = 'Test';
// Act - send command to message bus const result = await messageBus.sendCommand<CreateUserResult>( 'User.Commands.CreateUser', command );
// Assert expect(result).toMatchObject({ userId: expect.any(String), email: 'integration@example.com', createdAt: expect.any(String) }); });});Coverage Requirements
Section titled “Coverage Requirements”90%+ Coverage Threshold
Section titled “90%+ Coverage Threshold”Required coverage:
- Branches: 90%
- Functions: 90%
- Lines: 90%
- Statements: 90%
Check coverage:
pnpm run test:coverageCoverage report:
-------------------------------|---------|----------|---------|---------|File | % Stmts | % Branch | % Funcs | % Lines |-------------------------------|---------|----------|---------|---------|All files | 94.23 | 92.85 | 95.12 | 94.15 | commands | 96.15 | 94.44 | 97.22 | 96.10 | CreateUserHandler.ts | 97.50 | 95.83 | 100.00 | 97.44 | UpdateUserHandler.ts | 95.00 | 93.33 | 95.00 | 94.87 | queries | 93.75 | 91.66 | 94.44 | 93.65 | GetUserHandler.ts | 95.45 | 93.75 | 96.77 | 95.38 | ListUsersHandler.ts | 92.30 | 90.00 | 92.85 | 92.10 |-------------------------------|---------|----------|---------|---------|Excluded from Coverage
Section titled “Excluded from Coverage”Coverage collection excludes:
- Test files (
*.test.ts,*.spec.ts) - Mock files (
__mocks__/*) - Setup files (
test-setup.ts) - Entry points (
main.ts,index.ts)
Test Organization
Section titled “Test Organization”Directory Structure
Section titled “Directory Structure”src/├── commands/│ ├── CreateUserHandler.ts│ ├── CreateUserHandler.test.ts # Unit tests│ └── __tests__/│ └── CreateUser.integration.test.ts├── queries/│ ├── GetUserHandler.ts│ ├── GetUserHandler.test.ts│ └── __tests__/│ └── GetUser.integration.test.ts├── subscriptions/│ ├── UserCreatedHandler.ts│ └── UserCreatedHandler.test.ts└── __tests__/ └── e2e/ └── UserService.e2e.test.tsTest Naming
Section titled “Test Naming”DO:
// Unit testsCreateUserHandler.test.tsGetUserHandler.test.ts
// Integration testsCreateUser.integration.test.tsGetUser.integration.test.ts
// E2E testsUserService.e2e.test.tsDON’T:
test-create-user.tsCreateUser.spec.tsuser-handler-tests.tsBest Practices
Section titled “Best Practices”Test Structure
Section titled “Test Structure”DO:
describe('CreateUserHandler', () => { // Setup let handler: CreateUserHandler;
beforeEach(() => { handler = new CreateUserHandler(); });
// Group related tests describe('handle', () => { it('should create user with valid input', async () => { // Arrange const command = new CreateUserCommand();
// Act const result = await handler.handle(command, mockUser);
// Assert expect(result).toBeDefined(); }); });});DON’T:
// No organizationtest('test1', () => { /* ... */ });test('test2', () => { /* ... */ });test('test3', () => { /* ... */ });Assertions
Section titled “Assertions”DO:
// Specific assertionsexpect(result.userId).toBe('user-123');expect(result.email).toBe('test@example.com');expect(result.createdAt).toMatch(/^\d{4}-\d{2}-\d{2}/);
// Object matchingexpect(result).toMatchObject({ userId: 'user-123', email: 'test@example.com'});DON’T:
// Vague assertionsexpect(result).toBeDefined();expect(result).toBeTruthy();expect(result.userId).not.toBeNull();Mocking
Section titled “Mocking”DO:
// Mock external dependenciesconst mockClient = { sendCommand: jest.fn().mockResolvedValue({ success: true })};
// Verify mock callsexpect(mockClient.sendCommand).toHaveBeenCalledWith( 'Some.Command', expect.objectContaining({ userId: 'user-123' }));DON’T:
// Don't mock everythingconst mockHandler = { handle: jest.fn().mockResolvedValue({ userId: '123' })};
// This doesn't test your code!expect(mockHandler.handle).toHaveBeenCalled();Troubleshooting
Section titled “Troubleshooting””Cannot find module” errors
Section titled “”Cannot find module” errors”- Check imports use
.jsextensions - Check
moduleResolutionset toNode16in tsconfig - Add
.jsextensions to all imports
”Decorator errors” in tests
Section titled “”Decorator errors” in tests”- Check
experimentalDecoratorsenabled in tsconfig - Check
emitDecoratorMetadataenabled - Import
reflect-metadataif needed
Coverage below 90%
Section titled “Coverage below 90%”- Check all code paths are tested
- Add tests for error cases
- Add tests for edge cases
- Review uncovered branches in coverage report
Related Resources
Section titled “Related Resources”- Writing Handlers - Handler implementation patterns
- Using Service Clients - Testing cross-service calls
- Data Access Patterns - Testing event sourcing
Next Steps:
- Set up Jest configuration with 90%+ thresholds
- Write unit tests for all handlers
- Add integration tests for critical flows
- Monitor coverage reports
- Refactor to improve testability