将功能测试构建为 Continuation 树 (2010)
将功能测试构建为 Continuation 树
作者:Evan Miller 2010年6月15日
维护计算机代码长期质量的最重要实践之一是编写自动化测试,以确保程序在其他人(包括未来的你)修改它时,仍然按照预期运行。
测试代码通常比被测试的代码更长。一位前同事估计,正确比例大约是每行“真实”代码对应 3 行功能测试代码。编写测试代码通常是枯燥乏味且重复的,因为你所做的只是追踪用户可能采取的所有步骤,并记录下每个步骤中应该为真的所有事情。
大量的测试代码仅仅是为了设置测试而存在的。有时你可以将逻辑抽象到 setup()
和 teardown()
函数中,并将测试所需的虚假数据放入称为 fixtures 的东西中。即便如此,你仍然会有一部分测试需要额外的设置,并且测试代码经常会散布着微型的、非官方的设置函数。
问题
令我惊讶的是,大多数测试环境的结构都是_测试列表_。测试列表对于单元测试来说是合理的,但对于功能测试来说却是一场灾难。列表结构是功能测试套件如此重复的主要原因之一。
假设你想测试一个 5 步流程,并测试每个步骤中的“Continue”和“Cancel”按钮。你只能通过在之前的步骤中按下“Continue”来进入下一步。使用传统的“测试列表”范例,你必须编写类似以下内容:
test_cancel_at_step_one() {
Result = do_step_one("Cancel");
assert_something(Result);
}
test_cancel_at_step_two() {
Result = do_step_one("Continue");
assert_something(Result);
Result = do_step_two("Cancel");
assert_something(Result);
}
test_cancel_at_step_three() {
do_step_one("Continue"); # tested above
Result = do_step_two("Continue");
assert_something(Result);
Result = do_step_three("Cancel");
assert_something(Result);
}
test_cancel_at_step_four() {
do_step_one("Continue"); # tested above
do_step_two("Continue"); # tested above
Result = do_step_three("Continue");
assert_something(Result);
Result = do_step_four("Cancel");
assert_something(Result);
}
test_cancel_at_step_five() {
do_step_one("Continue"); # tested above
do_step_two("Continue"); # tested above
do_step_three("Continue"); # tested above
Result = do_step_four("Continue");
assert_something(Result);
Result = do_step_five("Cancel");
assert_something(Result);
}
正如你开始看到的那样,每个测试的长度都在我们测试的步骤数量上线性增长,因此整个测试套件的长度最终为 O(N2)(其中 N 是步骤的数量)。
解决方案
更适合功能测试的数据结构是_测试树_。该树本质上映射了每个步骤中可能的操作。在每个节点上,都有一组断言,父节点将_状态副本_传递给每个子节点(代表可能的用户操作)。子节点可以自由修改和断言从父节点接收到的状态,并将修改后的状态副本传递给_其_子节点。节点不应影响父节点或兄弟节点的状态。
让我们举一个具体的例子。在一个 5 步流程中,该树看起来像:
- 步骤 1
- Cancel
- Continue to Step 2
- Cancel
- Continue to Step 3
- Cancel
- Continue to Step 4
- Cancel
- Continue to Step 5
在这里,第一个“Cancel”和“Continue to Step 2”就像平行宇宙。我们不想重复步骤 1 来测试每一个,而是想在步骤 1 结束时自动复制宇宙,然后在每个平行宇宙上运行子测试。如果我们能够以这种方式将测试编写为树,那么整个测试套件的长度将为 O(N)(其中 N 是步骤的数量),而不是 O(N2)。
对于现代 Web 应用程序,所有状态都存储在数据库中。因此,要“复制宇宙”,我们只需要一种方法来复制数据库,并将其传递给子测试,同时保留树中更上面的测试可以复制和使用的旧版本副本。
解决方案是实现一个_数据库堆栈_。当我们沿着测试树向下走时,我们将当前数据库的副本推入堆栈,并且子节点可以使用堆栈顶部的数据库。当我们完成一组子节点并返回到测试树上时,我们会将修改后的数据库从堆栈中弹出,返回到之前的数据库版本。
例子
我不会详细介绍如何编写测试框架或数据库堆栈,但这是你如何使用 Chicago Boss 的测试框架测试多步骤流程的。这是一个在 Erlang 中实现为嵌套回调的树。每个“节点”都是一个 HTTP 请求,其中包含对响应进行断言的回调列表,以及标记的 continuation 回调列表——这些是子节点。每个子节点都会收到一个新的数据库副本,它可以随心所欲地使用。管理数据库堆栈的所有操作都在后台完成。
生成的测试代码非常优雅:
start() ->
boss_test:get_request("/step1", [],
[ % Three assertions on the response
fun boss_assert:http_ok/1,
fun(Res) -> boss_assert:link_with_text("Continue", Res) end
fun(Res) -> boss_assert:link_with_text("Cancel", Res) end
],
[ % A list of two labeled continuations; each takes the previous
% response as the argument
"Cancel at Step 1", % First continuation
fun(Response1) ->
boss_test:follow_link("Cancel", Response1,
[ fun boss_assert:http_ok/1 ], []) % One assertion, no continuations
end,
"Continue at Step 1", % Second continuation
fun(Response1) ->
boss_test:follow_link("Continue", Response1,
[ fun boss_assert:http_ok/1 ], [
"Cancel at Step 2", % Two more continuations
fun(Response2) ->
boss_test:follow_link("Cancel", Response2,
[ fun boss_assert:http_ok/1 ], [])
end,
"Continue at Step 2",
fun(Response2) ->
boss_test:follow_link("Continue", Response2,
[ fun boss_assert:http_ok/1 ], [
"Cancel at Step 3",
fun(Response3) ->
boss_test:follow_link("Cancel", Response3,
[ fun boss_assert:http_ok/1 ], [])
end,
"Continue at Step 3",
fun(Response3) ->
boss_test:follow_link("Continue", Response3,
[ fun boss_assert:http_ok/1 ], [
"Cancel at Step 4",
fun(Response4) ->
boss_test:follow_link("Cancel", Response4,
[ fun boss_assert:http_ok/1 ], [])
end,
"Continue at Step 4",
fun(Response4) ->
boss_test:follow_link("Continue", Response4,
[ fun boss_assert:http_ok/1 ], [])
end ]) end ]) end ]) end ]).
如果缩进变得难以控制,我们可以简单地将 continuation 列表放入一个新函数中。
结论
将功能测试构建为 continuation 树有几个好处:
- 消除代码重复。 我们不需要为步骤 4 中可以采取的每个操作重复步骤 1-3。这减少了代码库,以及执行所需的时间。
- 消除大多数设置代码。 只要设置操作与 HTTP 接口相关联,所有设置都可以作为测试树的一部分完成,而不会降低性能。
- 精确定位失败测试的来源。 如果父节点中的断言失败,我们可以立即停止,然后再运行子节点。相比之下,测试列表通常会为一个 bug 产生很长的失败列表,从而更难找到根源。
- 测试结构良好。 节点和用户看到的内容之间存在 1 对 1 的映射,子节点和用户接下来可以执行的操作之间存在 1 对 1 的映射。测试以结构良好的层次结构出现,而不是杂乱无章的“我能想到的所有事物列表”。
- 先前响应的轨迹都在作用域内。 这种好处是支持闭包的语言中回调实现的独特之处——如果你需要比较两个被几个中间请求分隔的请求的输出,这会很方便。使用测试列表,你必须在返回值和函数参数中传递数据,但在这里,所有先前的响应都触手可及,如 Response1、Response2 等。
为什么我找不到其他人使用这种方法?我的猜测是,面向对象语言的所有副作用都会鼓励一种破坏性的心态,即在单元测试方面——在每次测试后销毁所有可能的状态。但是对于具有许多步骤的功能测试,这种方法非常低效——如果你想测试梯子上的每一根横档,那么每次测试都爬到地面并费力地爬回去是毫无意义的。
要编写你自己的基于 continuation 树的测试框架,你只需要一个数据库堆栈(或者更确切地说,是一个支持回滚到任意版本的数据库)。我不知道哪些数据库支持这种版本的控制功能,但是向 Chicago Boss 的内存数据库添加该功能大约需要 25 行 Erlang 代码。
一旦你开始将功能测试编写为断言和 continuation 树,你真的会想知道你以前是怎么做的。这只是那些事后看来太明显的想法之一。