Стратегия тестирования
Пирамида тестов
┌─────────┐
/ 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 |