在Haskell中测试执行IO操作的函数

34

我正在学习《Real World Haskell》。这是一本早期练习题的解决方案:

-- | 4) Counts the number of characters in a file
numCharactersInFile :: FilePath -> IO Int
numCharactersInFile fileName = do
    contents <- readFile fileName
    return (length contents)
我的问题是:你会如何测试这个函数?有没有一种方法可以创建一个“模拟”输入而不需要实际与文件系统交互来进行测试?Haskell非常强调纯函数,我想这应该很容易做到。

我的问题是:您如何测试此函数?是否有一种方法可以创建“模拟”输入,而无需实际与文件系统交互即可进行测试?Haskell非常注重纯函数,因此我认为这应该很容易实现。


3
“模拟”文件系统之后还能用什么来测试呢?是length函数吗? - Alexander Poluektov
1
Haskell可能会强调纯度,但IO单子并不是纯的。 - Dietrich Epp
1
如果您在文件内容上执行的操作比 length 更有趣,那么您可以轻松地测试 函数,该函数将是 String -> a 的某个值 a - Dan Burton
9
@Dietrich 是的,没错。它的 unsafePerformIO 才是不纯的 ;) - alternative
1
@Dietrich 不是纯的,而 >>=、>> 和 return 是纯的。 - alternative
显示剩余3条评论
5个回答

46

您可以通过使用类约束类型变量而不是IO,使您的代码可测试。

首先,让我们处理导入的内容。

{-# LANGUAGE FlexibleInstances #-}
import qualified Prelude
import Prelude hiding(readFile)
import Control.Monad.State

我们想要测试的代码:

class Monad m => FSMonad m where
    readFile :: FilePath -> m String

-- | 4) Counts the number of characters in a file
numCharactersInFile :: FSMonad m => FilePath -> m Int
numCharactersInFile fileName = do
    contents <- readFile fileName
    return (length contents)

稍后,我们可以运行它:

instance FSMonad IO where
    readFile = Prelude.readFile

还需要测试一下:

data MockFS = SingleFile FilePath String

instance FSMonad (State MockFS) where 
               -- ^ Reader would be enough in this particular case though
    readFile pathRequested = do
        (SingleFile pathExisting contents) <- get
        if pathExisting == pathRequested
            then return contents
            else fail "file not found"


testNumCharactersInFile :: Bool
testNumCharactersInFile =
    evalState
        (numCharactersInFile "test.txt") 
        (SingleFile "test.txt" "hello world")
      == 11
这样你测试的代码就需要很少的修改。

5
在我看来,这是唯一正确的方法。不幸的是,目前还没有一组标准的可模拟类型类和标准的可模拟性。 - user239558
这个解决方案使用了TypeSynonymInstances。这样做是否会被反对? - David
我知道我在问一个新手问题(而且我是一个新手 Haskell 程序员),但我尝试不使用语言扩展,但无法使其工作,你能再分享一下代码吗? - Milad
@Milad,我添加了导入部分,所以代码现在是自包含的。如果你想摆脱FlexibleInstances,你需要将State MockFS包装成一个新类型。 - Rotsor
嗨,谢谢回复。我已经删除了 FlexibleInstances,并添加了 newtype,但它无法编译 - 请参见 https://gist.github.com/miladhub/e039b38d1dcb24f4234da79d8780fe58错误是: testing.hs:20:18: error: • 预期种类为 ‘* -> *’,但 ‘StateMockFS’ 的种类为 ‘*’ • 在 ‘FSMonad’ 的第一个参数中,即 ‘StateMockFS’ 在 ‘FSMonad StateMockFS’ 的实例声明中 | 20 | instance FSMonad StateMockFS where | ^^^^^^^^^^^ - Milad
显示剩余2条评论

19

正如Alexander Poluektov指出的那样,你试图测试的代码可以很容易地分成纯函数和不纯函数两个部分。尽管如此,我认为了解如何在Haskell中测试这种不纯的函数很重要。
在Haskell中进行测试的通常方法是使用quickcheck,这也是我用于不纯代码的方式。

下面是一个示例,演示了如何实现你想要做的事情,并提供了一种模拟行为*

import Test.QuickCheck
import Test.QuickCheck.Monadic(monadicIO,run,assert)
import System.Directory(removeFile,getTemporaryDirectory)
import System.IO
import Control.Exception(finally,bracket)

numCharactersInFile :: FilePath -> IO Int
numCharactersInFile fileName = do
    contents <- readFile fileName
    return (length contents)

现在提供一个备选函数(针对模型进行测试):

numAlternative ::  FilePath -> IO Integer
numAlternative p = bracket (openFile p ReadMode) hClose hFileSize

为测试环境提供一个任意实例:

data TestFile = TestFile String deriving (Eq,Ord,Show)
instance Arbitrary TestFile where
  arbitrary = do
    n <- choose (0,2000)
    testString <- vectorOf n $ elements ['a'..'z'] 
    return $ TestFile testString

使用QuickCheck for monadic code针对模型进行属性测试:

prop_charsInFile (TestFile string) = 
  length string > 0 ==> monadicIO $ do
    (res,alternative) <- run $ createTmpFile string $
      \p h -> do
          alternative <- numAlternative p
          testRes <- numCharactersInFile p
          return (testRes,alternative)
    assert $ res == fromInteger alternative

还有一个小助手函数:

createTmpFile :: String -> (FilePath -> Handle -> IO a) -> IO a
createTmpFile content func = do
      tempdir <- catch getTemporaryDirectory (\_ -> return ".")
      (tempfile, temph) <- openTempFile tempdir ""
      hPutStr temph content
      hFlush temph
      hClose temph
      finally (func tempfile temph) 
              (removeFile tempfile)

这将使 quickCheck 为您创建一些随机文件,并针对模型函数测试您的实现。

$ quickCheck prop_charsInFile 
+++ OK, passed 100 tests.

当然,根据您的用例,您也可以测试一些其他属性。


*有关我使用术语模拟行为的说明:
从面向对象的角度来看,“模拟”这个术语可能不是最好的。但是“模拟”的目的是什么呢?
它使您能够测试需要访问通常无法在测试时获得或不易控制且难以验证的资源的代码。

  • 要么在测试时不可用
  • 要么不易于控制,因此不易于验证。

通过将提供此类资源的责任转移给 quickcheck,突然之间可以为测试下的代码提供可在测试运行后验证的环境。
马丁·福勒在一篇有关模拟的文章中描述得非常好:
“模拟是...预先编程的对象,它们带有期望,形成了对它们所期望接收的调用的规范。”
对于 quickcheck 设置而言,生成作为输入的文件已“预先编程”,以便我们了解它们的大小(== 期望)。然后根据我们的规范(== 属性)对它们进行验证。


12
这似乎不像一个模拟。模拟不应该触及文件系统。我认为 OP 可能需要引入一个类型类来抽象化 IO 的使用,以便使用模拟对象,就像其他语言一样。例如,在 C++ 中需要虚函数,在 Java 中需要接口才能使用模拟框架。 - user239558
1
@user239558:好的评论。我对“mock”一词的使用进行了澄清。 - oliver

9
为此,您需要修改函数,使其变为以下形式:
numCharactersInFile :: (FilePath -> IO String) -> FilePath -> IO Int
numCharactersInFile reader fileName = do
                         contents <- reader fileName
                         return (length contents)

现在您可以传递任何接受文件路径并返回IO字符串的模拟函数,例如:

fakeFile :: FilePath -> IO String
fakeFile fileName = return "Fake content"

将此函数传递给 numCharactersInFile

1
Monad m => m 替换 IO,这样就足够 Haskell 化了。 - Rotsor

8
该函数分为两部分:不纯的部分(读取内容作为字符串)和纯净的部分(计算字符串长度)。
由于定义,不纯的部分不能进行“单元”测试。纯净的部分只是调用库函数(当然你可以测试它,如果你想的话 :) )。
所以在这个例子中没有什么可以模拟和单元测试的。
换个角度考虑。假设您有一个相等的C++或Java实现(*):读取内容,然后计算其长度。您真正想要模拟什么,之后什么还需要测试呢?
(*)这当然不是您在C++或Java中使用的方式,但那是离题了。

5
我已经给这个答案点了踩,因为它并不是一个可行的回答。这个答案可以概括为“你不能”,但这并不完全正确,因此也没有什么用处。此外,我并不明白哪个单元测试的定义禁止对不纯函数进行测试(在Java中它们经常这样做:D)。 - Rotsor
1
我同意并不同意这篇文章。关键的见解是正确的——分别测试纯部分和不纯部分。readFile 的结果的“模拟”只是一个字符串——正是你一开始传递给 length 的!我的不同意在于,当然可以对 readFile 进行单元测试——只是在这样做时,当然不应该模拟它。 - sclv
1
总的来说,这取决于您使用的“单元测试”定义。我使用的是禁止触及执行IO操作的函数的定义。 - Alexander Poluektov
就目前而言,这个问题并没有包含单词“unit”。 - steamer25
@steamer25:但随后的文本暗示了这一点:如果进行(非单元)测试,为什么要模拟某些东西呢? - Alexander Poluektov
@Alexander Poluekov:也许是自动行为/功能测试?例如,“如果一个文件不存在,系统应该...” - steamer25

6

基于我对 Haskell 的门外汉理解,我得出了以下结论:

  1. 如果一个函数使用了 IO monad,那么模拟测试将会变得不可能。因此,避免在函数中硬编码 IO monad。

  2. 创建一个带有其他可能执行 IO 操作的函数作为参数的辅助版本。结果将如下所示:

numCharactersInFile' :: Monad m => (FilePath -> m String) -> FilePath -> m Int
numCharactersInFile' f filePath = do
    contents <- f filePath
    return (length contents)
"

numCharactersInFile'现在可以使用mock进行测试!

"
mockFileSystem :: FilePath -> Identity String
mockFileSystem "fileName" = return "mock file contents"

现在你可以验证'numCharactersInFile'返回预期结果而无需IO:
18 == (runIdentity .  numCharactersInFile' mockFileSystem $ "fileName")

最后,导出您原始函数签名的版本以供IO使用。
numCharactersInFile :: IO Int
numCharactersInFile = NumCharactersInFile' readFile

所以说,最终,“numCharactersInFile”可以使用mock进行测试。“numCharactersInFile”只是“numCharactersInFile'”的一种变体。

FYI,这是Ankur的解决方案与Rotsor的评论相结合的组合。 - David

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