Автоматизация тестирования: путь от 0% до 87% покрытия кода

Начальное состояние: ручной хаос

«ЭдуПлатформа» — edtech-сервис с 200 000 активных студентов. Стек: React (фронтенд), Node.js + Python (бэкенд), 8 микросервисов, PostgreSQL, Redis, S3 для видео.

Что мы увидели на старте:

  • 0 автоматических тестов во всей кодовой базе
  • 5 ручных QA-инженеров, каждый релиз — 3 дня ручного прогона
  • Релизный цикл: 2 недели, из которых неделя — тестирование
  • Регрессии при каждом релизе — в среднем 3.7 критических багов в продакшне
  • Нет CI/CD — деплой вручную, через SSH на сервер
  • Нет staging-окружения — тестирование на dev-стенде с синтетическими данными

Первое, что мы сделали — убедили руководство, что автоматизация тестирования — это не расход, а инвестиция. Подсчитали стоимость багов в продакшне (потеря студентов, возвраты, репутация) и показали, что каждый месяц ручного QA обходится дороже, чем полугодовой проект по автоматизации.

Пирамида тестирования: план атаки

Мы не стали пытаться покрыть всё сразу. План на 6 месяцев:

  1. Месяц 1–2: Unit-тесты для критичной бизнес-логики (оплата, доступ к курсам, прогресс)
  2. Месяц 2–3: Интеграционные тесты для API и взаимодействия сервисов
  3. Месяц 3–4: E2E-тесты для ключевых пользовательских сценариев
  4. Месяц 4–5: Контрактные тесты между микросервисами
  5. Месяц 5–6: Нагрузочное тестирование, мутационное тестирование, CI/CD

Unit-тесты: Jest + pytest

Фронтенд и Node.js-сервисы тестировали Jest, Python-сервисы — pytest. Начали с модуля оплаты — самого критичного.

Пример: тестирование расчёта стоимости подписки

// subscription-pricing.test.ts
import { calculatePrice } from '../pricing';

describe('calculatePrice', () => {
  it('should apply annual discount of 20%', () => {
    const price = calculatePrice({
      plan: 'pro',
      period: 'annual',
      baseMonthlyPrice: 990,
    });
    expect(price).toEqual({
      monthly: 792,
      total: 9504,
      discount: 2376,
      discountPercent: 20,
    });
  });

  it('should apply promo code on top of annual discount', () => {
    const price = calculatePrice({
      plan: 'pro',
      period: 'annual',
      baseMonthlyPrice: 990,
      promoCode: { type: 'percent', value: 10 },
    });
    // 990 * 0.8 (annual) * 0.9 (promo) = 712.8 → 713 (ceiling)
    expect(price.monthly).toBe(713);
  });

  it('should not allow negative price with stacked discounts', () => {
    const price = calculatePrice({
      plan: 'basic',
      period: 'annual',
      baseMonthlyPrice: 490,
      promoCode: { type: 'fixed', value: 500 },
    });
    expect(price.monthly).toBe(0);
    expect(price.total).toBe(0);
  });

  it('should handle trial period correctly', () => {
    const price = calculatePrice({
      plan: 'pro',
      period: 'monthly',
      baseMonthlyPrice: 990,
      trialDays: 14,
    });
    expect(price.firstPaymentDate).toEqual(
      expect.any(Date)
    );
    expect(price.trialDays).toBe(14);
    expect(price.immediateCharge).toBe(0);
  });
});

Python: тестирование ML-скоринга

# tests/test_student_scoring.py
import pytest
from scoring.predictor import predict_dropout_risk

class TestDropoutPrediction:
    """Модель предсказания оттока студентов."""

    def test_active_student_low_risk(self):
        features = {
            "lessons_completed_7d": 5,
            "avg_session_minutes": 45,
            "days_since_last_login": 1,
            "homework_completion_rate": 0.85,
            "forum_posts_30d": 3,
        }
        risk = predict_dropout_risk(features)
        assert risk.score < 0.3
        assert risk.category == "low"

    def test_inactive_student_high_risk(self):
        features = {
            "lessons_completed_7d": 0,
            "avg_session_minutes": 0,
            "days_since_last_login": 14,
            "homework_completion_rate": 0.2,
            "forum_posts_30d": 0,
        }
        risk = predict_dropout_risk(features)
        assert risk.score > 0.7
        assert risk.category == "high"

    @pytest.mark.parametrize("days,expected_category", [
        (1, "low"),
        (7, "medium"),
        (21, "high"),
        (30, "critical"),
    ])
    def test_inactivity_thresholds(self, days, expected_category):
        features = {
            "lessons_completed_7d": 0,
            "avg_session_minutes": 0,
            "days_since_last_login": days,
            "homework_completion_rate": 0.5,
            "forum_posts_30d": 0,
        }
        risk = predict_dropout_risk(features)
        assert risk.category == expected_category

Интеграционные тесты: TestContainers

Интеграционные тесты запускают реальные PostgreSQL и Redis в Docker-контейнерах. Это позволяет тестировать SQL-запросы, миграции и кэширование без моков:

// tests/integration/course-access.test.ts
import { PostgreSqlContainer } from '@testcontainers/postgresql';
import { GenericContainer } from 'testcontainers';
import { createApp } from '../../src/app';
import { runMigrations } from '../../src/db/migrate';

describe('Course Access API', () => {
  let pgContainer, redisContainer, app, db;

  beforeAll(async () => {
    // Запуск контейнеров — ~5 сек
    pgContainer = await new PostgreSqlContainer('postgres:16')
      .withDatabase('eduplatform_test')
      .start();

    redisContainer = await new GenericContainer('redis:7')
      .withExposedPorts(6379)
      .start();

    db = createDbConnection(pgContainer.getConnectionUri());
    await runMigrations(db);

    app = createApp({ db, redisUrl: redisContainer.getConnectionUrl() });
  }, 30000);

  afterAll(async () => {
    await pgContainer.stop();
    await redisContainer.stop();
  });

  it('should grant access after successful payment', async () => {
    // Arrange: создаём юзера и курс
    const user = await db.users.create({ email: 'student@test.com' });
    const course = await db.courses.create({ title: 'Node.js Advanced' });

    // Act: имитируем вебхук оплаты
    const res = await request(app)
      .post('/api/webhooks/payment')
      .send({
        event: 'payment.succeeded',
        userId: user.id,
        courseId: course.id,
        amount: 9900,
      })
      .expect(200);

    // Assert: проверяем доступ
    const access = await db.courseAccess.findOne({
      where: { userId: user.id, courseId: course.id },
    });
    expect(access).not.toBeNull();
    expect(access.status).toBe('active');
    expect(access.expiresAt).toBeNull(); // Бессрочный доступ
  });

  it('should revoke access on refund', async () => {
    // ... тест отзыва доступа при возврате
  });
});

E2E-тесты: Playwright

Для E2E выбрали Playwright — быстрее Cypress, нативная поддержка нескольких браузеров, удобный API для работы с сетью.

// tests/e2e/student-journey.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Student Learning Journey', () => {
  test('should complete lesson and track progress', async ({ page }) => {
    // Авторизация
    await page.goto('/login');
    await page.fill('[data-testid="email"]', 'test-student@example.com');
    await page.fill('[data-testid="password"]', 'TestPass123!');
    await page.click('[data-testid="login-btn"]');
    await expect(page).toHaveURL('/dashboard');

    // Переход к курсу
    await page.click('text=Node.js Advanced');
    await expect(page.locator('.course-header')).toContainText('Node.js Advanced');

    // Открытие урока
    await page.click('[data-testid="lesson-1"]');
    await expect(page.locator('video')).toBeVisible();

    // Просмотр видео (ускоренно)
    const video = page.locator('video');
    await video.evaluate((el) => {
      el.currentTime = el.duration - 5;
      el.play();
    });

    // Ожидание завершения
    await page.waitForSelector('[data-testid="lesson-complete"]', {
      timeout: 15000,
    });

    // Проверка прогресса
    await page.goto('/dashboard');
    const progress = page.locator('[data-testid="course-progress"]');
    await expect(progress).toContainText('1 из');
  });

  test('should handle video playback errors gracefully', async ({ page }) => {
    // Имитируем ошибку CDN
    await page.route('**/video/**', (route) => {
      route.fulfill({ status: 503 });
    });

    await page.goto('/courses/nodejs/lessons/1');
    await expect(page.locator('.error-message')).toContainText(
      'Видео временно недоступно'
    );
    await expect(page.locator('[data-testid="retry-btn"]')).toBeVisible();
  });
});

Контрактные тесты: Pact

При 8 микросервисах интеграционных проблем на стыках было больше всего. Pact позволяет каждому сервису проверять контракт независимо:

// consumer: course-service expects user-service API
import { PactV3, MatchersV3 } from '@pact-foundation/pact';

const provider = new PactV3({
  consumer: 'CourseService',
  provider: 'UserService',
});

describe('User Service Contract', () => {
  it('should return user profile', async () => {
    await provider
      .given('user with id 123 exists')
      .uponReceiving('a request for user profile')
      .withRequest({
        method: 'GET',
        path: '/api/users/123',
        headers: { Authorization: MatchersV3.like('Bearer token') },
      })
      .willRespondWith({
        status: 200,
        body: MatchersV3.like({
          id: '123',
          email: 'student@example.com',
          name: 'Test Student',
          plan: 'pro',
          createdAt: MatchersV3.iso8601DateTime(),
        }),
      })
      .executeTest(async (mockServer) => {
        const client = new UserServiceClient(mockServer.url);
        const user = await client.getUser('123');
        expect(user.id).toBe('123');
        expect(user.plan).toBe('pro');
      });
  });
});

Провайдер (user-service) запускает верификацию контракта в своём CI, получая контракт из Pact Broker. Если провайдер ломает контракт — его сборка падает, а не сборка потребителя. Это радикально сократило время поиска проблем на стыках сервисов.

Нагрузочное тестирование: k6

k6 запускается еженедельно по расписанию и при каждом релизе. Сценарий имитирует пиковую нагрузку — начало учебного года, когда одновременно заходят 50 000 студентов:

// load-tests/peak-load.js
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate, Trend } from 'k6/metrics';

const errorRate = new Rate('errors');
const lessonLoadTime = new Trend('lesson_load_time');

export const options = {
  stages: [
    { duration: '2m', target: 1000 },   // Ramp-up
    { duration: '5m', target: 5000 },   // Пиковая нагрузка
    { duration: '2m', target: 10000 },  // Стресс
    { duration: '3m', target: 0 },      // Ramp-down
  ],
  thresholds: {
    http_req_duration: ['p(95)<500', 'p(99)<1000'],
    errors: ['rate<0.01'],         // Менее 1% ошибок
    lesson_load_time: ['p(95)<800'],
  },
};

export default function () {
  // Имитация сессии студента
  const loginRes = http.post(`${__ENV.BASE_URL}/api/auth/login`, JSON.stringify({
    email: `student${__VU}@loadtest.internal`,
    password: 'LoadTest123!',
  }), { headers: { 'Content-Type': 'application/json' } });

  check(loginRes, { 'login successful': (r) => r.status === 200 });
  const token = loginRes.json('token');
  const headers = { Authorization: `Bearer ${token}` };

  // Загрузка дашборда
  const dashboard = http.get(`${__ENV.BASE_URL}/api/dashboard`, { headers });
  check(dashboard, { 'dashboard loaded': (r) => r.status === 200 });

  // Открытие урока
  const start = Date.now();
  const lesson = http.get(`${__ENV.BASE_URL}/api/lessons/random`, { headers });
  lessonLoadTime.add(Date.now() - start);
  errorRate.add(lesson.status !== 200);

  sleep(Math.random() * 3 + 1); // 1-4 сек между действиями
}

Мутационное тестирование

Покрытие 87% — это хорошо, но покрытие не равно качеству тестов. Stryker Mutator вносит мутации в код (меняет > на <, удаляет условия, подставляет другие значения) и проверяет, замечают ли тесты изменения:

// stryker.conf.mjs
export default {
  mutator: {
    plugins: ['@stryker-mutator/typescript-checker'],
    excludedMutations: ['StringLiteral'], // Не мутируем строки
  },
  testRunner: 'jest',
  jest: {
    configFile: 'jest.config.ts',
  },
  reporters: ['html', 'clear-text', 'progress'],
  coverageAnalysis: 'perTest',
  thresholds: {
    high: 80,
    low: 60,
    break: 50, // CI падает если MSI ниже 50%
  },
};

Первый запуск показал Mutation Score Indicator (MSI) в 62% — значит, 38% мутаций выживали, то есть тесты их не ловили. За 2 недели мы довели MSI до 78%, дописав тесты на граничные случаи.

CI-интеграция и Quality Gates

# .github/workflows/test.yml
name: Test Pipeline
on: [push, pull_request]

jobs:
  unit:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        service: [course-svc, user-svc, payment-svc, scoring-svc]
    steps:
      - uses: actions/checkout@v4
      - run: cd services/${{ matrix.service }} && npm ci
      - run: cd services/${{ matrix.service }} && npm test -- --coverage
      - uses: codecov/codecov-action@v4
        with:
          flags: ${{ matrix.service }}

  integration:
    needs: unit
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_DB: test
          POSTGRES_PASSWORD: test
        ports: ['5432:5432']
      redis:
        image: redis:7
        ports: ['6379:6379']
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm run test:integration

  e2e:
    needs: integration
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: docker compose -f docker-compose.test.yml up -d
      - uses: microsoft/playwright-github-action@v1
      - run: npx playwright test
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report
          path: playwright-report/

  quality-gate:
    needs: [unit, integration, e2e]
    runs-on: ubuntu-latest
    steps:
      - name: Check coverage threshold
        run: |
          COVERAGE=$(curl -s "https://codecov.io/api/v2/..." | jq '.totals.coverage')
          if (( $(echo "$COVERAGE < 80" | bc -l) )); then
            echo "Coverage $COVERAGE% is below 80% threshold"
            exit 1
          fi

Борьба с flaky-тестами

Flaky-тесты — бич автоматизации. У нас было 12% flaky-тестов через месяц после запуска. Что помогло:

  • Quarantine: flaky-тест автоматически помечается после 2 нестабильных прогонов и не блокирует CI, но создаёт тикет
  • Retry с анализом: тесты ретраятся до 2 раз, все результаты логируются. Если тест проходит со 2-й попытки — он flaky, не зелёный
  • Изоляция данных: каждый тест создаёт свои данные и чистит за собой. Никаких shared fixtures
  • Фиксированное время: заменили Date.now() на инжектируемые часы в тестах

Через 3 месяца flaky rate снизился с 12% до 0.8%.

Культурный сдвиг

Самая сложная часть — не инструменты, а люди. Разработчики привыкли «кидать через стену» на QA. Что мы изменили:

  • Правило: PR без тестов не мержится. Без исключений
  • Code review: ревьюер обязан проверить тесты, а не только продакшн-код
  • Метрики: дашборд покрытия висит на мониторе в офисе
  • QA-инженеры стали automation engineers: 3 из 5 QA переучились на автоматизаторов, 2 перешли в продуктовую аналитику

Результаты за 6 месяцев

МетрикаДоПосле
Покрытие кода0%87%
Релизный цикл2 неделиЕжедневно
Критические баги в продакшне3.7 / релиз0.3 / релиз
Время от коммита до продакшна2 недели45 минут
Количество автотестов02 340
Время прогона CI12 минут
Mutation Score78%

Автоматизация тестирования — это марафон, не спринт. 87% покрытия — не финальная точка, а результат полугода системной работы. Начинайте с самого критичного, двигайтесь по пирамиде снизу вверх, и не забывайте про культуру: инструменты без процессов бесполезны.

Нужна помощь с проектом?

Специалисты АйТи Фреш помогут с архитектурой, DevOps, безопасностью и разработкой — 15+ лет опыта

📞 Связаться с нами
#ciинтеграция#devops#e2eтесты#flakyтестами#gates#jest#pact#playwright
Комментарии 0

Оставить комментарий

загрузка...