锻造车间 - RSpec

作者:Reuven M. Lerner

上个月,我介绍了 Shoulda,一个 Ruby gem,它允许您使用一种名为行为驱动开发的方法来测试您的代码。BDD,众所周知,与测试驱动开发 (TDD) 密切相关,后者在过去几年中,特别是在 Ruby 社区中,变得越来越流行。

在 BDD 和 TDD 中,您都从编写一个程序应该通过的测试开始编程,如果它工作正常的话。当然,因为程序还没有编写,测试将会失败。然后,您编写尽可能少的代码来确保测试通过。当这种情况发生时,您继续编写另一个测试来继续编码。您的代码被完全测试这一事实使您有信心和灵活性去“重构”,移动代码并将其连接在一起,而无需担心引入新的、细微的错误。

BDD 与 TDD 的不同之处不在于其总体方法,而在于其方法和语义。BDD 专注于从外部而不是代码内部看待事物。在 Web 应用程序的情况下,这通常意味着从用户的角度看待事物,或者如果您是顾问,则从客户的角度看待事物。您不再是测试代码,而是检查它是否符合其规范。因此,使用 BDD 需要您始终将自己视为特定代码的使用者,并考虑它在每个点应该做什么,如果它要正确工作的话。我特意在这里使用“应该”这个词,因为正如您将看到的,这是 RSpec 词汇表中一个特别重要的词,并且几乎出现在每个测试中。

RSpec 在 Ruby 程序员中,尤其是 Rails 程序员中,已经变得非常流行。它还与其他几种高质量的测试技术紧密相连,例如 Cucumber 和 Celerity,我将在未来几个月内探讨这些技术。而且,尽管 RSpec 不是每个人的菜,但它足够流行,如果您进行任何 Ruby 开发,您都应该期望遇到它。此外,尝试一些不同的东西通常是好的,而 RSpec 绝对是不同的,它提供了一种看待测试的新方式。

安装 RSpec

RSpec 的主页是 rspec.info,其中包含安装 RSpec 的说明,可以单独安装,也可以作为 Rails 应用程序的一部分安装。本月我将以一个简单的 Rails 应用程序为例,因此您需要安装这两个部分。

首要要求是安装两个 Ruby gem,这两个 gem 都存储在流行的开源项目仓库 GitHub 上。您可以使用以下命令安装这些 gem

sudo gem install rspec rspec-rails -V --source
 ↪http://gems.github.com/

(如果您已经安装了 GitHub 作为 gem 安装的来源,则无需在此命令中指定它。)

请注意,如果您安装了较旧的 RSpec 相关 gem,例如 rspec_generator 或 spicycode_rspec_extensions,您可能应该从系统中删除它们。当前版本的 RSpec 为您处理这些功能,并且当我删除这些旧 gem 时,我遇到了问题和冲突消失了。

现在您已经安装了 RSpec,让我们创建一个新的、简单的 Rails 项目。我经常喜欢使用地址簿(和约会日历)作为我的示例,所以让我们创建一个

rails --database=postgresql appointments

请记住,Rails 假设您的应用程序有三个数据库,开发、测试和生产环境各一个。数据库参数在 config/database.yml 中定义。我假设您能够正确设置这些配置参数。虽然出于本专栏的目的,您不一定需要生产数据库,但您将需要开发和测试数据库。

现在您必须告诉 Rails 应用程序包含 RSpec。RSpec 有插件,但我通常更喜欢在可能的情况下使用 gem。现代版本的 Rails 允许您通过在 config/environment.rb 中添加以下两行来包含 gem

config.gem "rspec", :lib => false, :version => ">= 1.2.0"
config.gem "rspec-rails", :lib => false, :version => ">= 1.2.0"

有了 gem,您现在可以将 RSpec 放入您的 Rails 应用程序中

./script/generate rspec

这将创建一个 spec 目录(与 test 目录并行,它有效地取代了 test 目录)。默认情况下,spec 目录包含三个文件

  • rcov.opts:设置从 RSpec 中运行时运行 Ruby 覆盖率工具 rcov 的选项。

  • rspec.opts:设置 RSpec 本身的选项。

  • spec_helper.rb:一个 Ruby 文件,包含各个规范的全局定义和配置,很像 test_helper.rb 在 Test::Unit 中执行的功能。

有了 spec 目录,您就可以开始使用特殊的 RSpec 生成器来生成模型、控制器和脚手架。例如,您通常会使用以下命令生成 person 模型

./script/generate model person first_name:text last_name:text

这仍然有效,但任何自动生成的测试都将使用 Test::Unit,并将文件安装到 test 目录中。相比之下,您可以使用

./script/generate rspec_model person first_name:text last_name:text

这将创建相同的模型文件,但也创建一组 RSpec 测试的框架。

使用 RSpec 进行模型测试

让我们创建一个稍微更复杂的 person 模型版本

./script/generate rspec_model person first_name:text \
    last_name:text email_address:text phone_number:text \
    sex:text

这将创建一个迁移,您可以使用它来创建 person 模型的第一个版本

rake db:migrate

现在,确实您应该进入迁移文件并修改内容,以便(例如)person 的姓名、电子邮件地址和性别都是必填项。但是,让我们暂时忽略这一步,并假设您希望所有的验证逻辑都在应用程序层。在这种情况下,您将需要在模型文件中放置一些验证。

好吧,您可以这样做,但这对于您来说不是很有 BDD 风格,不是吗?相反,您应该想象消费者或管理者可能希望从“person”对象中获得的规范,然后构建该对象以符合这些标准。

例如,您可能想要确保名字和姓氏的存在。因此,要修改的第一个文件是 spec/models/person_spec.rb,而不是 app/models/person.rb。(由于我不太理解的原因,Test::Unit 将模型测试称为单元测试,而 RSpec 将其称为模型测试,控制器测试称为功能测试。)如果您打开该文件,您将看到一个新的、基本的规范

require File.expand_path(File.dirname(__FILE__) + '/../spec_helper')

describe Person do
  before(:each) do
    @valid_attributes = {
      :first_name => "value for first_name",
      :last_name => "value for last_name",
      :email_address => "value for email_address",
      :phone_number => "value for phone_number",
      :sex => "value for sex"
    }
  end

  it "should create a new instance given valid attributes" do
    Person.create!(@valid_attributes)
  end
end

您可以随时通过键入以下命令来运行您的完整规范套件

rake spec

第一行导入 spec_helper 中定义的任何内容,我之前提到过。接下来是 describe 行;对于那些看过 Shoulda 的人来说,这将很熟悉。基本思想是,阅读规范的人读取“describe”的参数,然后读取以“it”开头的每个单独的规范。换句话说,此规范文件试图说“Person 应该在给定有效属性的情况下创建一个新实例。”而且,果然,它做到了。

before(:each) 块告诉 RSpec 在每个“it”块之前应该调用什么。这确保了在运行每个规范之前,@valid_attributes 实例变量将被设置为可预测的值。然后,您可以在每个规范中根据需要修改 @valid_attributes,正如您很快将看到的。

问题是,您正在通过创建 Person 的新实例来检查规范的有效性。您可以这样做,但如果规范失败,您最终会在报告中混入代码回溯。因此,我将更改现有的规范定义,使其如下所示

it "should create a new instance given valid attributes" do
  p = Person.new(@valid_attributes)
  p.should be_valid
  p.save.should_not == false
end

现在您调用的是 Person.new 而不是 Person.create,并将其分配给变量 p。让我们用两种不同的方式检查 p,一次使用 should,另一次使用 should_not。这些方法由 RSpec 混入到 Object 类中,并包含大量的幕后魔法,使规范可读,几乎就像它们是用简单的英语编写的一样。例如,当您说

p.should be_valid

RSpec 的 should 方法会查找该对象的名为 valid? 的方法,并检查此方法的调用是否返回 true。这适用于任何谓词(即返回 true 或 false 的方法)。如果 should 或 should_not 后跟 be_XXX,RSpec 会将其转换为在对象实例上调用 XXX? 方法。

因此,您可以理解说

p.save.should_not == false

您可以等效地以更积极、乐观的方式编写

p.save.should == true

在这两种情况下,您都调用对象的 save 方法并检查其返回值是否为 true。您可能会争辩说,您不需要在对象上同时调用 new 和 save,但我喜欢确保对象在 Ruby 和数据库中都有效。毕竟,可能是您告诉数据库拒绝空值,但您允许使用 ActiveRecord 定义中的验证。

现在让我们稍微超越默认值,为属性设置一些限制。据推测,您希望数据库中的人员都定义了所有这些字段(名字、姓氏、电子邮件地址、电话号码和性别)。如果您以非 TDD/BDD 方式开发,您首先会为所有这些字段设置验证,然后添加一些测试。但是,在这里您尝试首先编写测试,从“外部”思考您的对象可能如何表现。实际上,每个人都应该有名字、姓氏、电子邮件地址和电话号码。(现在看来可能很奇怪,但曾经有一段时间,拥有电子邮件地址并不是预期的。)

因此,例如,您可以包含以下内容

it "should not be valid without a first name" do
  @valid_attributes.delete[:first_name]
  p = Person.new(@valid_attributes)
  p.should_not be_valid
  p.save.should == false
end

换句话说,您获取 @valid_attributes,从中删除 :first_name 键,然后使用 @valid_attributes 中的其余名称-值对创建一个新 person。这应该不起作用,因为每个人都需要一个名字。但是当我运行规范时,我得到

1)
'Person should not be valid without a first name' FAILED
expected valid? to return false, got true
./spec/models/person_spec.rb:23:

Finished in 0.038731 seconds

2 examples, 1 failure

换句话说,规范失败了。但这没关系——这正是您在以 BDD 方式工作时想要的。您编写了一个测试,它失败了,现在您可以进入代码并修改它,以确保测试通过。确保当前测试通过只需在您的 ActiveRecord 模型中添加一个验证即可。而不是空的默认值

class Person < ActiveRecord::Base
end

您需要将其更改为

class Person < ActiveRecord::Base
    validates_presence_of :first_name
end

我保存此更改,运行rake spec再次,果然,我得到

Finished in 0.070752 seconds
2 examples, 0 failures

下一步是什么?现在我可以逐个处理其他字段,以便测试它们。实际上,这种来回正是您在以 TDD/BDD 方式编程时想要的工作方式。您添加一个规范,指示对象应该做什么,观看规范失败,然后添加相应的行或行使其以这种方式工作。

您可以比仅仅检查属性是否存在更复杂一些。RSpec 的 should 方法非常强大,允许您检查相等性 (==)、数值比较 (< 和 >) 和正则表达式匹配等。

在模型上使用 RSpec 时,在很大程度上,您可以依赖 Rails 提供的内置验证。例如,您大概希望 sex 字段包含 M 或 F。如果有人输入的值不是其中之一,则不应将其保存到数据库。实现此功能的第一个步骤是引入一个新的规范

it "should forbid characters other than M and F" do
  @valid_attributes[:sex] = 'Z'
  p = Person.new(@valid_attributes)
  p.should_not be_valid
  p.save.should == false
end

我运行rake spec,并发现此测试失败。同样,这是预期的,现在我可以修改我的 Person 类,使其更具限制性

class Person < ActiveRecord::Base
  validates_presence_of :first_name
  validates_inclusion_of :sex, :in => %w(M F), :message => 
  "Sex must be either M or F"
end

当我运行rake spec,我得到了一个失败,但不是来自最新的规范,最新的规范已经顺利通过,告诉我 Z 是非法的。相反,失败的是第一个规范,其中 @valid_attributes 已将键 sex 设置为 sex 的值。再一次,这没关系;我以小的、递增的步骤前进这一事实使我有机会在事情变得无法控制之前识别并修复此类问题。通过修改 @valid_attributes 以使其使用 M(或者如果您喜欢 F),规范就可以工作了。

结论

RSpec 为测试问题提供了一种令人耳目一新但仍然有些熟悉的方法。通过从行为和规范而不是配置和内部结构的角度进行思考,创建测试变得更加容易。RSpec 中使用的自然术语“describe”、“it”和“should”是经过仔细选择的,它们有助于将测试变成所有各方(而不仅仅是程序员)之间的共同事业。

虽然我只介绍了内置的 RSpec 匹配器(即 should 之后的测试),但可以甚至鼓励为项目中的对象创建您自己的自定义匹配器。

下个月,我将继续探索 RSpec,研究如何测试控制器。这引发了很多问题,包括那些与在控制器内部实例化的模型对象有关的问题。正如您将看到的,RSpec 的“模拟对象”将使这个问题比其他情况要轻松得多。

资源

RSpec 的主页是 rspec.info,其中包含安装和配置文档,以及指向其他文档的指针。Pragmatic Programmers 最近发布了一本书,名为 The RSpec Book,由 RSpec 维护者 David Chelimsky 和许多其他积极参与 RSpec 社区的人撰写。如果您有兴趣使用 RSpec(或其近亲 BDD 工具 Cucumber),本书是一个很好的起点。RSpec 邮件列表(很有帮助且友好,但数量很大)位于 groups.google.com/group/rspec

Reuven M. Lerner,一位长期的 Web/数据库开发人员和顾问,是西北大学学习科学专业的博士候选人,研究在线学习社区。最近,他在芝加哥地区生活四年后,与妻子和三个孩子返回了他们在以色列莫迪因的家。

加载 Disqus 评论