如何单元测试以 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
外层循环负责:
- 读取键盘或定时事件。
- 调用
step。 - 执行
Effect。 - 把随机结果转换成下一次输入或事件。
这样,大部分游戏规则都能写成普通纯函数。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 或普通断言对核心逻辑进行确定性测试。
备注:内容仅供参考。
