A universal approach to mocking with continuations

A universal approach to mocking with continuations

使用 Continuation(延续)进行 Mock 的通用方法

A universal approach to mocking Building on continuations I recently wrote about how to abstract code that might run synchronously or asynchronously and with or without the possibility of errors. The abstraction introduced was continuations and it is proven that continuations can represent any monadic computation. 在延续(continuation)的基础上构建 Mock 的通用方法:我最近写了一篇文章,探讨了如何抽象那些可能同步或异步运行、且可能包含或不包含错误的代码。所引入的抽象概念是“延续”,并且已经证明延续可以表示任何单子(monadic)计算。

In this post I show how we can defunctionalise the continuations to a single data type that makes for clean and effective testing. For a refresher on continuations in Gleam check out the previous post. In that post we ended up with the following business logic function. 在这篇文章中,我将展示如何将延续去函数化(defunctionalise)为一个单一的数据类型,从而实现简洁有效的测试。如果需要回顾 Gleam 中的延续概念,请查看上一篇文章。在那篇文章中,我们最终得到了如下的业务逻辑函数。

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))
  })
}

For each shape of computation (each reification of t) we implement a runner. So there was a runner for fallible computation, where t was a Result, and another for asynchronous, where t was a Promise. 对于每种计算形态(t 的每种具体化),我们都会实现一个运行器(runner)。因此,对于 t 为 Result 的易错计算,有一个运行器;对于 t 为 Promise 的异步计算,则有另一个运行器。

Testing requirements

测试需求

Testing our implementation of task needs to check: 测试我们的 task 实现需要检查:

  • That fetch is called with the correct arguments.
  • fetch 是否以正确的参数被调用。
  • That the return from fetch is processed correctly.
  • fetch 的返回值是否被正确处理。

We are injecting a value of fetch so we can provide a dummy value and use that in our testing. 我们正在注入一个 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)
}

This is ok, but not great. First up we aren’t testing that the key is correct. Second because we always return the same value we aren’t testing with different length values. Maybe our implementation returns the list reversed and we would never know. 这还可以,但不够好。首先,我们没有测试 key 是否正确。其次,因为我们总是返回相同的值,所以我们没有测试不同长度的值。也许我们的实现把列表反转了,而我们永远不会发现。

Is better mocking the solution? 更好的 Mock 是解决方案吗?

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)
}

This is a more sophisticated version of fetch. It returns different length values and breaks our test for the wrong key. But it’s not perfect, it still doesn’t assert that the effects are called in the right order. The logic in fetch is growing and we need to write a new mock for each test. The distance between an implementation and assertion is also growing. To check that the value 6 is correct you need to go up to the declaration of the string “banana”. For larger tests and mocks this distance only increases. 这是一个更复杂的 fetch 版本。它返回不同长度的值,并且会在 key 错误时使测试失败。但它并不完美,它仍然没有断言副作用(effects)是否按正确的顺序调用。fetch 中的逻辑在增长,我们需要为每个测试编写一个新的 mock。实现与断言之间的距离也在增加。要检查值 6 是否正确,你需要向上查看字符串 “banana” 的声明。对于更大的测试和 mock,这种距离只会进一步增加。

Capture effects instead of performing them

捕获副作用而非执行它们

The trick to improve our testing is to realise that fetch doesn’t need to call our continuation at all. Instead we can create a new type Effect that captures a call to fetch returning both the key and resume function to the caller. In this case the caller will be the test. This is clearest to see from the implementation. 改进测试的诀窍在于意识到 fetch 根本不需要调用我们的延续。相反,我们可以创建一个新的 Effect 类型,它捕获对 fetch 的调用,并将 key 和恢复函数(resume function)返回给调用者。在这种情况下,调用者就是测试。从实现中可以最清楚地看到这一点。

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)
}

The type Effect has variants for each effect our task might use and a final variant Pure for when the task has finished. In this example the only effect variant is Fetch. Supporting a new effect in task is as simple as adding a new variant to Effect. The implementation of fetch just builds an Effect type, it does not call resume. Effect 类型为我们的 task 可能使用的每个副作用提供了变体,并为任务完成时提供了一个最终变体 Pure。在这个例子中,唯一的副作用变体是 Fetch。在 task 中支持一个新的副作用就像在 Effect 中添加一个新变体一样简单。fetch 的实现只是构建了一个 Effect 类型,它不会调用 resume。

Tests drive the computation

测试驱动计算

Let’s rewrite the task test using this runner. 让我们使用这个运行器重写 task 测试。

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")
}

This is much clearer and has several benefits: 这清晰多了,并且有几个好处:

  • There is no test specific mock, the implementation of fetch is shared across all tests.
  • 没有特定于测试的 mock,fetch 的实现可以在所有测试中共享。
  • Order of calls is checked, we check fetch is first called with A and then a second time with B.
  • 调用顺序得到了检查,我们检查 fetch 是否首先以 A 调用,然后第二次以 B 调用。
  • We assert there are no extra effectful calls.
  • 我们断言没有额外的副作用调用。
  • Resume values are close to assertions, “apple” and “banana” are defined in the test, not the mock.
  • 恢复值(Resume values)紧邻断言,“apple” 和 “banana” 是在测试中定义的,而不是在 mock 中。

Conclusion

结论

I like this approach because it works the same way for every effect. The downside is it forces continuations everywhere you have effectful code. A quick side note: I still try to keep my core logic pure and effects at the edges of my system. So for much of the code there is no change. I’m also mostly building on the JavaScript runtime so replacing promises, which there is no way to avoid, with continuations is not much of a cost. In return I get a clean approach to testing because of everything it doesn’t contain. I have no mocking library, no stub objects, no dependency injection framework, no async test runner. 我喜欢这种方法,因为它对每个副作用都以相同的方式工作。缺点是它强制你在所有有副作用代码的地方使用延续。顺便提一下:我仍然尽量保持核心逻辑纯净,并将副作用放在系统的边缘。因此,对于大部分代码来说,没有任何变化。我主要是在 JavaScript 运行时上构建,所以用延续替换无法避免的 Promise 并没有太大的代价。作为回报,我获得了一种简洁的测试方法,因为它不包含任何多余的东西。我没有 mock 库,没有存根对象,没有依赖注入框架,也没有异步测试运行器。

I’m building EYG an experiment in a building better languages and tools; for some measure of better. All progress is reported in my irregular newsletter. 我正在构建 EYG,这是一个关于构建更好的语言和工具的实验;在某种程度上是“更好”。所有的进展都会在我不定期更新的时事通讯中报告。