Skip to content

Стратегия тестирования

Пирамида тестов

              ┌─────────┐
             /   E2E    \            5%  — Критические пользовательские сценарии
            /   Tests    \
           /──────────────\
          /  Integration   \        25%  — API эндпоинты, запросы к БД
         /     Tests        \
        /────────────────────\
       /      Unit Tests      \     70%  — Доменная логика, утилиты
      /────────────────────────\

Структура тестов

apps/api/
├── src/
│   └── modules/
│       └── mlm/
│           └── commission/
│               ├── domain/
│               │   └── services/
│               │       ├── commission-calculator.service.ts
│               │       └── commission-calculator.service.spec.ts  ← Unit тест
│               └── application/
│                   └── commands/
│                       ├── calculate-commission.handler.ts
│                       └── calculate-commission.handler.spec.ts  ← Unit тест
├── test/
│   ├── integration/           ← Интеграционные тесты
│   │   ├── auth.e2e-spec.ts
│   │   ├── mlm.e2e-spec.ts
│   │   └── orders.e2e-spec.ts
│   ├── e2e/                   ← End-to-end тесты
│   │   └── checkout-flow.e2e-spec.ts
│   ├── fixtures/              ← Фабрики тестовых данных
│   │   ├── user.fixture.ts
│   │   ├── partner.fixture.ts
│   │   └── order.fixture.ts
│   └── setup/
│       ├── test-database.ts   ← Настройка тестовой БД
│       └── mock-providers.ts  ← Моки внешних сервисов

Инструменты тестирования

НазначениеИнструмент
Запуск тестовJest
HTTP тестированиеSupertest
МокированиеJest mocks + кастомные фабрики
Покрытие кодаJest coverage
База данныхTestcontainers (PostgreSQL)
ФикстурыКастомные фабричные функции

Правила Unit тестирования

typescript
// Доменные сервисы ДОЛЖНЫ быть независимы от фреймворка и легко тестируемы

// ✅ Хорошо: Чистая доменная логика
class CommissionCalculator {
  calculate(amount: number, tiers: CommissionTier[]): CommissionResult {
    // Чистая логика, без зависимостей
  }
}

// ✅ Хорошо: Зависимости внедряются через интерфейсы
class CommissionService {
  constructor(
    private readonly partnerRepo: IPartnerRepository,  // Интерфейс, не реализация
    private readonly calculator: CommissionCalculator,
  ) {}
}

// ❌ Плохо: Прямая зависимость от фреймворка/инфраструктуры
class CommissionService {
  constructor(
    private readonly prisma: PrismaClient,  // Прямая инфраструктурная зависимость
  ) {}
}

Пример Unit теста

typescript
// commission-calculator.service.spec.ts
describe('CommissionCalculator', () => {
  let calculator: CommissionCalculator;

  beforeEach(() => {
    calculator = new CommissionCalculator();
  });

  describe('calculate', () => {
    it('должен рассчитывать комиссию для одного уровня', () => {
      const tiers = [{ levelDepth: 1, percentage: 10 }];
      const result = calculator.calculate(1000, tiers);

      expect(result.commissions).toHaveLength(1);
      expect(result.commissions[0].amount).toBe(100);
    });

    it('должен применять минимальное требование ранга', () => {
      const tiers = [{ levelDepth: 1, percentage: 10, minRankLevel: 3 }];
      const partnerRank = 2;

      const result = calculator.calculate(1000, tiers, partnerRank);

      expect(result.commissions).toHaveLength(0);
    });

    it('должен корректно обрабатывать пустые уровни', () => {
      const result = calculator.calculate(1000, []);

      expect(result.commissions).toHaveLength(0);
      expect(result.totalCommission).toBe(0);
    });
  });
});

Пример интеграционного теста

typescript
// test/integration/mlm.e2e-spec.ts
describe('MLM Module (Integration)', () => {
  let app: INestApplication;
  let prisma: PrismaService;

  beforeAll(async () => {
    const moduleRef = await Test.createTestingModule({
      imports: [AppModule],
    })
      .overrideProvider(IPaymentGateway)
      .useClass(MockPaymentGateway)
      .overrideProvider(INotificationSender)
      .useClass(MockNotificationSender)
      .compile();

    app = moduleRef.createNestApplication();
    prisma = moduleRef.get(PrismaService);
    await app.init();
  });

  beforeEach(async () => {
    // Очистка базы данных между тестами
    await prisma.$executeRaw`TRUNCATE TABLE mlm.commission_transactions CASCADE`;
  });

  afterAll(async () => {
    await app.close();
  });

  describe('POST /api/v1/mlm/commissions/calculate', () => {
    it('должен рассчитывать комиссии для заказа', async () => {
      // Arrange
      const sponsor = await createPartnerFixture(prisma);
      const order = await createOrderFixture(prisma, { referringPartnerId: sponsor.id });

      // Act
      const response = await request(app.getHttpServer())
        .post('/api/v1/mlm/commissions/calculate')
        .send({ orderId: order.id })
        .expect(201);

      // Assert
      expect(response.body.commissions).toHaveLength(1);
      expect(response.body.commissions[0].partnerId).toBe(sponsor.id);

      // Проверка состояния базы данных
      const dbCommissions = await prisma.commissionTransaction.findMany({
        where: { sourceId: order.id },
      });
      expect(dbCommissions).toHaveLength(1);
    });
  });
});

Стратегия тестовой базы данных

typescript
// test/setup/test-database.ts
import { PostgreSqlContainer } from '@testcontainers/postgresql';

let container: StartedPostgreSqlContainer;

export async function setupTestDatabase() {
  container = await new PostgreSqlContainer('postgres:15')
    .withDatabase('iwm_test')
    .withUsername('test')
    .withPassword('test')
    .start();

  process.env.DATABASE_URL = container.getConnectionUri();

  // Запуск миграций
  execSync('npx prisma migrate deploy', { stdio: 'inherit' });
}

export async function teardownTestDatabase() {
  await container.stop();
}

// jest.config.js
module.exports = {
  globalSetup: './test/setup/global-setup.ts',
  globalTeardown: './test/setup/global-teardown.ts',
};

Mock провайдеры

typescript
// test/setup/mock-providers.ts

export class MockPaymentGateway implements IPaymentGateway {
  private responses = new Map<string, PaymentResult>();

  // Настройка mock ответов для тестов
  mockPaymentResult(intentId: string, result: PaymentResult) {
    this.responses.set(intentId, result);
  }

  async createPaymentIntent(params: CreatePaymentParams): Promise<PaymentIntent> {
    return {
      id: `pi_test_${Date.now()}`,
      amount: params.amount,
      currency: params.currency,
      status: 'PENDING',
    };
  }

  async confirmPayment(intentId: string): Promise<PaymentResult> {
    return this.responses.get(intentId) ?? { success: true, transactionId: 'txn_test' };
  }
}

export class MockNotificationSender implements INotificationSender {
  public sentEmails: EmailParams[] = [];
  public sentSMS: SMSParams[] = [];

  async sendEmail(params: EmailParams): Promise<SendResult> {
    this.sentEmails.push(params);
    return { success: true, messageId: `msg_${Date.now()}` };
  }

  async sendSMS(params: SMSParams): Promise<SendResult> {
    this.sentSMS.push(params);
    return { success: true, messageId: `msg_${Date.now()}` };
  }
}

Целевые показатели покрытия

МодульЦельОбоснование
Доменные сервисы90%+Ключевая бизнес-логика
Application команды/запросы80%+Оркестрация use case
Инфраструктура60%+Точки интеграции
Presentation (контроллеры)70%+API контракты
Общее75%+Разумно для MVP

Что тестировать

СлойЧто тестироватьЧто НЕ тестировать
DomainБизнес-правила, расчёты, валидации
ApplicationОркестрацию команд/запросов, обработку событийВызовы внешних сервисов (мокировать их)
InfrastructureЗапросы репозиториев, преобразования мапперовВнутренности фреймворка
PresentationВалидацию запросов, формат ответов, auth guardsСами декораторы NestJS

Связанные документы