Эффективная работа с легаси-кодом. Часть 2
В первой части мы рассмотрели введение, причины изменения кода, швы и инструменты автоматизированного рефакторинга. Сегодня говорим об изменении кода, когда не хватает времени, добавлении фич, TDD и зависимостях.
Глава 6. У меня нет времени, но мне нужно что-то поменять
Рефакторинг и тесты — это дополнительная работа. Но в будущем она упростит внесение изменений в код и поможет быстрее отлавливать баги.
Почкование метода
Если надо добавить фичу, которую можно выразить в новом коде, то лучше написать этот код в новом методе, и вызывать новый метод. Новый метод можно будет покрыть тестами.
class UserList {
// метод отправляет уведомления об акции
// указанным пользователям
notifyOfAction(users) {
const message = "some message to send";
users.forEach((user) => {
this.sendEmail(user, message);
});
}
// ...
}
Если мы хотим, чтобы уведомление отправлялось только совершеннолетним, то можно профильтровать список пользователей прямо в методе:
class UserList {
notifyOfAction(users) {
const message = "some message to send";
users = users.filter((user) => user.age >= 18);
users.forEach((user) => {
this.sendEmail(user, message);
});
}
// ...
}
Но таким образом мы не поймём, где заканчивается старый код, где начинается новый, и как это тестировать. Лучше вынести фильтрацию в новый метод:
class UserList {
notifyOfAction(users) {
const message = "some message to send";
users = this.filterInAdults(users);
users.forEach((user) => {
this.sendEmail(user, message);
});
}
filterInAdults(users) {
return users.filter((user) => user.age >= 18);
}
// ...
}
Теперь новый метод filterInAdults
можно покрыть тестами.
Алгоритм метода:
- найти место, которое надо поменять;
- если изменение можно вызвать как отдельный метод, то написать код вызова этого метода, добавив комментарии к нему;
- определить все локальные переменные, необходимые для вызова и сделать их аргументами нового метода;
- определиться со значением, которое должен возвращать новый метод;
- создать новый метод, пользуясь TDD;
- убрать комментарий, заменить его на настоящий новый метод.
Минусы:
- вы на какое-то время оставляете исходный метод без внимания, не собираясь его тестировать или менять;
- иногда становится непонятно, почему действия происходят в другом методе.
Плюсы:
- новый код полностью отделён от старого;
- возможно протестировать;
- можно наблюдать за всеми переменными, которые меняют поведение метода.
Почкование можно применить для целого класса, а не только метода. Например, если нужно поменять формат вывода результата со строки на HTML.
Оборачивание метода
Ещё один способ добавить поведение к уже существующему коду. Метод pay
класса Employee
вызывает paymentDispatcher
, чтобы выплатить деньги сотруднику.
class Employee {
pay(amount) {
const date = // ...
paymentDispatcher(date, amount)
}
}
Допустим, мы хотим логировать каждую оплату. Можно добавить логирование напрямую в метод pay
, но можно создать новый метод с такой же сигнатурой, и вызывать его внутри:
class Employee {
// новый метод со старым кодом
dispatchPayment(amount) {
const date = // ...
paymentDispatcher(date, amount)
}
// старый метод с вызовом выделенного и добавленным логированием
pay(amount) {
this.logPayment(amount)
this.dispatchPayment(amount)
}
// добавочный метод
logPayment(amount) {
// ...
}
}
Алгоритм:
- определить метод, который надо изменить;
- создать новый метод с такой же сигнатурой;
- вызвать новый метод внутри старого;
- создать метод для нового поведения через TDD и вызывать его в старом методе.
Плюсы:
- старые методы не меняются, только лишь переименовываются;
- новые функции явно не зависимы от других.
Минусы:
- могут появиться неудачные названия методов.
Можно так же обернуть не только метод, но и класс. Для примера выше можно создать класс LoggingEmployee
, который будет наследоваться от Employee
, и перегрузить метод pay
, добавив нужное новое поведение. По-другому это ещё называется декоратором.
Глава 7. Изменения до бесконечности
Когда код зависит от какого-то интерфейса, то эта зависимость обычно слабая. Менять код нужно, только если сам интерфейс поменяется. Поэтому лучше иметь зависимости от абстрактных интерфейсов, чем от конкретных классов.
Применяйте интерфейсы для разрыва зависимостей в проекте перед рефакторингом. Это занимает время, но в итоге появляются участки кода, с которыми легко работать и тестировать.
Глава 8. Как добавить фичу
Алгоритм работы по TDD:
- написать отказной тест;
- написать функцию, чтобы тест перестал падать;
- убрать дублирование кода;
- повторить.
На примерах, первый шаг:
// пишем тест, который заведомо упадёт
it("Возвращает сумму двух однозначных чисел", () => {
expect(sum(1, 1)).toEqual(1 + 1);
});
// функция пока пустая
const sum = (a, b) => {};
Убеждаемся, что тест падает с той причиной, которую мы ожидаем. На втором шаге:
// меняем реализацию функции или метода так, чтобы тест проходил
const sum = (a, b) => a + b;
На третьем проверяем, чтобы не было дублирования и рефакторим код метода и тестов. Постепенно описываем примеры прохождения теста и примеры непрохождения. В легаси-коде тестируемые методы и классы вначале готовят к тестированию, а потом переходят к алгоритму TDD.
Глава 9. Я не могу протестировать этот класс
Некоторые классы трудно тестировать: дорого создавать объекты этого класса, при создании появляются сайд-эффекты, куча параллельной работы выполняется. Самый простой способ узнать, сколько проблем доставит создание экземпляра такого класса — создать его. Попробуйте создать объект, отладчик скажет, чего не хватает для создания.
Тестовый код не обязательно должен жить по тем же стандартам, что продакшн-код. Можно нарушать инкапсуляцию, если это делает написание тестов проще. Но тесты должны быть понятными и чистыми. Выносите повторяющийся код для тестов в сетап.
Если создание класса требует экземпляров других классов, попробуйте создать их с пустыми значениями в их конструкторах.
Если у класса есть скрытые зависимости в конструкторе, подумайте над тем, чтобы сделать их явными. Например, передавать эту зависимость в конструктор извне.
Глава 10. Я не могу выполнить этот метод в средствах тестирования
Иногда методы класса тоже трудно протестировать:
- он приватный;
- трудно создать аргументы для вызова этого метода;
- метод имеет сайд-эффекты.
Если метод приватный, надо подумать, можно ли его протестировать через публичный метод. В общем случае, если надо тестировать приватный метод, то следует сделать его публичным. Если же это парит, то, видимо, класс делает слишком много.
При наличии сайд-эффектов стоит попробовать извлечь метод. Класс ниже создаёт элементы интерфейса и решает, какую информацию в них отображать:
class AccountDetails {
// ...
handlePerformedAction(event) {
const { type } = event;
if (type === "project activity") {
const modal = this.createModal();
const display = this.createElement("input");
modal.setDescription("modal text");
modal.show();
let accountDescription = this.modal.getSymbol();
accountDescription += ": ";
// ...
display.value = accountDescription;
}
}
}
Можно отделить код, который не зависит от интерфейса, от кода, который зависит. Выделим методы обработки команды, чтобы методы стали тестируемыми и независимыми друг от друга:
class AccountDetails {
// ...
handlePerformedAction(event) {
const { type } = event;
this.performCommand(type);
}
performCommand(type) {
if (type === "project activity") {
this.setDescription("modal text");
this.updateModalValue();
}
}
setDescription(text) {
const modal = this.createModal();
modal.setDescription(text);
modal.show();
}
updateModalValue() {
const display = this.createElement("input");
let accountDescription = this.modal.getSymbol();
accountDescription += ": ";
// ...
display.value = accountDescription;
}
}
Разделение команд и запросов. Метод может быть либо командным, либо запросным, но не тем и не другим одновременно. Командный — который видоизменяет состояние объекта, но не возвращает значение. Запросный возвращает значение, но не видоизменяющий объект.
Глава 11. Надо изменить код, но непонятно, какие методы тестировать
Изменение в методе вызывает цепочку изменений в других местах, которые связаны с этим методом. Если код структурирован хорошо, то схемы таких связей будут простыми.
Воздействия распространяются в коде тремя основными путями.
- когда программа использует значения, которые возвращает метод;
- когда метод изменяет параметры, которые ему передаются в качестве аргументов, а программа использует их после изменений;
- когда метод меняет глобальные объекты или переменные.
Алгоритм распознавания воздействий:
- определить метод, который надо поменять;
- если метод возвращает значение, проверить места, где этот метод вызывается;
- если метод меняет какие-то параметры, проверить места, которые используют эти параметры;
- проверить суперклассы и подклассы объекта с этим методом;
- найти глобальные переменные, которые этот метод меняет в ходе работы.
Главы 12–13. Какие тесты писать, чтобы изменить код
Поведение легаси кода стоит принимать за аксиому: раз он так работает, значит, так надо. Поэтому поведение этого кода нужно сохранять. Характеристические тесты — тесты, которые сохраняют поведение.
Алгоритм создания характеристических тестов:
- начать тестировать небольшой фрагмент кода;
- написать заведомо отказной тест;
- выявить поведение кода;
- поправить тест так, чтобы в нём предполагалось это поведение.
В легаси могут быть ошибки. Поэтому при обнаружении странного поведения, надо узнать, баг это или фича.
Глава 14. Зависимости от библиотек меня убивают
Избегайте беспорядочного расположения прямых вызовов библиотек в своем коде.
В следующий раз
Поговорим о непонятном коде, коде без структуры, огромных классах и ситуациях, когда ничто не помогает.