如何在函数式编程中编写好的单元测试

时间:2011-08-06 13:45:29

标签: unit-testing language-agnostic dependency-injection functional-programming

我正在使用函数而不是类,我发现我无法分辨它依赖的另一个函数是应该单独进行单元测试的依赖还是不应该进行单元测试的内部实现细节。你怎么知道它是哪一个?

一点上下文:我正在编写一个非常简单的Lisp解释器,它具有eval()函数。它将承担很多责任,实际上太多,例如以不同于列表的方式评估符号(其他一切评估自己)。在评估符号时,它有自己的复杂工作流程(环境查找),在评估列表时,它甚至更复杂,因为列表可以是宏,函数或特殊形式,每个都有自己复杂的工作流程和一套责任。

我无法判断我的eval_symbol()eval_list()函数是否应被视为eval()的内部实现细节,应通过eval()自己的单元测试进行测试,或者他们自己的真正依赖,应该独立于eval()的单元测试进行单元测试。

5 个答案:

答案 0 :(得分:16)

“单元测试”概念的一个重要动机是控制所需测试用例的组合爆炸。我们来看看evaleval_symboleval_list的示例。

eval_symbol的情况下,我们希望测试符号绑定的偶发事件:

  • 缺失(即符号未绑定)

  • 在全球环境中

  • 直接位于当前环境中

  • 从包含环境继承

  • 遮蔽另一个绑定

  • ......等等

eval_list的情况下,我们将要测试(除其他外)当列表的函数位置包含符号时​​会发生什么:

  • 无功能或宏绑定

  • 功能绑定

  • 宏绑定

每当需要符号绑定时,

eval_list将调用eval_symbol(假设是LISP-1)。假设eval_symbol的{​​{1}}和 L 符号相关测试用例存在 S 测试用例。eval_list。如果我们分别测试这些函数中的每一个,我们可以使用大致 S + L 符号相关的测试用例。但是,如果我们希望将eval_list视为黑匣子并在不知道其内部使用eval_symbol的情况下对其进行详尽测试,那么我们将面临 S x L 符号相关的测试用例(例如全局函数绑定,全局宏绑定,本地函数绑定,本地宏绑定,继承函数绑定,继承宏绑定等)。这是更多的情况。 eval更糟糕的是:作为一个黑匣子,组合的数量可能变得非常大 - 因此术语组合爆炸

因此,我们面临着理论纯度与实际可行性的选择。毫无疑问,只运行“公共API”(在这种情况下,eval)的一组全面的测试用例给出了最大的信心,即没有错误。毕竟,通过运用所有可能的组合,我们可能会发现微妙的集成错误。然而,这种组合的数量可能非常大,以至于排除了这种测试。更不用说程序员可能会犯错误(或疯狂)审查大量仅以微妙方式不同的测试用例。通过对较小的内部组件进行单元测试,可以大大减少所需测试用例的数量,同时仍然保持对结果的高度信任 - 这是一种实用的解决方案。

因此,我认为确定单元测试粒度的指导原则是:如果测试用例的数量很大,那就开始寻找更小的单元进行测试。

就目前的情况而言,由于组合爆炸,我绝对主张将evaleval-listeval-symbol作为单独的单位进行测试。在为eval-list编写测试时,您可以依靠eval-symbol坚如磐石,并将注意力集中在eval-list自身添加的功能上。 eval-list中也可能包含其他可测试单元,例如eval-functioneval-macroeval-lambdaeval-arglist等。

答案 1 :(得分:5)

我的建议非常简单:“从某处开始!”

  • 如果你看到某个def(或deffun)的名字看起来可能很脆弱,那么,你可能想测试它,不是吗?
  • 如果您在尝试弄清楚客户端代码如何与其他代码单元进行交互时遇到一些麻烦,那么您可能希望在某处编写一些测试,以便您创建如何正确使用该函数的示例。
  • 如果某些功能似乎对数据值敏感,那么您可能想要编写一些测试,不仅可以验证它是否可以正确处理任何合理的输入,还可以专门处理边界条件和奇数或异常数据输入。
  • 无论什么似乎容易出错都应该进行测试。
  • 无论什么似乎都不清楚应该进行测试。
  • 无论看起来多么复杂都应该进行测试。
  • 无论什么似乎重要都应该进行测试。

稍后,您可以将覆盖率提高到100%。但是你会发现,你可能会从前20%的单元测试编码中得到80%的实际结果(倒置的“关键少数法则”)。

所以,回顾我谦虚的方法的主要观点,“从某个地方开始!”

关于问题的最后一部分,我建议您考虑任何可能的递归或者您或后续开发人员将来可能创建的“客户端”函数的任何其他可能的重用,这些函数也会调用eval_symbol()或eval_list( )。

关于递归,函数式编程风格使用了很多,并且很难做到正确,特别是对于我们这些来自程序或面向对象编程的人来说,递归似乎很少遇到。获得正确递归的最佳方法是使用单元测试精确地定位任何递归功能,以确保验证所有可能的递归用例。

关于重用,如果你的函数可能被eval()函数单独使用以外的任何东西调用,它们应该被视为真正的依赖,值得进行独立的单元测试。

作为最后的提示,“单位”一词在单位测试领域的技术定义为"the smallest piece of code software that can be tested in isolation."。这是一个非常古老的基本定义,可能会很快为您澄清您的情况。

答案 2 :(得分:3)

这与您的问题内容有些正交,但直接解决了标题中提出的问题。

惯用函数式编程主要涉及无副作用的代码片段,这使得单元测试通常更容易。定义单元测试通常涉及断言有关被测函数的逻辑属性,而不是构建大量脆弱的脚手架以建立合适的测试环境。

举个例子,假设我们将extendEnvlookupEnv函数作为解释器的一部分进行测试。对这些函数进行良好的单元测试将检查如果我们使用绑定到不同值的相同变量扩展环境两次,则lookupEnv仅返回最新值。

在Haskell中,对此属性的测试可能如下所示:

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

此测试为我们提供了一些保证,除了创建我们感兴趣的env值之外,不需要任何设置或拆解。但是,测试中的值非常具体。这只测试一个特定的环境,因此一个微妙的bug很容易漏掉。我们宁愿做一个更一般的陈述:对于所有变量x和值vw,环境env扩展两次,x绑定到{在v绑定xw后,{1}}。

一般情况下,我们需要一个正式的证据(可能是使用像Coq,Agda或Isabelle这样的证明助手进行机械化),以证明这样的属性成立。但是,通过使用QuickCheck,我们可以比指定测试值更接近,{{3}}是一个可用于大多数函数式语言的库,它为我们定义为布尔函数的属性生成大量任意测试输入:

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使用一些非常好的(和可扩展的)魔术来产生这些任意值,但它的功能编程使这些值变得有用。通过使副作用成为异常(对不起)而不是规则,单元测试变得不再是手动指定测试用例的任务,更多的是断言关于函数行为的通用属性。

此过程会让您感到惊讶。在这个级别的推理让你的大脑有更多机会注意到你的设计中的缺陷,使你更有可能在你运行代码之前发现错误。

答案 3 :(得分:2)

我并没有真正意识到这方面的任何特定经验法则。但看起来你应该问自己两个问题:

  1. 您是否可以定义eval_symboleval_list的目的,而无需说“eval的实施的一部分?
  2. 如果您发现eval的测试失败,那么查看eval_symboleval_list的任何测试是否也失败会有用吗?
  3. 如果其中任何一个的答案都是肯定的,我会单独测试它们。

答案 4 :(得分:0)

几个月前,我在Python中编写了一个简单的“几乎Lisp”解释器来进行分配。我使用Interpreter设计模式设计它,单元测试评估代码。然后我添加了打印和解析代码,并将测试装置从抽象语法表示(对象)转换为具体的语法字符串。部分任务是编写简单的递归列表处理函数,因此我将它们添加为功能测试。

要回答一般问题,规则与OO完全相同。您应该涵盖所有公共功能。在OO中,公共方法是类或接口的一部分,在函数式编程中,您最常使用基于模块的可见性控制(类似于接口)。理想情况下,您可以全面覆盖所有功能,但如果无法做到这一点,请考虑TDD方法 - 首先编写测试,了解您所需要的内容并实施它们。辅助函数将是重构的结果,并且当你为之前重要的事情编写测试时,如果测试在重构之后工作,那么你就完成了,并且可以编写另一个测试(迭代)。

祝你好运!