【问题标题】:Testing functions in Haskell that do IO在 Haskell 中测试执行 IO 的函数
【发布时间】:2011-09-10 06:37:51
【问题描述】:

现在正在使用真实世界的 Haskell。以下是本书早期练习的解决方案:

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

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

【问题讨论】:

  • 在“模拟”文件系统之后还有什么需要测试的? length函数?
  • Haskell 可能强调纯度,但 IO monad 并不纯粹。
  • 如果你对文件的内容做了比length更有趣的事情,你可以很容易地测试那个函数,对于一些a来说是String -&gt; a .
  • @Dietrich 是的。它的unsafePerformIO 不纯;)
  • @Dietrich No. >>= 和 >> 和 return 是纯的。

标签: testing haskell


【解决方案1】:

您可以通过使用类型类约束类型变量而不是 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

这样您的测试代码几乎不需要修改。

【讨论】:

  • 这是 IMO 唯一正确的方法。不幸的是,这些不是一组标准的可模拟类型类,以及标准的可模拟性。
  • 同意。解决“真实”模拟的最佳尝试。
  • 此解决方案使用 TypeSynonymInstances。这是不受欢迎的吗?
  • @Dave,该扩展仅用于 brewity 的测试代码中,您可以通过创建 newtype 轻松避免这种情况。
  • 我知道我在问一个菜鸟问题(而且我是菜鸟),但我尝试不使用语言扩展,但我无法让它工作,可以请再次分享代码?
【解决方案2】:

正如 Alexander Poluektov 已经指出的那样,您尝试测试的代码可以很容易地分为纯部分和不纯部分。 尽管如此,我认为知道如何在 haskell 中测试这些不纯函数是件好事。
在haskell 中进行测试的常用方法是使用quickcheck,这也是我倾向于用于不纯代码的方法。

这是一个示例,说明您如何实现您正在尝试做的事情,它为您提供了一种 mock 行为 *

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 中很好地描述了这一点:
“Mocks 是...预先编程的对象,这些对象形成了它们期望接收的调用的规范。”
对于快速检查设置,我会说作为输入生成的文件是“预编程的”,以便我们知道它们的大小(== 期望)。然后根据我们的规范(== 属性)对它们进行验证。

【讨论】:

  • 这不会像模拟一样行动、说话或说话。模拟不应触及文件系统。我确实认为 OP 可能需要引入一个类型类来抽象 IO 的使用,以便使用模拟对象,就像任何其他语言一样。即在 C++ 中需要虚函数,在 Java 中,需要接口才能使用模拟框架。
  • @user239558:说得好。我对模拟一词的用法进行了说明。
【解决方案3】:

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

根据定义,不纯的部分不能进行“单元”测试。纯部分只是调用库函数(当然,如果需要,您可以对其进行测试:))。

所以在这个例子中没有什么可以模拟和单元测试的。

换一种说法。假设您有相同的 C++ 或 Java 实现 (*):读取内容然后计算其长度。您真正想要模拟什么以及之后要测试的内容是什么?


(*) 这当然不是你会在 C++ 或 Java 中做的方式,但那是题外话。

【讨论】:

  • 我投了反对票,因为这不是答案。这个答案可以概括为“你不能”,这不是真的,因此没有用。另外,我看不出单元测试的什么定义不允许测试不纯函数(他们在 Java 中一直这样做:D)。
  • 我同意和不同意这篇文章。关键见解是正确的——分别测试纯部分和不纯部分。 readFile 结果的“模拟”只是一个字符串——正是你传递给 length 的开头!我不同意的是,当然可以对readFile 进行单元测试——只是这样做时,当然应该模拟它。
  • @sclv 总而言之,这取决于您使用的“unit-testing”的定义。我使用一个禁止触摸做 IO 事情的函数。
  • FWIW,目前的问题不包含“单位”一词。
  • @steamer25:但后面的文字暗示了这一点:如果他/她进行(非单元)测试,为什么要模拟某些东西?
【解决方案4】:

为此,您需要修改函数,使其变为:

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

【讨论】:

  • IO 替换为Monad m =&gt; m 就足够Haskell-ish了。
【解决方案5】:

根据我外行对 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' 现在可以用模拟测试了!

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' 的变体。

【讨论】:

  • 仅供参考,这是 Ankur 解决方案的结合,结合了 Rotsor 的评论。
猜你喜欢
  • 2016-03-04
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2013-02-01
  • 1970-01-01
  • 2022-12-08
相关资源
最近更新 更多