Подтвердить что ты не робот

Как написать хорошие модульные тесты в функциональном программировании

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

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

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

4b9b3361

Ответ 1

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

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

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

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

  • находится непосредственно в текущей среде

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

  • затенение другого связывания

  • ... и т.д.

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

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

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

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

eval_list будет ссылаться на eval_symbol всякий раз, когда требуется привязка символа (предполагается, что это LISP -1). Скажем, что для eval_symbol и L связанных с символами тестовых случаев для eval_list есть S тестовых примеров. Если мы проверим каждую из этих функций по отдельности, мы можем уйти с примерными примерами, связанными с символами 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 и т.д.

Ответ 2

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

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

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

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

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

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

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

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

Ответ 3

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

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

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

В Haskell тест для этого свойства может выглядеть так:

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.

В общем, нам нужно формальное доказательство (возможно, механизированное с помощью помощника по доказательству, такого как Coq, Agda или Isabelle), чтобы показать, что такое свойство имеет место. Тем не менее, мы можем приблизиться к определению тестовых значений, используя 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 использует некоторую очень приятную (и расширяемую) магию для создания этих произвольных значений, но это функциональное программирование, которое делает использование этих значений полезным. Исходя из исключений (извините), а не правила, побочные эффекты становятся менее сложными вручную задавать тестовые примеры и, более того, утверждают обобщенные свойства поведения ваших функций.

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

Ответ 4

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

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

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

Ответ 5

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

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

Удачи!