Саша Беспоясов
Это я.

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

Приёмы из этой книги мне помогают в работе, поэтому я решил составить по ней конспект. Примеры в ней написаны на Джаве и С++, поэтому не всё получилось перевести на 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 и зависимостях.

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

Эффективная работа с легаси-кодом. Часть 2Тонкое искусство пофигизма. Марк Мэнсон