
Test::Spec (https://metacpan.org/pod/Test::Spec) — модуль для декларативного написания юнит-тестов на Perl. Мы в REG.RU активно его используем, поэтому хочу рассказать, зачем он нужен, чем отличается от других модулей для тестирования, указать на его преимущества, недостатки и особенности реализации.
Эта статья не является вводной ни в юнит-тестирование в целом, ни в использование Test::Spec в частности. Информацию по работе с Test::Spec можно получить из документации (https://metacpan.org/pod/Test::Spec и https://metacpan.org/pod/Test::Spec::Mocks). В статье же речь пойдёт о специфике и нюансах этого модуля.
Оглавление:
Спецификации на тестируемый код
Юнит-тестирование с использованием mock-объектов
Ещё мелкие полезности
Понятный вывод исключений
Автоматически импортирует strict/warnings;
Простой и удобный выборочный запуск тестов
Альтернатив не видно
Особенности работы и грабли
Вывод имён теста в ok/is/прочие не работает
Нельзя размещать тесты внутри before/after
Блоки before/after меняют структуру кода
Оператор local больше не работает
DSL
Глобальные кэши
Глобальные переменные
А как по-другому?
Ещё про it и local
Общий код
Сложно написать хелперы, работающие одновременно и с Test::Spec, и с Test::More
Функция with работает только для классов
Функция with не видит разницы между хэшем и массивом
Проблемы с тестированием вещей типа утечек памяти
Функция use_ok уже не к месту
Интересное
О том, как технически подделываются объекты методом expects
Выводы
Test::Spec хорош для юнит-тестирования высокоуровневого кода
Спецификации на тестируемый код
Возьмём простой тест на Test::More.
Тестируемый код:
package MyModule; use strict; use warnings; sub mid { my ($first, $last) = @_; $first + int( ($last - $first) / 2 ); } 1;
Сам тест:
use strict; use warnings; use Test::More; use MyModule; is MyModule::mid(8, 12), 10, "mid should work"; is MyModule::mid(10, 11), 10, "mid should round the way we want"; done_testing;
результат работы:
ok 1 - mid should workok 2 - mid should round the way we want1..2Эквивалентный тест на Test::Spec:
use Test::Spec; use MyModule; describe "MyModule" => sub { describe "mid" => sub { it "should work" => sub { is MyModule::mid(8, 12), 10; }; it "should round the way we want" => sub { is MyModule::mid(10, 11), 10; }; }; }; runtests unless caller;
и результат его работы:
ok 1 - MyModule mid should workok 2 - MyModule mid should round the way we want1..2Всё очень похоже. Отличия в структуре теста.
Test::Spec — это способ декларативно описывать спецификации на тестируемый код. Этот модуль создан по подобию широко известного пакета RSpec из мира Ruby, который, в свою очередь, работает в соответствии с принципами TDD и BDD. Спецификация на тестируемый код описывает функциональное поведение тестируемой единицы кода (http://en.wikipedia.org/wiki/Behavior-driven_development#Story_versus_specification). Она позволяет легче читать исходный код теста и понимать, что и как мы тестируем. Одновременно строки-описания поведения и сущностей, которым это поведение соответствует, используются при выводе информации об успешных или провалившихся тестах.
Сравните эти записи:
Ruby:
describe SomeClass do describe :process do @instance = nil before :all do @instance = SomeClass.new(45) end it "should return to_i" do @instance.to_i.should == 45 end end end
Perl:
describe SomeClass => sub { describe process => sub { my $instance; before all => sub { $instance = SomeClass->new(45); }; it "should return to_i" => sub { is $instance->to_i, 45; }; }; };
describe — блок, где находятся тесты (должен описывать, что мы тестируем). Вложенность блоков describe не ограничена, что позволяет структурно декларировать в тесте желаемое поведение и задавать сценарии тестирования.
it — один отдельный тест (должен описывать, что должно делать то, что мы тестируем). Само тестирование происходит внутри блоков «it», реализуется привычными функциями ok/is/like (по умолчанию импортируются все функции из Test::More, Test::Deep и Test::Trap).
before/after — позволяют производить различные действия перед каждым тестом, или перед каждым блоком тестов.
Юнит-тестирование с использованием mock-объектов
Test::Spec идеален для юнит-тестирования с использованием mock-объектов (https://metacpan.org/pod/Test::Spec::Mocks#Using-mock-objects).Это основное его преимущество перед остальными библиотеками для тестов.

Чтобы реализовать юнит-тестирование по принципу «тестируется только один модуль/функция в одно время», практически необходимо активно использовать mock-объекты.
Например, следующий метод модуля User является реализацией бизнес-логики по предоставлению скидок при покупке:
sub apply_discount { my ($self, $shopping_cart) = @_; if ($shopping_cart->total_amount >= Discounts::MIN_AMOUNT && Discounts::is_discount_date) { if ($shopping_cart->items_count > 10) { $self->set_discount(DISCOUNT_BIG); } else { $self->set_discount(DISCOUNT_MINI); } } }
Один из вариантов его тестирования мог бы быть такой: создание объекта User ($self) со всеми зависимостями, создание корзины с нужным количеством товаров и с нужной суммой и тестированием результата.
В случае же юнит-теста, тестируется только этот участок кода, при этом создания User и Shopping cart удаётся избежать.
Тест (на одну ветку «if») выглядит примерно так:
describe discount => sub { it "should work" => sub { my $user = bless {}, 'User'; my $shopping_cart = mock(); $shopping_cart->expects('total_amount')->returns(4_000)->once; Discounts->expects('is_discount_date')->returns(1)->once; $shopping_cart->expects('items_count')->returns(11); $user->expects('set_discount')->with(Discounts::DISCOUNT_BIG); ok $user->apply_discount($shopping_cart); }; };
Здесь используются функции Test::Spec::Mocks: expects, returns, with, once.
Происходит следующее: вызывается метод User::apply_discount, в него передаётся mock-объект $shopping_cart. При этом проверяется, чтобы метод total_amount объекта $shopping_cart вызывался ровно один раз (на самом деле никакой настоящий код не будет вызываться — вместо этого этот метод вернёт число 4000). Аналогично, метод класса Discounts::is_discount_date должен вызваться один раз, и вернёт единицу. Метод items_count объекта $shopping_cart вызовется как минимум один раз и вернёт 11. И в итоге должен вызваться $user->set_discount c аргументом Discounts::DISCOUNT_BIG
То есть фактически мы самым естественным образом проверяем каждое ветвление логики.
Такой подход нам даёт следующие преимущества:
- Тест написать проще.
- Он менее хрупкий: если мы полностью пытались бы воссоздать объект User в тесте, пришлось бы бороться с поломками, связанными с тем, что изменились детали реализации чего-либо, вообще не используемого в тестируемой функции.
- Тест быстрее работает.
- Бизнес-логика более понятно изложена (документирована) в тесте.
- Если баг в коде, то падают не 100500 разных тестов, а какой-то один, и по нему точно можно понять, что именно нарушено.
Если бы эквивалентный юнит-тест пришлось писать на чистом Perl и Test::More, он бы выглядел примерно так:
use strict; use warnings; use Test::More; my $user = bless {}, 'User'; my $shopping_cart = bless {}, 'ShoppingCart'; no warnings 'redefine', 'once'; my $sc_called = 0; local *ShoppingCart::total_amount = sub { $sc_called++; 4_000 }; my $idd_called = 0; local *Discounts::is_discount_date = sub { $idd_called++; 1 }; my $sc2_called = 0; local *ShoppingCart::items_count = sub { $sc2_called++; 11 }; my $sd_called = 0; local *User::set_discount = sub { my ($self, $amount) = @_; is $amount, Discounts::DISCOUNT_BIG; $sd_called = 1; }; ok $user->apply_discount($shopping_cart); is $sc_called, 1; is $idd_called, 1; ok $sc2_called; is $sd_called, 1; done_testing;
Здесь очевидно, что происходит много рутинной работы по подделке функций, которую можно было бы автоматизировать.
Ещё мелкие полезности
Понятный вывод исключений
use Test::Spec; describe "mycode" => sub { it "should work" => sub { is 1+1, 2; }; it "should work great" => sub { die "WAT? Unexpected error"; is 2+2, 4; }; }; runtests unless caller;
выдаёт:
ok 1 - mycode should worknot ok 2 - mycode should work great# Failed test 'mycode should work great' by dying:# WAT? Unexpected error# at test.t line 8.1..2# Looks like you failed 1 test of 2.что содержит, кроме номера строки, имя теста — «mycode should work great». Голый Test::More этим похвастаться не может и не сможет, так как имя теста ещё не известно, пока к нему идут приготовления.
Автоматически импортирует strict/warnings;
То есть фактически их писать не обязательно. Но будьте осторожны, если у вас принят другой модуль требований к коду, например Modern::Perl. В таком случае включайте его после Test::Spec.
Простой и удобный выборочный запуск тестов
Просто задав переменную среды SPEC=pattern в командной строке, можно выполнить только некоторые тесты. Что крайне удобно, когда вы отлаживаете один тест и вам не нужен вывод на экран от остальных.
Пример:
use Test::Spec; describe "mycode" => sub { it "should add" => sub { is 1+1, 2; }; it "should substract" => sub { is 4-2, 2; }; }; runtests unless caller;
Если запустить его как SPEC=add perl test.t, то выполнится только тест «mycode should add».
Подробнее: https://metacpan.org/pod/Test::Spec#runtests-patterns.
Альтернатив не видно
Модули, позволяющие структурировано организовывать код теста, наподобие RSpec, конечно, существуют. А вот альтернатив, в плане работы с mock-объектами, не видно.
Создатель модуля Test::MockObject — Chromatic https://metacpan.org/author/CHROMATIC (автор книги Modern Perl, участвовал в разработке Perl 5, Perl 6 и многих популярных модулей на CPAN), не признаёт юнит-тестирование, в документации к модулю mock-объекты описываются как «Test::MockObject — Perl extension for emulating troublesome interfaces» (ключевое слово troublesome interfaces), о чём он даже написал пост: http://modernperlbooks.com/mt/2012/04/mock-objects-despoil-your-tests.htmlЕго подход явно не для нас.
Так же он отметил: «Note: See Test::MockModule for an alternate (and better) approach».
Test::MockModule крайне неудобен, не поддерживается (автора не видно с 2005 года) и сломан в perl 5.21 (https://rt.cpan.org/Ticket/Display.html?id=87004)
Особенности работы и грабли
Вывод имён теста в ok/is/прочие не работает
Точнее говоря, работает, но портит логику формирования имён тестов в Test::Spec.
describe "Our great code" => sub { it "should work" => sub { is 2+2, 4; }; };
выводит:
ok 1 - Our great code should work1..1а код:
describe "Our great code" => sub { it "should work" => sub { is 2+2, 4, "should add right"; }; };
выводит:
ok 1 - should add right1..1Как видим «Our great code» потерялось, что сводит на нет использование текста в describe/it.
Получается, сообщения в ok и is лучше не использовать.
Но что же делать, если мы хотим два теста в блоке it?
describe "Our great code" => sub { it "should work" => sub { is 2+2, 4; is 10-2, 8; }; };
выведет:
ok 1 - Our great code should workok 2 - Our great code should work1..2Как видим, индивидуальных сообщений на каждый тест нет. Если внимательно посмотреть примеры в документации Test::Spec, можно увидеть, что каждый отдельный тест должен быть в отдельном it:
describe "Our great code" => sub { it "should add right" => sub { is 2+2, 4; }; it "should substract right" => sub { is 10-2, 8; }; };
выведет:
ok 1 - Our great code should add rightok 2 - Our great code should substract right1..2Что, правда, не очень удобно и громоздко для некоторых случаев.
Наблюдаются проблемы с другими модулями, сделанными для Test::More, например, https://metacpan.org/pod/Test::Exception по дефолту ставит автоматически сгенерированное сообщение для ok, соответственно, вместо него нужно явно указать пустую строку.
Нельзя размещать тесты внутри before/after
Блок before вам придётся очень часто использовать, в нём будет происходить инициализация переменных перед тестами. Блок after в основном нужен чтобы отменять изменения, сделанные во внешнем мире, включая глобальные переменные и пр.
В них не нужно пытаться размещать сами тесты, которые должны быть в it. Например:
use Test::Spec; describe "mycode" => sub { my $s; before each => sub { $s = stub(mycode=>sub{42}); }; after each => sub { is $s->mycode, 42; }; it "should work" => sub { is $s->mycode, 42; }; }; runtests unless caller;
Выдаёт ошибку:
ok 1 - mycode should worknot ok 2 - mycode should work# Failed test 'mycode should work' by dying:# Can't locate object method "mycode" via package "Test::Spec::Mocks::MockObject"# at test.t line 9.1..2# Looks like you failed 1 test of 2.Как видим, в блоке after mock-объект, созданный в блоке before, уже не работает. А значит, если у вас много блоков it, и в конце каждого блока хочется проводить одни и те же тесты, то вынести их в блок after уже не получится. Можно вынести их в отдельную функцию, и вызывать её из каждого it, но это уже похоже на дублирование функционала.

Блоки before/after меняют структуру кода
В примере ниже нам нужно для каждого теста проинициализировать новый объект Counter (давайте представим, что это сложно и занимает много строчек кода, так что copy/paste — не вариант). Это будет выглядеть так:
use Test::Spec; use Counter; describe "counter" => sub { my $c; before each => sub { $c = Counter->new(); }; it "should calc average" => sub { $c->add(2); $c->add(4); is $c->avg, 3; }; it "should calc sum" => sub { $c->add(2); $c->add(4); is $c->avg, 3; }; }; runtests unless caller;
То есть — используется лексическая переменная $c, которая будет доступна в области видимости блоков «it». Перед каждым из них вызывается блок «before», и переменная заново инициализируется.
Если аналогичный тест написать без Test::Spec, то получится так:
use strict; use warnings; use Test::More; use Counter; sub test_case(&) { my ($callback) = @_; my $c = Counter->new(); $callback->($c); } test_case { my ($c) = @_; $c->add(2); $c->add(4); is $c->avg, 3, "should calc average"; }; test_case { my ($c) = @_; $c->add(2); $c->add(4); is $c->sum, 6, "should calc sum"; }; done_testing;
То есть в функцию test_case передаётся коллбэк, далее test_case создаёт объект Counter и вызывает коллбэк, передавая созданный объект как параметр.
В принципе, в Test::More можно организовать тест как душа пожелает, но пример выше — универсальное, масштабируемое решение.
Если попытаться сделать кальку с Test::Spec — лексическую переменную, которая инициализируется перед каждым тестом, получится нечто «не очень правильное»:
use strict; use warnings; use Test::More; use Counter; my $c; sub init { $c = Counter->new(); } init(); $c->add(2); $c->add(4); is $c->avg, 3, "should calc average"; init(); $c->add(2); $c->add(4); is $c->sum, 6, "should calc sum"; done_testing;
В этом коде функция модифицирует переменную, которая не передаётся ей как аргумент, что уже считается плохим стилем. Однако, технически — это то же самое, что в варианте с Test::Spec (там тоже код в блоке before модифицирует переменную, не переданную ему явно), но в нём это считается «нормальным».
Мы видим, что в Test::More и Test::Spec код организован по-разному. Применяются разные возможности языка для организации работы теста.
Оператор local больше не работает
Точнее говоря, работает, но не всегда.
Так не работает:
use Test::Spec; our $_somevar = 11; describe "foo" => sub { local $_somevar = 42; it "should work" => sub { is $_somevar, 42; }; }; runtests unless caller;
not ok 1 - foo should work# Failed test 'foo should work'# at test-local-doesnt-work.t line 8.# got: '11'# expected: '42'1..1# Looks like you failed 1 test of 1.Так — работает:
use Test::Spec; our $_somevar = 11; describe "foo" => sub { it "should work" => sub { local $_somevar = 42; is $_somevar, 42; }; }; runtests unless caller;
ok 1 - foo should work1..1Всё дело в том, что it не выполняет переданный ему callback (вернее, это уже можно считать замыканием), а запоминает ссылку на него. Выполняется же оно во время вызова runtests. А как мы знаем, local, в отличие от my, действует «во времени», а не «в пространстве».
Какие это может вызвать проблемы? local в тестах может быть нужен для двух вещей — подделать какую-либо функцию и подделать какую-либо переменную. Теперь это сделать не так-то просто.
В принципе, то, что подделать функцию с помощью local нельзя (а без него это не практично — придётся руками возвращать старую функцию назад), только на пользу. В Test::Spec есть свой механизм подделки функций (о нём было выше), и другой поддерживать не стоит.
А вот невозможность сброса переменной — это уже хуже.
Если вы сейчас не используете local в Perl, это не значит, что он не понадобится вам в тестах. В следующих трёх параграфах расскажу, зачем он может быть нужен.
DSL
Дело в том что DSL (http://www.slideshare.net/mayperl/dsl-perl) в Perl очень часто делаются с помощью local переменных.
Например, нам нужно в Web приложении, в контроллерах, получать данные из БД. При этом у нас настроена master/slave репликация. По умолчанию данные нужно получать со slave серверов, но в случае, если мы собираемся модифицировать полученные данные, и записывать их в БД, исходные данные перед модификацией нужно получать с master сервера.
Таким образом нам нужно во все наши функции для получения данных из БД, передавать информацию: со slave сервера брать данные или с master. Можно просто передавать им коннект к БД, но это слишком громоздко — таких функций может быть множество, они могут вызывать друг друга.
Допустим, код получения данных из БД выглядит следующим образом:
sub get_data { mydatabase->query("select * from ... "); }
Тогда мы можем сделать следующий API: mydatabase будет возвращать коннект к slave БД, mydatabase внутри блока with_mysql_master будет возвращать коннект к master БД.
Так выглядит чтение данных со slave:
$some_data = get_data(); $even_more_data = mydatabase->query("select * from anothertable … ");
Так выглядит чтение данных с master и запись в master:
with_mysql_master { $some_data = get_data(); mydatabase->query("insert into … ", $some_data); };
Функцию with_mysql_master проще всего реализовать с помощью local:
our $_current_db = get_slave_db_handle(); sub mydatabase { $_current_db } sub with_mysql_master(&) { my ($cb) = @_; local $_current_db = get_master_db_handle(); $cb->(); }
Таким образом, mydatabase внутри блока with_mysql_master будет возвращать соединение с master БД, так как находится в «зоне действия» local переопределения $_current_db, а вне этого блока — соединение со slave БД.
Так вот, в Test::Spec со всеми подобными конструкциями могут быть затруднения.
Test::Spec сделан по образу и подобию Ruby библиотек, там DSL организуется без local (а аналога local там вообще нет), так что этот нюанс не предусмотрели.
Глобальные кэши
Поищите в вашем коде «state». Любое его использование обычно можно классифицировать как глобальный кэш чего-либо. Когда говорят, что глобальные переменные — это плохо, это часто относится и к такому «не глобальному» state.
Проблема со state — что его вообще нельзя протестировать (см. http://perlmonks.org/?node_id=1072981). Нельзя из одного процесса много раз вызвать функцию, где что-то кэшируется с помощью state, и сбрасывать кэши. Придётся заменить state на старый добрый our. И как раз при тестировании сбрасывать его:
local %SomeModule::SomeData;
Если с Test::Spec понадобится потестировать такую функцию, и кэш будет мешать, можно заменить на две отдельные функции — первая возвращает данные без кэширования (скажем, get_data), вторая — занимается только кэшированием (cached_get_data). И тестировать только первую из них. Это будет юнит-тест (тестирует одну функцию отдельно). Вторую из этих функций вообще не протестировать, но это и не особо нужно: она простая — придётся верить что она работает.
Если у вас интеграционный тест, который тестирует целый стек вызовов, то придётся в нём подделать вызов cached_get_data и заменять его на get_data без кэширования.
Глобальные переменные
Какой-нибудь %SomeModule::CONFIG — вполне нормальный use-case для использования глобальных переменных. С помощью local удобно подменять конфиг перед вызовом функций.
Если с этим будут затруднения в Test::Spec, лучше сделать функцию, которая возвращает CONFIG, и подделывать её.
А как по-другому?
Надо заметить, что есть модули, в которых доступно такое же структурное описание тестов (даже с теми же «describe» и «it»), но без этой проблемы с local, например https://metacpan.org/pod/Test::Kantan. Однако этот модуль, кроме структурного описания тестов, никаких возможностей не предоставляет.
Ещё про it и local
В начале статьи мы выяснили, что в каждом «it» должен быть один тест. Так изначально задумывалось, и только так это нормально работает. Что же делать, если у нас целый цикл, где в каждой итерации по тесту?
Предполагается, что правильный способ это сделать такой:
use Test::Spec; describe "foo" => sub { for my $i (1..7) { my $n = $i + 10; it "should work" => sub { is $n, $i + 10; }; } }; runtests unless caller;
Но так как каждый «it» только запоминает замыкание, но не выполняет его сразу, в этом коде уже нельзя использовать local, такой тест полностью проваливается:
use Test::Spec; our $_somevar = 11; describe "foo" => sub { local $_somevar = 42; for my $i (1..7) { my $n = $i + $_somevar; it "should work" => sub { is $n, $i + $_somevar; }; } }; runtests unless caller;
not ok 1 - foo should work# Failed test 'foo should work'# at t6-local.pl line 9.# got: '43'# expected: '12'not ok 2 - foo should work# Failed test 'foo should work'# at t6-local.pl line 9.# got: '44'# expected: '13'not ok 3 - foo should work# Failed test 'foo should work'# at t6-local.pl line 9.# got: '45'# expected: '14'not ok 4 - foo should work# Failed test 'foo should work'# at t6-local.pl line 9.# got: '46'# expected: '15'not ok 5 - foo should work# Failed test 'foo should work'# at t6-local.pl line 9.# got: '47'# expected: '16'not ok 6 - foo should work# Failed test 'foo should work'# at t6-local.pl line 9.# got: '48'# expected: '17'not ok 7 - foo should work# Failed test 'foo should work'# at t6-local.pl line 9.# got: '49'# expected: '18'1..7# Looks like you failed 7 tests of 7.Да, и каждый «describe» тоже запоминает замыкание, а не выполняет его, так что к нему относится всё то же, что и к «it».
Общий код
Есть механизм для подключения общего кода, который можно выполнять в разных тестах. Вот документация: https://metacpan.org/pod/Test::Spec#spec_helper-FILESPEC, а вот реализация: https://metacpan.org/source/PHILIP/Test-Spec-0.47/lib/Test/Spec.pm#L354.
Как он работает?
- Ищет включаемый файл на диске с помощью File::Spec (в обход perl механизма @INC и механизма загрузки файлов require).
- Загружает файл в память, составляет строку с perl кодом, где сначала меняется package, потом просто включено содержимое прочитанного файла «как есть».
- Выполняет эту строку как eval EXPR.
- Сам загружаемый файл имеет расширение .pl, всё работает, но при этом это может быть не валидный perl файл, в нём может не хватать use, могут быть указаны не те пути и так далее, соответственно, с точки зрения perl в нём синтаксические ошибки. В общем случае это неработающий кусок кода, который нужно хранить в отдельном файле.
То есть — абсолютный хак.
Впрочем, вполне возможно написать общий код обычным способом — оформить в виде функции, вынести его в модули:
Тестируемый код:
package User; use strict; use warnings; sub home_page { my ($self) = @_; "http://www.example.com/".$self->login; } sub id { my ($self) = @_; my $id = $self->login; $id =~ s/\-/_/g; $id; } 1;
Наш модуль с общим кодом для тестов:
package MyTestHelper; use strict; use warnings; use Test::Spec; sub fake_user { my ($login) = @_; my $user = bless {}, 'User'; $user->expects("login")->returns($login); $user; } 1;
Сам тест:
use Test::Spec; use User; use MyTestHelper; describe user => sub { it "login should work" => sub { my $user = MyTestHelper::fake_user('abc'); is $user->home_page, 'http://www.example.com/abc'; }; it "should work" => sub { my $user = MyTestHelper::fake_user('hello-world'); is $user->id, 'hello_world'; }; }; runtests unless caller;
Функция fake_user создаёт объект User, одновременно подделывая метод login этого объекта, чтобы он возвращал тот логин, что мы сейчас хотим (тоже передаётся в fake_user). В тестах мы проверяем логику работы методов User::home_page и User::id (зная логин, мы знаем, что должны возвращать эти методы).Таки образом, функция fake_user представляет собой пример повторного использования кода по созданию объекта User и настройки поддельных методов.
Сложно написать хелперы, работающие одновременно и с Test::Spec, и с Test::More
Как видим, порядок построения тестов у Test::Spec и Test::More сильно различается. Обычно у нас не получается написать библиотеку, работающую в обоих тестовых окружениях (всякие трюки не берём в расчёт).
Например, у нас есть хелпер для Test::More, помогающий в тесте обращаться к Redis. Это нужно для интеграционного тестирования кода, который с этим Redis работает, а так же удобно для некоторых других тестов (например, тесты с fork, где Redis используется для обмена тестовыми данными между разными процессами).
Этот хелпер даёт следующий DSL:
redis_next_test $redis_connection => sub { ... }
Эта функция выполняет код, переданный как последний аргумент. Внутри кода доступна функция namespace. Внутри каждого блока redis_next_test, namespace уникален. Его можно и нужно использовать для именования ключей Redis. В конце блока все ключи с таким префиксом удаляются. Всё это нужно, чтобы тесты могли исполняться параллельно сами с собой на CI сервере, и при этом не портили ключи друг друга, а так же чтобы не захламлять машины девелоперов ненужными ключами.
Упрощённый вариант этого хелпера:
package RedisUniqueKeysForTestMore; use strict; use warnings; use Exporter 'import'; our @EXPORT = qw/ namespace redis_next_test /; our $_namespace; sub namespace() { $_namespace }; sub redis_next_test { my ($conn, $cb) = @_; local $_namespace = $$.rand(); $cb->(); my @all_keys = $conn->keys($_namespace."*"); $conn->del(@all_keys) if @all_keys; } 1;
Пример теста с ним:
use strict; use warnings; use Test::More; use RedisUniqueKeysForTestMore; my $conn = connect_to_redis(); # external sub redis_next_test $conn => sub { my $key = namespace(); $conn->set($key, 42); is $conn->get($key), 42; }; done_testing;
Для Test::Spec это уже не подойдёт, так как:
- Понятие «внутри redis_next_test» совершенно естественно реализуется с помощью local, а с local в Test::Spec проблемы, как мы видели выше.
- Даже если бы в redis_next_test отсутствовал бы local, а вместо local $_namespace = $$.rand() было бы просто $_namespace = $$.rand() (что сделало бы невозможным вложенные вызовы redis_next_test), это всё равно бы не работало, так как $conn->del( @all_keys) if @all_keys; выполнялся бы не после теста, а после того, как коллбэк теста добавится во внутренние структуры Test::Spec (фактически, та же история, что и с local).
Подойдёт функция, принимающая коллбэк и выполняющая его внутри describe блока, с блоками before (генерирует namespace) и after (удаляет за собой ключи). Вот она:
package RedisUniqueKeysForTestSpec; use strict; use warnings; use Test::Spec; use Exporter 'import'; our @EXPORT = qw/ describe_redis namespace /; my $_namespace; sub namespace() { $_namespace }; sub describe_redis { my ($conn, $example_group) = @_; describe "in unique namespace" => sub { before each => sub { $_namespace = $$.rand(); }; after each => sub { my @all_keys = $conn->keys($_namespace."*"); $conn->del(@all_keys) if @all_keys; }; $example_group->(); }; }
А так выглядит тест с ней:
use Test::Spec; use RedisUniqueKeysForTestSpec; my $conn = connect_to_redis(); describe "Redis" => sub { describe_redis $conn => sub { it "should work" => sub { my $key = namespace(); $conn->set($key, 42); is $conn->get($key), 42; }; }; }; runtests unless caller;
Функция with работает только для классов
MyModule.pm
package MyModule; use strict; use warnings; sub f2 { 1 }; sub f1 { f2(42); }; 1;
Тест:
use Test::Spec; use MyModule; describe "foo" => sub { it "should work with returns" => sub { MyModule->expects("f2")->returns(sub { is shift, 42}); MyModule::f1(); }; it "should work with with" => sub { MyModule->expects("f2")->with(42); MyModule::f1(); }; }; runtests unless caller;
Результат:
ok 1 - foo should work with returnsnot ok 2 - foo should work with with# Failed test 'foo should work with with' by dying:# Number of arguments don't match expectation# at /usr/local/share/perl/5.14.2/Test/Spec/Mocks.pm line 434.1..2# Looks like you failed 1 test of 2.Таким образом её можно применять только для работы с методами классов, если же perl package используется не как класс, а как модуль (процедурное программирование), это не работает. Test::Spec попросту ждёт первым аргументом $self, всегда.
Функция with не видит разницы между хэшем и массивом
MyClass.pm:
package MyClass; use strict; use warnings; sub anotherfunc { 1; } sub myfunc { my ($self, %h) = @_; $self->anotherfunc(%h); } 1;
Тест:
use Test::Spec; use MyClass; describe "foo" => sub { my $o = bless {}, 'MyClass'; it "should work with with" => sub { MyClass->expects("anotherfunc")->with(a => 1, b => 2, c => 3); $o->myfunc(a => 1, b => 2, c => 3); }; }; runtests unless caller;
Результат:
not ok 1 - foo should work with with# Failed test 'foo should work with with' by dying:# Expected argument in position 0 to be 'a', but it was 'c'Expected argument in position 1 to be '1', but it was '3'Expected argument in position 2 to be 'b', but it was 'a'Expected argument in position 3 to be '2', but it was '1'Expected argument in position 4 to be 'c', but it was 'b'Expected argument in position 5 to be '3', but it was '2'# at /usr/local/share/perl/5.14.2/Test/Spec/Mocks.pm line 434.1..1# Looks like you failed 1 test of 1.Собственно, Perl тоже не видит разницы. И порядок элементов в хэше не определён. Это можно было бы учесть при разработке API функции with и сделать способ, облегчающий проверку хэшей.
Обойти эту недоработку можно с использованием returns и проверкой данных в его коллбэке. Для примера выше это будет:
MyClass->expects("anotherfunc")->returns(sub { shift; cmp_deeply +{@_}, +{a => 1, b => 2, c => 3} });
Проблемы с тестированием вещей типа утечек памяти
Например, функция stub() сама по себе является утечкой (видимо, стабы где-то хранятся). Так что вот такой тест не работает:MyModule.pm:
package MyModule; sub myfunc { my ($data) = @_; ### Memory leak BUG #$data->{x} = $data; ### /Memory leak BUG $data; } 1;
Тест:
use Test::Spec; use Scalar::Util qw( weaken ); use MyModule; describe "foo" => sub { it "should not leak memory" => sub { my $leakdetector; { my $r = stub( service_id => 1 ); MyModule::myfunc($r); $leakdetector = $r; weaken($leakdetector); } ok ! defined $leakdetector; } }; runtests unless caller;
Этот тест показывает утечку памяти, даже когда её нет.
Тест, написанный без stub, работает нормально (фейлится, только если строчку с багом в MyModule.pm раскомментировать):
use Test::Spec; use Scalar::Util qw( weaken ); use MyModule; describe "foo" => sub { it "should not leak memory" => sub { my $leakdetector; { my $r = bless { service_id => 1 }, "SomeClass"; MyModule::myfunc($r); $leakdetector = $r; weaken($leakdetector); } ok ! defined $leakdetector; } }; runtests unless caller;
В любом случае, раз «describe» и «it» запоминают замыкания, это уже само по себе может мешать поиску утечек, так как замыкание может содержать ссылки на все переменные, что в нём используются.
Функция use_ok уже не к месту
Если вы ранее и использовали use_ok в тестах, то теперь с ней можете попрощаться. Судя по документации её можно использовать только в BEGIN блоке (см. https://metacpan.org/pod/Test::More#use_ok), и это правильно, так как вне BEGIN она может сработать не совсем так, как в реальности (например, не импортировать прототипы функций), и смысла использовать такую «правильную» конструкцию для тестирования импорта из модулей, нарушая этот самый импорт, нет.
Так вот, в Test::Spec не принято писать тесты вне «it», а внутри «it» BEGIN блок выполнится… как если бы он был вне «it».
Так что сделать всё «красиво и правильно» не получится, если же «красиво и правильно» не интересует, то подойдёт обычный use.
Интересное
О том, как технически подделываются объекты методом expects

Отдельно стоит отметить, как технически удаётся добиться перекрытия метода expects у любого объекта или класса.
Делается это с помощью создания метода (сюрприз!) expects в коде пакета UNIVERSAL.
Попробуем проделать такой же трюк:
package User; use strict; use warnings; sub somecode {} package main; use strict; use warnings; { no warnings 'once'; *UNIVERSAL::expects = sub { print "Hello there [".join(',', @_)."]\n"; }; } User->expects(42); my $u =bless { x => 123}, 'User'; $u->expects(11);
выведет:
Hello there [User,42]Hello there [User=HASH(0x8a6688),11]то есть, всё работает — перекрыть метод удалось.
Выводы
Test::Spec хорош для юнит-тестирования высокоуровневого кода
Test::Spec хорош для юнит-тестов, то есть когда тестируется только один «слой», а весь остальной стек функций подделывается.
Для интеграционных тестов, когда нас больше интересует не быстрое, удобное и правильное тестирование единицы кода и всех пограничных случаев в нём, а работает ли всё, и всё ли правильно «соединено» — тогда больше подходят Test::More и аналоги.
Другой критерий — высокоуровневый vs низкоуровневый код. В высокоуровневом коде часто приходится тестировать бизнес-логику, для этого идеально подходят mock-объекты. Всё, кроме самой логики, подделывается, тест становится простым и понятным.
Для низкоуровневого кода иногда нет смысла писать отдельно «настоящий» юнит-тест, отдельно «интеграционный», так как в низкоуровневом коде обычно один «слой» и подделывать нечего. Юнит-тест будет являться и интеграционным. Test::More в этих случаях предпочтительнее потому, что в Test::Spec есть вещи, не очень удачно перенесённые из мира Ruby, без учёта реалий Perl, и методы построения кода меняются без весомых причин.
Юнит-тесты высокоуровневого кода довольно однотипны, так что для них ограничения и перечисленные недостатки Test::Spec не очень большая проблема, а для низкоуровневого кода и интеграционных тестов лучше оставить пространство для манёвра и использовать Test::More.
Статья подготовлена при активном участии отдела разработки REG.RU. Особая благодарность S-F-X за многочисленные дополнения, akzhan за экспертизу и информацию из мира Ruby и dmvaskin за найденные три бага в модуле Test::Spec, а также imagostorm, Chips, evostrov, TimurN, nugged, vadiml.