将功能测试构建为 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 步流程中,该树看起来像:

在这里,第一个“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 树有几个好处:

为什么我找不到其他人使用这种方法?我的猜测是,面向对象语言的所有副作用都会鼓励一种破坏性的心态,即在单元测试方面——在每次测试后销毁所有可能的状态。但是对于具有许多步骤的功能测试,这种方法非常低效——如果你想测试梯子上的每一根横档,那么每次测试都爬到地面并费力地爬回去是毫无意义的。

要编写你自己的基于 continuation 树的测试框架,你只需要一个数据库堆栈(或者更确切地说,是一个支持回滚到任意版本的数据库)。我不知道哪些数据库支持这种版本的控制功能,但是向 Chicago Boss 的内存数据库添加该功能大约需要 25 行 Erlang 代码。

一旦你开始将功能测试编写为断言和 continuation 树,你真的会想知道你以前是怎么做的。这只是那些事后看来太明显的想法之一。