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

Программирование Функциональное программирование Unit тестирование в Erlang на примере

В этой теме 0 ответов, 1 участник, последнее обновление  Васильев Владимир Сергеевич 1 месяц, 1 неделя назад.

  • Автор
    Сообщения
  • #5259
    @admin

    Продолжаем изучение языка программирования 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. https://habrahabr.ru/post/100869/
    2. http://erlang.org/doc/apps/eunit/chapter.html#EUnit_macros
    3. Ф. Чезарини, С. Томпсон, Программирование в Erlang

Для ответа в этой теме необходимо авторизоваться.