如何在Haskell中进行测试模拟?

37

假设我正在定义一个Haskell函数f(可以是纯函数或操作),并且在f的某个地方调用了函数g。例如:

f = ...
    g someParms
    ...

我如何在单元测试中使用mock版本替换函数g?

如果我在Java中工作,g将是SomeServiceImpl类上的一个方法,该类实现了SomeService接口。然后,我可以使用依赖注入来告诉f要使用SomeServiceImplMockSomeServiceImpl。但我不知道如何在Haskell中实现这个功能。

引入一个类型类SomeService是否是最好的方法呢:

class SomeService a where
    g :: a -> typeOfSomeParms -> gReturnType

data SomeServiceImpl = SomeServiceImpl
data MockSomeServiceImpl = MockSomeServiceImpl

instance SomeService SomeServiceImpl where
    g _ someParms = ... -- real implementation of g

instance SomeService MockSomeServiceImpl where
    g _ someParms = ... -- mock implementation of g

然后,将f重新定义如下:

f someService ... = ...
                    g someService someParms
                    ...
似乎这样做可以解决问题,但我刚学 Haskell,想知道这是否是最好的方法?更普遍地说,我喜欢依赖注入的想法,不仅用于模拟,还可使代码更具可定制性和可重用性。通常情况下,我喜欢不被锁定在代码使用的任何服务的单个实现中。广泛使用上述技巧以获得依赖注入的好处是否被认为是一个好主意?
编辑:
让我们更进一步。 假设我在一个模块中有一系列函数 a、b、c、d、e 和 f,它们都需要能够引用来自另一个模块的函数 g、h、i 和 j。并且假设我想能够模拟函数 g、h、i 和 j。我显然可以将这 4 个函数作为参数传递给 a-f,但是要向所有函数添加 4 个参数有点麻烦。此外,如果我需要更改 a-f 中的任何一个实现以调用另一种方法,则需要更改其签名,这可能会创建一个难以维护的重构练习。
有什么技巧可以使这种类型的情况更容易处理吗?例如,在 Java 中,我可以构造一个对象,并将所有外部服务存储在成员变量中。构造函数将服务存储在成员变量中。然后,任何方法都可以通过成员变量访问这些服务。因此,当向服务添加方法时,不会更改任何方法签名。如果需要新服务,则只需更改构造函数方法签名。

3
为什么会有人想要嘲笑纯函数? - yairchu
1
好观点,yairchu。你可能只会模拟行动。 - Clint Miller
3
@yairchu,我会出于效率原因这样做。测试所需的时间是生产力的重要因素,无论测试需要x还是100x的时间。因此,我想说阶乘100000000=<一些大数>是为了测试目的(作为另一个测试的输入/模拟)。但当测试运行器在阶乘模块上运行时,阶乘100000000=<一些大数>本身就可以成为一个测试。 - user239558
4
为什么有人想嘲笑纯函数呢?是为了确保它们停止运行吗? ;) - obecalp
text-fixture 是一个用于模拟测试 mtl 风格代码的新包。这里有一篇介绍性的博客文章。 - sjakobi
7个回答

22
为什么要使用单元测试,当你可以使用自动规范化测试? QuickCheck 库可以为您完成此操作。它可以使用 Arbitrary 类型类生成任意(模拟)函数和数据。
“依赖注入”是隐式参数传递的一种退化形式。在 Haskell 中,您可以使用 ReaderFree 以更 Haskelly 的方式实现相同的功能。

10
“依赖注入”是隐式参数传递的一种退化形式。 - Bjorn
15
与问题不同,此答案缺少任何代码示例,并略带“一个单子仅仅是内函子范畴中的一个幺半群”这样的味道,即该答案对 Haskell 内部人士可读,而问题显然来自具有 Java 背景的人。简而言之,此答案的意思是:“你的方法不好,去学习正确的方法,我就不再多说了”。 - Utgarda

17

另一种选择:

{-# LANGUAGE FlexibleContexts, RankNTypes #-}

import Control.Monad.RWS

data (Monad m) => ServiceImplementation m = ServiceImplementation
  { serviceHello :: m ()
  , serviceGetLine :: m String
  , servicePutLine :: String -> m ()
  }

serviceHelloBase :: (Monad m) => ServiceImplementation m -> m ()
serviceHelloBase impl = do
    name <- serviceGetLine impl
    servicePutLine impl $ "Hello, " ++ name

realImpl :: ServiceImplementation IO
realImpl = ServiceImplementation
  { serviceHello = serviceHelloBase realImpl
  , serviceGetLine = getLine
  , servicePutLine = putStrLn
  }

mockImpl :: (Monad m, MonadReader String m, MonadWriter String m) =>
    ServiceImplementation m
mockImpl = ServiceImplementation
  { serviceHello = serviceHelloBase mockImpl
  , serviceGetLine = ask
  , servicePutLine = tell
  }

main = serviceHello realImpl
test = case runRWS (serviceHello mockImpl) "Dave" () of
    (_, _, "Hello, Dave") -> True; _ -> False

这实际上是在Haskell中创建面向对象风格代码的众多方式之一。


1
这真的很好 - main 在 IO () 中运行,但 test 是一个纯函数。编译器扩展让我有点害怕,而 Control.Monad.RWS 是什么?谷歌并没有提供太多帮助。看来我还有更多的学习要做... - minimalis
1
@minimalis Control.Monad.RWS 定义了 ReaderWriterState 三个单子类。 - ephemient
1
谢谢,现在我明白了。如果您将mockImpl的类型更改为mockImpl :: ServiceImplementation (RWS String String ()),似乎您不需要语言扩展。 - minimalis

5

针对关于多个函数的编辑问题,一种选择是将它们放在一个记录类型中并传递该记录。然后,您可以通过更新记录类型来添加新的函数。例如:

data FunctionGroup t = FunctionGroup { g :: Int -> Int, h :: t -> Int }

a grp ... = ... g grp someThing ... h grp someThingElse ...

在某些情况下可能可行的另一个选择是使用类型类。例如:

class HasFunctionGroup t where
    g :: Int -> t
    h :: t -> Int

a :: HasFunctionGroup t => <some type involving t>
a ... = ... g someThing ... h someThingElse

只有当您可以找到函数所共有的类型(如果使用多参数类型类,则可能有多种类型)时,该方法才有效,但在适当的情况下,它将为您提供漂亮的惯用Haskell代码。


如果有自然类型可以附加,我会更喜欢使用类型类。否则,可能会使用记录。 - GS - Apologise to Monica

3

你是否可以将名为g的函数传递给f?只要g满足接口typeOfSomeParms -> gReturnType,那么您应该可以传递真实函数或模拟函数。

例如:

f g = do
  ...
  g someParams
  ...

我自己没有在Java中使用过依赖注入,但我读过的文本让它听起来很像传递高阶函数,所以也许这可以满足你的需求。


对编辑的回应:如果您需要以企业方式解决问题,则ephemient的答案更好,因为您可以定义一个包含多个函数的类型。我提出的原型方式只会传递一个函数元组,而不定义一个包含类型。但是我几乎从不编写类型注释,因此重构并不是非常困难。


1
“enterprisey”是不是应该被视为一种赞美? ;) - ephemient

1
一个简单的解决方案是更改您的
f x = ...

f2 g x = ... 

然后

f = f2 g
ftest = f2 gtest

1
当期望g能够递归调用f时,这个问题变得更加“有趣”,但是在这种基于函数和基于类的解决方案中都可以解决。 - ephemient
这很有道理。我想,如果我只是从一个服务中使用一个函数(一些逻辑分组的函数),那么将函数作为参数传递是最简单的。但是,如果我正在使用多个函数,则构建一个类将减少参数的数量。 - Clint Miller

1

如果你所依赖的函数在另一个模块中,那么你可以通过可见模块配置来玩弄真实模块或模拟模块的导入。

然而,我想问一下为什么你觉得需要使用模拟函数进行单元测试。你只需要证明你正在工作的模块能够完成它的工作。因此,首先证明你的低级模块(你想要模拟的模块)能够工作,然后在其上构建新模块并证明它也能工作。

当然,这假设你不是在使用单子值,因此无论调用什么或使用什么参数都没有关系。在这种情况下,你可能需要证明正确的副作用在正确的时间被调用,因此需要监视何时调用了什么。

或者你只是按照企业标准工作,要求单元测试只对一个模块进行测试,而整个系统的其余部分都被模拟?这是一种非常糟糕的测试方式。更好的方法是从底层开始构建你的模块,在每个级别上证明模块符合规格,然后再进行下一个级别。Quickcheck 是你的朋友。


0

你可以使用不同的函数名称来实现两个函数的功能,g 可以是一个变量,根据需要定义为其中之一。

g :: typeOfSomeParms -> gReturnType
g = g_mock -- change this to "g_real" when you need to

g_mock someParms = ... -- mock implementation of g

g_real someParms = ... -- real implementation of g

1
这种方法的缺点是每次运行测试或真实产品时,我都必须更改源代码以在g_mock和g_real之间切换。 - Clint Miller

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接