Тестируя нетестируемое - битва с легаси-кодом

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

Хотя к какому бы разряду он не относился, работа с легаси-кодом  —  это всегда боль. Этот процесс несет за собой риски, а недостаток тестов порой заставляет покрываться потом от волнения  —  откуда нам знать, не сломали ли мы что-нибудь? Приходится вносить минимальные изменения и, скрестив пальцы, уповать на то, что карточный домик не развалится.

Первые шаги обратно к осмысленности

При встрече с легаси-кодом есть ряд шагов, которые позволяют привести его в нормальный вид. Первым делом следует выяснить поведение программы. Значит, нужно прочитать документацию и сопутствующие спецификации? Что ж, если верить закону Хайрума, то не всегда.

При достаточном числе пользователей API неважно, что вы обещаете в контракте: все наблюдаемое поведение вашей системы будет зависеть от кого-то (закон Хайрума).

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

Тест определения характеристик

В начале карьеры я работал над приложением, которое разрабатывал другой программист. Сам продукт стал ключевым в линейке компании, но его разработчик в итоге разочаровался и ушел в другую сферу. У этого приложения совсем не было тестов, и оно все было напичкано различными хаками. В результате получился сильно связанный код, который было сложно разделить. Прежде чем начать его исправление, требовался прочный фундамент, на который можно опереться.

В своей книге “Working Effectively with Legacy Code” Майкл Физерс предлагает стратегию для раскрытия и сохранения реального поведения системы.

Состоит она из следующих шагов:

1.Использовать фрагмент кода в тестовой системе.

2. Написать утверждение, которое априори даст сбой.

3. Отталкиваясь от этого сбоя, определить поведение.

4. Изменить тест, чтобы он ожидал выявленное поведение кода.

5. Повторить.

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

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

Legacy in a box + Test Containers

Отличным источником информации для следования общим трендам индустрии является Thoughtworks Technology Radar. Несколько лет назад они добавили “Legacy in a Box”  —  стратегию, помогающую программистам работать с легаси-программами.

Работа с легаси-кодом, особенно с масштабными монолитами, представляет самый неприятный и трудоемкий опыт для разработчиков. Чтобы сократить сложность этого процесса, мы начали использовать контейнеры Docker для создания немутабельных образов легаси-систем и их конфигураций. Цель состоит в изолировании легаси внутри коробки, что позволяет выполнять его локально, исключая необходимость воссоздания, перенастройки или совместного использования сред (Thoghtworks).

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

Заключительный элемент паззла состоит в успешном взаимодействии с этими контейнерами. Можно, конечно, использовать команды оболочки (Docker/Docker compose), но есть и более удобные способы.

Test Containers  —  это Java-библиотека, которая упрощает использование контейнеров Docker в качестве среды комплексного тестирования. Она предоставляет удобную возможность привязки (bind mount) для управления и наблюдения за выполнением контейнеров в комплексе вашего набора тестов. Этот инструмент оказывается бесценен для упрощения всего процесса.

Практический эксперимент

Разберем короткий эксперимент. Для начала нам потребуется образец приложения в качестве кандидата  —  на его роль мы возьмем REST-клиента Spring Petclinic.

Я не говорю, что это приложение для клиники домашних животных является легаси  —  на деле все наоборот. 

  1. Это приложение с некоторой сложностью (то есть не какое то там “hello world”).
  2. В нем есть зависимости и состояние  —  база данных SQL.
  3. Это ПО, написанное кем-то другим, значит мы можем начать работать с ним, как с незнакомым нам проектом.

Часть I  —  заключение легаси в коробку

Попробуйте вспомнить начало фильма “Охотники за привидениями”. В нем охотников зовут разобраться с одержимым поеданием изысканной пищи призраком, который терроризирует двенадцатый этаж пятизвездочного отеля Sedgewick. Здесь они встречаются со своим первым потенциальным уловом  —  Лизуном. К ужасу руководства отеля охотники в ходе поимки разносят все вокруг протонными бластерами, но в итоге все же запирают дебошира в ловушке  —  Лизун поглощен и изолирован.

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

В данном случае для поимки приложения в ловушку нужно проделать три шага:

  1. Добавить приложение вместе со всеми зависимостями в docker-compose

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

2. Понять приложение и настроить его вместе с его зависимостями

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

3. Настройка запуска

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

Нам нужно добавить компонент, который поможет организовать правильный порядок запуска. Для этого прекрасно подойдет wait-for-it.sh. Его можно внедрить в имеющийся образ, чтобы заблокировать запуск приложения до момента готовности MySQL. В данном случае этого оказалось достаточно, но иногда может потребоваться внесение и других скриптов.

FROM springcommunity/spring-petclinic-rest

FROM openjdk:18-slim-buster
COPY --from=0 /app /app
COPY wait-for-it.sh /wait-for-it.sh
RUN chmod 755 /wait-for-it.sh

version: '3'
services:
  
  petclinic:
    build:
      context: .
    entrypoint: bash -c "/wait-for-it.sh -h mysql -p 3306 -- java -Dspring.datasource.url=jdbc:mysql://mysql:3306/petclinic?enabledTLSProtocols=TLSv1.2 -Dspring.datasource.username=pc -Dspring.jpa.hibernate.ddl-auto=update -Dspring.jpa.database-platform=org.hibernate.dialect.MySQL8Dialect -cp /app/resources:/app/classes:/app/libs/* org.springframework.samples.petclinic.PetClinicApplication"
    ports:
      - "9966"
    environment:
      - "SPRING_PROFILES_ACTIVE=mysql,spring-data-jpa"
    depends_on:
      - mysql
      
  mysql:
    image: "mysql:8"
    ports:
      - "3306"
    environment:
      - MYSQL_ROOT_PASSWORD=
      - MYSQL_ALLOW_EMPTY_PASSWORD=true
      - MYSQL_USER=pc
      - MYSQL_PASSWORD=petclinic
      - MYSQL_DATABASE=petclinic
version: '3'
services:
  
  petclinic:
    build:
      context: .
    entrypoint: bash -c "/wait-for-it.sh -h mysql -p 3306 -- java -Dspring.datasource.url=jdbc:mysql://mysql:3306/petclinic?enabledTLSProtocols=TLSv1.2 -Dspring.datasource.username=pc -Dspring.jpa.hibernate.ddl-auto=update -Dspring.jpa.database-platform=org.hibernate.dialect.MySQL8Dialect -cp /app/resources:/app/classes:/app/libs/* org.springframework.samples.petclinic.PetClinicApplication"
    ports:
      - "9966"
    environment:
      - "SPRING_PROFILES_ACTIVE=mysql,spring-data-jpa"
    depends_on:
      - mysql
      
  mysql:
    image: "mysql:8"
    ports:
      - "3306"
    environment:
      - MYSQL_ROOT_PASSWORD=
      - MYSQL_ALLOW_EMPTY_PASSWORD=true
      - MYSQL_USER=pc
      - MYSQL_PASSWORD=petclinic
      - MYSQL_DATABASE=petclinic

Часть 2  —  подключение к системе тестирования с помощью Test Containers

Теперь, когда мы успешно создали среду для тестирования, можно начинать подключать к ней тесты. Именно здесь и вступает в дело библиотека Test Containers. Все, что нужно сделать,  —  это указать ей на только что созданный docker-compose.yml, после чего она самостоятельно обработает все взаимодействие с Docker, включая ожидание доступности нужных портов.

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

@Testcontainers
class TodoSpec extends Specification {

@Shared
DockerComposeContainer environment = new DockerComposeContainer(new File("./docker-compose.yml"))
// ожидает доступности отображенного порта 9966
.withExposedService("petclinic_1", 9966)
//ожидает доступности отображенного порта 3306
.withExposedService("mysql_1", 3306)

def "connects to container"() {

when: "I request details about the started containers"
def petclinic = environment.getContainerByServiceName("petclinic_1").get()
def mysql = environment.getContainerByServiceName("mysql_1").get()

then: "I see on which host/port each service is running"
println("Petclinic => ${petclinic.getHost()}:${petclinic.getMappedPort(9966)}")
println("MySQL => ${mysql.getHost()}:${mysql.getMappedPort(3306)}")

}

}

Отступление на тему продуктивной разработки тестов

Test Containers отлично подходит для организации свежей среды тестирования при каждом запуске. Но все же нежелательно создавать полностью новый набор контейнеров при каждом выполнении тестов, так как это всякий раз будет приводить к стартовой задержке в 20–30 секунд. Нас интересует быстрая, почти мгновенная, отдача от вносимых изменений  —  иначе медлительность будет только расстраивать.

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

TestEnvironment  —  это предустановленная среда, которая перекладывает всю ответственность на Test Containers, как мы видели ранее.

DevEnvironment  —  после включения она будет искать существующие контейнеры, соответствующие определенному критерию (имени, портам и т. д.). В итоге тесты будут использовать их, а не запускать новый набор контейнеров, что существенно упростит бутстрэппинг. В этом случае ответственность подразумевает использование вами клиента docker-compose для самостоятельного управления средой по ходу разработки.

Если вас интересует, как все это устроено, то вот рабочий пример.

Часть 3  —  написание теста

У нас есть рабочая среда, к которой мы можем подключаться. Пришло время написать тест, что уже будет совсем несложно.

@Testcontainers
class OwnerSpec extends Specification {

@Shared
Environment environment = Environment.get()

@Shared
def petclinic = environment.petclinic()

@Shared
def json = new JsonSlurper()

@Shared
def client = new RESTClient( "http://${petclinic.getHost()}:${petclinic.getPort()}")

def "creates a new owner"() {

when: "I make a request to create a new owner"

def owner = json.parseText('''{
"address": "101 Bachmann St",
"city": "Silent Hill",
"firstName": "Harry",
"id": 0,
"lastName": "Mason",
"telephone": "01234567890",
"pets": []
}''')

def response = client.post(
path: '/petclinic/api/owners',
body: owner,
requestContentType: JSON)


then: "I should get a successful response (201)"
assert response.status == 201


and: "The petclinic should have 1 active owner"
def owners = client.get(path: "/petclinic/api/owners")
assert owners.data.size() == 1

}

}

С помощью Spock мы просто указываем клиенту REST на среду, предоставленную Test Containers, и начинаем работу. Можно даже делать утверждения, обращаясь к приложению для проверки его состояния.

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

Часть 4  —  фикстуры и изоляция тестов

Добавить изоляцию тестов также просто. Аналогично тому, как мы подключали к приложению клиента REST, можно подключить и БД. Перед каждым тестом эту БД можно будет сбрасывать к известному состоянию.

Для реализации этого сделаем так, чтобы интерфейс Environment возвращал объект утилиты mysql для инкапсуляции общих функций базы данных. Эта утилита имеет простую функцию #clear, которая перебирает несколько таблиц баз данных и удаляет их перед выполнением теста.

public void clear() {
execute((conn) -> {
DSLContext ctx = DSL.using(conn, SQLDialect.MYSQL);
try {
ctx.execute("SET FOREIGN_KEY_CHECKS=0");
ctx.meta()
.filterSchemas(s -> s.getName().equals(DB))
.getTables()
.forEach(table -> ctx.truncate(table.getName()).execute());
} finally {
ctx.execute("SET FOREIGN_KEY_CHECKS=1");
}
});
}
@Testcontainers
class OwnerSpec extends Specification {

@Shared
Environment environment = Environment.get()

@Shared
def petclinic = environment.petclinic()

@Shared
def json = new JsonSlurper()

@Shared
def client = new RESTClient( "http://${petclinic.getHost()}:${petclinic.getPort()}")

def setup() {
environment.mysql().clear()
}

def "creates a new owner"() {

when: "I make a request to create a new owner"

def owner = json.parseText('''{
"address": "101 Bachmann St",
"city": "Silent Hill",
"firstName": "Harry",
"id": 0,
"lastName": "Mason",
"telephone": "01234567890",
"pets": []
}''')

def response = client.post(
path: '/petclinic/api/owners',
body: owner,
requestContentType: JSON)


then: "I should get a successful response (201)"
assert response.status == 201


and: "The petclinic should have 1 active owner"
def owners = client.get(path: "/petclinic/api/owners")
assert owners.data.size() == 1

}

}

Заключение

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

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

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

Читайте также:

Читайте нас в TelegramVK и Яндекс.Дзен


Перевод статьи Matthew Lucas: Testing the Untestable — The Battle With Legacy Code

Предыдущая статьяPydantic  —  гарантия надежного и безошибочного кода Python 
Следующая статьяСегментация по границам объекта и областям изображения с реализацией в Python