Выпуск 14. Апрель 2014

От редактора | Содержание | Pjam — сервер сборки перловых приложений

Тестирование в Perl. Лучшие практики

Рассмотрены основные практики для улучшения качества тестирования в Perl

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

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

Основные преимущества разработки через тестирование

  • Интерфейс создается автоматически.

    Нет необходимости продумывать интерфейс класса. Он сам собой вырисовывается во время использования (тестирования).

  • Имплементируется только необходимое.

    Нет кода, который не используется. Все, что не протестировано, выкидывается.

  • Система разрабатывается небольшими шагами.

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

  • Поощряется написание модульного кода.

    Модульные тесты пишутся на классы. Чем несвязаннее классы — тем легче их тестировать.

  • Ошибки проектирования выявляются как можно раньше.

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

  • Модуль работает.

    Есть уверенность в том, что модуль хоть как-то работает до его внедрения.

  • Возможность рефакторинга.

    При наличии тестовой базы рефакторинг становится безопасным. Изменение поведения и наличие регрессий контролируется тестами.

Основные недостатки разработки через тестирование

  • Одни и те же ошибки могут быть оставлены как в коде, так и в тестах.

    Тест может тестировать неправильную логику работы модуля.

  • Ложная уверенность в работоспособности системы.

    При наличии большого количества тестов может сложиться ложная уверенность в отсутствии ошибок в системе.

  • Больше кода для поддержки

    Тесты это тоже код и его нужно поддерживать. Это время.

  • Тесты могут быть хрупкими и ломаться при каждом незначительном изменении кода.

Большинство недостатков можно побороть, следуя лучшим практикам.

Лучшие практики

Следование циклу разработки

Разработка через тестирование должна следовать циклу: красный, зеленый, рефакторинг. Что обычно означает:

  • Написать тест и убедиться, что тест не проходит.

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

  • Написать класс и убедиться, что тест проходит.
  • Отрефакторить написанный код.

    Это касается и класса, и теста.

    Почему возникла ошибка? Достаточно ли читабелен сам код?

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

Тесты должны быть простыми и понятными

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

Тесты не должны зависеть друг от друга

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

Тесты должны быть сгруппированы

У каждого тестового случая (не путать с тестом на класс) должна быть своя область видимости для избежания влияния тестов друг на друга. Очень часто приходится видеть подобные тесты:

my $foo = Foo->new(name => 'Name', last_name => 'Last Name');
ok($foo);
is($foo->name, 'Name');
is($foo->last_name, 'Last Name');

Гораздо лучше написать тест следующим образом:

subtest 'create new object' => sub {
    my $foo = Foo->new;

    ok($foo);
};

subtest 'correcly initialize object' => sub {
    my $foo = Foo->new(name => 'Name', last_name => 'Last Name');

    is($foo->name, 'Name');
    is($foo->last_name, 'Last Name');
};

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

Один тест — одна проверка

Каждый тестовый случай должен проверять единственную функциональность. Таким образом никогда не возникнет тестов, которые тестируют “все”, обычно они называются test_general, test_ok и тому подобное. В предыдущем примере четко видно, что каждый субтест тестирует конкретную возможность класса.

3A, AAA, Arrange-Act-Assert

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

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

    subtest 'correcly concatenates two strings' => sub {
    
        # Подготовка
        my $concatenator = Concatenator->new;
    
        # Выполнение
        my $result = $concatenator->cat('foo', 'bar');
    
        # Проверка
        is $result, 'foobar';
    };

Это делает тесты проще. Легко видеть что тестируется, в каких условиях.

Тестировать поведение, а не конкретную реализацию

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

eval { $object->die_hard };
is "$@", "We died here for the good reason. Error 42";

В данном случае малейшее изменение текста ошибки приведет к сломанному тесту. В нашем случае, мы тестируем, что в тексте ошибки должно присутствовать Error <код-ошибки>.

eval { $object->die_hard };
like "$@", qr/\s* Error \s+ \d+$/xms;

Не увлекаться mock-объектами

Mock-объекты — это объекты, которые можно использовать вместо настоящих объектов, когда их создание невозможно, сложно или они еще не реализованы.

Сами mock-объекты заслуживают своей отдельной статьи, здесь лишь скажем, что не стоит их использовать повсеместно. Основной недостаток mock-объектов проявляется при изменений поведения или интерфейсов настоящих объектов. В данном случае ваши старые тесты будут проходить и дальше, даже несмотря на то, что вы поменяли метод foo на bar в настоящем классе.

Рекомендуется использовать mock-объекты в режиме подстановки отдельных методов на настоящих объектах, например, используя Test::MonkeyMock:

my $mocked_url_fetcher = Test::MockObject->new;
$mocked_url_fetcher->mock(request => sub { 'OK' });

my $real_object = RealObject->new;
$real_object = Test::MonkeyMock->new($real_object);
$real_object->mock(_build_url_fetcher => sub { $mocked_url_fetcher });

Таким образом, мы подменили фабричный метод, который создавал объект для получения файла по URL на свой тестовый.

Вместо фабричных методов, конечно, можно использовать и инъекцию зависимостей (если она поддерживается):

my $mocked_url_fetcher = Test::MockObject->new;
$mocked_url_fetcher->mock(request => sub { 'OK' });

my $real_object = RealObject->new(url_fetcher => $mocked_url_fetcher);

Перенос создания объектов в фабричные методы

В тестах, как и в коде, рекомендуется использовать фабрики или фабричные методы для создания тестируемых объектов. Это упрощает их инициализацию, избавляет от дублирования. Для тестов, которые используют наследование (например, на основе Test::Unit или Test::Class) эти методы позволяют подставлять нужные объекты в тесты. Например:

package TestBase;
use base 'Test::Unit::TestCase';

sub test_create_timer {
    my $self = shift;

    my $timer = $self->_build_timer;

    $self->assert($timer);
}

sub test_increment_time {
    my $self = shift;

    my $timer = $self->_build_timer;

    my $old_time = $timer->time;

    $timer->tick;

    my $new_time = $timer->time;

    $self->assert($new_time > $old_time);
}

package OldTimerTest;
use base 'TestBase';

use OldTimer;

sub _build_timer {
    my $self = shift;

    return OldTimer->new;
}

package NewTimerTest;
use base 'TestBase';

use NewTimer;

sub test_some_new_functionality {
    my $self = shift;

    my $timer = $self->_build_timer;

    $self->assert($timer->new_tick);
}

sub _build_timer {
    my $self = shift;

    return NewTimer->new;
}

Таким образом, тестируется и соответствие нового класса старому интерфейсу и новый функционал.

Перенос фикстур в отдельные классы

При тестировании большого проекта возникает необходимость создания типовых объектов практически в каждом тесте. Чтобы избежать дублирования, создаются специальные классы-фабрики для создания и инициализации этих объектов. Сама реализация этих фабрик может отличаться. Это может быть один класс для создания всех объектов или же разные классы для создания разных групп объектов. В английской терминологии это Mother или God Object и Test Data Builder.

Использование первого:

subtest 'table is round' => sub {
    my $table = MotherObject->createTable();

    ok $table->is_round;
};

Использование второго:

subtest 'table is round' => sub {
    my $table = TableBuilder->create();

    ok $table->is_round;
};

Простота важнее абстракций

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

Юнит-тестирования недостаточно

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

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

Заключение

Это статья лишь бегло покрывает основы тестирования. Еще многое можно сказать о рекомендуемых модулях, организации тестов, mock-объектах, сложностях тестирования, как убедить коллег писать тесты, как убедить заказчика в необходимости рефакторинга и тестов. Пожалуйста, оставляйте свои комментарии, если и дальше хотите видеть в журнале статьи подобной тематики.

Вячеслав Тихановский


От редактора | Содержание | Pjam — сервер сборки перловых приложений
Нас уже 1393. Больше подписчиков — лучше выпуски!

Комментарии к статье