Как сделать легаси-код тестабельным

«Автотесты легаси-кода обеспечивают безопасность модификации компонентов системы, предупреждая о проблемах на ранних этапах цикла разработки, и дефекты не попадают на прод. Но если этот «старый» код плохо покрыт автотестами (или вообще не покрыт), то создание тестов с нуля, тем более автоматизированных, кажется сложной задачей, и она откладывается «на потом». Это создает «эффект снежного кома, катящегося с горы»: со временем сложность кода растет, изменения становятся все более рискованными в плане дефектов, и сложными в имплементации; участки кода, которые должны быть покрыты автотестами, увеличиваются, и, хотя все больше времени тратится на обслуживание кода, не остается времени на улучшение тестируемости (тестабельности) кодовой базы. 

Далее рассмотрим некоторые практики улучшения тестабельности легаси-кода.

Часть 1. Типы автотестов

Сначала рассмотрим типы автотестов и проблемы, с ними связанные.

Сквозные тесты

Сквозные тесты (e2e) — это автоматизированные имплементации, имитирующие взаимодействие конечного пользователя с системой. Обычно они представляют собой определенный поток (flow, или user story), например, «пользователь может ответить на комментарий», в который включены все шаги, который выполняет пользователь, такие как нажатие кнопок, ввод текста, прокрутка и т.п., охватывая, таким образом, все приложение от начала до конца. Такие тесты обычно считаются наиболее ценными и информативными, поскольку они имитируют взаимодействие с пользователем и гарантируют, что вся система функционирует правильно.

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

Поскольку e2e-тесты имитируют взаимодействие пользователя с системой, а не проверяют ее мелкие компоненты, их применение в legacy-системах принципиально не отличается от применения в новых системах. Как правило, инструменты сквозного тестирования проверяют скорее интерфейс, а не «глубинный код» системы. Поэтому далее не будем рассматривать e2e-тесты.

Юнит-тесты

Юнит-тесты проверяют «атомарные» компоненты системы, такие как функции и методы. Например, функция, подсчитывающая общую стоимость товаров в корзине, может быть предметом юнит-тестирования, проверяющего некоторые сценарии вводов и выводов. Юнит-тестирование — наиболее распространенный тип тестирования, в том плане что юнит-тестов больше всего, и его легче выполнять; оно имеет решающее значение для обнаружения багов на ранней стадии и, таким образом, облегчает внесение изменений в систему.

Юнит-тесты могут стать проблемой, если их слишком много или они чрезмерно детализированы, что приводит к высоким затратам на обслуживание тестов или простоям CI-пайплайна. Задержка цикла на уровне юнит-тестов также может привести к пропуску дефектов системного уровня.

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

Интеграционные тесты

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

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

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

Тесты производительности

Тесты производительности измеряют скорость, отзывчивость и стабильность системы. Они обеспечивают соответствие требованиям к производительности и способность приложения справляться с ожидаемым трафиком. На практике это стандартное нагрузочное, стресс-тестирование и тестирование всплесков трафика.

Недостаток тестов производительности в том, что они требуют значительных ресурсов и времени. Кроме того, их сложно воспроизводить, что приводит к негарантированным результатам.

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

Итак

Выше перечислены некоторые из существующих в QA-индустрии типов автотестов легаси-кода; далее сосредоточимся на самых важных — модульных и интеграционных.

Часть 2: Что тестировать

Теперь, когда мы определились с общей стратегией (модульные и интеграционные тесты), следующий шаг — понять, с каким кодом мы имеем дело, и какие подходы следует использовать. Код можно классифицировать следующим образом:

  1. Обычный (Trivial): Это код типа геттеров/сеттеров и подобный boilerplate-код с низким уровнем сложности, который практически не влияет на функциональность и производительность приложения. Обычно он существуют только потому, что этого требует конкретный язык или фреймворк, и тестировать его необязательно, за исключением специфических сценариев.
  2. Основной (Core) код: Самая важная часть кодовой базы, поскольку она содержит бизнес-логику, преобразование данных, и все решения, принимаемые в приложении. Основной код обычно находится в бэкенде системы (но не всегда). Другие уровни, такие как компоненты пользовательского интерфейса, также могут заключать в себе значительную часть логики и считаться основным кодом. В большинстве случаев следует сосредоточить усилия на написании автотестов (модульных и интеграционных) основного кода.
  3. Оркестраторы (Orchestrators): Это компоненты, которые подключаются к другим компонентам или выступают в роли «связующего звена» между ними. В веб-разработке, например, часто встречается контроллер, который принимает пользовательский ввод и вызывает соответствующую core-логику для обработки данных и возврата ответа. Если эти компоненты являются не более чем оркестраторами, нет особого смысла тестировать их с помощью юнит-тестов, но почти наверняка нужно протестировать в с помощью интеграционных.
  4. Спагетти-код: Это неупорядоченный старый код, который объединяет в себе качества, описанные выше. Нередко он превращается в огромные классы с тысячами строк. Даже если эти классы в целом работают, писать тесты для них сложно, потому что у компонентов кода нет четко определенных задач. Проблема в том, что этот код почти наверняка содержит и core-код, который необходимо протестировать. Каково же решение? Рефакторинг! Об этом и поговорим далее.

Часть 3: Рефакторинг

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

Если вернуться к типам кода, которые мы рассмотрели выше, то главная цель работы с легаси-кодом — это разбить код 4-го типа (спагетти-код) на мелкие, лучше контролируемые части кода, имеющие свойства типа 1,2,3, перечисленные выше; и уже после этого создавать автотесты, для наиболее важных частей кода.

Пример

Посмотрим:

public class UserDataProcessor {
  public void processUserData(String name, int age, String address) {
    validateUserData(name, age, address);
    processMainLogic(name, age, address);
    logProcessedData(name, age, address);
  }

  private void validateUserData(String name, int age, String address) {
    if (name == null || name.isEmpty()) {
      throw new IllegalArgumentException("Name cannot be empty");
    }

    if (age < 0 || age > 120) {
      throw new IllegalArgumentException("Invalid age");
    }
    
    if (address == null || address.isEmpty()) {
      throw new IllegalArgumentException("Address cannot be empty");
    }
  }

  private void processMainLogic(String name, int age, String address) {
    // Perform the main processing logic
    // ...
  }

  private void logProcessedData(String name, int age, String address) {
    Logger.log("Processed data - Name: " + name + ", Age: " + age + ", Address: " + address);
  }
}

В этом примере метод processUserData выполняет валидацию, обработку и запись в лог. Мы можем улучшить читаемость и сопровождаемость этого кода, разделив его на мелкие функции:

public class UserDataProcessor {
  public void processUserData(String name, int age, String address) {
    validateUserData(name, age, address);
    processMainLogic(name, age, address);
    logProcessedData(name, age, address);
  }

  private void validateUserData(String name, int age, String address) {
    if (name == null || name.isEmpty()) {
      throw new IllegalArgumentException("Name cannot be empty");
    }

    if (age < 0 || age > 120) {
      throw new IllegalArgumentException("Invalid age");
    }
    
    if (address == null || address.isEmpty()) {
      throw new IllegalArgumentException("Address cannot be empty");
    }
  }

  private void processMainLogic(String name, int age, String address) {
    // Perform the main processing logic
    // ...
  }

  private void logProcessedData(String name, int age, String address) {
    Logger.log("Processed data - Name: " + name + ", Age: " + age + ", Address: " + address);
  }
}

Теперь функция processUserData превратилась в функцию-оркестратор, которая вызывает отдельные функции по каждой из трех задач. Если понадобится протестировать какую-либо из этих задач, мы сможем это сделать, поскольку зависимость между ними устранена.

И нам не обязательно делать всё сразу. Мы можем начать с выделения в функции только тех частей, которые будут изменены, или являются более важными. В предыдущем примере мы могли разбить на части только одну из задач, а остальные оставить в исходной функции. Вообще, мы можем улучшать код постепенно. В предыдущем примере можно разделить функцию validateUserData на три мелкие функции:

public class UserDataProcessor {
  public void processUserData(String name, int age, String address) {
   validateUserData(name, age, address);
   processMainLogic(name, age, address);
   logProcessedData(name, age, address);
}

  private void validateUserData(String name, int age, String address) {
   validateUserName(name);
   validateUserAge(age);
   validateUserAddress(address);
}

  private void validateUserName(String name) {
   if (name == null || name.isEmpty()) {
     throw new IllegalArgumentException("Name cannot be empty");
}
}

  private void validateUserName(int age) {
   if (age < 0 || age > 120) {
     throw new IllegalArgumentException("Invalid age");
}
}

  private void validateUserName(String address) {
   if (address == null || address.isEmpty()) {
     throw new IllegalArgumentException("Address cannot be empty");
}
}

  private void processMainLogic(String name, int age, String address) {
   // Perform the main processing logic
   // ...
}

  private void logProcessedData(String name, int age, String address) {
   Logger.log("Processed data - Name: " + name + ", Age: " + age + ", Address: " + address);
}
}

Можно придумать множество других способов сделать код более модульным. Например, функции валидатора могут cтать частью класса UserDataValidator, и так далее. Это может показаться излишним, но суть в том, что рефакторинг легаси-кода можно рассматривать как процесс его непрерывного улучшения, при необходимости.

Любой из этих небольших фрагментов кода теперь гораздо проще протестировать, чем исходную функцию. В примере у нас была достаточно простая функция, и мы могли легко разделить ее на 1-2-3. В реальных сценариях может понадобиться начать процесс с выделения одной небольшой части большой функции, а затем написать тесты только для этой небольшой части. По мере продвижения рефакторинга тестировать каждый компонент становится проще.

Примечание: тестирование private-функций

В примере мы разделили исходную public-функцию, создавая новые мелкие функции. Однако теперь это private-функции, и возможно это будет вызывать соответствующие предупреждения, потому что тестирование private-функций часто считается плохой практикой. Да, но с этим можно смириться, зная что после рефакторинга код будет в лучшем состоянии: небольшие протестированные части кода всегда лучше чем большие не протестированные.

Принципы рефакторинга

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

Не будем подробно обсуждать что такое чистый код, но все же рассмотрим наиболее важные принципы: SOLID, DRY и KISS.

Solid

Вероятно, самое известное в этом списке слово SOLID является акронимом следующих пяти принципов объектно-ориентированного программирования:

  • Принцип единой ответственности: каждый класс или модуль должен иметь одну и только одну ответственность (предназначение), выполнять только одну функцию.
  • Принцип открытости-закрытости: компоненты программы должны быть открыты для расширения, но закрыты для модификации.
  • Принцип подстановки: Объекты суперкласса должны быть легко заменяемы любыми объектами его подклассов.
  • Принцип разделения интерфейса: Клиенты не должны зависеть от интерфейсов, которые они не используют.
  • Принцип инверсии зависимостей: Зависимость должна быть от абстракций (интерфейсов, абстрактных классов и т. д.), а не от чего-то конкретного (реализации конкретного подкласса).

DRY

Это принцип «Не повторяй себя / Don’t Repeat Yourself», неповторения кода. Вместо того чтобы дублировать один и тот же код в нескольких местах, лучше изолировать (выделить) из кода логику, и ссылаться на нее в других местах. Мы часто применяем следующее эмпирическое правило: Если что-то происходит в первый раз, мы пишем код. Если во второй раз, дублируем этот код. В третий раз мы рефакторим код, чтобы сделать его реюзабельным (многократно используемым).

KISS

Принцип KISS — максимально упростить код, аббревиатура расшифровывается как «Keep It Simple, Stupid». Это позволяет избегать излишней сложности, так называемого over-engineering, который встречается в некоторых проектах.

Источник


Видео по теме:

Также статьи на Хабре: Укрощая зверя: legacy-код, тесты и вы и Код без тестов — легаси

Какой была ваша первая зарплата в QA и как вы искали первую работу?

Мега обсуждение в нашем телеграм-канале о поиске первой работы. Обмен опытом и мнения.

Подписаться
Уведомить о
guest

0 комментариев
Межтекстовые Отзывы
Посмотреть все комментарии

Мы в Telegram

Наш официальный канал
Полезные материалы и тесты
Готовимся к собеседованию
Project- и Product-менеджмент

🔥 Популярное

💬 Telegram-обсуждения

Наши подписчики обсуждают, как искали первую работу в QA. Некоторые ищут ее прямо сейчас.
Наши подписчики рассказывают о том, как не бояться задавать тупые вопросы и чувствовать себя уверенно в новой команде.
Обсуждаем, куда лучше податься - в менеджмент или по технической ветке?
Говорим о конфликтных ситуациях в команде и о том, как их избежать
$1100*
медианная зарплата в QA в июне 2023

*по результатам опроса QA-инженеров в нашем телеграм-канале

Собеседование

19%*
IT-специалистов переехало или приняло решение о переезде из России по состоянию на конец марта 2022

*по результатам опроса в нашем телеграм-канале

live

Обсуждают сейчас