你是否遇到过这样的场景:
那么本文就是为您而作——以一个具体的例子阐述了如何基于已有的代码库启用验收测试驱动开发(acceptance-test driven development)。这是应对技术债解决方案的一部分。
这是带有一定缺陷的现实世界的样例,并不像教科书中的样例那样完美。所以完全是来自于实战。我只会使用Java与Junit,而没有使用其他的第三方测试框架(这些框架基本上已经被过度使用了)。
免责声明:我并不是说这就是正确地方式,关于ATDD有很多其他的“偏好(flavors)” 。同时,本文中也没有太多新鲜的或革新性的内容,它只是一些确定的实践以及通过辛苦得来的经验。
几天前,我开始为webwhiteboard.com(我的小项目)添加密码保护的特性。很久以来,人们就希望有一种密码保护在线白板的方式,现在该实现这个功能了。
听起来这是一个很简单的特性,但是需要做很多的设计决策。到目前为止,webwhiteboard.com是基于匿名使用的,没有任何的账户、登录或密码这样的东西。什么人能够保护白板呢?谁能访问它呢?如果我忘记密码该怎么办?我们如何在足够安全的同时保证尽可能简单?
webwhiteboard的代码库有着很棒的单元测试和集成测试的覆盖率。但是它并没有验收测试,也就是站在用户的角度进行端对端流程的测试。
webwhiteboard的主要设计目标是很简单的:尽可能简化登录、账户以及其他繁琐事情的需求。所以我为密码特性建立了两个设计限制:
这里有很多尚未确定的事情:我并不确定它会如何工作,更不确定如何去实现它。因此,以下就是我的实现方式(基本的ATDD):
这是一个迭代式的过程,所以每一步中我都可能会返回上一步进行调整(这是我经常做的事情)。
假设这个特性已经完成了。在我睡觉的时候有个天使来了并实现了这个特性。这听起来美妙得难以令人置信!那我该如何对其进行检验呢?要进行手工测试的话,我首先要做什么呢?应该是:
当我编写完这个小的测试脚本后,我意识到要考虑很多可选的流程。但这就是主要的场景。如果我能够让它运行起来,就已经取得了很大的进展。
这是一个需要技巧的步骤。我并没有其他端到端的验收测试,所以我该如何开始呢?这个特性会与第三方的认证系统(我最初的选择是使用Janrain)以及数据库交互,并且这里还有Web相关的内容,包括弹出对话框、令牌(token)以及重定向等等。
现在该退后一步。在解决“我该如何编写这个验收测试”之前,我首先要解决一个更为基本的问题,那就是“基于这个代码库,我到底该怎样编写验收测试呢?”
为了推进这个问题,我试图识别可以进行测试的“尽可能简单的特性”,这就是今天已经可用的一些特性。
以下就是我能够想到的测试步骤:
我该如何实现这个测试呢?使用什么框架?什么工具?它是否应该涉及到GUI,或者忽略它?是否涉及到客户端代码还是直接与服务器进行交流?
有很多的问题。技巧在于:不要回答这些问题!只要假设这些问题已经以某种方式很漂亮地解决了,并将测试编写为伪代码的形式。如下。
public class AcceptanceTest { @Test public void openWhiteboardThatDoesntExist() { //1. 试图打开一个不存在的白板 //2. 检查确认无法得到白板 } } |
我运行它,并且成功了!太棒了!但是稍等一下,这是错误的!在TDD三角(“红-绿-重构”)中的第一步是红色。所以,我需要让这个测试失败,以证明这是一个需要实现的特性。
我会编写一些真正的测试代码。不过,这些伪代码能够保证我的方向是正确的。
为了将其变成真正的测试,我构建了一个名为AcceptanceTestClient的类,我假装它已经魔法般地解决了所有的问题并且为我提供了漂亮的、高级的API来运行验收测试。它的使用很简单,如下:
client.openWhiteboard("xyz");
assertFalse(client.hasWhiteboard());
当我编写这些代码的时候,创建了一个API来适应这个测试用例的需求。它应该与伪代码的行数差不多。
接下来,我使用Eclispe的快捷键自动生成空白版本的AcceptanceTestClient以及我所需要的方法:
public class AcceptanceTestClient { public void openWhiteboard(String string) { // TODO Auto-generated method stub } public boolean hasWhiteboard() { // TODO Auto-generated method stub return false; } } |
以下是完整的测试类:
public class AcceptanceTest { AcceptanceTestClient client; @Test public void openWhiteboardThatDoesntExist() { //1. 试图打开一个不存在的白板 client.openWhiteboard("xyz"); //2. 检查确认我无法得到白板 assertFalse(client.hasWhiteboard()); } } |
运行测试,但是它会失败(因为客户端为null)。很好!
我都做了些什么呢?其实没有太多。但这是一个起点。验收测试的帮助类AcceptanceTestClient已经有了雏形。
下一步就是将这个验收测试变为绿色。
我现在所要解决的是一个更为简单的问题。我不需要关心认证以及多用户等等的问题。稍后我可以为这些问题添加测试。
对于AcceptanceTestClient,实现很标准——模拟数据库(我已经有这样的代码了)并运行一个内存版本的完整的webwhiteboard系统。
以下为生产环境的设置:
(点击图片放大)
技术细节:Web Whiteboard使用了GWT(Google Web Toolkit)。任何事情都是使用Java编写的,但是GWT会自动将客户端代码转换为JavaScript,并插入RPC(Remote Procedure Calls)的逻辑,从而封装异步客户端-服务器通信的繁琐细节。
在验收测试的环境中,我对系统进行了“短路(short circuit)”,移除掉了所有的框架、第三方服务以及网络通信。
(点击图片放大)
我创建了AcceptanceTest客户端,它会与web whiteboard服务进行交互,就像真实的客户端一样。区别都是在幕后的:
同时,验收测试配置将mongo数据库(基于云的NoSQL数据库)替换为虚拟的内存数据库。
这种虚拟的原因在于简化环境、让测试运行得更快并且确保独立于框架和网络因素来测试业务逻辑。
看起来这似乎是一个很复杂的环境搭建过程,但实际上只是包含3行代码的初始化方法。
public class AcceptanceTest { AcceptanceTestClient client; @Before public void initClient() { WhiteboardStorage fakeStorage = new FakeWhiteboardStorage(); WhiteboardService service = new WhiteboardServiceImpl(fakeStorage); client = new AcceptanceTestClient(service); } @Test public void openWhiteboardThatDoesntExist() { client.openWhiteboard("xyz"); assertFalse(client.hasWhiteboard()); } } |
WhiteboardServiceImpl是web whiteboard系统中已有的服务端实现。
注意AcceptanceTestClient的构造函数中接受一个WhiteboardService实例(这种模式称之为”依赖注入“)。这种方式给我们带来了一种便利:它不关心配置。相同的AcceptanceTestClient可以用于真实环境的测试,只需将真实配置的WhiteboardService实例传递给它即可。
public class AcceptanceTestClient { private final WhiteboardService service; private WhiteboardEnvelope envelope; public AcceptanceTestClient(WhiteboardService service) { this.service = service; } public void openWhiteboard(String whiteboardId) { boolean createIfMissing = false; this.envelope = service.getWhiteboard(whiteboardId, createIfMissing); } public boolean hasWhiteboard() { return envelope != null; } } |
总而言之,AcceptanceTestClient模拟了真实的web whiteboard客户端所做的事情,同时又为验收测试提供了较高层次的API。
你可能想知道,“既然我们已经有了WhiteboardService可以进行直接交互,为什么还要AcceptanceTestClient呢?”。这里有两个原因:
我不会向你过多地阐述AcceptanceTestClient的细节,因为本文不会探究web whiteboard的内部实现。简单来说,AcceptanceTestClient将与白板服务接口交互的低层次细节匹配到验收测试的需要上。这很容易实现,因为真正的客户端代码可以作为如何与服务进行交互的教程。
到此为止,我们尽可能简单的验收测试可以通过了!
@Test public void openWhiteboardThatDoesntExist() { myClient.openWhiteboard("xyz"); assertFalse(myClient.hasWhiteboard()); } |
下一步要进行一些清理。
实际上,我并没有为此编写任何的生产环境代码(因为这些特性已经存在并且可用),这是测试框架的代码。我需要花几分钟的时间对其进行清理、移除重复内容、让方法名更为整洁等。
最后,我又添加了一个测试,只是为了完整性,而且它确实很简单。
@Test public void createNewWhiteboard() { client.createNewWhiteboard(); assertTrue(client.hasWhiteboard()); } |
非常好,我们有了一个测试框架!我们甚至没有使用任何第三方的库,只是Java和Junit。
现在,该对我的密码保护特性添加测试了。
首先,我将最初的“规范(spec)”复制为伪代码:
@Test public void passwordProtect() { //1. 我创建一个新的白板。 //2. 我对其设置一个密码。 //3. Joe试图打开我的白板,被要求输入密码。 //4. Joe输入错误的密码,被拒绝访问。 //5. Joe再次尝试输入正确的密码,可以进行访问。 } |
现在,我再次编写测试代码,假设AcceptanceTestClient已经具备了所有需要的东西,并且完全按照我要求的方式,我发现这种技术是相当有用的。
@Test public void passwordProtect() { //1. 我创建一个新的白板。 myClient.createNewWhiteboard(); String whiteboardId = myClient.getCurrentWhiteboardId(); //2. 我对其设置一个密码。 myClient.protectWhiteboard("bigsecret"); //3. Joe试图打开我的白板,被要求输入密码。 try { joesClient.openWhiteboard(whiteboardId); fail("Expected WhiteboardProtectedException"); } catch (WhiteboardProtectedException err) { //Good } assertFalse(joesClient.hasWhiteboard()); //4. Joe输入错误的密码,被拒绝访问。 try { joesClient.openProtectedWhiteboard(whiteboardId, "wildguess"); fail("Expected WhiteboardProtectedException"); } catch (WhiteboardProtectedException err) { //Good } assertFalse(joesClient.hasWhiteboard()); //5. Joe再次尝试输入正确的密码,可以进行访问。 joesClient.openProtectedWhiteboard(whiteboardId, "bigsecret"); assertTrue(joesClient.hasWhiteboard()); } |
这个测试代码只需要几分钟就能编写完成,因为我可以在进一步编写代码的时候再将这些逻辑组织起来。这些方法在AcceptanceTestClient中几乎都(还)不存在。
当我编写这些代码的时候,我需要做出一些设计决策。不要费力去想,做第一时间进入你脑海的事情。完美是足够好的敌人,现在,我已经足够好了,也就是一个可运行的失败的测试用例。稍后,当运行测试变成绿色时,我再进行重构并进一步思考设计。
现在就进行重构是一件很有诱惑力的事情,尤其是重构这些丑陋的try/catch语句。但是TDD规约中有一点就是在进行重构之前要首先将其变成绿色,因为测试会保护你的重构。所以我决定先暂时等待一下再进行清理。
按照测试三角,下一步要运行测试,但是会失败。
同样,我使用Eclipse快捷键来创建缺失方法的空白版本。很好!运行测试,看,出现了红色!
现在,我需要编写一些生产级别的代码。我为系统添加一些新的概念,有一些所添加的代码并不是试验性的,因此需要进行单元测试。我使用了TDD的方式,它与ATDD类似,但是范围更小一些。
以下展现了ATDD和TDD如何组合在一起。可以将ATDD视为外部的循环:
对于每个验收测试循环(在特性级别)的回路中,我们都会有很多单元测试的回路(在类和方法级别)。
所以,尽管我在较高的层次上关注于将验收测试变为绿色(这可能会耗费几个小时的时间),但是在较低的层次上我可能会关注于将下一个单元测试变为红色(这可能只会耗费几分钟的时间)。
这并不是非常严格的TDD(Leather & Whip TDD)。 这更像是“至少要保证单元测试与生产级别的代码是同时提交的”。这种提交每小时会发生多次,大致就可以将其称之为TDD了。
像通常那样,在验收测试变成绿色之后,就要进行清理工作了。不要试图越过这个步骤!就像在饭后清洗餐具一样——需要马上去做。
我不仅清理了生产环境中的代码,还清理了测试代码。例如,我将凌乱的try-catch部分抽取到一个帮助方法之中,从而最终实现了漂亮且整洁的测试方法:
@Test public void passwordProtect() { myClient.createNewWhiteboard(); String whiteboardId = myClient.getCurrentWhiteboardId(); myClient.protectWhiteboard("bigsecret"); assertCantOpenWhiteboard(joesClient, whiteboardId); assertCantOpenWhiteboard(joesClient, whiteboardId, "wildguess"); joesClient.openProtectedWhiteboard(whiteboardId, "bigsecret"); assertTrue(joesClient.hasWhiteboard()); } |
我的目标是让验收测试尽可能简短、整洁并且易于使用,以至于注释都是多余的。最初的伪代码或注释会作为模板——“我希望代码就是如此得简洁!”。移除注释会给我一种成就感,它的一个积极作用就是让方法更加简短了。
重复地进行净化。在第一个测试用例通过之后,我就要开始思考缺失了什么。例如,密码保护应该还需要用户认证。所以,我为此添加一个测试、使其变红色、再变成绿色然后进行清理。诸如此类。
以下就是我(到目前为止)为该特性所添加的完整的测试:
当发现缺陷或添加新特性时,我稍后肯定会添加新的测试。
我总共用了大约两天的时间进行高效地编码。在这个过程中,有很大一部分是回过头去重新编码和设计,并不像本文所展示那样线性进行。
在自动化测试变成绿色后,我也会进行很多的手工测试。但鉴于自动化测试已经覆盖了基本的功能和很多边界场景,因此手工测试可以更多地关注主观性和探查性的内容。高水平的用户体验是什么样的?流程合理吗?它易于理解吗?我需要在什么地方添加帮助文本?按照美学,设计是否可接受?我不想去争取什么设计大奖,但我也不想让它很丑陋。
强大的验收测试能够让我们不必再进行单调且重复性的手工测试(也被称为“搞怪测试monkey testing”),进而节省出时间来进行更有意思和更有价值的手工测试。
理想情况下,我应该在开始阶段就构建验收测试,所以一定程度上来讲这种方式是在偿还技术债。
就这样,我希望这个样例对你有用!它阐述了一种典型的场景——“我要实现新的特性,最好要编写验收测试,但是到目前为止还没有这样的测试,我不知道该使用什么框架,甚至不知道该如何开始”。
我非常喜欢这种模式,借助这种方式我多次走出了困境。总结如下:
完成这些后,你就已经越过了最困难的门槛,已经开始了ATDD!
Henrik Kniberg是斯德哥尔摩Crisp的敏捷/精益教练,主要的工作内容是Spotify。他很乐意帮助公司在软件开发的技术和人力方面取得成功,就像他的图书“Scrum and XP from the Trenches”(本书中文版书名为《硝烟中的Scrum与XP》)、“Kanban and Scrum, making the most of both”以及“Lean from the Trenches”(本书中文版书名为《精益开发实战:用看板管理大型项目》)所描写的那样。
原文英文链接:ATDD From the Trenches