Пример использования Common Test, EUnit и Meck

15 мая 2013

В этой заметке рассматривается написание автоматических тестов на 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 и тп, потому что они все равно не используются. Есть идеи, какие еще тесты тут можно придумать?

Метки: , , , .


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