You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何对基于IO层的单子变换器栈中的计算进行单元测试?

提升Monad变换器栈中IO依赖函数的可测试性

我写了一个极简的Haskell程序,用ReaderT提供只读状态(比如屏幕尺寸),StateT处理可变游戏状态,同时依赖MonadIO做屏幕输出和生成随机数:

module Main where

import MTLPrelude
import System.Random

main :: IO ()
main = do
  let s0 = "inital state"
  let env = "read-only env"
  sEnd <- computation `execStateT` s0 `runReaderT` env
  print sEnd

computation :: (MonadIO m,
                MonadState String m,
                MonadReader String m)
            => m ()
computation = do
  e <- ask
  s <- get
  i <- liftIO $ randomRIO (0 :: Int, 10)
  put $ s ++ e ++ show i

实际场景里,computation还用到了MonadWriter和MaybeT,完整代码是我练手的贪吃蛇游戏。

我已经把computation拆成了多个步骤,其中不需要MonadIO的部分很容易测试;仅依赖MonadIO的简单操作(比如休眠)也不用多测。但像示例里调用randomRIO这种同时依赖MonadIO和其他变换器层的复杂操作,没法像纯函数那样用QuickCheck轻松测试。

怎么改写代码或调整思路,提升这类函数的可测试性?

我自己想了两个方向,但都有问题:

  1. 试着把IO操作(比如putStr、randomRIO)放到Reader层传递,但还是离不开liftIO和MonadIO约束,测试时还是要写模拟IO动作,没解决根本问题:
-- 导入同上
main :: IO ()
main = do
  let s0 = "inital state"
  let env = "read-only env"
  sEnd <- computation `execStateT` s0 `runReaderT` (env, randomRIO)
  print sEnd

computation :: (MonadIO m,
                MonadState String m,
                MonadReader (String, (Int,Int) -> IO Int) m)
            => m ()
computation = do
  (e, io) <- ask
  s <- get
  i <- liftIO $ io (0 :: Int, 10)
  put $ s ++ e ++ show i
  1. 我觉得理想的方向应该是把真实IO(比如输出字符改变屏幕状态)替换成State String,用String模拟屏幕状态,但不知道具体怎么整合到现有变换器栈里。

我知道有讨论IO函数测试的帖子,但这个场景更具体,涉及Monad变换器栈,不算重复问题。


解决方案:用效应抽象替代直接依赖MonadIO

核心思路是把IO相关的操作抽象成类型类,让业务逻辑只依赖抽象接口,而不是具体的IO实现。这样测试时可以用纯模拟实例,运行时用IO实例。

1. 抽象IO相关的效应

比如针对随机数和屏幕输出,定义对应的类型类:

-- 随机数生成效应
class Monad m => MonadRandom m where
  randomR :: (Int, Int) -> m Int

-- 屏幕输出效应
class Monad m => MonadScreen m where
  output :: String -> m ()

2. 改写业务逻辑,依赖抽象效应

把原来的computation改成依赖这些抽象类,去掉MonadIO约束:

computation :: (MonadRandom m,
                MonadState String m,
                MonadReader String m)
            => m ()
computation = do
  e <- ask
  s <- get
  i <- randomR (0 :: Int, 10)  -- 直接用抽象接口,不用liftIO
  put $ s ++ e ++ show i

3. 为IO栈实现抽象效应

在真实运行时,给包含IO的变换器栈提供这些类的实例:

instance MonadRandom IO where
  randomR = randomRIO

-- 如果你的变换器栈是ReaderT r (StateT s IO),可以通过lift传递实例
instance (MonadRandom m) => MonadRandom (ReaderT r m) where
  randomR = lift . randomR

instance (MonadRandom m) => MonadRandom (StateT s m) where
  randomR = lift . randomR

-- 屏幕输出的实例同理
instance MonadScreen IO where
  output = putStrLn

instance (MonadScreen m) => MonadScreen (ReaderT r m) where
  output = lift . output

instance (MonadScreen m) => MonadScreen (StateT s m) where
  output = lift . output

4. 测试时用纯模拟实例

比如测试随机数逻辑时,用一个固定输出的纯Monad实例:

-- 用State存储预设的随机数序列
newtype TestRandom a = TestRandom { runTestRandom :: [Int] -> (a, [Int]) }

instance Monad TestRandom where
  return x = TestRandom $ \rs -> (x, rs)
  TestRandom f >>= g = TestRandom $ \rs ->
    let (x, rs') = f rs
        (y, rs'') = runTestRandom (g x) rs'
    in (y, rs'')

instance MonadRandom TestRandom where
  randomR _ = TestRandom $ \rs -> (head rs, tail rs)

然后把computation跑在TestRandom + StateT + ReaderT的纯栈上,用预设的随机数序列验证结果:

testComputation :: Bool
testComputation =
  let initialState = "initial"
      env = "env"
      testRands = [5]
      (finalState, _) = runReaderT (runStateT computation initialState) env
                         & flip runTestRandom testRands
  in finalState == "initialenv5"

5. 针对屏幕输出的纯模拟

如果是屏幕输出,你可以用WriterT [String]来收集输出内容,而不是真的打印:

instance MonadScreen (WriterT [String] m) where
  output s = tell [s]

测试时就可以通过查看Writer收集的内容,验证输出是否符合预期。


这种方式的好处是:

  • 业务逻辑和具体IO实现完全解耦,不用碰MonadIO
  • 测试时可以用纯函数式的模拟,轻松用QuickCheck生成测试用例
  • 扩展新效应(比如输入)时,只需要加新的类型类和实例,不影响现有逻辑

内容的提问来源于stack exchange,提问作者Enlico

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.15 08:02:41