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 work
ok 2 - mid should round the way we want
1..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 work
ok 2 - MyModule mid should round the way we want
1..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 work
not 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 work
1..1
а код:
describe "Our great code" => sub {
it "should work" => sub {
is 2+2, 4, "should add right";
};
};
выводит:
ok 1 - should add right
1..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 work
ok 2 - Our great code should work
1..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 right
ok 2 - Our great code should substract right
1..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 work
not 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 work
1..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 returns
not 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.