一种基于延续的通用模拟方法
摘要
一篇博客文章,介绍了在测试中使用延续来捕获效果,从而实现一种通用的模拟方法。这种方法允许测试在不执行效果的情况下,验证正确的参数和调用顺序。该技术以 Gleam 编程语言演示。
<p><a href="https://lobste.rs/s/01n0ss/universal_approach_mocking_with">评论</a></p>
查看缓存全文
缓存时间: 2026/07/30 13:51
# 通用的模拟方法
来源:https://crowdhailer.me/2026-07-30/a-universal-approach-to-mocking/
## 基于延续
我最近写了一篇文章,介绍如何抽象可能同步或异步执行的代码,以及可能包含或排除错误的情况。该抽象引入了延续,并且已被证明(https://dl.acm.org/doi/10.1145/174675.178047)延续可以表示任何单子计算。
在这篇文章中,我将展示如何将延续去函数化(defunctionalise)为单一的数据类型,从而实现干净高效的测试。
关于 Gleam 中延续的复习内容,请查看上一篇文章(https://crowdhailer.me/2026-07-15/abstracting-effects-with-continuations/)。那篇文章中,我们最终得到了如下的业务逻辑函数。
``
import midas/continuation.{type Continuation as K}
pub fn task(fetch: fn(String) -> K(t, String)) -> K(t, List(Int)) {
let keys = ["a", "b"]
continuation.each(keys, fn(key) {
let key = string.uppercase(key)
use value <- continuation.then(fetch(key))
continuation.return(string.length(value))
})
}
``
对于每一种计算形态(即每种 `t` 的具体化),我们实现了对应的运行器。例如,有一个用于可能失败的计算的运行器,其中 `t` 是 `Result`;另一个用于异步计算,其中 `t` 是 `Promise`。
## 测试需求
测试 `task` 的实现需要检查:
1. `fetch` 是否以正确的参数被调用。
2. `fetch` 的返回值是否被正确处理。
我们注入了 `fetch` 的值,因此可以提供一个虚拟值并在测试中使用。
``
import midas/continuation
fn run_simple(task) -> List(Int) {
let fetch = fn(_key) { continuation.return("yes") }
task(fetch)(fn(x) { x })
}
fn run_simple_test() {
let assert [3, 3] = run_simple(task)
}
``
这样还可以,但不够好。首先,我们没有测试键是否正确;其次,因为总是返回相同的值,我们无法测试不同长度的值。也许我们的实现返回了反转后的列表,而我们永远无法知道。
那么更好的模拟方法是不是这样?
``
import midas/continuation
fn run_simple(task) -> List(Int) {
let fetch = fn(key) {
let value = case key {
"A" -> "apple"
"B" -> "banana"
_ -> panic as "unexpected key"
}
continuation.return(value)
}
task(fetch)(fn(x) { x })
}
fn run_simple_test() {
let assert [5, 6] = run_simple(task)
}
``
这是一个更复杂的 `fetch` 版本。它返回不同长度的值,并在键错误时破坏我们的测试。但这并不完美,它仍然没有断言这些效应被按正确的顺序调用。
`fetch` 中的逻辑变得越来越复杂,并且我们需要为每个测试编写一个新的模拟。实现与断言之间的距离也在增加。要检查值 `6` 是否正确,你需要向上追溯到字符串 `"banana"` 的声明。对于更大的测试和模拟,这个距离只会越来越大。
## 捕获效应而非执行它们
改进测试的技巧在于认识到 `fetch` 根本不需要调用我们的延续。相反,我们可以创建一个新的类型 `Effect`,它捕获对 `fetch` 的调用,同时将键和恢复函数返回给调用者。在这种情况下,调用者将是测试。
从实现中看这一点最为清晰。
``
import midas/continuation.{type Continuation as K}
pub type Effect(r) {
Fetch(key: String, resume: fn(String) -> Effect(r))
Pure(value: r)
}
pub fn fetch(key: String) -> K(Effect(t), String) {
fn(resume) { Fetch(key, resume) }
}
fn run_capture(task) -> Effect(_) {
task(fetch)(Pure)
}
``
`Effect` 类型为任务可能使用的每个效应提供了一个变体,以及一个最终变体 `Pure`,用于任务完成时的情形。在这个例子中,唯一的效应变体是 `Fetch`。如果在 `task` 中支持新的效应,只需向 `Effect` 添加一个新变体即可。
`fetch` 的实现仅仅构建了一个 `Effect` 类型,它并未调用 `resume`。
## 测试驱动计算
让我们用这个运行器重写任务测试。
``
fn capture_test() {
let assert Fetch("A", resume) = run_capture(task)
let assert Fetch("B", resume) = resume("apple")
let assert Pure([5, 6]) = resume("banana")
}
``
这清晰得多,并且有几个好处:
- 没有针对特定测试的模拟,`fetch` 的实现是所有测试共享的。
- 调用的顺序被检查了:我们检查 `fetch` 首先以 `"A"` 被调用,然后第二次以 `"B"` 被调用。
- 我们断言没有额外的效应调用。
- 恢复值靠近断言:`"apple"` 和 `"banana"` 是在测试中定义的,而不是在模拟中。
## 结论
我喜欢这种方法,因为它对每一种效应都以相同的方式工作。缺点是在所有带有效应代码的地方都强制使用了延续。快速提一点:我仍然尽量保持核心逻辑纯函数化,将效应放在系统边界。所以对于大部分代码来说,没有变化。
而且我主要构建在 JavaScript 运行时之上,所以用延续替代无法避免的 promises 并没有太大成本。
作为回报,我获得了一个干净的测试方法,因为它不包含任何额外的东西。没有模拟库,没有桩对象,没有依赖注入框架,没有异步测试运行器。
---
我正在构建 EYG(https://eyg.run/),这是一个关于构建更好语言和工具的实验——所谓“更好”也是相对的。
所有进展都会在我的不定期通讯中报告。
相似文章
使用延续抽象效果
本文演示了如何在Gleam编程语言中使用延续来抽象不同的计算效果(如错误处理和异步),从而实现可重用的业务逻辑。
上下文使测试更具可重用性
作者分享了在Guile中设计测试框架的经验,重点探讨了向测试定义添加上下文如何使测试更可重用并改善开发者体验。
模拟 Rust 代码的所有方法
一篇教程,涵盖了在 Rust 中模拟网络调用的多种策略,以发出事件的 Kubernetes 控制器为例,重点强调不降低生产代码的可测试性。
创建一个假敏捷包装器:技术上敏捷,但在其所属单元之外毫无用处(第 1 部分)
Raymond Chen 介绍了一种在 COM 中创建假敏捷包装器的技术,该技术利用全局接口表(GIT)在对象所在的源单元关闭时自动释放对象,从而解决了上下文回调失败的问题。
代数效应:给普通人的解释
这是一篇教育性博客文章,通过类比 try/catch 和 async/await 来解释编程中的代数效应概念,并讨论了它们与 React 及未来编程范式的潜在关联。