Testing
Comprehensive testing strategy using Jest for unit, integration, and end-to-end tests.
Overview
- Jest as testing framework
- Unit tests for domain logic
- Integration tests for use cases
- Repository tests for data access
- Test factories for consistent test data
- Mocks for external dependencies
- Coverage tracking for code quality
Jest Configuration
Setup
// jest.config.ts
import type { Config } from 'jest';
const config: Config = {
verbose: true,
testEnvironment: 'node',
testMatch: ['**/tests/**/*.test.ts'],
transform: {
'^.+.tsx?$': ['ts-jest', {}],
},
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/$1',
},
clearMocks: true,
setupFilesAfterEnv: [
'<rootDir>/tests/mocks/redis.ts',
'<rootDir>/tests/mocks/setup.ts',
],
};
export default config;
Running Tests
# Run all tests
pnpm test
# Run specific test file
pnpm test tests/domain/orders/order.entity.test.ts
# Run tests in watch mode
pnpm test --watch
# Run tests with coverage
pnpm test --coverage
# CI mode (for pipelines)
pnpm test:ci
Test Structure
File Organization
tests/
├── domain/ # Domain entity tests
│ ├── user/
│ │ └── user.entity.test.ts
│ ├── orders/
│ │ └── order.entity.test.ts
│ └── service/
│ └── service.entity.test.ts
├── app/ # Use case tests
│ ├── order/
│ │ ├── payment.usecases.test.ts
│ │ └── chat.usecases.test.ts
│ └── service/
│ └── service.usecases.test.ts
├── adapters/ # Repository and adapter tests
│ ├── order/
│ │ └── order.repository.test.ts
│ └── service/
│ └── service.repository.test.ts
├── web/ # Controller/API tests
│ └── auth/
│ └── auth.service.test.ts
├── factories.ts # Test data factories
├── utils.ts # Test utilities
└── mocks/ # Mock implementations
├── cache.mock.ts
├── logger.mock.ts
└── setup.ts
Naming Convention
[module].[type].test.ts
Examples:
user.entity.test.ts
order.usecases.test.ts
service.repository.test.ts
auth.controller.test.ts
Test Factories
Factory Pattern
Create consistent test data:
// tests/factories.ts
import { faker } from '@faker-js/faker';
import { createId } from '@paralleldrive/cuid2';
import { User } from '@/domain/user/user.entity';
import { Order } from '@/domain/orders/order.entity';
import Decimal from 'decimal.js';
export function getUser(updates: Partial<User> = {}): User {
const data = {
id: createId(),
fullName: faker.person.fullName(),
username: faker.internet.username(),
email: faker.internet.email(),
passwordHash: faker.string.uuid(),
isActive: false,
isStaff: false,
isSuperuser: false,
lastLogin: faker.date.recent(),
...updates,
};
return User.create(
data.id,
data.fullName,
data.username,
data.email,
data.passwordHash,
data.lastLogin,
data.isActive,
data.isStaff,
data.isSuperuser,
);
}
export function getOrder(updates: Partial<Order> = {}): Order {
const data = {
id: createId(),
sellerId: createId(),
customerId: createId(),
serviceId: createId(),
serviceTitle: faker.commerce.productName(),
price: new Decimal(faker.number.int({ min: 1000, max: 100000 })),
deliveryTime: 5 * 24 * 60 * 60, // 5 days in seconds
options: [],
paymentId: createId(),
platformFeeRate: new Decimal(0.05),
paymentStatus: 'pending',
createdAt: faker.date.recent(),
...updates,
};
return Order.create(
data.id,
data.sellerId,
data.customerId,
data.serviceId,
data.serviceTitle,
data.price,
data.deliveryTime,
data.options,
data.paymentId,
data.platformFeeRate,
data.paymentStatus,
null,
data.createdAt,
);
}
export function getService(updates: Partial<Service> = {}): Service {
const data = {
id: createId(),
title: faker.commerce.productName(),
description: faker.commerce.productDescription(),
price: new Decimal(faker.number.int({ min: 1000, max: 50000 })),
deliveryTime: 3 * 24 * 60 * 60, // 3 days
categoryId: createId(),
ownerId: createId(),
status: 'active' as const,
...updates,
};
return Service.create(
data.id,
data.title,
data.description,
data.price,
data.deliveryTime,
data.categoryId,
data.ownerId,
data.status,
);
}
Using Factories
describe('Order', () => {
it('should create order with factory', () => {
const order = getOrder({
price: new Decimal(5000),
status: 'pending',
});
expect(order.price.toNumber()).toBe(5000);
expect(order.status).toBe('pending');
});
});
Unit Tests
Domain Entity Tests
Test business logic in isolation:
// tests/domain/orders/order.entity.test.ts
import { Order } from '@/domain/orders/order.entity';
import Decimal from 'decimal.js';
describe('Order Entity', () => {
describe('getAssumedDeliveryDate', () => {
it('should calculate delivery date correctly', () => {
const createdAt = new Date('2023-10-01T00:00:00Z');
const deliveryTime = 5 * 24 * 60 * 60; // 5 days in seconds
const order = Order.create(
'1',
'seller1',
'customer1',
'service1',
'Service Title',
new Decimal(100),
deliveryTime,
[],
'payment1',
new Decimal(0.05),
'paid',
null,
createdAt,
);
expect(order.getAssumedDeliveryDate()).toEqual(
new Date('2023-10-06T00:00:00Z')
);
});
});
describe('accept', () => {
it('should accept pending order', () => {
const order = getOrder({ status: 'pending' });
order.accept();
expect(order.status).toBe('accepted');
});
it('should throw error if order not pending', () => {
const order = getOrder({ status: 'completed' });
expect(() => order.accept()).toThrow('Order must be pending');
});
});
describe('calculatePlatformFee', () => {
it('should calculate fee correctly', () => {
const order = getOrder({
price: new Decimal(10000),
platformFeeRate: new Decimal(0.05), // 5%
});
const fee = order.calculatePlatformFee();
expect(fee.toNumber()).toBe(500); // 5% of 10000
});
});
});
Value Object Tests
// tests/domain/service/option.vo.test.ts
describe('Option Value Object', () => {
it('should create valid option', () => {
const option = new Option('Color', 'Red', new Decimal(500));
expect(option.name).toBe('Color');
expect(option.value).toBe('Red');
expect(option.price.toNumber()).toBe(500);
});
it('should throw error for negative price', () => {
expect(() => {
new Option('Color', 'Red', new Decimal(-100));
}).toThrow('Option price cannot be negative');
});
});
Integration Tests
Use Case Tests
Test complete workflows:
// tests/app/service/service.usecases.test.ts
import serviceUseCases from '@/app/service/usecases';
import { ServiceRepository } from '@/domain/service/service.repository';
import { ServiceSqlRepository } from '@/adapters/service/service.repository';
import { getUser, getCategory } from '@/tests/factories';
import { ForbiddenError, ValidationError } from '@/app/shared/exceptions';
describe('Service Use Cases', () => {
let serviceRepository: ServiceRepository;
let user: User;
let category: Category;
beforeEach(async () => {
serviceRepository = new ServiceSqlRepository();
user = getUser({ roles: [Role.SELLER] });
user.approveKyc(); // User must be KYC approved
category = await categoryRepository.create(getCategory());
});
describe('createService', () => {
it('should create service with valid data', async () => {
const serviceData = {
title: 'Logo Design',
description: 'Professional logo design',
price: 5000,
deliveryTime: 3 * 24 * 60 * 60, // 3 days
categoryId: category.id,
};
const service = await serviceUseCases.create(
serviceData,
user.id,
serviceRepository,
);
expect(service.title).toBe('Logo Design');
expect(service.price.toNumber()).toBe(5000);
expect(service.ownerId).toBe(user.id);
expect(service.status).toBe('pending');
});
it('should reject if user not KYC approved', async () => {
const unapprovedUser = getUser({ roles: [Role.SELLER] });
const serviceData = {
title: 'Logo Design',
description: 'Professional logo design',
price: 5000,
deliveryTime: 3 * 24 * 60 * 60,
categoryId: category.id,
};
await expect(
serviceUseCases.create(
serviceData,
unapprovedUser.id,
serviceRepository,
)
).rejects.toThrow(ForbiddenError);
});
it('should validate required fields', async () => {
const invalidData = {
title: '', // Empty title
description: 'Description',
price: 5000,
deliveryTime: 3 * 24 * 60 * 60,
categoryId: category.id,
};
await expect(
serviceUseCases.create(
invalidData,
user.id,
serviceRepository,
)
).rejects.toThrow(ValidationError);
});
});
describe('updateService', () => {
it('should update owned service', async () => {
const service = await serviceRepository.create(
getService({ ownerId: user.id })
);
const updated = await serviceUseCases.update(
service.id,
{ title: 'Updated Title' },
user.id,
serviceRepository,
);
expect(updated.title).toBe('Updated Title');
});
it('should reject if not owner', async () => {
const service = await serviceRepository.create(
getService({ ownerId: 'other-user' })
);
await expect(
serviceUseCases.update(
service.id,
{ title: 'Updated Title' },
user.id,
serviceRepository,
)
).rejects.toThrow(ForbiddenError);
});
});
});
Repository Tests
Database Tests
Test data access layer:
// tests/adapters/service/service.repository.test.ts
import { ServiceSqlRepository } from '@/adapters/service/service.repository';
import { getService } from '@/tests/factories';
import { NotFound } from '@/domain/shared/repository/exceptions';
describe('ServiceRepository', () => {
let repository: ServiceSqlRepository;
beforeEach(() => {
repository = new ServiceSqlRepository();
});
describe('create', () => {
it('should create and retrieve service', async () => {
const service = getService({ title: 'Logo Design' });
await repository.add(service);
const retrieved = await repository.get(service.id);
expect(retrieved.title).toBe('Logo Design');
expect(retrieved.id).toBe(service.id);
});
});
describe('update', () => {
it('should update existing service', async () => {
const service = getService();
await repository.add(service);
service.updateTitle('Updated Title');
await repository.update(service);
const retrieved = await repository.get(service.id);
expect(retrieved.title).toBe('Updated Title');
});
});
describe('delete', () => {
it('should delete service', async () => {
const service = getService();
await repository.add(service);
await repository.delete(service.id);
await expect(
repository.get(service.id)
).rejects.toThrow(NotFound);
});
});
describe('findByOwner', () => {
it('should find services by owner', async () => {
const ownerId = 'owner-123';
const service1 = getService({ ownerId });
const service2 = getService({ ownerId });
await repository.bulkAdd([service1, service2]);
const services = await repository.findByOwner(ownerId);
expect(services).toHaveLength(2);
expect(services.every(s => s.ownerId === ownerId)).toBe(true);
});
});
});
Mocking
Mock Dependencies
// tests/mocks/cache.mock.ts
import { AbstractCache } from '@/app/shared/cache';
export class MockCache implements AbstractCache {
private store = new Map<string, any>();
async get(key: string): Promise<any> {
return this.store.get(key) || null;
}
async set(key: string, value: any, ttl?: number): Promise<void> {
this.store.set(key, value);
}
async delete(key: string): Promise<void> {
this.store.delete(key);
}
async clear(): Promise<void> {
this.store.clear();
}
// Test helper
has(key: string): boolean {
return this.store.has(key);
}
}
Mock Logger
// tests/mocks/logger.mock.ts
export class MockLogger {
private logs: Array<{ level: string; message: string }> = [];
info(message: string): void {
this.logs.push({ level: 'info', message });
}
error(message: string, error?: Error): void {
this.logs.push({ level: 'error', message });
}
warn(message: string): void {
this.logs.push({ level: 'warn', message });
}
debug(message: string): void {
this.logs.push({ level: 'debug', message });
}
// Test helpers
getLogs() {
return this.logs;
}
clear() {
this.logs = [];
}
hasLog(message: string): boolean {
return this.logs.some(log => log.message.includes(message));
}
}
Mock Notifier
// tests/mocks/notifier.mock.ts
import { Notifier } from '@/shared/notifications/base';
import { User } from '@/domain/user/user.entity';
export class MockNotifier extends Notifier {
private notifications: Array<{
subject: string;
message: string;
to: User;
}> = [];
async notify(
subject: string,
message: string,
to: User,
type: string,
): Promise<void> {
this.notifications.push({ subject, message, to });
}
// Test helpers
getNotifications() {
return this.notifications;
}
clear() {
this.notifications = [];
}
wasSentTo(email: string): boolean {
return this.notifications.some(n => n.to.email === email);
}
}
Test Utilities
Helper Functions
// tests/utils.ts
export function timestampUnsensitive(item: object) {
Object.assign(item, {
createdAt: expect.any(Date),
updatedAt: expect.any(Date),
});
return item;
}
export function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
export function expectDecimal(actual: Decimal, expected: number) {
expect(actual.toNumber()).toBe(expected);
}
Custom Matchers
// tests/mocks/setup.ts
expect.extend({
toBeDecimal(received: any, expected: number) {
const pass = received instanceof Decimal &&
received.toNumber() === expected;
return {
pass,
message: () =>
`expected ${received} to be Decimal with value ${expected}`,
};
},
});
Coverage
Generating Coverage Reports
# Run tests with coverage
pnpm test --coverage
# View HTML coverage report
open coverage/lcov-report/index.html
Coverage Thresholds
// jest.config.ts
const config: Config = {
// ...
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
};
Best Practices
✅ Do
- Write tests first – Test-driven development (TDD)
- Test behavior, not implementation – Focus on what, not how
- Use factories – Consistent test data
- Mock external dependencies – Isolate unit tests
- Test edge cases – Null, empty, invalid inputs
- Use descriptive names –
should accept pending order - Keep tests independent – No shared state
- Clean up after tests –
afterEachcleanup - Test error cases – Not just happy paths
- Maintain high coverage – Aim for 80%+
❌ Don't
- Don't test implementation details – Test public interfaces
- Don't share state between tests – Use fresh data
- Don't skip cleanup – Clean database/cache
- Don't write slow tests – Mock heavy operations
- Don't ignore failing tests – Fix or remove
- Don't test third-party code – Trust their tests
- Don't duplicate tests – DRY principle applies
- Don't make tests complex – Simple and clear
Testing Checklist
Before committing:
- [ ] All tests pass –
pnpm testsucceeds - [ ] New tests added – For new features
- [ ] Edge cases covered – Null, empty, invalid
- [ ] Mocks used – External dependencies mocked
- [ ] Coverage maintained – No coverage drops
- [ ] Tests are fast – Under 5 seconds total
- [ ] Tests are isolated – No shared state
- [ ] Descriptive names – Clear test descriptions
Summary
Comprehensive testing ensures:
- Code quality – Catch bugs early
- Confidence – Safe refactoring
- Documentation – Tests as specs
- Maintainability – Prevent regressions
- Team velocity – Fast feedback loop
Write tests for every feature and maintain high coverage for production-ready code.