Unit тестирование в Erlang на примере

Продолжаем изучение языка программирования Erlang. На этот раз будет рассмотрена задача о разборе и выполнении арифметических выражений из книги Чезарини (в книге задача была сложнее и с множеством дополнений типа “упрощение выражений”, “дополнение выражений оператором ветвления” и т.п.).

Помимо разбора выражений (строк) в статье описано Unit-тестирование последовательных программ без побочных эффектов с использованием стандартного модуля EUnit.

Сформулируем задачу:

Построить вычислитель выражений следующего вида:

<выражение> := <целое> | ~
<выражение> |
(<выражение> <оператор> <выражение>);
<целое> := <цифра> | <цифра> <целое>;
<цифра> := 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
<оператор> := + | - | *;

Например,(7+~((2+3)-4)), тильда обозначает унарный минус.

Для решения задачи удобно преобразовать строку-выражение в формат, более пригодный для вычисления. Более удобной структурой может являться например дерево разбора (синтаксическое дерево) или обратная польская запись (ПОЛИЗ) [1].

Чезарини предлагает преобразовать выражение в дерево разбора, в котором:

  • константы представлены кортежем { num, Value };
  • бинарные операторы – { тип оператора, аргумент 1, аргумент 2 };
  • унарные операторы – { тип оператора, аргумент };

Позже Чезарини пишет про стековую машину (а она подразумевает использование ПОЛИЗ), но мы упростим задачу и проведем вычисления прямо на дереве разбора.

Итак, у нас в программе будет 3 части:

  • преобразователь арифметического выражения, заданного строкой в синтаксическое дерево;
  • преобразователь синтаксического дерева в строку (удобно для проверки);
  • вычислитель выражений, заданных деревом.

Разбор и вычисление арифметических выражений

Для разбора нам потребуется несколько вспомогательных функций, самая большая из них – get_num (считывает целое число из начала строки). Если в начале строки нет цифры – функция завершится неудачей.

% ----------------------------------
% get_num считывает целое беззнаковое число
% использует get_strNum
% ret: {строка-результат, остаток строки}
get_num([H|T]) when H >= $0, H =< $9 ->
  { TN, TR } = get_strNum([H|T]), { list_to_integer(TN), TR }.

% ----------------------------------
% get_strNum вспомогательная функция get_num,
% считывает число в виде строки
get_strNum([H|T]) when H >= $0, H =< $9 ->
  { TN, TR } = get_strNum(T), { [H|TN], TR };
get_strNum(L) -> {[], L}.

Кроме того, нам потребуется функция, кодирующая операторы как показано ниже:

% ----------------------------------
% operator преобразует символьное представление оператора в константу
operator($-) -> minus;
operator($+) -> plus;
operator($*) -> mul.

Когда у нас появились такие функции, мы можем приступить непосредственно к разбору выражения:

% ----------------------------------
% exp_with_tail вспомогательная функция exp, возвращает конец строки в кортеже
% выражение -> целое | ~выражение | (выражение оператор выражение)
% ret: { результат, остаток строки }
exp_with_tail([H|T]) when H >= $0, H =< $9 ->
% целое
  { Num, TR } = get_num([H|T]),
  { {num, Num}, TR };
exp_with_tail([$~|T]) ->
% выражение с унарными минусом
  {Exp, TR} = exp_with_tail(T),
  { {un_min, Exp}, TR };
exp_with_tail([$(|T]) ->
% выражение в скобках (с оператором)
  {Op1, TR1} = exp_with_tail(T), % первый операнд
  [Op|TR2] = TR1, % оператор
  {Op2, TR3} = exp_with_tail(TR2), % второй операнд
  [$)|TR] = TR3,
  { {operator(Op), Op1, Op2}, TR }.

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

% ----------------------------------
% list_to_exp разбирает арифметические выражения вида:
% выражение -> целое | ~выражение | (выражение оператор выражение)
% использует вспомогательную функцию exp_with_tail
% результат - выражение для стек-машины в префиксной записи
% пример {minus, {plus, {num, 1}, {num, 2}}, {num, 3}} соответствует ((1+2)-3)
list_to_exp(S) -> {R, _} = exp_with_tail(S), R.

Теперь у нас есть функция, преобразующая строку с арифметическим выражением в дерево синтаксического разбора, с которым удобно работать. При помощи одного обхода можно преобразовать это дерево в ПОЛИЗ – эту часть я предлагаю решить читателям блога и прикрепить в комментариях к статье.

Дальше мы посмотрим как производится юнит-тестирование в Erlang, и при этом нам очень удобно будет использовать функцию, производящую действия, обратные функции list_to_exp. Функция exp_to_list преобразует дерево разбора в выражение-строку.

% ----------------------------------
% exp_to_list - преобразователь выражения в строку
exp_to_list({num, X}) -> integer_to_list(X);
exp_to_list({un_min, X}) -> [$~|exp_to_list(X)];
exp_to_list({plus, A, B}) -> [$(|exp_to_list(A)] ++ [$+|exp_to_list(B)] ++ [$)];
exp_to_list({minus, A, B}) -> [$(|exp_to_list(A)] ++ [$-|exp_to_list(B)] ++ [$)];
exp_to_list({mul, A, B}) -> [$(|exp_to_list(A)] ++ [$*|exp_to_list(B)] ++ [$)].

Как видим, функция очень проста – при помощи сопоставления с образцом определяется тип узла дерева, производится его обработка и рекурсивная обработка дочерних элементов.

Функция exp_calc вычисления такого выражения выглядит еще проще (и работает по тому же принципу что и exp_to_list).

% ----------------------------------
% exp_calc - вычислитель выражения
exp_calc({num, X}) -> X;
exp_calc({un_min, X}) -> -exp_calc(X);
exp_calc({plus, A, B}) -> exp_calc(A) + exp_calc(B);
exp_calc({minus, A, B}) -> exp_calc(A) - exp_calc(B);
exp_calc({mul, A, B}) -> exp_calc(A) * exp_calc(B).

Переходим к тестированию…

Unit-тестирование в Erlang

Когда мы пишем программы, мы постоянно запускаем только что написанные ее части чтобы проверить правильность работы. Например, я мог бы запускать на разных примерах exp_calc и сверять результаты с ожидаемыми.

Кроме того, в нормальную программу постоянно вносятся изменения и дополнения. Очень хорошо бы проверять правильность работы программы после каждого изменения, но не всегда это делают (ведь лень) – в конторе где я писал игрушки несколько раз в релизную ветку проекта коммитили непроверенный код, который даже не компилировался.

Юнит-тесты не решают проблему лени, но значительно упрощают тестирование. Например, нам потребовалось добавить в функцию exp_calc поддержку оператора деления, ответственный программист напишет код, запустит его пару раз и проверит работу новой фичи. Однако, изменяя уже написанный код, он мог нечаянно что-то сломать (например, иногда неправильно стало работать умножение).

Тестировать код руками после каждого, даже незначительного, изменения очень трудно, однако, этот процесс можно автоматизировать (при помощи юнит-тестов). При этом мы лишь один раз формируем наборы входных данных и эталонов результата, а затем запускаем тестирование после каждого внесенного изменения. Если что-то пойдет не так – мы узнаем номера тестов, которые не сработали.

Unit-тесты в Erlang помещаются внутрь функций, имена которых имеют постфикс “_test”. Например, exp_calc_test. Внутри таких функций записываются утверждения, которые должны быть верны, например мы можем записать:

list_to_exp_test() ->
  {num,1} =:= list_to_exp('1'),
  ?assertEqual({minus,{plus,{num,1},{num,2}},{num,3}}, list_to_exp('((1+2)-3)')).

Приведенный пример содержит 2 теста. В третьей строке показано использование макроса ?assertEqual, который рекомендуется использовать вместо оператора сравнения в тестах, т.к. он дает больше информации в случае ошибки. Кроме макроса ?assertEqual существуют и другие [2], но все они определяются через макрос ?assert (который тоже можно использовать).

Ниже приведены примеры юнит-тестов для функций, описанных в предыдущем разделе:

list_to_exp_test() ->
  ?assertEqual({num,1}, list_to_exp('1')),
  ?assertEqual({un_min, {num,1}}, list_to_exp('~1')),
  ?assertEqual({minus,{plus,{num,1},{num,2}},{num,3}}, list_to_exp('((1+2)-3)')),
  ?assertEqual({un_min,
    {plus,
      {mul, {num, 2}, {num, 3}},
      {mul, {num, 3}, {num, 4}}
    }
  }, list_to_exp('~((2*3)+(3*4))')).

calc_test() ->
  ?assertEqual(1, exp_calc(list_to_exp('1'))),
  ?assertEqual(-1, exp_calc(list_to_exp('~1'))),
  ?assertEqual(0, exp_calc(list_to_exp('((1+2)-3)'))),
  ?assertEqual(-18, exp_calc(list_to_exp('~((2*3)+(3*4))'))).

exp_to_list_test() ->
  ?assertEqual('1', exp_to_list(list_to_exp('1'))),
  ?assertEqual('~1', exp_to_list(list_to_exp('~1'))),
  ?assertEqual('((1+2)-3)', exp_to_list(list_to_exp('((1+2)-3)'))),
  ?assertEqual('~((2*3)+(3*4))', exp_to_list(list_to_exp('~((2*3)+(3*4))'))).

В модуль, содержащий описание тестов должен быть включен файл “eunit/include/eunit.hrl” при помощи директивы include_lib. Если тесты размещены в отдельном модуле – все тестируемые функции импортируются (а в тестируемом модуле, соответственно, экспортируются). Подробнее о директивах Erlang можно прочитать в документации.

Перед запуском тестов должны быть скомпилированы и тестируемый модуль, и модуль с тестами. Запуск тестов осуществляется вызовом функции eunit:test/1, примерно следующим образом:

c(task_3_8, [debug_info]).
c(task_3_8_tests, [debug_info]).
eunit:test(task_3_8).

Отмечу, что юнит-тесты обычно должны содержать описания “критических случаев”, т.е. такие примеры исходных данных, в которых наиболее ожидаемо падение программы. Такими примерами могут быть очень большие , или наоборот пустые, строки, списки. Для нашей задачи такие примером могло бы быть выражение, не содержащие ни одного символа или выражение, содержащее пробелы и лишние скобки.

У нас не описаны такие тесты, т.к. в задаче не оговорено как в этих случаях должна вести себя программа.

На рис. 1 показано, что написанная программа успешно проходит все приведенные тесты. На рис. 2 приведен пример сообщения об ошибке (мы намеренно внесли ошибку – поставили в exp_calc оператор умножения вместо оператора сложения). Видно, что приводится исчерпывающая информация об ошибке.

рис. 1 запуск unit-тестов Erlang (без ошибок)

рис. 1 запуск unit-тестов Erlang (без ошибок)

рис. 2 запуск unit-тестов Erlang (с ошибками)

рис. 2 запуск unit-тестов Erlang (с ошибками)

Как всегда, прикрепляю архив с исходным кодом: разбор выражений[Erlang].

Ссылки:

  1. http://habrahabr.ru/post/100869/
  2. http://www.erlang.org/doc/apps/eunit/chapter.html#EUnit_macros
  3. Ф. Чезарини, С. Томпсон, Программирование в Erlang

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Вы не робот? *