Как написать хорошие модульные тесты в функциональном программировании - PullRequest
14 голосов
/ 06 августа 2011

Я использую функции вместо классов, и я обнаружил, что не могу сказать, когда другая функция, на которую она опирается, является зависимостью, которая должна быть индивидуально проверена модулем, или внутренней деталью реализации, которая не должна. Как вы можете сказать, какой это?

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

Я не могу сказать, должны ли мои функции eval_symbol() и eval_list() считаться внутренними деталями реализации eval(), которые должны быть проверены с помощью собственных модульных тестов eval(), или подлинными зависимостями, которые сами по себе должен быть проверен юнитом независимо от юнит-тестов eval().

Ответы [ 5 ]

17 голосов
/ 12 августа 2011

Существенной мотивацией для концепции «модульного теста» является контроль комбинаторного взрыва требуемых тестовых случаев. Давайте посмотрим на примеры eval, eval_symbol и eval_list.

В случае eval_symbol мы захотим проверить непредвиденные обстоятельства, где привязка символа:

  • отсутствует (т. Е. Символ не связан)

  • в глобальной среде

  • непосредственно в текущей среде

  • унаследовано от окружающей среды

  • отслеживание другой привязки

  • ... и т. Д.

В случае eval_list мы захотим проверить (среди прочего), что происходит, когда позиция функции списка содержит символ с:

  • без функции или макроса привязки

  • функция привязки

  • макрос привязки

eval_list будет вызывать eval_symbol всякий раз, когда ему понадобится привязка символа (то есть, если принять LISP-1). Скажем, есть S тестовых случаев для eval_symbol и L связанных с символами тестовых случаев для eval_list. Если мы протестируем каждую из этих функций в отдельности, мы сможем избежать примерно 1060 * S + L тестовых случаев, связанных с символами. Однако, если мы хотим трактовать eval_list как черный ящик и исчерпывающе протестировать его, не зная, что он использует eval_symbol внутри, то мы столкнулись с S x L тестовыми примерами, связанными с символами ( например, глобальная привязка функции, глобальная привязка макроса, локальная привязка функции, локальная привязка макроса, привязка наследуемой функции, привязка наследуемого макроса и т. д.). Это намного больше случаев. eval еще хуже: как черный ящик количество комбинаций может стать невероятно большим - отсюда и термин комбинаторный взрыв .

Итак, перед нами стоит выбор теоретической чистоты в сравнении с реальной практичностью. Нет никаких сомнений в том, что полный набор тестовых примеров, который использует только «открытый API» (в данном случае eval), дает наибольшую уверенность в том, что ошибок нет. В конце концов, используя каждую возможную комбинацию, мы можем выявить тонкие ошибки интеграции. Однако количество таких комбинаций может быть настолько непомерно большим, что это исключает возможность такого тестирования. Не говоря уже о том, что программист, вероятно, допустит ошибки (или сойдет с ума), просматривая огромное количество тестовых случаев, которые отличаются лишь тонкими способами. Проведя модульное тестирование небольших внутренних компонентов, можно значительно сократить количество необходимых тестовых случаев, сохраняя при этом высокий уровень достоверности результатов - практическое решение.

Итак, я думаю, что руководство по определению гранулярности модульного тестирования таково: если количество тестовых случаев слишком велико, начните искать меньшие блоки для тестирования.

В данном случае я бы настоятельно рекомендовал проверить eval, eval-list и eval-symbol как отдельные единицы именно из-за комбинаторного взрыва. При написании тестов для eval-list вы можете рассчитывать на то, что eval-symbol будет надежным и ограничит ваше внимание функциональностью, которую eval-list добавляет самостоятельно. В eval-list также могут быть другие тестируемые модули, такие как eval-function, eval-macro, eval-lambda, eval-arglist и т. Д.

6 голосов
/ 07 августа 2011

Мой совет довольно прост: «Начни с чего-нибудь!»

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

Позже вы можете увеличить покрытие до 100%.Но вы обнаружите, что вы, вероятно, получите 80% ваших реальных результатов от первых 20% кода вашего модульного теста (перевернутый «Закон критического мало»).

Итак, чтобы рассмотреть основную мысльмоего скромного подхода «Начни с чего-нибудь!»

Что касается последней части вашего вопроса, я бы порекомендовал вам подумать о любой возможной рекурсии или любом дополнительном возможном повторном использовании «клиентскими» функциями, которые вы или последующие разработчики могли бы создатьв будущем это также вызовет eval_symbol () или eval_list ().

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

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

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

3 голосов
/ 12 августа 2011

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

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

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

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

test = 
  let env = extendEnv "x" 5 (extendEnv "x" 6 emptyEnv)
  in lookupEnv env "x" == Just 5

Этот тест дает нам некоторую уверенность и не требует каких-либо настроек или демонтажа, кроме создания значения env, которое мы заинтересованы в тестировании.Однако тестируемые значения очень специфичны.Это тестирует только одну конкретную среду, поэтому небольшая ошибка может легко ускользнуть.Мы бы предпочли сделать более общее утверждение: для всех переменных x и значений v и w среда env расширяется дважды с x, связанным с v после x, связанным с w, lookupEnv env x == Just w.

В общем, нам нужно формальное доказательство (возможно, механизированное с помощью помощника по доказательству, такого как Кок, Агда или Изабель), чтобы показать, что свойство, подобное этому, имеет место.Однако мы можем стать намного ближе, чем указывать значения тестов, используя QuickCheck , библиотеку, доступную для большинства функциональных языков, которая генерирует большое количество произвольных входных данных теста для свойств, которые мы определяем как булевы функции:

prop_test x v w env' =
  let env = extendEnv x v (extendEnv x w env')
  in lookupEnv env x == Just w

По приглашению QuickCheck может сгенерировать произвольные входные данные для этой функции и посмотреть, остается ли она верной для всех из них:

*Main> quickCheck prop_test
+++ OK, passed 100 tests.
*Main> quickCheckWith (stdArgs { maxSuccess = 1000 }) prop_test
+++ OK, passed 1000 tests.

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

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

2 голосов
/ 06 августа 2011

Я на самом деле не знаю ни одного конкретного практического правила для этого. Но, похоже, вам следует задать себе два вопроса:

  1. Можете ли вы определить назначение eval_symbol и eval_list без необходимости говорить "часть реализации eval?
  2. Если вы видите сбой теста для eval, было бы полезно выяснить, не проходят ли какие-либо тесты для eval_symbol и eval_list также?

Если ответ на любой из этих вопросов - да, я бы протестировал их отдельно.

0 голосов
/ 07 августа 2011

Несколько месяцев назад я написал для Python простой «почти Lisp» интерпретатор для назначения.Я разработал его, используя шаблон проектирования Interpreter, модуль тестировал код оценки.Затем я добавил код печати и анализа и преобразовал тестовые данные из абстрактного синтаксического представления (объектов) в конкретные синтаксические строки.Часть задания заключалась в программировании простых функций обработки рекурсивных списков, поэтому я добавил их в качестве функциональных тестов.

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

Удачи!

...