27 сентября 2016 в 10:58

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

Ни один разработчик в здравом уме и трезвой памяти при разработке сложных приложений (> 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 может прийти от какого-нибудь скучающего охранника автостоянки, решившего в эту ночь побыть программистом), модульными тестами должна быть покрыта каждая строка кода, даже самая тривиальная.


Ссылки


Alex Gusev @flancer
карма
15,0
рейтинг –0,7
Разработка web-приложений (e-commerce)
Самое читаемое Разработка

Комментарии (19)

  • +1

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

  • 0

    Ну как же не сказано? Вот, в самом начале:


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

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

  • 0

    Если рассуждать с точки зрения TDD, то вы совершенно правы.

    • 0

      А вы про TDD не упомянули, что ужасно, потому что это один из самых важных доводов писать тесты.


      оффтопик

      Если вы нажмёте на коментарии кнопку ответить, то именно ответите на коментарий, а не напишете ещё один коментарий на верхнем уровне.

      • 0

        Извините, пожалуйста, не хотел никого испугать.

  • НЛО прилетело и опубликовало эту надпись здесь
    • 0

      А вы пробовали DUnit? Да, он давно не менялся, но и Delphi не вчера родилось. Может вполне юзабельно окажется.

      • НЛО прилетело и опубликовало эту надпись здесь
        • 0

          Если я правильно понимаю, то вы используете предопределенные компоненты в качестве базовых, создаете на их основе собственные, которые связываете мышкой и немножко с клавиатуры. Полагаем, что предопределенные компоненты уже оттестированы, и нам их тестировать нет нужды. Значит нужно протестировать заданные свойства вновь созданных компонентов их связи между собой. Вряд ли легковесный CppUnit встроен с C++ Builder настолько, что все можно сделать также мышкой, поэтому придется делать ручками с клавиатуры. Т.к. для строительства приложения вы используете компоненты, то unit-тестирование — самое то. Мокируем окружение, создаем собственную компоненту и проверяем те свойства, которые мы напрограммировали для этой компоненты мышкой и с клавы. Главный вопрос — есть ли подходящая билиотека для создания моков? Сообщество говорит, что есть opmock. Сам я подобных связок не пробовал, но начал бы копать с этого.


          всё работает без единой строчки кода.

          подозреваю, что если как следует поискать в файлах проекта, то все-таки код можно будет обнаружить :)

          • НЛО прилетело и опубликовало эту надпись здесь
        • 0

          и еще такой момент, коллега — судя по вашему описанию, вы широко используете возможности по генерации кода, предоставляемые средой. Тестовый код по отношению к тестируемому идет как 1:2/3/4… (в зависимости от сложности логики тестируемого кода). Если вы до сих пор не столкнулись с удобным инструментом для автоматической генерации unit-тестов в своей среде, то вряд ли он существует. Захотите ли вы вручную создавать тесты для того кода, который нагенерит ваша среда? Посчитайте LOC вашего проекта и умножьте его на 2, для начала.

          • НЛО прилетело и опубликовало эту надпись здесь
            • 0

              "Визуальное программирование" в среде builder'а подразумевает автоматическую генерацию кода на выходе (для каждого фрейма и так три файла — h,cpp,dfm). Если продолжать данную традицию, то можно ожидать, что также возможно создание соответствующего unit-теста (*_Test.cpp или что-то подобное) для каждого компонента, в том же автоматическом режиме. Я сам с таким не сталкивался, но допускаю, что если можно сгенерировать по шаблону некий код, то можно сгенерировать и unit-тест для него.

              • НЛО прилетело и опубликовало эту надпись здесь
  • 0
    такого же эффекта можно добиться гораздо быстрее, если делать контрольную копию файла с исходным кодом и сообщать при тестировании обо всех отклонениях текущего кода от контрольной копии.

    Что здесь имеется ввиду?
    • 0

      Я имел в виду, что если просто делать копии рабочих исходников (например, с расширением .orig), то по ним точно так же можно отслеживать изменения кода в рабочих исходниках, как и при использовании мокирования для тестирования кода содержащего линейную последовательность действий. Вот только можно не писать юнит-тест для такого класса, а просто брать эталонный исходник, подписывать его "сениорской" пописью и проверять, что "джуниор" не запорол код при рефакторинге. Абсурд, в общем, тут имелся в виду.

      • 0
        Спасибо, но, по-моему, это ерунда полная.
  • 0

    Да, так и есть. Если очень сильно применять SRP, то начинают попадаться классы, мокать которые при тестировании — полная ерунда. Коллега Dimitar Ginev как раз и рекомендует не заниматься ерундой и не тестировать подобные классы (orchestrators).

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