REG.RU
Компания
60,79
рейтинг
7 октября 2014 в 13:40

Разработка → Test::Spec: плюсы, минусы и особенности tutorial

image

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).Это основное его преимущество перед остальными библиотеками для тестов.
image
Чтобы реализовать юнит-тестирование по принципу «тестируется только один модуль/функция в одно время», практически необходимо активно использовать 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

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

Такой подход нам даёт следующие преимущества:

  1. Тест написать проще.
  2. Он менее хрупкий: если мы полностью пытались бы воссоздать объект User в тесте, пришлось бы бороться с поломками, связанными с тем, что изменились детали реализации чего-либо, вообще не используемого в тестируемой функции.
  3. Тест быстрее работает.
  4. Бизнес-логика более понятно изложена (документирована) в тесте.
  5. Если баг в коде, то падают не 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-объектами, не видно.

imageСоздатель модуля 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, но это уже похоже на дублирование функционала.

image

Блоки 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.

Как он работает?

  1. Ищет включаемый файл на диске с помощью File::Spec (в обход perl механизма @INC и механизма загрузки файлов require).
  2. Загружает файл в память, составляет строку с perl кодом, где сначала меняется package, потом просто включено содержимое прочитанного файла «как есть».
  3. Выполняет эту строку как eval EXPR.
  4. Сам загружаемый файл имеет расширение .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 это уже не подойдёт, так как:

  1. Понятие «внутри redis_next_test» совершенно естественно реализуется с помощью local, а с local в Test::Spec проблемы, как мы видели выше.
  2. Даже если бы в 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}  });


Проблемы с тестированием вещей типа утечек памяти


imageНапример, функция 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

image
Отдельно стоит отметить, как технически удаётся добиться перекрытия метода 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.
Автор: @vsespb
REG.RU
рейтинг 60,79
Компания прекратила активность на сайте

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

  • 0
    Расскажите лучше, как вы тестируете скорость модулей, когда она напрямую зависит от скорости работы других модулей.
    Например, модуль A 15 раз вызывает модуль B, который делает запросы в БД, которые могут тормозить. Получается ведь, чтобы протестировать скорость работы модуля A, нужно не только не подделывать модуль B и БД, но и иметь в тестовой БД что-то отдалённо напоминающее реальную базу.

    Я в своём фреймворке запилил декларирование таблиц для каждого теста и воссоздание их структуры в тестовой БД. Но это, во-первых, не быстро, во-вторых, декларировать не особо удобно, в-третьих, хотелось бы ещё как-то хитро переносить не только структуру, но и часть данных, чтобы не писать каждый раз генератор тестовых данных.

    Или может я не того хочу?
    • +1
      У нас тесты отдельно, бенчмарки отдельно. В статье речь только про тесты. В бенчмарках же мы используем Devel::NYTProf, он может показывать inclusive time/exclusive time для
      выполняемого кода. В общем, ничего подделывать никому не понадобилось пока, мы, наверное, не настолько сильно увлечены оптимизацией.
    • +1
      У нас скорость работы основных узлов сайта с точки зрения пользователя меряется функциональными тестами. А целью покрыть всю систему «юнит-бенчмарками» мы никогда не задавались. Это вряд ли реально, да и непонятно, зачем нужно. Самые тормозные места у нас в 99,9% случаев — запросы к БД либо сетевые запросы к внешним сервисам. Первое — проверяется в момент разработки. Со вторым, во-первых, всё равно мало что можно сделать, во-вторых, по большей части оно вынесено во всякие асинхронные очереди и т. д., чтобы минимально напрягать пользователя.
      • 0
        Я не про юнит-тестирование сейчас. Понятно, что бенчмарки тестируют систему в целом. Я про инфраструктуру таких бенчмарков, а точнее про тестовые данные к ним.
        Как фиксятся тормозные места понятно, речь о том, как они автоматизированно проверяются.
        • +1
          К сожалению, как показывает практика, автоматический инструмент для поиска тормозных мест способен, обычно, только указать направление, в котором стоит копать далее. В любом случае, выхлоп такого инструмента придется анализировать руками и проверять, а есть ли проблема. Да и не все тормозные места можно вообще бенчмарками обнаружить. Точнее не так. Обнаружить факт наличия тормозов можно, а вот их местоположение – далеко не факт.
        • +2
          Ну вот у нас автоматизированно проверяется производительность только функциональными тестами. Тесты эти работают на сервере с копией боевой БД (а некоторые даже на продакшне прогоняются). Так что специально данные для них мы не готовим, а работаем на реальных (или их копии).

          Да и как ещё можно тестировать производительность работы с БД, если не на реальных данных? На частичной копии вы ведь производительность всё равно не проверите, потому что она напрямую зависит от количества данных. Попытки автогенерации данных, соответствующих объёму продакшн-базы, тоже вряд ли будут удачными — очень сложно будет добиться таких же характеристик данных, такого же распределения и т. п. А если не добиться — то результаты бенчмарка опять же будут иметь мало общего с реальностью.

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

            А если функционал активно меняет данные? После каждого теста восстанавливать всю базу в исходное состояние? Это явно долго.
            • +1
              Серебрянной пули тут не будет. Можно, например, тесты внутри транзакции прогонять и откатывать её после завершения. Можно вручную чистить/восстанавливать именно конкретные изменённые данные. Можно полностью восстанавливать БД. Всё зависит от объёма данных, от частоты прогона тестов, от того, что они делают, и т. п.

              А самый главный вопрос — как результат выполнения теста влияет на последующие тесты (и влияет ли вообще как-то)? Функциональные тесты в большинстве случаев не делают ничего такого, чтобы друг другу помешать. Консистентность базы они не ломают, количество данных не меняют заметно. Так что восстанавливать если и есть необходимость, то только после прогона всей порции тестов.

              И, естественно, на искуственном наборе данных у вас будут ровно такие же сложности.

              Впрочем, это уже с тестированием производительности мало общего имеет, это общая специфика любого тестирования, завязанного на БД.
              • 0
                Можно, например, тесты внутри транзакции прогонять и откатывать её после завершения.

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

                А самый главный вопрос — как результат выполнения теста влияет на последующие тесты (и влияет ли вообще как-то)?

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

                В целом я вас услышал, спасибо.
  • 0
    Подскажите, а как вы организуете запуск и хранение тестов на диске? Набор .t-файлов? Test::Class? Что-то еще?
    • +1
      Такие же соглашения как у CPAN модулей — тесты это .t файлы находящиеся в t/ директории.
      При этом для модуля lib/AAA/BBB/CCC.pm тест находится в t/AAA/BBB/CCC.t или есть несколько тестов t/AAA/BBB/CCC_xxx.t
      При этом в одной из директорий в lib/ (вне t/) находится общий код для тестов (библиотеки для тестирования)

      Механизмом Test::Spec для общего кода habrahabr.ru/company/regru/blog/239417/#shared_code сейчас стараемся не пользоваться.
      • 0
        Спасибо. Если можно, еще уточню: а тесты — это классы? Приходилось делать абстрактные базовые классы тестов? Если да, то как?
        • +1
          Нет, не классы. Тесты либо на базе Test::More либо на базе Test::Spec, ни там ни там не нужно базовых классов и их не делаем.
  • +1
    Запуск через prove --jobs X

    на самом деле структура более сложная — тесты в Jenkins прогоняются скриптом, который отбирает стабильные тесты и парсит TAP-выхлоп удобным образом.
  • +1
    Статья просто супер, побольше бы таких!

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

Самое читаемое Разработка