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
То есть фактически мы самым естественным образом проверяем каждое ветвление логики.
Такой подход нам даёт следующие преимущества:
- Тест написать проще.
- Он менее хрупкий: если мы полностью пытались бы воссоздать объект User в тесте, пришлось бы бороться с поломками, связанными с тем, что изменились детали реализации чего-либо, вообще не используемого в тестируемой функции.
- Тест быстрее работает.
- Бизнес-логика более понятно изложена (документирована) в тесте.
- Если баг в коде, то падают не 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».
Общий код
Как он работает?
- Ищет включаемый файл на диске с помощью File::Spec (в обход perl механизма @INC и механизма загрузки файлов require).
- Загружает файл в память, составляет строку с perl кодом, где сначала меняется package, потом просто включено содержимое прочитанного файла «как есть».
- Выполняет эту строку как eval EXPR.
- Сам загружаемый файл имеет расширение .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 это уже не подойдёт, так как:
- Понятие «внутри 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).
Функция 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.