Test::Spec: плюсы, минусы и особенности

Test::Spec: плюсы, минусы и особенности

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). В статье же речь пойдёт о специфике и нюансах этого модуля.

Спецификации на тестируемый код

Возьмём простой тест на Test::More.

результат работы: ok 1 - mid should work ok 2 - mid should round the way we want 1..2

Эквивалентный тест на Test::Spec:

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

Сравните эти записи:

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 является реализацией бизнес-логики по предоставлению скидок при покупке: Один из вариантов его тестирования мог бы быть такой: создание объекта User ($self) со всеми зависимостями, создание корзины с нужным количеством товаров и с нужной суммой и тестированием результата.

В случае же юнит-теста, тестируется только этот участок кода, при этом создания User и Shopping cart удаётся избежать.

Тест (на одну ветку «if») выглядит примерно так: Здесь используются функции 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 разных тестов, а какой-то один, и по нему точно можно понять, что именно нарушено.

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

Ещё мелкие полезности

Понятный вывод исключений

выдаёт: 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 в командной строке, можно выполнить только некоторые тесты. Что крайне удобно, когда вы отлаживаете один тест и вам не нужен вывод на экран от остальных.

Пример: Если запустить его как SPEC=add perl test.t, то выполнится только тест «mycode should add».

Альтернатив не видно

Модули, позволяющие структурировано организовывать код теста, наподобие 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.

выводит: ok 1 - Our great code should work 1..1

выводит: ok 1 - should add right 1..1

Как видим «Our great code» потерялось, что сводит на нет использование текста в describe/it.

Получается, сообщения в ok и is лучше не использовать.

Но что же делать, если мы хотим два теста в блоке it?

выведет: ok 1 - Our great code should work ok 2 - Our great code should work 1..2

Как видим, индивидуальных сообщений на каждый тест нет. Если внимательно посмотреть примеры в документации Test::Spec, можно увидеть, что каждый отдельный тест должен быть в отдельном it: выведет: 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. Например:

Выдаёт ошибку: 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 — не вариант). Это будет выглядеть так: То есть — используется лексическая переменная $c, которая будет доступна в области видимости блоков «it». Перед каждым из них вызывается блок «before», и переменная заново инициализируется.

Если аналогичный тест написать без Test::Spec, то получится так: То есть в функцию test_case передаётся коллбэк, далее test_case создаёт объект Counter и вызывает коллбэк, передавая созданный объект как параметр.

В принципе, в Test::More можно организовать тест как душа пожелает, но пример выше — универсальное, масштабируемое решение.

Если попытаться сделать кальку с Test::Spec — лексическую переменную, которая инициализируется перед каждым тестом, получится нечто «не очень правильное»: В этом коде функция модифицирует переменную, которая не передаётся ей как аргумент, что уже считается плохим стилем. Однако, технически — это то же самое, что в варианте с Test::Spec (там тоже код в блоке before модифицирует переменную, не переданную ему явно), но в нём это считается «нормальным».

Мы видим, что в Test::More и Test::Spec код организован по-разному. Применяются разные возможности языка для организации работы теста.

Оператор local больше не работает

Точнее говоря, работает, но не всегда.

Так не работает: 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.

Так — работает: ok 1 - foo should work 1..1

Всё дело в том, что it не выполняет переданный ему callback (вернее, это уже можно считать замыканием), а запоминает ссылку на него. Выполняется же оно во время вызова runtests. А как мы знаем, local, в отличие от my, действует «во времени», а не «в пространстве».

Какие это может вызвать проблемы? local в тестах может быть нужен для двух вещей — подделать какую-либо функцию и подделать какую-либо переменную. Теперь это сделать не так-то просто.

В принципе, то, что подделать функцию с помощью local нельзя (а без него это не практично — придётся руками возвращать старую функцию назад), только на пользу. В Test::Spec есть свой механизм подделки функций (о нём было выше), и другой поддерживать не стоит.

А вот невозможность сброса переменной — это уже хуже.

Если вы сейчас не используете local в Perl, это не значит, что он не понадобится вам в тестах. В следующих трёх параграфах расскажу, зачем он может быть нужен.

Дело в том что DSL (http://www.slideshare.net/mayperl/dsl-perl) в Perl очень часто делаются с помощью local переменных.

Например, нам нужно в Web приложении, в контроллерах, получать данные из БД. При этом у нас настроена master/slave репликация. По умолчанию данные нужно получать со slave серверов, но в случае, если мы собираемся модифицировать полученные данные, и записывать их в БД, исходные данные перед модификацией нужно получать с master сервера.

Таким образом нам нужно во все наши функции для получения данных из БД, передавать информацию: со slave сервера брать данные или с master. Можно просто передавать им коннект к БД, но это слишком громоздко — таких функций может быть множество, они могут вызывать друг друга.

Допустим, код получения данных из БД выглядит следующим образом: Тогда мы можем сделать следующий API: mydatabase будет возвращать коннект к slave БД, mydatabase внутри блока with_mysql_master будет возвращать коннект к master БД.

Так выглядит чтение данных со slave: Так выглядит чтение данных с master и запись в master: Функцию with_mysql_master проще всего реализовать с помощью local: Таким образом, 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. И как раз при тестировании сбрасывать его: Если с 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» должен быть один тест. Так изначально задумывалось, и только так это нормально работает. Что же делать, если у нас целый цикл, где в каждой итерации по тесту?

Предполагается, что правильный способ это сделать такой: Но так как каждый «it» только запоминает замыкание, но не выполняет его сразу, в этом коде уже нельзя использовать local, такой тест полностью проваливается: 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».

Общий код

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

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

Впрочем, вполне возможно написать общий код обычным способом — оформить в виде функции, вынести его в модули:

Тестируемый код: Наш модуль с общим кодом для тестов: Сам тест: Функция 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: Эта функция выполняет код, переданный как последний аргумент. Внутри кода доступна функция namespace. Внутри каждого блока redis_next_test, namespace уникален. Его можно и нужно использовать для именования ключей Redis. В конце блока все ключи с таким префиксом удаляются. Всё это нужно, чтобы тесты могли исполняться параллельно сами с собой на CI сервере, и при этом не портили ключи друг друга, а так же чтобы не захламлять машины девелоперов ненужными ключами.

Упрощённый вариант этого хелпера: Пример теста с ним: Для 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).

Функция with работает только для классов

MyModule.pm Тест: Результат: 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: Тест: Результат: 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 и проверкой данных в его коллбэке. Для примера выше это будет:

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

Например, функция stub() сама по себе является утечкой (видимо, стабы где-то хранятся). Так что вот такой тест не работает:

MyModule.pm: Тест: Этот тест показывает утечку памяти, даже когда её нет.

Тест, написанный без stub, работает нормально (фейлится, только если строчку с багом в MyModule.pm раскомментировать): В любом случае, раз «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.

Попробуем проделать такой же трюк: выведет: 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.

📎📎📎📎📎📎📎📎📎📎