Jamie Phillips 撰写了一系列文章,展示他如何结合编码招式、行为驱动开发以及项目模板,以提高他自己的开发实践能力,这一系列文章由 3 部分组成,这是第 2 部分。这个部分 Jamie 将给读者介绍行为驱动开发(BDD),同时他会解释 BDD 如何提高单元测试的有效性。
我很优秀,但我能变得更好吗?
第 II 部分:行为驱动开发(BDD)
测试驱动开发能克服开发团队遇到的许多问题:开发团队经常在代码实现之后才创建单元测试。使用测试驱动开发,在实现代码时,就要仔细考虑测试,并建立起测试。行为驱动开发则更进一步,通过使用自然语言,直接把单元测试(以及测试用例)同需求联系起来。那么这会带来什么?简而言之——团队所有成员都能理解测试用例以及单元测试;从需求分析师到测试人员到开发人员。
第 I 部分探讨了编码招式,接着让我们继续我在 BDD 中的发现之旅。完成保龄球招式几天后,我开始意识到我可以把重点放到实际的编码风格以及如何最好地去重构代码上,而不是问题本身。这就是 BDD 部分出现的地方。尽管我曾听说过 BDD,也阅读过一些资料,但我还未曾有机会去实际使用它;就像我之前提到过的,我的许多单元测试是基于产品代码的,在产品代码中启用“实验性”的概念是不合时宜的。因此我在实践编程招式时,总是去回想 David 和 Ben 在会上是怎么做的,当我意识到 David 使用 MSpec(Machine Specification——一个.NET 的上下文 / 需求规格框架)就是引入了 BDD,那就是我需要的。David 为了在他的单元测试中使用 BDD 的格式,他使用了 MSpec 程序集,这种方式严重依赖 ReSharper,MSpec 是作为 ReSharper 插件使用的。出于我的经验,以及参与构建系统的经历,我坚持自己的立场,那就是无论我在我的机器上能做什么,在构建机器上也要能做。以 ReSharper 插件方式使用实际上不是一个合适的选择,因为在没有安装 ReSharper 的机器上就没法那么做了。
行为驱动开发
行为驱动开发是一种敏捷软件的开发技术,通过需求分析师、软件测试人员和软件开发人员的紧密合作,将附带测试用例的用例与单元测试紧紧连接在一起。
通常,编写完业务需求后,团队成员会进行深入的探讨,建立起具体的用例,由这些用例驱动测试用例、单元测试和最终代码的实现。通常认为 BDD 比 TDD(测试驱动开发)更进一步,它扩展了测试先行的想法,在编码实现前,预期的结果就已经定义好,并容易理解。
行为驱动开发的本质是想让开发人员、测试人员以及非技术人员或者业务人员可以一起协作,通过使用自然语言一起参与软件的设计,从需求定义到测试,再到实现。这在单元测试层面有巨大的影响,不仅涉及到如何编写测试代码,也牵涉到测试类和方法的命名规范。看看下面这个测试类和测试方法是如何实现的:
<p>[<span color="#0080ff">TestClass</span>]<br></br><span color="#0000ff">public class</span> <span color="#0080ff">ItemLoadTests</span><br></br>{<br></br> [<span color="#0080ff">TestMethod</span>]<br></br><span color="#0000ff"> public void</span> TestLoadNullCustomer()<br></br> {</p><p><span color="#008000"> // Arrange<br></br> // Create the stub instance</span><br></br> <span color="#0080ff">INorthwindContext</span> context = <span color="#0080ff">MockRepository</span>.GenerateStub<<span color="#0080ff">INorthwindContext</span>>();<br></br> <span color="#008000">//IObjectSet<Customer> customers</span> <span color="#008000">= new MockEntitySets.CustomerSet();</span><br></br> <span color="#0080ff">IObjectSet</span><<span color="#0080ff">Customer</span>> customers = <span color="#0080ff">TestHelper</span>.CreateCustomerList().AsObjectSet();</p><p> <span color="#008000">// declare the dummy ID we will use as the first parameter<br></br></span> <span color="#0000ff">const string</span> customerId = <span color="#ff0000">"500"</span>;</p><p> <span color="#008000">// declare the dummy instance we are going to use</span></p><br></br> <span color="#0080ff">Customer</span> loadedCustomer;<p> <span color="#008000">// Explicitly state how the stubs should behave<br></br></span> context.Stub(stub => stub.Customers).Return(customers);</p><p> <span color="#008000">// Create a real instance of the CustomerManager that we want to put under test<br></br></span> Managers.<span color="#0080ff">CustomerManager</span> manager = <span color="#0000ff">new</span> Managers.<span color="#0080ff">CustomerManager</span>(context);</p><p> <span color="#008000">// Act</span></p><br></br> manager.Load(customerId, <span color="#0000ff">out</span> loadedCustomer);<p> <span color="#008000">// Assert</span></p><br></br> context.AssertWasCalled(stub => { <span color="#0000ff">var</span> temp = stub.Customers; });<br></br> <span color="#008000">// Check the expected nature of the dummy intance<br></br></span> <span color="#0080ff">Assert</span>.IsNull(loadedCustomer);<br></br> }<br></br>}
你会注意到单元测试多数是以 AAA 的形式编写的(Arrange 准备、Act 执行、Assert 断言),同时测试的方法名对编写它的开发人员 / 测试人员都非常清楚——别忘了,我们是在测试载入 Null 客户时会发生什么。
顺便说一句,在这个例子中我使用了 RhinoMocks,它是一个 Mock 的框架,用于创建我的 EntityFramework Context 接口 INorthwindContext 的 Mock 实例,不要把它与稍后在 BDD 中使用的 Context 混淆了。
从 BDD 的角度出发,我们扪心自问,这确实是这个功能的意图吗?也许为这个特殊场景编写的用例更像是这样的:
在 Northwind 客户管理的上下文中,当使用系统中不存在的客户 ID 加载客户细节时,应该返回一个空实例。
前面为 Null 客户所做的测试想要去证明这个用例(但它可能是虚构的),这做的不错。不幸的是,漫不经心的观察者会忽略它的语法和上下文。
抓住下面相同的实例,并从用例的角度出发来驱动测试,你就会得到完全不为同的情形。首先要建立一个上下文基类,可以在后续场景中使用它,继承自 Eric Lee 编写的 ContextSpecification 类,它是专门为了在 MSTest 中使用 BDD 编写的。
/// <summary> /// <span color="#008000">Base Context class for CustomerManager Testing<br></br></span>/// </summary> <span color="#0000ff">public class</span> <span color="#0080ff">CustomerManagerContext</span> : <span color="#0080ff">ContextSpecification</span> { <span color="#0000ff">protected</span> <span color="#0080ff">INorthwindContext</span> _nwContext; <span color="#0000ff">protected</span> <span color="#0080ff">IObjectSet</span><<span color="#0080ff">Customer</span>> _customers; <span color="#0000ff">protected string</span> _customerId; <span color="#0000ff">protected</span> <span color="#0080ff">Customer</span> _loadedCustomer; <span color="#0000ff">protected</span> Managers.<span color="#0080ff">CustomerManager</span> _manager; /// <summary> <span color="#008000">/// Prepare the base context to be used by child classes<br></br> </span>/// </summary> <span color="#0000ff">protected override void</span> Context() { <span color="#008000">// Create the stub instance</span> _nwContext = <span color="#0080ff">MockRepository</span>.GenerateStub<<span color="#0080ff">INorthwindContext</span>>(); _customers = <span color="#0080ff">TestHelper</span>.CreateCustomerList().AsObjectSet(); <span color="#008000">// Create a real instance of the CustomerManager that we want to put under test<br></br></span> _manager = <span color="#0000ff">new</span> Managers.<span color="#0080ff">CustomerManager</span>(_nwContext); }
下面一部分代码是实际的测试类(继承了上面的 CustomerManagerContext 类),实现了一些辅助方法和测试方法:
<p>/// <summary><br></br><span color="#008000">/// Test class for CustomerManager Context</span> <br></br>/// </summary><br></br>[<span color="#0080ff">TestClass</span>]<br></br><span color="#0000ff">public class</span> <span color="#0080ff">when_trying_to_load_an_employee_using_a_non_existent_id : CustomerManagerContext</span><br></br>{<br></br> /// <summary><br></br> <span color="#008000">/// The "Given some initial context" method<br></br> </span>/// </summary><br></br> <span color="#0000ff">protected override void</span> Context()<br></br> {<br></br> <span color="#0000ff">base</span>.Context();<br></br> _customerId = <span color="#ff0000">"500"</span>;</p><p> <span color="#008000">// Explicitly state how the stubs should behave<br></br></span> _nwContext.Stub(stub => stub.Customers).Return(_customers);</p><br></br> }<p> /// <summary></p><br></br> <span color="#008000">/// The "When an event occurs" method<br></br> </span>/// </summary><br></br> <span color="#0000ff">protected override void</span> BecauseOf()<br></br> {<br></br> _manager.Load(_customerId, <span color="#0000ff">out</span> _loadedCustomer);<br></br> }<p> /// <summary></p><br></br> <span color="#008000">/// The "then ensure some outcome" method.<br></br> </span>/// </summary><br></br> [<span color="#0080ff">TestMethod</span>]<br></br> <span color="#0000ff">public void</span> the_employee_instance_should_be_null()<br></br> {<br></br> _nwContext.AssertWasCalled(stub => { <span color="#0000ff">var</span> temp = stub.Customers; });<br></br> <span color="#008000">// Check the expected nature of the dummy intance<br></br></span> _loadedCustomer.ShouldEqual(<span color="#0000ff">null</span>);<br></br> }<br></br>}
你马上会发现,类和方法的命名规范跟之前的例子不同,删除掉下划线(_)就变成人可以阅读的结果,尤其当你将它们像下面这样比较时:
好的,命名规范不是唯一的不同……尽管对于重写方法,可能会多一点开销,但让编写测试的人专注于正在发生的事情是很重要的。
Context 方法类似于原先测试中的 Arrange,但这里我们单独考虑它——确保那是我们将要做的所有事情。
BecauseOf 方法类似于原先测试中的 Act,这里我们再次看到,它被分隔成单独的区域,以确保我们专注于测试对象的因果关系——比如,因为我们做了一些事情,所以我们应该得到一个结果。
最后,实际的 MSTest TestMethod 本身就是结果——如果你喜欢的话,就是“应该怎样”的格式;它类似于之前单元测试中的 Assert。因此,从单元测试的角度来看,BDD 利用了 TDD,并且进一步推动它,将它与我们关心的用例联系了起来。
如果我们回到先前实践的保龄球 Kata,我们的测试方法是下面这种格式(准备 Arrange——执行 Act——断言 Assert):
/// <summary> /// <span color="#008000">Given that we are playing bowling</span> /// <span color="#008000">When I bowl all gutter balls</span> /// <span color="#008000">Then my score should be 0<br></br></span>/// </summary> [<span color="#0080ff">TestMethod</span>] <span color="#0000ff">public void</span> Bowl_all_gutter_balls() { <span color="#008000">// Arrange<br></br> // Given that we are playing bowling</span> <span color="#0080ff">Game</span> game = <span color="#0000ff">new</span> <span color="#0080ff">Game</span>(); <span color="#008000">// Act<br></br> // when I bowl all gutter balls</span> <span color="#0000ff">for</span> (<span color="#0000ff">int</span> i = 0; i < 10; i++) { game.roll(0); game.roll(0); } {1} <span color="#008000">// Assert<br></br> // then my score should be 0</span> <span color="#0080ff">Assert</span>.AreEqual(0, game.score()); }
现在测试方法是下面这种格式(BDD):
[<span color="#0080ff">TestClass</span>] <span color="#0000ff">public class</span> <span color="#0080ff">when_bowling_all_gutter_balls</span> : GameContext { /// <summary> <span color="#008000">/// The "When an event occurs" method<br></br></span> /// </summary> <span color="#0000ff">protected override void</span> BecauseOf() { <span color="#0000ff">for</span> (<span color="#0000ff">int</span> i = 0; i < 10; i++) { _game.Roll(0); _game.Roll(0); } } /// <summary> <span color="#008000">/// The "then ensure some outcome" method.<br></br></span> /// </summary> [<span color="#0080ff">TestMethod</span>] <span color="#0000ff">public void</span> the_score_should_equal_zero() { _game.Score().ShouldEqual(0); } }
原先的测试结果是这样的:
现在的测试结果是这样的:
的确,这里的例子基于非常简单的用例,但却进一步阐明了一种观点:越是复杂的用例,它的测试会出现问题的情况就越明显。因此我们可以进一步划分这个用例(没有双关语意的)
下周Jamie Phillips 会做一个总结,展示如何使用VS2010 的项目模板来消除反复建立测试用例和项目的工作。
查看英文原文: Using Coding Katas, BDD and VS2010 Project Templates: Part 2
感谢陈宇对本文的审校。
给InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家加入到 InfoQ 中文站用户讨论组中与我们的编辑和其他读者朋友交流。
评论