ruby on rails中使用 RSpec 进行行为驱动测试

来源:互联网 发布:查看windows更新日志 编辑:程序博客网 时间:2024/04/29 22:38

简介: 测试热潮现在传播到了 Ruby 编程社区,并且愈演愈热。在过去一年里,测试领域中最为瞩目的创新应属 RSpec 的引入和快速发展,这是一种行为驱动测试工具。通过本文了解 RSpec 如何改变人们思考测试的方式。


在过去十年中,软件开发人员对测试的热情日渐低迷。同一时期出现的动态语言并没有提供编译程序来捕捉最基本的错误,这使得测试变得更加重要。随着测试社区的成长,开发人员开始注意到,除了捕获bug等最基本的优点外,测试还具有以下优势:

  • 测试能够改进您的设计。进行测试的每个目标对象必须具备至少两个客户机:生产代码和测试用例。这些客户机强制您对代码进行解耦。测试还鼓励开发人员使用更小、更简单的方法。

  • 测试减少了不必要的代码。在编写测试用例时,您养成了很好的测试习惯,即只编写运行测试用例所需的最少代码。您抵制住了对功能进行编码的诱惑,因为您目前还不需要它。

  • 推动了测试优先开发。您编写的每个测试用例会确定一个小问题。使用代码解决这个问题非常有用并且可以推动开发。当我进行测试驱动开发时,时间过得飞快。

  • 测试提供了更多的自主权。在使用测试用例捕获可能的错误时,您会发现自己非常愿意对代码进行改进。

测试驱动的开发和RSpec

有关测试的优点无需赘述,我将向您介绍一个简单的使用RSpec的测试驱动开发示例。RSpec工具是一个Ruby软件包,可以用它构建有关您的软件的规范。该规范实际上是一个描述系统行为的测试。使用RSpec的开发流程如下:

  • 编写一个测试。该测试描述系统中某个较小元素的行为。

  • 运行测试。由于尚没有为系统中的相应部分构建代码,测试失败。这一重要步骤将测试您的测试用例,检验测试用例是否在应当失败的时候失败。

  • 编写足够的代码,使测试通过。

  • 运行测试,检验测试是否成功。

实质上,RSpec开发人员所做的工作就是将失败的测试用例调试为成功的测试用例。这是一个主动的过程。本文中,我将介绍RSpec的基本用法。

首先,假设您已安装了Rubygems。您还需要安装RSpec。输入下面的内容:

gem install rspec

使用示例

接下来,我将逐步构建一个状态机。我将遵循TDD规则。首先编写自己的测试用例,并且直到测试用例需要时才编写代码。Rake的创建者JimWeirich认为这有助于角色扮演。在编写实际的生产代码时,您希望充当一回jerk开发人员的角色,只完成最少量的工作来使测试通过。在编写测试时,您则扮演测试人员的角色,试图为开发人员提供一些有益的帮助。

以下的示例展示了如何构建一个状态机。状态机具有多种状态。每种状态支持可以转换状态机状态的事件。测试驱动开发入门的关键就是从零入手,尽量少地使用假设条件。针对测试进行程序设计。

使用清单1的内容创建名为machine_spec.rb的文件。该文件就是您的规范。您还不了解machine.rb文件的作用,目前先创建一个空文件。


清单1.最初的machine_spec.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  endend



该规范描述了目前尚不存在的 Machine 类。describe 方法提供了RSpec描述,您将传入测试类的名称和包含实际规范的代码块。通常,测试用例需要执行一定数量的设置工作。在RSpec中,将由 before 方法完成这些设置工作。您向 before 方法传递一个可选的标志和一个代码块。代码块中包含设置工作。标志确定RSpec执行代码块的频率。默认的标志为 :each,表示RSpec将在每次测试之前调用setup代码块。您也可以指定 :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 Machineend



保存文件,然后运行测试。毫无疑问,清单6显示的测试报告没有出现错误:


清单6.测试Machine是否存在

~/rspec batate$ spec machine_spec.rb Finished in 5.0e-06 seconds0 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 F1)NoMethodError in 'Machine should initially have a state of :initial'undefined method `state' for #<Machine:0x10c7f8c>./machine_spec.rb:9:Finished in 0.005577 seconds1 example, 1 failure



注意这条规则: it"should initially have a state of :initial" do @machine.state.should == :initialend。首先注意到这条规则读起来像是一个英文句子。删除标点,将得到 itshould initially have a state ofinitial。然后会注意到这条规则并不像是典型的面向对象代码。它确实不是。您现在有一个方法,称为 it。该方法具有一个使用引号括起来的字符串参数和一个代码块。字符串应该描述测试需求。最后,do 和 end 之间的代码块包含测试用例的代码。

可以看到,测试进度划分得很细。这些微小的步骤产生的收益却很大。它们使我能够改进测试密度,提供时间供我思考期望的行为以及实现行为所需的API。这些步骤还能使我在开发期间跟踪代码覆盖情况,从而构建更加丰富的规范。

这种风格的测试具有双重作用:测试实现并在测试的同时构建需求设计文档。稍后,我将通过测试用例构建一个需求列表。

我使用最简单的方式修复了测试,返回 :initial,如清单8所示:


清单8.指定初始状态

class Machine    def state    :initial  endend    



当查看实现时,您可能会放声大笑或感觉受到了愚弄。对于测试驱动开发,您必须稍微改变一下思考方式。您的目标并不是编写最终的生产代码,至少现在不是。您的目标是使测试通过。当掌握以这种方式工作时,您可能会发现新的实现,并且编写的代码要远远少于采用TDD时编写的代码。

下一步是运行代码,查看它是否通过测试:


清单9.运行初始状态测试

~/rspec batate$ spec machine_spec.rb .Finished in 0.005364 seconds1 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 .F1)NoMethodError in 'Machine should remember a list of valid states'undefined method `states=' for #<Machine:0x10c7154>./machine_spec.rb:13:Finished in 0.005923 seconds2 examples, 1 failure



在清单10中,出现了一个RSpec形式的断言。该断言从 should 方法开始,然后添加了一些比较关系。should 方法对应用程序进行某种观察。工作中的应用程序应该以某种方式运行。should 方法很好地捕获了这种需求。在本例中,我的状态机应该记忆两种不同的状态。

现在,应该添加一个实例变量来实际记忆状态。像以往一样,我在修改代码后运行测试用例,并观察测试是否成功。


清单11.创建一个属性以记忆状态

class Machine  attr_accessor :states  def state    :initial  endend~/rspec batate$ spec machine_spec.rb ..Finished in 0.00606 seconds2 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 == :shoppingend~/rspec batate$ spec machine_spec.rb F.1)'Machine should initially have a state of the first state' FAILEDexpected :shopping, got :initial (using ==)./machine_spec.rb:10:Finished in 0.005846 seconds2 examples, 1 failure



可以这样说,测试用例起到作用了,因为它运行失败,因此我现在需要修改代码以使其工作。显而易见,我的任务就是使测试通过。我喜欢这种测试目的,因为我的测试用例正在驱动我进行设计。我将把初始状态传递给 new 方法。我将对实现稍作修改,以符合修改后的规范,如清单13所示。


清单13.指定初始状态

start to fix itclass Machine  attr_accessor :states  attr_reader :state  def initialize(states)    @states = states    @state = @states[0]  endend~/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 seconds2 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 seconds2 examples, 0 failures



状态机开始逐渐成型。代码仍然有一些问题,但是正在向良好的方向演化。我将开始对状态机进行一些转换。这些转换将促使代码实际记忆当前状态。

测试用例促使我全面地思考API的设计。我需要知道如何表示事件和转换。首先,我将使用一个散列表表示转换,而没有使用成熟的面向对象实现。随后,测试需求可能会要求我修改假设条件,但是目前,我仍然保持这种简单性。清单15显示了修改后的代码:


清单15.添加事件和转换

remember events... change before conditionsrequire '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 FFF1)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 seconds3 examples, 3 failures



由于新的测试代码位于 before 中,将我的三个测试分解开来。尽管如此,清单16中展示的测试非常容易修复。我将添加另一个访问程序:


清单16.记忆事件

class Machine  attr_accessor :states, :events  attr_reader :state  def initialize(states)    @states = states    @state = @states[0]  endend~/rspec batate$ spec machine_spec.rb ...Finished in 0.00652 seconds3 examples, 0 failurestest



测试全部通过。我得到了一个能正常运行的状态机。接下来的几个测试将使它更加完善。

接近真实的应用程序

目前为止,我所做的不过是触发了一次状态转换,但是我已经做好了所有基础工作。我得到了一组需求。我还构建了一组测试。我的代码可以为状态机提供使用的数据。此时,管理单个状态机转换仅表示一次简单的转换,因此我将添加如清单17所示的测试:


清单17.构建状态机的状态转换

it "should transition to :checking_out upon #trigger(:checkout) event " do  @machine.trigger(:checkout)  @machine.state.should == :checking_outend~/rspec batate$ spec machine_spec.rb ...F1)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 seconds4 examples, 1 failure



我需要抵制快速构建大量功能的诱惑。我应该只编写少量代码,只要使测试通过即可。清单18展示的迭代将表示API和需求。这就足够了:


清单18.定义 trigger 方法

def trigger(event)  @state = :checking_outend~/rspec batate$ spec machine_spec.rb ....Finished in 0.005959 seconds4 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 ....F1)'Machine should transition to :success upon #trigger(:accept_card)' FAILEDexpected :success, got :checking_out (using ==)./machine_spec.rb:37:Finished in 0.007564 seconds5 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 seconds5 examples, 0 failures



测试可以运行。这些粗糙的代码第一次演变为真正可以称之为状态机的东西。但是这还远远不够。目前,状态机缺乏严密性。不管状态如何,状态机都会触发事件。例如,当处于 shopping 状态时,触发 :accept_card 并不会转换为 :success 状态。您只能够从:checking_out 状态触发 :accept_card。在编程术语中,trigger 方法的范围应针对事件。我将编写一个测试来解决问题,然后修复bug。我将编写一个负测试(negativetest),即断言一个不应该出现的行为,如清单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 .....F1)'Machine should not transition from :shopping to :success upon :accept_card' FAILEDexpected not == :success, got :success./machine_spec.rb:47:Finished in 0.006582 seconds6 examples, 1 failure



现在可以再次运行测试,其中一个测试如预期一样运行失败。修复代码同样只有一行,如清单22所示:


清单22.修复 trigger 中的范围问题 

def trigger(event)    @state = events[event][:to] if state == events[event][:from]  endrspec batate$ spec machine_spec.rb ......Finished in 0.006873 seconds6 examples, 0 failures



组合代码

现在,我具有一个可简单运行的状态机。无论从哪方面来说,它都不是一个完美的程序。它还具有下面这些问题:

  • 状态散列实际上不具备任何功能。我应该根据状态对事件及其转换进行验证,或者将所有状态集中起来。后续需求很可能会要求这样做。

  • 某个既定事件只能存在于一个状态中。这种限制并不合理。例如,submit 和 cancel 事件可能需要处于多个状态。

  • 代码并不具备明显的面向对象特征。为使配置保持简单,我将大量数据置入散列中。后续的迭代会进一步驱动设计,使其朝面向对象设计方向发展。

但是,您还可以看到,这个状态机已经能够满足一些需求了。我还具备一个描述系统行为的文档,这是进行一系列测试的好起点。每个测试用例都支持系统的一个基本需求。事实上,通过运行 specmachine_spec.rb --format specdoc,您可以查看由系统规范组成的基本报告,如清单23所示:


清单23.查看规范

spec machine_spec.rb --format specdocMachine- 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_cardFinished in 0.006868 seconds



测试驱动方法并不适合所有人,但是越来越多的人开始使用这种技术,使用它构建具有灵活性和适应性的高质量代码,并且根据测试从头构建代码。当然,您也可以通过其他框架(如test_unit)获得相同的优点。RSpec还提供了优秀的实现方法。这种新测试框架的一大亮点就是代码的表示。新手尤其可以从这种行为驱动的测试方法中受益。请尝试使用该框架并告诉我您的感受。


原创粉丝点击