В этой заметке рассматривается написание автоматических тестов на Erlang. Автотесты помогают находить не только мелкие ошибки, случайно допущенные при внесении изменений в коде, но и серьезные, сложные в обнаружении, ошибки, такие, как состояние гонки. Также тесты полезны по той причине, что чем больше ситуаций и способов использования модуля в них проверяется, тем более продуманные интерфейсы мы пишем.
Давайте покроем тестами библиотеку erlymemo . Попробуем придумать десяток тестов:
- Функция, вызываемая через erlymemo, должна возвращать свой результат;
- После второго вызова функции через erlymemo размер ETS не должен меняться;
- При двух последовательных вызовах функции через erlymemo должен произойти только один вызов, первый;
- При вызове большого количества разных функций с разными аргументами размер ETS не должен превышать предела, заданного в конфиге;
- Вызываем функцию A, делаем много вызовов B, снова вызываем A, в результате функция A должна вызваться дважды;
- После вызова
erlymemo: clean / 0
ETS должная стать пустой; - Запускаем 100 процессов, каждый из которых делает по несколько тысяч случайных вызовов одной из трех функций, в результате функции должны работать правильно, ничто не должно упасть;
- При вызове несуществующей функции бросается исключение;
- При вызове функции с неверными аргументами бросается исключение;
- Если приложение остановлено, при вызове функции, запросе текущего размера таблицы и тд бросается исключение;
Для написания автоматических тестов огромной популярностью в мире Erlang пользуется фреймворк Common Test. Описать все его возможности в рамках одного поста не представляется возможным, к тому же, я все равно их все не знаю. Мы рассмотрим лишь основы использования Common Test.
Тесты принято складывать в каталоге test исходного кода проекта. Тесты объединяются в наборы тестов, которые хранятся в файлах с именами имя_набора_SUITE.erl
. Типичная структура набора тестов выглядит так:
— compile ( export_all ) .
— include_lib ( «common_test/include/ct.hrl» ) .
— include_lib ( «eunit/include/eunit.hrl» ) .
all ( ) ->
[
% список тестов
] .
init_per_suite ( Config ) ->
% действия, выполняемые перед запуском набора тестов
Config .
init_per_testcase ( _ , Config ) ->
% действия, выполняемые перед запуском теста
Config .
end_per_testcase ( _ , Config ) ->
% действия, выполняемые после завершения теста
Config .
end_per_suite ( Config ) ->
% действия, выполняемые после завершения всего набора тестов
Config .
% код тестов
% обратите внимание, что ни одно из объявлений
% init_*/end_* функций не является обязательным
В нашем случае перед запуском и после выполнения набора тестов мы будем просто запускать и останавливать тестируемое приложение соответственно:
ok = application : start ( erlymemo ) ,
Config .
end_per_suite ( Config ) ->
ok = application : stop ( erlymemo ) ,
Config .
С действиями, выполняемыми перед и после каждого теста, чуточку интереснее:
ets : new ( ? MODULE , [ set , named_table , public ] ) ,
zero_call_counter ( ) ,
meck: new ( foo ) ,
meck: expect ( foo , bar , fun ( X , Y ) -> inc_call_counter ( ) , X + Y end ) ,
meck: expect ( foo , baz , fun ( X , Y ) -> X — Y end ) ,
meck: expect ( foo , qux , fun ( X , Y ) -> X * Y end ) ,
erlymemo: clean ( ) ,
Config .
end_per_testcase ( _ , Config ) ->
meck : unload ( foo ) ,
ets : delete ( ? MODULE ) ,
Config .
Библиотека meck предназначена для создания mock-модулей. В функции init_per_testcase / 2
мы создаем модуль foo с простыми функциями bar / 2
, baz / 2
и qux / 2
, а в функции end_per_testcase / 2
— удаляем его. Также создается/удаляется ETS-таблица, которую мы будем использовать для подсчета количества вызовов функции foo: bar / 2
. Наконец, перед запуском каждого теста мы удаляем все данные, сохраненные в erlymemo.
Вам, вероятно, интересно, почему нельзя создавать ETS’ку и модуль foo в init_per_suite / 1
. К сожалению, это не будет работать. ETS уничтожается после остановки создавшего его процесса (если только таблица не была передана другому процессу и не был указан процесс-наследник). Гарантируется, что init_per_testcase / 2
и end_per_testcase / 2
вызываются одним и тем же процессом. Для других функций таких гарантий нет.
Наконец, перейдем к нашему первому тесту. Функция, вызываемая через erlymemo, должна возвращать свой результат:
List = [ 1 , 2 , 3 ] ,
Result = erlymemo: call ( erlang , length , [ List ] ) ,
? assertEqual ( 3 , Result ) .
Здесь мы использовали макрос ?assertEqual
, объявленный в файле eunit.hrl. Как несложно догадаться, он проверяет равенство своих аргументов. Вообще-то говоря, EUnit представляет собой совершенно самостоятельный фреймворк для модульного тестирования . Однако я нахожу его менее удобным и мощным, чем Common Test. Поэтому в рамках данной заметки из EUnit нам понадобятся только макросы. Использование макросов EUnit в Common Test, если что, является совершенно обыденным делом в мире Erlang’а.
Следующие несколько тестов довольно однообразны. Давайте не будем на них смотреть, а лучше сразу перейдем к интересному тесту. Запускаем 100 процессов, каждый из которых делает по несколько тысяч случайных вызовов одной из трех функций, в результате функции должны работать правильно, ничто не должно упасть:
Parent = self ( ) ,
PidList = [ spawn_link (
fun ( ) ->
[ erlymemo: call ( foo , Fun , [ Alpha , Beta ] )
|| Fun <- [ bar , baz , qux ] ,
Alpha <- [ random : uniform ( 10 ) || _J <- lists : seq ( 1 , 50 ) ] ,
Beta <- [ random : uniform ( 10 ) || _K <- lists : seq ( 1 , 50 ) ]
] ,
Parent ! { ok , self ( ) }
end )
|| _I <- lists : seq ( 1 , 100 ) ] ,
lists : foreach (
fun ( Pid ) ->
receive
{ ok , Pid } -> ok
after
30000 -> throw ( ‘timeout’ )
end
end ,
PidList ) .
Этот тест довольно прост, однако благодаря ему мне удалось отловить состояние гонки и соответствующим образом исправить библиотеку.
Рассмотрим еще один, последний тест. При вызове функции с неверными аргументами должно бросаться исключение:
? assertError ( badarith , erlymemo: call ( lists , sum , [ [ 1 , 2 , 3 , bang ] ] ) ) .
Помимо макросов, проверяющих равенство и тому подобные вещи, в EUnit есть очень полезные макросы ?assertError
, ?assertThrow
и ?assertExit
. Примеры использования каждого из них вы найдете в коде тестов к erlymemo на GitHub .
Если вы никогда раньше не работали с Common Test, обязательно попробуйте скачать исходники erlymemo, собрать их и прогнать тесты. Все это делается простой командой make
, если, конечно, у вас уже установлены Erlang и Rebar . Обратите внимание на созданный с помощью Common Test красивый HTML-отчет (файл logs/index.html), а также на то, что в этом отчете вы можете оценить степень покрытия кода тестами. Ну и, если вдруг у вас не пройдут некоторые тесты, обязательно пошлите мне багрепорт !
Дополнительные материалы:
- Весьма объемная документация по Common Test ;
- Обсуждение проблемы использования ETS в CT на StackOverflow;
- Главы о Common Test и EUnit в «Learn You Some Erlang for Great Good!»;
- Meck на GitHub , не поленитесь заглянуть в исходники;
В сумме мною было написано тринадцать тестов к erlymemo, которые покрывают 91% кода. Не покрыты handle_call, handle_info и тп, потому что они все равно не используются. Есть идеи, какие еще тесты тут можно придумать?