Эффективная работа с легаси-кодом. Часть 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. Зависимости от библиотек меня убивают

Избегайте беспорядочного расположения прямых вызовов библиотек в своем коде.

В следующий раз

Поговорим о непонятном коде, коде без структуры, огромных классах и ситуациях, когда ничто не помогает.

Ссылки по теме