Pull to refresh

Unit-тестирование в сложных приложениях

Reading time5 min
Views16K

Ни один разработчик в здравом уме и трезвой памяти при разработке сложных приложений (> 100K LOC, например) не станет отрицать необходимость использования тестирования вообще и модульного тестирования (unit tests) в частности. Это так же верно, как и то, что каждый разработчик постарается исключить бессмысленную работу из творческого процесса создания приложения. Где же та грань, которая отделяет необходимость от бессмысленности, если мы говорим о модульном тестировании в контексте сложных приложений? Пару своих соображений по этому поводу я изложил под катом.


Назначение


Wikipedia:


Модульное тестирование, или юнит-тестирование (англ. unit testing) — процесс в программировании, позволяющий проверить на корректность отдельные модули исходного кода программы.

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

Все как бы понятно. Есть 5 строчек кода:


class Calculator {
    public function add($a, $b) {
        return $a + $b;
    }
}

Есть юнит-тест для него (уже 10 строк, но это нормально для юнит-теста, когда количество строк в тесте превышает количество строк тестируемого кода):


class CalculatorTests extends PHPUnit_Framework_TestCase {
    private $calculator;

    protected function setUp() {
        $this->calculator = new Calculator();
    }

    public function testAdd() {
        $result = $this->calculator->add(1, 2);
        $this->assertEquals(3, $result);
    }

}

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


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



(картинка взята из интернетов исключительно за свою треугольную форму и послойное перечисление некоторых типов тестирования; цифры процентов и прочие детали — несущественны в контексте изложенного;)


Тривиальная тривиальность


"… писать тесты для каждой нетривиальной функции или метода."


С кодом, который имплементирует логику согласно заданной спецификации, все понятно. А что делать с кодом, где этой самой логики нет? Например, с акцессорами в DTO-like классах?


Сетевой разум относит такие случаи к тривиальным, несмотря на ненулевую вероятность поиметь в коде ошибку типа:


public function getDateCreated()
{
    return $this->_dateUpdated;
}

Вероятность подобной ошибки сильно повышается при массовом применении в коде прогрессивной техники "Find&Replace", а желание применить прогрессивную технику возрастает с ростом проекта и более полным погружением в детали предметной области.


Компромиссным вариантом между бессмысленностью и необходимостью может быть обращение к акцессорам при подготовке данных для тестирования других, менее тривиальных классов (таких, как сервисы), в которых используются DTO-like объекты, или проверять через assert'ы результат по возвращении:


$in = new InDto();
$in->setId(4);
$out = $service->callMethod($in);
$this->assertEquals('success', $out->getStatus());

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


Нетривиальная тривиальность


Все объектно-ориентированные разработчики рано или поздно натыкались на аббревиатуру SOLID (кто не натыкался — самое время), в которой первая буква "S" соответствует SRP — "класс должен иметь только одну обязанность". Методичное и последовательное применение этого принципа приводит с одной стороны к упрощению кода отдельного класса, а с другой — к росту количества классов и связей между ними. Для преодоления проблемы роста с успехом используется модульный подход, многоуровневая архитектура и инверсия управления. В чистом остатке имеем сплошной профит в виде "упрощения кода отдельного класса", вплоть до вот таких реализаций отдельных методов:


public function execute($in)
{
    $order = $in->getOrder();
    $this->_service->saveOrder($order);
    $this->_otherSrv->accountOrder($order);
}

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


Коллега Dimitar Ginev рекомендует разделять в подобных случаях код по двум категориям классов (orchestrator и decision makers) и покрывать тестами код только второй категории.


Code coverage


Замечательной метрикой для оценки качества кода явлется % покрытия кода тестами. Этот процент можно рассчитать как для отдельного файла с исходным кодом, так и для всей кодовой базы проекта (например, покрытие модульными тестами Magento 2.1.1). Покрытие кода дает возможность визуально оценить проблемные области в разрабатываем исходном коде и должно стремиться к 100% покрытию значимого кода. Причем, чем сложнее разрабатываемое приложение, тем больше значимого кода в нем, и большее значение начинает иметь стопроцентность покрытия. Модульные тесты являются очень хорошими кандидатами для использования их результатов при расчетах этой метрики опять таки в силу своей независимости (друг от друга и от остального, нетестируемого в данный момент кода) и скорости выполнения.


Покрытие всего кода в проекте можно довести до 100% двумя путями:


  • создать тесты для непокрытого кода;
  • вывести непокрытый код из-под учета (например, @codeCoverageIgnore в PHPUnit);

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


Так где же баланс?


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


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


Ссылки


Tags:
Hubs:
+9
Comments19

Articles