使用 RSpec 进行行为驱动测试
Bruce Tate, CTO, WellGood LLC
在过去十年中,软件开发人员对测试的热情日渐低迷。同一时期出现的动态语言并没有提供编译程序来捕捉最基本的错误,这使得测试变得更加重要。随着测试社区的成长,开发人员开始注意到,除了捕获 bug 等最基本的优点外,测试还具有以下优势:
- 测试能够改进您的设计。进行测试的每个目标对象必须具备至少两个客户机:生产代码和测试用例。这些客户机强制您对代码进行解耦。测试还鼓励开发人员使用更小、更简单的方法。
- 测试减少了不必要的代码。在编写测试用例时,您养成了很好的测试习惯,即只编写运行测试用例所需的最少代码。您抵制住了对功能进行编码的诱惑,因为您目前还不需要它。
- 推动了测试优先开发。您编写的每个测试用例会确定一个小问题。使用代码解决这个问题非常有用并且可以推动开发。当我进行测试驱动开发时,时间过得飞快。
- 测试提供了更多的自主权。在使用测试用例捕获可能的错误时,您会发现自己非常愿意对代码进行改进。
有关测试的优点无需赘述,我将向您介绍一个简单的使用 RSpec 的测试驱动开发示例。RSpec 工具是一个 Ruby 软件包,可以用它构建有关您的软件的规范。该规范实际上是一个描述系统行为的测试。使用 RSpec 的开发流程如下:
- 编写一个测试。该测试描述系统中某个较小元素的行为。
- 运行测试。由于尚没有为系统中的相应部分构建代码,测试失败。这一重要步骤将测试您的测试用例,检验测试用例是否在应当失败的时候失败。
- 编写足够的代码,使测试通过。
- 运行测试,检验测试是否成功。
实质上,RSpec 开发人员所做的工作就是将失败的测试用例调试为成功的测试用例。这是一个主动的过程。本文中,我将介绍 RSpec 的基本用法。
首先,假设您已安装了 Ruby 和 gems。您还需要安装 RSpec。输入下面的内容:
gem install rspec
接下来,我将逐步构建一个状态机。我将遵循 TDD 规则。首先编写自己的测试用例,并且直到测试用例需要时才编写代码。Rake 的创建者 Jim Weirich 认为这有助于角色扮演。在编写实际的生产代码时,您希望充当一回 jerk 开发人员的角色,只完成最少量的工作来使测试通过。在编写测试时,您则扮演测试人员的角色,试图为开发人员提供一些有益的帮助。
以下的示例展示了如何构建一个状态机。如果您以前从未接触过状态机,请查阅 参考资料。状态机具有多种状态。每种状态支持可以转换状态机状态的事件。测试驱动开发入门的关键就是从零入手,尽量少地使用假设条件。针对测试进行程序设计。
使用清单 1 的内容创建名为 machine_spec.rb 的文件。该文件就是您的规范。您还不了解 machine.rb 文件的作用,目前先创建一个空文件。
require 'machine' |
接下来,需要运行测试。始终通过输入 spec machine_spec.rb
运行测试。清单 2 展示了预料之中的测试失败:
清单 2. 运行空的规范
~/rspec batate$ spec machine_spec.rb /opt/local/lib/ruby/site_ruby/1.8/rubygems/custom_require.rb:27:in `gem_original_require': no such file to load -- machine (LoadError) from /opt/local/lib/ruby/site_ruby/1.8/rubygems/custom_require.rb:27:in `require' from ./state_machine_spec.rb:1 from ... |
在测试驱动开发中,您需要进行增量开发,因此在进行下一次开发前,需要先解决此次测试出现的问题。现在,我将扮演 jerk 开发人员的角色,即只完成满足应用程序运行所需的最少工作量。我将创建一个名为 machine.rb 的空文件,使测试通过。我现在可以以逸待劳,测试通过而我几乎没做任何事情。
继续角色扮演。我现在扮演一个烦躁的测试人员,促使 jerk 开发人员做些实际的工作。我将编码以下规范,需要使用 Machine
类,如清单 3 所示:
清单 3. 初始规范
require 'machine' describe Machine do before :each do @machine = Machine end end |
该规范描述了目前尚不存在的 Machine
类。describe
方法提供了 RSpec 描述,您将传入测试类的名称和包含实际规范的代码块。通常,测试用例需要执行一定数量的设置工作。在 RSpec 中,将由 before
方法完成这些设置工作。您向 before
方法传递一个可选的标志和一个代码块。代码块中包含设置工作。标志确定 RSpec 执行代码块的频率。默认的标志为 :each
,表示 RSpec 将在每次测试之前调用 set up 代码块。您也可以指定 :all
,表示 RSpec 在执行所有测试之前只调用一次 before
代码块。您应该始终使用:each
,使各个测试彼此独立。
输入 spec
运行测试,如清单 4 所示:
清单 4. 存在性测试失败
~/rspec batate$ spec machine_spec.rb ./machine_spec.rb:3: uninitialized constant Machine (NameError) |
现在,烦躁的测试人员要促使 jerk 开发人员做点什么了 — jerk 开发人员现在需要创建某个类。对我来说,就是修复测试出现的错误。在 machine.rb
中,我输入最少量的代码,如清单 5 所示:
清单 5. 创建初始 Machine 类
class Machine end |
保存文件,然后运行测试。毫无疑问,清单 6 显示的测试报告没有出现错误: 清单 6. 测试 Machine 是否存在
~/rspec batate$ spec machine_spec.rb Finished in 5.0e-06 seconds 0 examples, 0 failures |
现在,我可以开始实现更多的行为。我知道,所有状态机必须在某些初始状态下启动。目前我还不是很清楚如何设计这个行为,因此我先编写一个非常简单的测试,首先假设 state
方法会返回 :initial
标志。我对 machine_spec.rb
进行修改并运行测试,如清单 7 所示:
清单 7. 实现初始状态并运行测试
require 'machine' describe Machine do before :each do @machine = Machine.new end it "should initially have a state of :initial" do @machine.state.should == :initial end end ~/rspec batate$ spec machine_spec.rb F 1) NoMethodError in 'Machine should initially have a state of :initial' undefined method `state' for #<Machine:0x10c7f8c> ./machine_spec.rb:9: Finished in 0.005577 seconds 1 example, 1 failure |
注意这条规则: it “should initially have a state of :initial” do @machine.state.should == :initial end
。首先注意到这条规则读起来像是一个英文句子。删除标点,将得到 it should initially have a state of initial
。然后会注意到这条规则并不像是典型的面向对象代码。它确实不是。您现在有一个方法,称为 it
。该方法具有一个使用引号括起来的字符串参数和一个代码块。字符串应该描述测试需求。最后,do
和 end
之间的代码块包含测试用例的代码。
可以看到,测试进度划分得很细。这些微小的步骤产生的收益却很大。它们使我能够改进测试密度,提供时间供我思考期望的行为以及实现行为所需的 API。这些步骤还能使我在开发期间跟踪代码覆盖情况,从而构建更加丰富的规范。
这种风格的测试具有双重作用:测试实现并在测试的同时构建需求设计文档。稍后,我将通过测试用例构建一个需求列表。
我使用最简单的方式修复了测试,返回 :initial
,如清单 8 所示:
清单 8. 指定初始状态
class Machine def state :initial end end |
当查看实现时,您可能会放声大笑或感觉受到了愚弄。对于测试驱动开发,您必须稍微改变一下思考方式。您的目标并不是编写最终的生产代码,至少现在不是。您的目标是使测试通过。当掌握以这种方式工作时,您可能会发现新的实现,并且编写的代码要远远少于采用 TDD 时编写的代码。
下一步是运行代码,查看它是否通过测试: 清单 9. 运行初始状态测试
~/rspec batate$ spec machine_spec.rb . Finished in 0.005364 seconds 1 example, 0 failures |
花些时间思考一下这个通过测试的迭代。如果查看代码的话,您可能会觉得气馁。因为并没有取得什么进展。如果查看整个迭代,将看到更多内容:您捕获了一个重要需求并编写测试用例实现需求。作为一名程序员,我的第一个行为测试帮助我明确了开发过程。因为实现细节随着测试的进行越来越清晰。
现在,我可以实现一个更健壮的状态实现。具体来讲,我需要处理状态机的多个状态。我需要创建一个新的规则获取有效状态列表。像以前一样,我将运行测试并查看是否通过。 清单 10. 实现有效状态规范
it "should remember a list of valid states" do @machine.states = [:shopping, :checking_out] @machine.states.should = [:shopping, :checking_out] end run test(note: failing first verifies test) ~/rspec batate$ spec machine_spec.rb .F 1) NoMethodError in 'Machine should remember a list of valid states' undefined method `states=' for #<Machine:0x10c7154> ./machine_spec.rb:13: Finished in 0.005923 seconds 2 examples, 1 failure |
在清单 10 中,出现了一个 RSpec 形式的断言。该断言从 should
方法开始,然后添加了一些比较关系。should
方法对应用程序进行某种观察。工作中的应用程序应该以某种方式运行。should
方法很好地捕获了这种需求。在本例中,我的状态机应该记忆两种不同的状态。
现在,应该添加一个实例变量来实际记忆状态。像以往一样,我在修改代码后运行测试用例,并观察测试是否成功。 清单 11. 创建一个属性以记忆状态
class Machine attr_accessor :states def state :initial end end ~/rspec batate$ spec machine_spec.rb .. Finished in 0.00606 seconds 2 examples, 0 failures |
此时,我并不想决定将 :initial
状态称为状态机的第一个状态。相反,我更希望第一个状态是状态数组中的第一个元素。我对状态机的理解在不断演变。这种现象并不少见。测试驱动开发经常迫使我重新考虑之前的假设。由于我已经通过测试用例捕获了早期需求,我可以轻松地对代码进行重构。在本例中,重构就是对代码进行调整,使其更好地工作。
修改第一个测试,使其如清单 12 所示,并运行测试: 清单 12. 初始状态应该为指定的第一个状态
it "should initially have a state of the first state" do @machine.states = [:shopping, :checking_out] @machine.state.should == :shopping end ~/rspec batate$ spec machine_spec.rb F. 1) 'Machine should initially have a state of the first state' FAILED expected :shopping, got :initial (using ==) ./machine_spec.rb:10: Finished in 0.005846 seconds 2 examples, 1 failure |
可以这样说,测试用例起到作用了,因为它运行失败,因此我现在需要修改代码以使其工作。显而易见,我的任务就是使测试通过。我喜欢这种测试目的,因为我的测试用例正在驱动我进行设计。我将把初始状态传递给 new
方法。我将对实现稍作修改,以符合修改后的规范,如清单 13 所示。
清单 13. 指定初始状态
start to fix it class Machine attr_accessor :states attr_reader :state def initialize(states) @states = states @state = @states[0] end end ~/rspec batate$ spec machine_spec.rb 1) ArgumentError in 'Machine should initially have a state of the first state' wrong number of arguments (0 for 1) ./machine_spec.rb:5:in `initialize' ./machine_spec.rb:5:in `new' ./machine_spec.rb:5: 2) ArgumentError in 'Machine should remember a list of valid states' wrong number of arguments (0 for 1) ./machine_spec.rb:5:in `initialize' ./machine_spec.rb:5:in `new' ./machine_spec.rb:5: Finished in 0.006391 seconds 2 examples, 2 failures |
现在,测试出现了一些错误。我找到了实现中的一些 bug。测试用例不再使用正确的接口,因为我没有把初始状态传递给状态机。可以看到,测试用例已经起到了保护作用。我进行了较大的更改,测试就发现了 bug。我们需要对测试进行重构以匹配新的接口,将初始状态列表传递给 new
方法。在这里我并没有重复初始化代码,而是将其放置在 before
方法中,如清单 14 所示:
清单 14. 在 “before
” 中初始化状态机
require 'machine' describe Machine do before :each do @machine = Machine.new([:shopping, :checking_out]) end it "should initially have a state of the first state" do @machine.state.should == :shopping end it "should remember a list of valid states" do @machine.states.should == [:shopping, :checking_out] end end ~/rspec batate$ spec machine_spec.rb .. Finished in 0.005542 seconds 2 examples, 0 failures |
状态机开始逐渐成型。代码仍然有一些问题,但是正在向良好的方向演化。我将开始对状态机进行一些转换。这些转换将促使代码实际记忆当前状态。
测试用例促使我全面地思考 API 的设计。我需要知道如何表示事件和转换。首先,我将使用一个散列表表示转换,而没有使用成熟的面向对象实现。随后,测试需求可能会要求我修改假设条件,但是目前,我仍然保持这种简单性。清单 15 显示了修改后的代码: 清单 15. 添加事件和转换
remember events... change before conditions require 'machine' describe Machine do before :each do @machine = Machine.new([:shopping, :checking_out]) @machine.events = {:checkout => {:from => :shopping, :to => :checking_out}} end it "should initially have a state of the first state" do @machine.state.should == :shopping end it "should remember a list of valid states" do @machine.states.should == [:shopping, :checking_out] end it "should remember a list of events with transitions" do @machine.events.should == {:checkout => {:from => :shopping, :to => :checking_out}} end end ~/rspec batate$ spec machine_spec.rb FFF 1) NoMethodError in 'Machine should initially have a state of the first state' undefined method `events=' for #<Machine:0x10c6f38> ./machine_spec.rb:6: 2) NoMethodError in 'Machine should remember a list of valid states' undefined method `events=' for #z7lt;Machine:0x10c5afc> ./machine_spec.rb:6: 3) NoMethodError in 'Machine should remember a list of events with transitions' undefined method `events=' for #<Machine:0x10c4a58> ./machine_spec.rb:6: Finished in 0.006597 seconds 3 examples, 3 failures |
由于新的测试代码位于 before
中,将我的三个测试分解开来。尽管如此,清单 16 中展示的测试非常容易修复。我将添加另一个访问程序:
清单 16. 记忆事件
class Machine attr_accessor :states, :events attr_reader :state def initialize(states) @states = states @state = @states[0] end end ~/rspec batate$ spec machine_spec.rb ... Finished in 0.00652 seconds 3 examples, 0 failures test |
测试全部通过。我得到了一个能正常运行的状态机。接下来的几个测试将使它更加完善。
目前为止,我所做的不过是触发了一次状态转换,但是我已经做好了所有基础工作。我得到了一组需求。我还构建了一组测试。我的代码可以为状态机提供使用的数据。此时,管理单个状态机转换仅表示一次简单的转换,因此我将添加如清单 17 所示的测试: 清单 17. 构建状态机的状态转换
it "should transition to :checking_out upon #trigger(:checkout) event " do @machine.trigger(:checkout) @machine.state.should == :checking_out end ~/rspec batate$ spec machine_spec.rb ...F 1) NoMethodError in 'Machine should transition to :checking_out upon #trigger(:checkout) event ' undefined method `trigger' for #<Machine:0x10c4d00> ./machine_spec.rb:24: Finished in 0.006153 seconds 4 examples, 1 failure |
我需要抵制快速构建大量功能的诱惑。我应该只编写少量代码,只要使测试通过即可。清单 18 展示的迭代将表示 API 和需求。这就足够了:
清单 18. 定义 trigger
方法
def trigger(event) @state = :checking_out end ~/rspec batate$ spec machine_spec.rb .... Finished in 0.005959 seconds 4 examples, 0 failures |
这里出现了一个有趣的边注。在编写代码时,我两次都弄错了这个简单的方法。第一次我返回了 :checkout
;第二次我将状态设置为:checkout
而不是 :checking_out
。在测试中使用较小的步骤可以为我节省大量时间,因为测试用例为我捕获的这些错误在将来的开发中很难捕获到。本文的最后一个步骤是实际执行一次状态机转换。在第一个示例中,我并不关心实际的机器状态是什么样子的。我仅仅是根据事件进行盲目转换,而不考虑状态。
两节点的状态机无法执行这个操作,我需要在第三个节点中构建。我没有使用已有的 before
方法,只是在新状态中添加另外的状态。我将在测试用例中进行两次转换,以确保状态机能够正确地执行转换,如清单 19 所示:
清单 19. 实现第一次转换
it "should transition to :success upon #trigger(:accept_card)" do @machine.events = { :checkout => {:from => :shopping, :to => :checking_out}, :accept_card => {:from => :checking_out, :to => :success} } @machine.trigger(:checkout) @machine.state.should == :checking_out @machine.trigger(:accept_card) @machine.state.should == :success end ~/rspec batate$ spec machine_spec.rb ....F 1) 'Machine should transition to :success upon #trigger(:accept_card)' FAILED expected :success, got :checking_out (using ==) ./machine_spec.rb:37: Finished in 0.007564 seconds 5 examples, 1 failure |
这个测试将使用 :checkout
和 :accept_card
事件建立新的状态机。在处理签出时,我选择使用两个事件而不是一个,这样可以防止发生双命令。签出代码可以确保状态机在签出之前处于 shopping
状态。第一次签出首先将状态机从 shopping
转换为 checking_out
。测试用例通过触发 checkout
和 accept_card
事件实现两个转换,并在调用事件之后检验事件状态是否正确。与预期一样,测试用例失败 — 我并没有编写处理多个转换的触发器方法。代码修正包含一行非常重要的代码。清单 20 展示了状态机的核心:
清单 20. 状态机的核心
def trigger(event) @state = events[event][:to] end ~/rspec batate$ spec machine_spec.rb ..... Finished in 0.006511 seconds 5 examples, 0 failures |
测试可以运行。这些粗糙的代码第一次演变为真正可以称之为状态机的东西。但是这还远远不够。目前,状态机缺乏严密性。不管状态如何,状态机都会触发事件。例如,当处于 shopping
状态时,触发 :accept_card
并不会转换为 :success
状态。您只能够从:checking_out
状态触发 :accept_card
。在编程术语中,trigger
方法的范围应针对事件。我将编写一个测试来解决问题,然后修复 bug。我将编写一个负测试(negative test),即断言一个不应该出现的行为,如清单 21 所示:
清单 21: 负测试
it "should not transition from :shopping to :success upon :accept_card" do @machine.events = { :checkout => {:from => :shopping, :to => :checking_out}, :accept_card => {:from => :checking_out, :to => :success} } @machine.trigger(:accept_card) @machine.state.should_not == :success end rspec batate$ spec machine_spec.rb .....F 1) 'Machine should not transition from :shopping to :success upon :accept_card' FAILED expected not == :success, got :success ./machine_spec.rb:47: Finished in 0.006582 seconds 6 examples, 1 failure |
现在可以再次运行测试,其中一个测试如预期一样运行失败。修复代码同样只有一行,如清单 22 所示:
清单 22. 修复 trigger
中的范围问题
def trigger(event) @state = events[event][:to] if state == events[event][:from] end rspec batate$ spec machine_spec.rb ...... Finished in 0.006873 seconds 6 examples, 0 failures |
现在,我具有一个可简单运行的状态机。无论从哪方面来说,它都不是一个完美的程序。它还具有下面这些问题:
- 状态散列实际上不具备任何功能。我应该根据状态对事件及其转换进行验证,或者将所有状态集中起来。后续需求很可能会要求这样做。
- 某个既定事件只能存在于一个状态中。这种限制并不合理。例如,
submit
和cancel
事件可能需要处于多个状态。 - 代码并不具备明显的面向对象特征。为使配置保持简单,我将大量数据置入散列中。后续的迭代会进一步驱动设计,使其朝面向对象设计方向发展。
但是,您还可以看到,这个状态机已经能够满足一些需求了。我还具备一个描述系统行为的文档,这是进行一系列测试的好起点。每个测试用例都支持系统的一个基本需求。事实上,通过运行 spec machine_spec.rb —format specdoc
,您可以查看由系统规范组成的基本报告,如清单 23 所示:
清单 23. 查看规范
spec machine_spec.rb --format specdoc Machine - should initially have a state of the first state - should remember a list of valid states - should remember a list of events with transitions - should transition to :checking_out upon #trigger(:checkout) event - should transition to :success upon #trigger(:accept_card) - should not transition from :shopping to :success upon :accept_card Finished in 0.006868 seconds |
测试驱动方法并不适合所有人,但是越来越多的人开始使用这种技术,使用它构建具有灵活性和适应性的高质量代码,并且根据测试从头构建代码。当然,您也可以通过其他框架(如 test_unit)获得相同的优点。RSpec 还提供了优秀的实现方法。这种新测试框架的一大亮点就是代码的表示。新手尤其可以从这种行为驱动的测试方法中受益。请尝试使用该框架并告诉我您的感受。
参考资料
学习
- 您可以参阅本文在 developerWorks 全球站点上的 英文原文 。
- RSpec 是一种行为驱动框架,可增强 Ruby 中的测试驱动开发。
- Martin Fowler 有关 Test Driven Development 的访谈将进一步使您了解这种技术如此强大和高效的原因。
- Ruby 主页 提供了优秀的 Ruby 编程语言入门资料。
- From Java to Ruby 一书也由本文作者编写。Bruce Tate 提供了一份管理人员指南,解释了为什么 Ruby 语言对于某些业务问题来说非常重要。
- 有限状态机 是一种软件概念,它将问题分为有限的状态,以及由事件触发的状态之间的转换。状态机构成了本文的基本内容。
- 查看所有 developerWorks 的 重要试用下载。
- 订阅 developerWorks Web 开发新闻。
- 从 Web 开发专区的技术库 获得更多 howto 文章。
讨论
- 加入 developerWorks 社区:blogs、论坛 等。