PAYATHON 2026

如何单元测试以 IO 为基础层的 monad transformer stack

技术老齐

结论

业务逻辑不应直接依赖 MonadIO。可以把随机数、屏幕输出、休眠等操作作为当前 monad 中的动作传入,也可以为这些操作定义专门的能力接口。生产环境将这些能力解释为 IO,测试环境则提供确定性的纯实现。

业务函数能否测试,取决于它是否直接调用了不可控的外部效果,与 transformer stack 的结构以及最底层是否为 IO 无关。只要随机数和屏幕操作可以替换,同一段业务逻辑就能在 Identity、State、Writer 等纯 monad 中运行。

最小改法:注入 m 中的动作

可以这样修改原代码:

{-# LANGUAGE FlexibleContexts #-}

import Control.Monad.Reader
import Control.Monad.State.Strict
import System.Random (randomRIO)

type Bounds = (Int, Int)

computation
  :: (MonadState String m, MonadReader String m)
  => (Bounds -> m Int)
  -> m ()
computation randomInt = do
  env <- ask
  state <- get
  value <- randomInt (0, 10)
  put (state ++ env ++ show value)

这里不再需要 MonadIO 约束。computation 只负责请求一个随机数,无须知道这个数来自 IO、固定值还是纯随机数生成器。

生产环境仍可使用 randomRIO:

type App = StateT String (ReaderT String IO)

randomIntIO :: Bounds -> App Int
randomIntIO bounds = liftIO (randomRIO bounds)

main :: IO ()
main = do
  let initialState = "initial state"
      env = "read-only env"

  finalState <-
    runReaderT
      (execStateT (computation randomIntIO) initialState)
      env

  print finalState

这样,MonadIO 只出现在程序边界的 randomIntIO 中,不会影响业务函数。

把 (Int, Int) -> IO Int 放进 ReaderT 无法解决这个问题,因为依赖的返回类型仍被固定为 IO Int。应当注入:

(Int, Int) -> m Int

而不是:

(Int, Int) -> IO Int

使用纯实现进行测试

测试时,可以让随机数始终返回指定值,并在 Identity 上运行整个 stack:

import Data.Functor.Identity

type TestApp = StateT String (ReaderT String Identity)

runTestApp :: Int -> String -> String -> String
runTestApp randomValue initialState env =
  runIdentity $
    runReaderT
      (execStateT program initialState)
      env
  where
    program :: TestApp ()
    program = computation (\_ -> pure randomValue)

例如:

example :: Bool
example =
  runTestApp 7 "initial state" "read-only env"
    == "initial stateread-only env7"

这个测试不会执行任何 IO,结果也完全确定。

使用 QuickCheck 时,可以先由 QuickCheck 生成随机值,再将其作为确定的输入传给 computation:

import Test.QuickCheck

prop_computation :: Property
prop_computation =
  forAll (choose (0, 10)) $ \value ->
    runTestApp value "initial state" "read-only env"
      === "initial stateread-only env" ++ show value

随机性由 QuickCheck 管理。属性测试失败后,QuickCheck 可以重放输入,业务代码在测试过程中不会调用 randomRIO。

同时抽象屏幕输出和休眠

外部操作较多时,可以把它们集中到一个能力记录中:

data Services m = Services
  { randomInt  :: Bounds -> m Int
  , writeText  :: String -> m ()
  , sleepForUs :: Int -> m ()
  }

业务函数接收 Services m:

gameAction
  :: (MonadState String m, MonadReader String m)
  => Services m
  -> m ()
gameAction services = do
  env <- ask
  state <- get
  value <- randomInt services (0, 10)

  let newState = state ++ env ++ show value

  put newState
  writeText services newState

生产环境的实现如下:

ioServices :: Services App
ioServices =
  Services
    { randomInt = liftIO . randomRIO
    , writeText = liftIO . putStrLn
    , sleepForUs = liftIO . threadDelay
    }

其中,threadDelay 需要从 Control.Concurrent 导入。

这种写法通常比在环境中存放 IO 函数更直接。ReaderT 可以继续保存屏幕尺寸等配置,外部能力则通过参数传入。如果项目需要统一管理依赖,也可以把 Services m 放进环境,但各字段仍应返回 m a,不能固定为 IO a。

用 Writer 记录屏幕操作

测试 Snake 这类游戏时,通常不需要真的绘制屏幕。可以先把绘制行为表示成数据:

data ScreenCommand
  = ClearScreen
  | DrawText Int Int String
  | MoveCursor Int Int
  deriving (Eq, Show)

业务逻辑负责生成命令:

import Control.Monad.Writer.Strict

renderScore
  :: MonadWriter [ScreenCommand] m
  => Int
  -> m ()
renderScore score =
  tell [DrawText 0 0 ("Score: " ++ show score)]

测试时只需检查生成的命令:

renderScoreTest :: Bool
renderScoreTest =
  execWriter (renderScore 12)
    == [DrawText 0 0 "Score: 12"]

实际运行游戏时,再用解释器执行这些命令:

runScreenCommand :: ScreenCommand -> IO ()
runScreenCommand command =
  case command of
    ClearScreen ->
      putStr "\ESC[2J"

    DrawText x y text ->
      putStrLn ("draw at " ++ show (x, y) ++ ": " ++ text)

    MoveCursor x y ->
      putStrLn ("move to " ++ show (x, y))

实际使用的 ANSI 转义序列或终端库调用应集中在解释器中。业务测试只检查应该绘制的内容。

也可以用 State Screen 模拟屏幕,这种方式尤其适合测试覆盖、碰撞和光标移动。如果业务只需要发出绘制指令,Writer [ScreenCommand] 通常更简单。只有需要读取当前屏幕内容时,才有必要引入 State Screen。

更适合游戏的设计:纯状态转换

还可以把游戏循环拆成纯计算和效果解释两部分:

data Input
  = Tick Int
  | KeyPressed Char
  deriving (Eq, Show)

data Effect
  = Render ScreenCommand
  | RequestRandom Bounds
  | Sleep Int
  deriving (Eq, Show)

step :: Config -> Input -> GameState -> (GameState, [Effect])
step config input state =
  -- 计算下一帧状态,并返回需要执行的效果
  undefined

外层循环负责:

  1. 读取键盘或定时事件。
  2. 调用 step。
  3. 执行 Effect。
  4. 把随机结果转换成下一次输入或事件。

这样,大部分游戏规则都能写成普通纯函数。QuickCheck 可以直接验证边界、碰撞、蛇身增长等性质,终端输出和随机数生成只需少量集成测试。

无须为了追求纯度一次性重写整个程序。先把 liftIO 从复杂的业务函数移到少数边界函数,通常已经能明显改善可测试性。

测试完整 transformer stack 时要注意什么

StateT、ReaderT、WriterT 和 MaybeT 本身不会妨碍测试。测试时按照 transformer 的嵌套顺序依次运行即可:

runIdentity
  . runReaderT
  . runStateT
  . runWriterT
  . runMaybeT

具体的参数顺序由实际 stack 决定。

transformer 的排列会改变语义,需要特别留意。例如:

MaybeT (State s)

和:

StateT s Maybe

在失败后能否观察到状态更新,两者的行为可能不同。WriterT 和 MaybeT 的排列也会影响失败时是否保留日志。测试 runner 应采用与生产环境相同的排列,除非测试明确要验证另一种解释方式。

是否需要完全避免 IO

不需要。单元测试本身运行在 IO 中很正常,Haskell 测试框架通常也是如此。测试时应避免以下情况:

  • 调用不可预测的 randomRIO。
  • 直接向真实终端输出。
  • 真的等待一段时间。
  • 依赖全局状态或当前时间。
  • 无法观察函数请求了哪些外部操作。

如果某项操作只能在 IO 中模拟,可以使用 IORef、队列或临时资源记录调用。对于随机数、屏幕命令和休眠,纯解释器一般更简洁,测试也更稳定。

复杂函数应依赖范围窄、含义明确的能力,例如“取得一个范围内的整数”或“提交一条屏幕命令”,再由程序最外层把这些能力连接到 IO。这样既能保留现有的 transformer stack,也能使用 QuickCheck 或普通断言对核心逻辑进行确定性测试。

备注:内容仅供参考。