在Haskell中测试执行IO的功能

时间:2011-09-10 06:37:51

标签: testing haskell

立即通过真实世界Haskell。这是本书中早期练习的解决方案:

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

我的问题是:你如何测试这个功能?有没有办法制作“模拟”输入而不是实际需要与文件系统进行交互来测试它? Haskell强调纯函数,我不得不想象这很容易做到。

5 个答案:

答案 0 :(得分:43)

您可以使用类型类约束的类型变量而不是IO来使代码可测试。例如,您可以这样做:

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)

稍后,您可以在IO中运行它:

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

正如您所看到的,这种方式您正在测试的代码需要最少量的修改。

完整代码可在此处找到:http://hpaste.org/51210

答案 1 :(得分: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)

现在提供一个替代函数(Testing against a model)

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.

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


* 请注意我对术语模拟行为的使用情况:
面向对象意义上的术语 mock 可能不是最好的。但是模拟背后的意图是什么? 它让你测试需要访问通常是

的资源的代码
  • 要么在测试时不可用
  • 或不易控制,因此不易验证。

通过将提供此类资源的责任转移到快速检查,突然变得可行的是为测试后的代码提供可在测试运行后验证的环境。 Martin Fowler在article about mocks中很好地描述了这一点: “模拟是......预先编程的对象具有预期,这些对象形成了他们期望接收的调用的规范。”
对于快速检查设置,我会说作为输入生成的文件是“预编程的”,以便我们知道它们的大小(==期望)。然后根据我们的规范(== property)验证它们。

答案 2 :(得分: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

答案 3 :(得分:8)

该函数由两部分组成:impure(读取部分内容为String)和pure(计算String的长度)。

根据定义,不纯的部分不能被“单位”测试。纯粹的部分只是调用库函数(当然你可以测试它,如果你想:) :)。

所以在这个例子中没有什么可以模拟,也没有什么可以进行单元测试。

换句话说。考虑您有一个相同的C ++或Java实现(*):读取内容然后计算其长度。你真的想要嘲笑什么以及之后测试的内容是什么?


(*)当然不是你将用C ++或Java做的方式,但这是offtopic。

答案 4 :(得分:5)

根据我的外行人对Haskell的理解,我得出以下结论:

  1. 如果某个函数使用IO monad,则模拟测试将无法进行。避免在函数中对IO monad进行硬编码。

  2. 制作函数的辅助版本,其中包含可能执行IO的其他函数。结果将如下所示:

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

    numCharactersInFile'现在可以用模拟测试了!

    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'可以使用模拟进行测试。 numCharactersInFile只是numCharactersInFile'的变体。