Skip to content

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 namesshould accept pending order
  • Keep tests independent – No shared state
  • Clean up after testsafterEach cleanup
  • 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 passpnpm test succeeds
  • [ ] 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.