Эффективная работа с легаси-кодом. Майкл Физерс

Приёмы из этой книги мне помогают в работе, поэтому я решил составить по ней конспект. Примеры в ней написаны на Джаве и С++, поэтому не всё получилось перевести на JS. Я постарался вытянуть наиболее важное, но советую прочесть книгу и самим. Теперь к делу.

Физерс определяет легаси-код, как код без тестов. В таком коде невозможно предсказать, сделает ли какое-то изменение этот код лучше или хуже.

Глава 1. Изменение софта

Менять код нужно, чтобы:

  • добавить фичу;
  • пофиксить баг;
  • улучшить дизайн кода;
  • оптимизировать использование ресурсов.

В программе самое важное — её поведение. Пользователи любят, когда мы добавляем новое, и не любят, когда мы меняем старое. При добавлении нового поведения мы неизбежно меняем старое.

// до изменения
class Player {
	addPlaylist(name, tracks) {
		// ...
	}
}

// после
class Player {
	addPlaylist(name, tracks) {
		// ...
	}

	deletePlaylist(name) {
		// ...
	}
}

Пока новый метод нигде не вызывается, поведение не меняется. Чтобы это поведение добавить, мы выводим кнопку на экране. Из-за этого интерфейс отрисовывается на долю секунды дольше. Это незаметно, но поведение поменялось.

Улучшение дизайна, рефакторинг, отличается тем, что не меняет поведение программы. Их цель — сделать код более читаемым, поддерживаемым и приятным.

Оптимизация похожа на рефакторинг, только рефакторим мы уже использование ресурсов, а не сам код.

Сохранить поведение неизменным трудно. Каждое изменение кода несёт риск изменения поведения. Чтобы смягчить риск, перед изменениями задавать себе три вопроса:

  • что надо поменять?
  • как узнать, что мы внесли изменения правильно?
  • как узнать, что мы не сломали остальное?

Разница между хорошими и плохими программными системами в том, что внесение изменений в хорошие не вызывает беспокойства. В плохих системах, чем дольше тянешь, тем страшнее менять в будущем.

Глава 2. Работать на обратную связь

Вносить изменения в код можно двумя путями: «запушить и молиться» и «покрыть тестами и менять». Смысл второго подхода в том, чтобы получать обратную связь постоянно и как можно быстрее. Тесты дают обратную связь, говоря, как наши изменения ломают поведение программы.

Хороший юнит-тест:

  • быстрый;
  • помогает найти проблему быстро;
  • не имеет внешних зависимостей.

Рефакторинг легаси-кода следует начинать с покрытия его тестами. Проблема в том, что часто у легаси куча зависимостей, из-за которых его трудно тестировать. Возникает дилемма: чтобы изменить код, надо иметь тесты к нему, а чтобы написать к нему тесты, его надо поменять.

Алгоритм изменения легаси-кода:

  • найти точки изменения;
  • найти точки тестирования;
  • разорвать зависимости;
  • написать тесты;
  • сделать изменения и отрефакторить.

Глава 3. Распознавание и разделение

Для тестирования нужно разрывать зависимости в коде по двум причинам:

  • если не можем получить доступ к значениям, которые код вычисляет;
  • если не можем ввести нужный фрагмент кода для выполнения в тесте.

Класс NetworkBridge получает список узлов, каждый из которых открывает сетевое соединение и общается с другими узлами:

class NetworkBridge {
	constructor(endpoints) {
		// ...
	}

	formRouting(sourceId, destId) {
		// ...
	}
}

Как его тестировать? Если он связан с железом, можем ли мы себе позволить на каждый тест нагружать оборудование? Можем ли создать тестовый кластер? Есть ли на это ресурсы и время? Такие проблемы возникают, когда мы не понимаем, как выделить нужную часть и тестировать её изолированно. Здесь могут помочь фиктивные объекты.

Фиктивные объекты олицетворяют какой-либо класс во время тестирования. Например, у нас есть класс Sale, который сканирует штрих-коды, и выводит сообщения на экран устройства через класс Display:

class Sale {
	constructor(display) {
		this._display = display;
	}

	scan(barcode) {
		// сканирует
		// ...
		// выводит сообщение
		this._display.showMessage('hello world');
	}
}

class Display {
	showMessage(msg) {
		// ...
	}
}

const display = new Display();
const sale = new Sale(display);

Чтобы не зависеть от конкретного оборудования, мы можем написать поддельный класс FakeDisplay:

class FakeDisplay {
	// вместо вывода на экран, будем запоминать сообщение
	// это метод, который имитирует настоящий метод класса Display
	showMessage(msg) {
		this.lastLine = msg;
	}

	// и потом выводить его по требованию
	// это доп. метод, который нужен именно в тестах
	getLastLine() {
		return this.lastLine;
	}
}

В тесте мы можем подменить класс, работающий с конкретным оборудованием на поддельный:

it('Выводит название товара на экран', () => {
	const fakeDisplay = new FakeDisplay();
	const saleTest = new Sale(fakeDisplay);

	saleTest.scan('1');

	expect(fakeDisplay.getLastLine).toEqual('Молоко');
});

Этот тест не упадёт, если не работает какая-то часть в настоящем классе Display. Но мы тестируем класс Sale, а не Display, поэтому конкретно в этом тесте это не важно.

Глава 4. Швы

Шов — место в программе, где можно изменить её поведение, без редактирования кода в этом месте. Рабочий код должен быть одинаков и в продакшене и в тестах. Швы помогают разорвать зависимости и оттестировать код, без его изменения.

Чтобы использовать шов, нужно определиться с разрешающей точкой — местом, где вы решаете заменить одно поведение на другое.

Удобнее всего в объектно-ориентированных языках использовать объектные швы, когда действие какого-то метода подменяется на другое. Например, при создании экземпляра класса в конструкторе.

Глава 5. Инструменты автоматизированного рефакторинга

Рефакторинг — упрощение или улучшение дизайна кода без изменения поведения. Есть среды разработки, которые предлагают автоматические инструменты для рефакторинга. Они помогают переименовывать переменные, удалять лишнее, выносить код в отдельные функции или классы.

Часто программисты полагаются на них и не пишут тесты к коду, который собираются рефакторить этими инструментами. Но таким образом можно пропустить изменение поведения. Например:

// класс до рефакторинга
class Example {
	alpha = 0;

	getValue() {
		this.alpha++;
		return 42;
	}

	doSomething() {
		let total = 0;
		const val = this.getValue();
		for (let i = 0; i < 5; i++) {
			total += val;
		}
	}
}

// после
class Example {
	alpha = 0;

	getValue() {
		this.alpha++;
		return 42;
	}

	doSomething() {
		let total = 0;
		for (let i = 0; i < 5; i++) {
			total += this.getValue();
		}
	}
}

Лишняя переменная исчезла, но вместе с этим alpha++ вызвалось 5 раз вместо 1. Юнит-тесты помогут выявить это изменение.

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

Поговорим об изменении кода, когда не хватает времени, добавлении фич, TDD и зависимостях.

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