Тестовые стратегии для 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 без проверки) — фиксируй конкретный вывод, а не просто что функция не падает