Тестовые стратегии для legacy кода

Legacy-код — код без тестов (по определению Майкла Физерса); стратегии для его покрытия включают characterization tests, seam-паттерн и постепенное добавление тестов при каждом изменении.

Зачем нужно

Нельзя безопасно рефакторить код без тестов. Написание тестов для legacy позволяет зафиксировать текущее поведение, выявить скрытые зависимости и безопасно улучшать код. Начинать с нуля переписывать — слишком рискованно и дорого.

Где используется

  • Большие старые кодовые базы с нулевым покрытием
  • Код перед рефакторингом или переходом на новый стек
  • Команды, принимающие чужой код

Основной контент

Правило Бойскаута для legacy

При каждом изменении legacy-файла:
1. Добавь хотя бы один тест для изменяемой части
2. Не уходи не улучшив покрытие
3. Не ломай существующее поведение

Результат: через 3-6 месяцев coverage растёт органически

Characterization Tests — документируют текущее поведение

// Не знаем что должна делать функция — документируем что она делает
// legacyPricingEngine.js — загадочный код без документации

test('characterization: calcPrice(100, "GOLD") returns 72.5', () => {
  // Запускаем и фиксируем реальный вывод
  expect(calcPrice(100, 'GOLD')).toBe(72.5);
});

test('characterization: calcPrice(0, "SILVER") returns 0', () => {
  expect(calcPrice(0, 'SILVER')).toBe(0);
});

// Теперь безопасно рефакторим — если результат изменится, тест упадёт

Seam — точки расширения для мокирования

// ПЛОХО — жёсткая зависимость, невозможно мокировать
function processOrder(orderId) {
  const db = new Database();           // Seam нет
  const order = db.getOrder(orderId); // невозможно подменить
  const mailer = new Mailer();
  mailer.send(order.email, 'Done!');
}

// ХОРОШО — инъекция зависимостей (dependency injection)
function processOrder(orderId, db, mailer) {
  const order = db.getOrder(orderId);  // теперь можно передать mock
  mailer.send(order.email, 'Done!');
}

// В тесте
test('processOrder отправляет письмо', async () => {
  const dbMock = { getOrder: jest.fn.mockReturnValue({ email: 'user@test.com' }) };
  const mailerMock = { send: jest.fn };

  processOrder(42, dbMock, mailerMock);

  expect(mailerMock.send).toHaveBeenCalledWith('user@test.com', 'Done!');
});

Оборачивание сложных зависимостей

// Если нельзя изменить legacy-код — оберни в тонкую прокси
class LegacyPaymentWrapper {
  constructor {
    this.legacy = new LegacyPaymentProcessor(); // нельзя трогать
  }

  async charge(amount, card) {
    return this.legacy.processPayment(amount, card.number, card.expiry);
  }
}

// Тестируем обёртку с моком legacy
test('LegacyPaymentWrapper.charge вызывает processPayment', async () => {
  const legacyMock = { processPayment: jest.fn.mockReturnValue({ success: true }) };
  const wrapper = new LegacyPaymentWrapper();
  wrapper.legacy = legacyMock;

  await wrapper.charge(100, { number: '4111111111111111', expiry: '12/25' });

  expect(legacyMock.processPayment).toHaveBeenCalledWith(100, '4111111111111111', '12/25');
});

Snapshot для фиксации вывода

// Быстро зафиксировать вывод функции без понимания деталей
test('legacyFormatter snapshot', () => {
  expect(legacyFormatter({ id: 1, name: 'Alice', data: [1, 2, 3] }))
    .toMatchSnapshot;
});
// При первом запуске — создаёт снимок
// При изменении кода — снимок падает и мы видим что изменилось

Стратегия по приоритету

1. Фиксируй баги тестом ДО исправления (bug → test → fix)
2. Добавляй тесты к файлам которые ты меняешь
3. Покрывай критичные бизнес-пути в первую очередь
4. Используй coverage для нахождения "горячих" непокрытых мест
5. Refactor постепенно под защитой добавленных тестов

Частые ошибки

  • Попытка покрыть всё сразу — с нуля до 80% coverage за неделю нереально; приоритизируй критичный код
  • Рефакторинг без тестов — "сначала чищу, потом пишу тесты" — без тестов нет уверенности что рефакторинг ничего не сломал
  • Тест без assert (characterization без проверки) — фиксируй конкретный вывод, а не просто что функция не падает

Связанные темы

Ресурсы