Уроки, извлеченные из тестирования и рефакторинга legacy

Когда я впервые столкнулся с автотестами, мне хотелось написать тесты на все свои проекты, над которыми работал в данный момент. Но внедрить автотесты в уже существующий проект довольно сложно. С одной стороны проблема заключается в том, что практически все унаследованные проекты не придерживались solid или kiss/dry принципов. И тестировать такой продукт очень сложно. Нужно будет отрефакторить такой код и начатать использовать автотесты. Но как узнать, что в процессе рефакторинга у нас ничего не сломалось? Какой-то тупик получается.
С другой стороны программисты, которые до этого прекрасно жили без автотестов тяжело приходят к их использованию. А начальство очень неохотно желает выделять средства на использование автотестов.

Тестирование поведения приложения

Когда я хочу отрефакторить код, я не начинаю с юнит тестирования. Я начинаю с написания тестов, которые фиксируют поведение приложения. Обычно пишу их, вызывая конечную точку http или cli, и проверяю вывод.

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

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

Предварительное тестирование

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

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

Проблемы при тестировании

Жесткие зависимости

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

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

Неконтролируемые переменные

Иногда приходится тестировать код, который зависит от окружения, от текущего времени, ip адреса и т.д. Необходимо реорганизовать такой код и избавиться от таких мест.

Например:

  1. Я найду все места, которые используютmktime(), new DateTime()и т. д.
  2. Я создам и добавлю Clockинтерфейс в качестве зависимости для этих классов.
  3. Я заменю время создания на $this->clock->now().
  4. Я напишу для него реализацию, использующую, DateTimeImmutable

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

Смешанные проблемы

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

$controller = new ProductController($dep1, $dep2, ...);

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

Длинные методы

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

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

Предположим, что каждый комментарий в этом примере соответствует примерно 20 строкам кода:

public function synchronizePrices(): void
{
// загрузить CSV из файла
// распарсить CSV файл
// создать массив продуктов
// если массив пуст, то бросить исключение
// найти продукт в базе
// если цена изменилась, то обновить ее
}

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

public function synchronizePrices(): void
{
// зависимость на файловую систему
$csv = $this->loadCsvFromFile();
$parsedCsv = $this->parseCsv($csv);
$products = $this->getProductsFromArray($parsedCsv);
foreach ($products as $product) {
// две зависимости на базу данных
$this->findProduct($product);
$this->updatePrice($product);
}
}

Теперь я должен переместить код из этих методов в новые классы, которые я вставлю через конструктор. Более 100 строк кода превращаются в это:

public function __construct(
ProductRepository $sourceProductRepository,
ProductRepository $targetProductRepository
) {
$this->sourceProductRepository = $sourceProductRepository;
$this->targetProductRepository = $targetProductRepository;
}
public function synchronizePrices(): void
{
$products = $this->sourceProductRepository->getAll();
foreach ($products as $product) {
$this->targetProductRepository->updatePrice($product);
}
}

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

Возможно, однажды мы прекратим поддержку работы с CSV файлами в пользу REST API. И тогда, вместо реализации CSV я подставлю реализацию REST API. Тесты переписывать не придется, все как работало, так и продолжает работать.

Рефакторинг

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

  • «Чистый код» Роберта С. Мартина
  • «Эффективная работа с устаревшим кодом» Майкла Фезерса
  • «Модернизация устаревших приложений в PHP» Пола М. Джонса
  • ну и конечно же https://refactoring.guru/ru/

Однако не пытайтесь делать все сразу. Как только у вас есть небольшой класс, который зависит только от нескольких интерфейсов и довольно короткого метода, как в примере с synchronizePrices, начинайте писать модульные тесты.

Модульные тесты (юнит-тесты)

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

protected function setUp(): void
{
// 50 строк кода для создания жестких зависимостей
$this->synchronizer = new Synchronizer();
}
public function testSynchronizePrices(): void
{
// 50 строк кода для создания CSV контента
// создание CSV файла и загрузка на FTP сервер
// 200 строк кода, для работы с ORM вызовами
}

Вот тест после рефакторинга:

protected function setUp(): void
{
$this->csvProductRepository = $this->createStub('ProductRepository');
$this->databaseProductRepository = $this->createMock('ProductRepository');
$this->synchronizer = new Synchronizer(
$this->csvProductRepository,
$this->databaseProductRepository
);
}
public function testSynchronizePrices_WithProducts_WillUpdatePrices(): void
{
$this->csvProductRepository
->method('getAll')
->willReturn([$product1, $product2]);
$this->databaseProductRepository
->expects($this->exactly(2))
->method('updatePrice')
->withConsecutive([
[$product1],
[$product2],
]);
$this->synchronizer->synchronize();
}

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

Полезные инструменты

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

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

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

--

--