在Haskell的单元测试中使用mock?

piv4azn7  于 2023-04-30  发布在  其他
关注(0)|答案(1)|浏览(156)

考虑下面的代码段:

data Slice = Slice
  { text :: String,
    color :: Color
  }

newtype Color = Color
  { string :: String
  }

mainList :: [FilePath] -> [FilePath] -> [String] -> [[Slice]]
mainList somethingA somethingB codedLines = ...

Slice记录表示用ANSI转义码解码字符串的结果。在mainList函数中,我使用了一些函数,我们称之为categorize,它将codedLines(这表示一行ANSI转义字符串)参数解析为[[Slice]]

第一个问题:

如何使用Quickcheck单元测试框架为mainList函数编写单元测试?我对Haskell还是个新手。现在,如果这是一些OOP语言,我知道该怎么做:让具有categorize方法的类作为参数传递给具有mainList方法的类的ctor,然后使用一些mocking库来模仿它,甚至手动编写mock。但是,在没有类的概念的Haskell中,一个人该怎么做呢?

第二个问题:

也许我可以像下面这样修改mainList函数:

mainList :: [FilePath] -> [FilePath] -> ([String] -> [[Slice]]) -> [[Slice]]

但是,我必须传递一个mock而不是第三个参数。既然Haskell不是一种面向对象的语言,那么有没有mock的概念呢?这是惯用的Haskell吗?或者我错误地将OOP原则投射到函数式语言上?
先谢谢你了。

t2a7ltrp

t2a7ltrp1#

你怎么能嘲笑

你已经正确地注意到,如果你想使用你正在测试的函数所调用的函数的不同实现,你可以简单地将它们转换为参数,而不是直接调用它们:

mainList :: TypeOfCategorize -> [FilePath] -> [FilePath] -> [String] -> [[Slice]]
mainList categorize ... =
   ... categorize ...

mainListForTesting = mainList categorizeMock

mainListForProd = mainList categorizeReal

事实上,这与“依赖注入框架”是一个非常复杂的版本的想法完全相同。如果您也希望Haskell中的操作更加复杂,您可以应用任何标准技术来管理需要传递的额外信息,而不想如此明确地管理;您这样做是为了测试的目的,这一事实与许多其他引起这种关注的环境没有很大的不同。我脑子里有几个想法:
1.您可以使用一个函数记录参数,其中包含您想要模拟测试的所有函数(而不是为每个函数单独添加一个额外的参数)。
1.您可以使用ReaderT来使这样的函数记录可访问,而不需要一直显式地传递。(如果你有很多函数使用相同的实现来调用对方,而不是每个函数都需要一组不同的mock函数或真实的函数,这将保存你很多钱)
1.您可以使用类型标记来指示运行函数的“目的”(生产或可扩展的测试目的集,如果您需要多个模拟),并使用类型类为每个目的提供正确的实现,而不是显式地传递它们。
但从根本上说,这一切都归结为:如果您希望在某些调用上使用与其他调用不同值,那么这些值就是某种形式的参数。在Haskell中,实现是一个函数,是一个值。因此,您可以使用相同的工具将不同的实现用于测试目的的mainList调用,这与您将任何其他值用于任何其他目的的mainList调用相同。

你该嘲笑我吗?

你还问“mocking”是否是惯用的Haskell,我会说答案是“这取决于,但很大程度上不是”(完全免责声明:我还要说,mocking在OOP测试中也被大量过度使用,所以也许我更喜欢的思考测试的方式不适合你的口味。
mainList是纯的(并且从类型上被限制为纯的,只要我们忽略unsafe*函数)。这意味着它所称的一切也都是纯粹的。几乎所有的时间,这意味着没有必要嘲笑任何东西。
mainList将使用某些特定参数调用categorise,可能会多次调用。要测试mainList是否正确,需要这些调用的返回值。因为这些调用是纯的,所以每个调用都有一个完全独立于程序中其他任何事情的正确答案;无需设置任何状态,没有您可能不想要的副作用,不需要外部环境。如果mainList在每次调用categorize时使用的不是唯一正确的返回值,那么您不是在测试mainList的真实的行为,而是在测试categorize返回的不是它应该返回的值时的假设行为;在生产环境中它不会这样做,所以测试它在这些条件下的表现并不是很有用。让mainList使用正确的值以便测试mainList的行为的一种方法是非常仔细地考虑正在运行的测试用例,并计算出正确的答案应该是什么,然后传入一个mock categorize,它返回该测试用例的正确答案。但是一个更简单的方法是只传入 * 真实的 * categorize,让mainList从你写的函数中得到正确的答案,以 * 产生 * 正确的答案。

这确实意味着你可能会在mainList中得到一个测试失败,而这个失败仅仅是因为categorize返回了错误的结果。但是mainList的测试并不能防止这个漏洞;categorize的测试完成了这项工作!如果mainListcategorize同时测试失败,只需首先开始调试categorize(这是一个好主意无论如何,以防您的更改需要在调用它的事物中进行流式更改,包括mainList)。与使用真实的的categorize相比,使用错误的mock的mainList进行假阴性测试的可能性要大得多。毕竟categorize是您专门设计的东西,可以为categorize调用返回正确的结果;没有人能像那份工作一样成功。如果categorize的需求改变了正确的结果(无论是由于外部更改还是由于最初的误解),那么最好让mainList的测试选择更改后的categorize并检测是否导致mainList失败。如果categorize被模拟,那么mainList的测试将继续通过categorize应该返回的 * 旧 * 定义,然后在生产中失败。在我看来,模拟与真实的实现不同步的危险比一个缺陷导致两个测试失败的危险要严重得多。
你可能需要嘲笑,如果:
1.你正在测试的函数调用的东西依赖于你的测试环境中不存在的外部环境(比如数据库、与用户的交互等)
1.您正在测试的函数调用了一些您不希望在测试环境中发生的副作用(您应该能够测试您的逻辑,以检测何时自动发射导弹,而无需实际发射导弹)
1.您正在测试的函数调用的东西需要花费很长的时间才能真正计算
在Haskell中,这通常是我们想要测试的函数的一小部分。纯粹性和强类型系统意味着我们被鼓励在纯函数中拥有 * 大部分 * 代码,可以独立于任何外部环境进行测试。代码库中只有一小部分直接处理外部环境(而不是参数化从外部环境计算出来的东西)。而花费很长时间的函数通常会随着某些输入的大小而缩放,因此可以用更少的时间来测试它们。
因此,在Haskell中,mocking通常不被认为是应该应用于所有事情的核心测试技术。
我们经常做的嘲笑并不被称为或认为是嘲笑。使用诸如monad转换器或效果系统之类的东西,我们经常编写依赖于环境的代码,或者在效果的特定实现中以多态的方式产生副作用。这样做的动机通常更多地是从获得更好的类型安全性和可组合性的Angular 来讨论,但作为一个副作用(双关语),它也意味着我们可以交换不同的实现进行测试。但这并不是用来模拟被测试的纯函数调用的任意纯函数。

基于属性的测试,如QuickCheck

您提到了“QuickCheck单元测试框架”。QuickCheck确实可以被视为一个单元测试框架,但它通常不是,因为它是一个用于属性测试的库,这是一种非常特定的单元测试(或者根本不是单元测试,这取决于你的定义)。
对于属性测试,您不只是编写一个特定的测试用例并检查是否得到了预期的输出。相反,你写一个属性,它是参数化的一些输入和测试,如果属性持有;这需要对属性进行一般测试,而不管输入的具体值如何(因此与典型的单元测试非常不同,在单元测试中,您检查特定已知输入的输出特性)。QuickCheck将随机生成 * 大量 * 的值,试图找到一个伪造您的属性;如果找不到测试就通过了。
标准的简单示例(来自QuickCheck docs)是:

import Test.QuickCheck

-- Reversing a list twice results in the original input list
prop_reverse :: [Int] -> Bool
prop_reverse xs = reverse (reverse xs) == xs

我们没有通过检查reverse (reverse [1, 2, 3]) == [1, 2, 3]来测试这个,我们写了一个属性来测试任何整数列表。
我并不打算过多地谈论基于属性的测试,但我希望您能看到我为什么在您关于模拟和使用QuickCheck进行测试的问题的上下文中提出这个问题。

如果您使用QuickCheck测试mainList,您将编写一般属性,这些属性应适用于QuickCheck生成的任何随机输入。这与模拟mainList调用的函数的想法不太一致。我们没有一个特定的测试用例,它的输入是已知的,因此应该对categorize进行已知的调用,所以我们不能只做一个mock categorize,忽略它的输入并返回我们期望mainList得到的值。在理想情况下,为mainList生成的输入将很好地表示mainList在生产中可能遇到的每个可能的输入和代码路径,因此我们需要一个可以为任何可能的调用返回正确结果的mock categorize。我们已经有了一个符合该规范的函数:categorize .第二次编写categorize只是为了测试mainList是一个坏主意。
(If由于性能原因,categorize有一个棘手/脆弱的实现,编写一个更直接和明显正确的实现将是测试categorize本身的绝佳方法;只需编写一个属性测试,即对于所有输入,明显正确但缓慢的实现总是返回与脆弱但快速的实现相同的结果!但是在 * 调用 * categorize)的属性测试中使用第二个实现仍然没有意义。
Haskell中基于属性的测试的流行是mocking不是一种常见技术的另一个原因。如果你的属性实际上做了一个合理的工作来描述函数的正确行为,那么你通常不能在没有真实的实现的情况下测试这些属性

相关问题