erlang-testing/

В этой заметке рассматривается написание автоматических тестов на Erlang. Автотесты помогают находить не только мелкие ошибки, случайно допущенные при внесении изменений в коде, но и серьезные, сложные в обнаружении, ошибки, такие, как состояние гонки. Также тесты полезны по той причине, что чем больше ситуаций и способов использования модуля в них проверяется, тем более продуманные интерфейсы мы пишем.

Давайте покроем тестами библиотеку erlymemo . Попробуем придумать десяток тестов:

  1. Функция, вызываемая через erlymemo, должна возвращать свой результат;
  2. После второго вызова функции через erlymemo размер ETS не должен меняться;
  3. При двух последовательных вызовах функции через erlymemo должен произойти только один вызов, первый;
  4. При вызове большого количества разных функций с разными аргументами размер ETS не должен превышать предела, заданного в конфиге;
  5. Вызываем функцию A, делаем много вызовов B, снова вызываем A, в результате функция A должна вызваться дважды;
  6. После вызова erlymemo: clean / 0 ETS должная стать пустой;
  7. Запускаем 100 процессов, каждый из которых делает по несколько тысяч случайных вызовов одной из трех функций, в результате функции должны работать правильно, ничто не должно упасть;
  8. При вызове несуществующей функции бросается исключение;
  9. При вызове функции с неверными аргументами бросается исключение;
  10. Если приложение остановлено, при вызове функции, запросе текущего размера таблицы и тд бросается исключение;

Для написания автоматических тестов огромной популярностью в мире Erlang пользуется фреймворк Common Test. Описать все его возможности в рамках одного поста не представляется возможным, к тому же, я все равно их все не знаю. Мы рассмотрим лишь основы использования Common Test.

Тесты принято складывать в каталоге test исходного кода проекта. Тесты объединяются в наборы тестов, которые хранятся в файлах с именами имя_набора_SUITE.erl . Типичная структура набора тестов выглядит так:

module ( basic_SUITE ) .

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_* функций не является обязательным

В нашем случае перед запуском и после выполнения набора тестов мы будем просто запускать и останавливать тестируемое приложение соответственно:

init_per_suite ( Config ) ->
ok = application : start ( erlymemo ) ,
Config .

end_per_suite ( Config ) ->
ok = application : stop ( erlymemo ) ,
Config .

С действиями, выполняемыми перед и после каждого теста, чуточку интереснее:

init_per_testcase ( _ , 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, должна возвращать свой результат:

call_test ( _Config ) ->
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 процессов, каждый из которых делает по несколько тысяч случайных вызовов одной из трех функций, в результате функции должны работать правильно, ничто не должно упасть:

parallel_test ( _Config ) ->
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 ) .

Этот тест довольно прост, однако благодаря ему мне удалось отловить состояние гонки и соответствующим образом исправить библиотеку.

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

invalid_arguments_test ( _Config ) ->
? 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), а также на то, что в этом отчете вы можете оценить степень покрытия кода тестами. Ну и, если вдруг у вас не пройдут некоторые тесты, обязательно пошлите мне багрепорт !

Дополнительные материалы:

В сумме мною было написано тринадцать тестов к erlymemo, которые покрывают 91% кода. Не покрыты handle_call, handle_info и тп, потому что они все равно не используются. Есть идеи, какие еще тесты тут можно придумать?

EnglishRussianUkrainian