在 Forge - 使用 Shoulda 测试 Rails 应用程序

作者:Reuven M. Lerner

在过去的几个月里,我一直在研究许多工具,这些工具使 Ruby on Rails 开发人员更容易使用自动化测试来提高软件的可靠性。即使您不完全认同测试驱动开发 (TDD) 或其近亲行为驱动开发 (BDD) 的概念,Rails 使测试代码的每个部分变得如此容易,这一事实也降低了愚蠢的错误潜入您的应用程序的可能性。

默认情况下,Rails 附带 Test::Unit,这是一个测试套件,它可以让您轻松地检查您的代码。结合 actionpage 附带的测试类(actionpage 是 Rails 附带的核心 Ruby gem 之一),您可以在单元(模型)、功能(控制器)和集成(跨控制器)级别创建一个全面的测试套件。如果您有一个全面的测试套件,您将很容易检测到并理解您对代码所做的更改对测试的影响。

尽管如此,Test::Unit 有时可能有点冗长和重复。如果您正在编写单元测试,并且您想确保某个特定属性已得到完整测试,那么能够快速简洁地表达多个测试用例会很好。测试在许多方面都可以充当一种规范(正如我将在未来几个月谈到 RSpec 和 Cucumber 时解释的那样),并且这些规范越容易阅读,奇怪的行为就越不可能溜走。而且,不言而喻,编写测试(尤其是全面的测试)越容易,您就越有可能编写它们。

这就是 Shoulda(一组与 Test::Unit 配合使用的宏)在 Ruby 开发人员(尤其是 Rails 开发人员)中变得流行的原因。Shoulda 由 Tammer Saleh 开发,他是在波士顿 Thoughtbot 咨询公司工作的程序员。Shoulda 是一组宏,它使使用 Test::Unit 编写测试更容易,也使阅读测试更容易。我已经开始在用 Test::Unit 测试的项目中使用 Shoulda,并发现它非常令人愉快。

本月,我将介绍 Shoulda,以及如何将它的宏集成到您在 Rails 应用程序中编写的测试中。我将解释 Shoulda 如何将测试划分为上下文,允许您即使在单个文件中也可以将测试分组在一起。我还将描述 Shoulda 的各种宏如何使您可以使用一个可读的行轻松运行多个测试。

我应该注意到,尽管 Shoulda 最初旨在与 Test::Unit 一起使用,并为 Test::Unit 用户提供类似 RSpec 的环境,但它也增加了对 RSpec 的支持。即使您使用 RSpec,您也可能希望考虑将 Shoulda 与您的标准 RSpec 测试(或规范)一起使用。我还没有在自己的工作中研究过这种组合,但它可能适合您正在做的事情。

安装和基本使用

Shoulda 打包为 Ruby gem,可以按如下方式安装

sudo gem install thoughtbot-shoulda --source=http://gems.github.com

早期版本的 Shoulda 使用略有不同的名称打包(Shoulda,而不是 thoughtbot-shoulda)。也可以将 Shoulda 安装为 Rails 插件;在本文中,我假设您已安装 gem 版本。

您可以将 gem 包含在您的配置文件 config/environment.rb 中

config.gem "thoughtbot-shoulda", :lib => "shoulda", 
 ↪:source => "http://gems.github.com"

完成配置后,您的 Rails 应用程序要么在安装 Shoulda 的情况下运行,要么加载失败,并抱怨 gem 尚未安装。在我最喜欢的 Rails 函数之一中,您可以键入

rake gems:install

然后您的 Rails 应用程序将检查其所需的 gem 列表,下载系统中尚不存在的 gem,并将它们安装在适当的位置。

假设您已创建一个简单的 Rails 应用程序,其中包含一个描述人员的简单模型。您可以通过以下方式创建它

rails simple
cd simple
./script/generate model Person firstname:text lastname:text
 ↪birthdate:date grade_in_school:integer phone_number:text 
 ↪email_address:text
rake db:migrate

此时,您现在拥有一个简单的 Rails 应用程序(使用内置的默认数据库 SQLite),其中定义了一个模型。通过使用生成器创建模型,您将获得以下简单的单元测试文件

require 'test_helper'

class PersonTest < ActiveSupport::TestCase
  # Replace this with your real tests.
  test "the truth" do
    assert true
  end
end

诚然,您可以调用rake test在这个测试上,测试将成功,但这仅仅是因为测试完全是空的。您可以编写

rake test:units

但成功并不能真正告诉您太多信息,除了您需要编写一些测试之外。

使用和不使用 Shoulda 进行测试

现在到了困难的部分。您想要编写哪些类型的测试?嗯,这取决于您对模型施加的约束,通常是通过使用 ActiveRecord 验证。

具体来说,您大概会想要确保人员具有名字和姓氏,并且他们的年级(为了演示一些额外的测试)大于 0 且小于 13。您将想要确保人员的出生日期是在过去。您还将想要确保系统中的每个电子邮件地址都是唯一的,以避免有多人使用相同的电子邮件地址。

在模型文件本身中,验证将如下所示

class Person < ActiveRecord::Base
  validates_presence_of :firstname, :lastname, :email_address
  validates_uniqueness_of :email_address
  validates_numericality_of :grade_in_school, 
  ↪:greater_than_or_equal_to => 0, :less_than_or_equal_to => 13
end

如果您只是使用 Test::Unit,您可能需要测试所有这些验证。这与测试验证关系不大,而与确保您的代码符合您制定的规范关系更大。(如果测试仅仅是检查代码正确性的一种手段,那么您可以对这些验证的测试提出一个很好的论点,因为 ActiveRecord 已经有一个相当广泛的测试套件。)

如果您要尝试测试这一行

validates_presence_of :firstname, :lastname, :email_address

您将需要迭代提到的三个字段中的每一个,检查如果缺少其中一个字段,模型是否仍然有效。请参阅清单 1,了解 person_test.rb(包含 Person 对象单元测试的文件)的外观,仅用于测试对每个字段的需求。

清单 1. person_test.rb

require 'test_helper'

class PersonTest < ActiveSupport::TestCase
 # Replace this with your real tests.
 test "working person" do
   person = Person.new(:firstname => 'First',
                       :lastname => 'Last',
                       :email_address => 'foo@example.com',
                       :grade_in_school => 10)
   assert person.valid?
 end

 test "person must have first name" do
   person = Person.new(:firstname => '',
                       :lastname => 'Last',
                       :email_address => 'foo@example.com',
                       :grade_in_school => 10)
   assert !person.valid?
 end

 test "person must have last name" do
   person = Person.new(:firstname => 'First',
                       :lastname => '',
                       :email_address => 'foo@example.com',
                       :grade_in_school => 10)
   assert !person.valid?
 end

 test "person must have e-mail address" do
   person = Person.new(:firstname => 'First',
                       :lastname => 'Last',
                       :email_address => '',
                       :grade_in_school => 10)
   assert !person.valid?
 end
end

但是,在创建这些冗长的测试时,您会丢失一些东西。这些测试不再充当代码的检查,也不再充当您打算做什么的某种规范,而是变得冗长、重复且难以阅读。

安装 Shoulda 后,您现在可以删除清单 1 中显示的所有测试用例,用一个简单的调用替换它们

should_validate_presence_of :firstname, :lastname, :email_address

Shoulda 附带了大量宏,可以帮助您以这种方式测试 ActiveRecord 模型。例如,您可以使用 Shoulda 宏测试为 Person 模型定义的所有验证

should_validate_presence_of :firstname, :lastname, :email_address
should_validate_uniqueness_of :email_address
should_validate_numericality_of :grade_in_school
should_ensure_value_in_range :grade_in_school, (1..12), 
 ↪:low_message => 'must be greater than or equal to 1', 
 ↪:high_message => 'must be less than or equal to 12'

请注意 Shoulda 宏的名称如何反映 ActiveRecord 验证器的名称。这是在 Shoulda 首次发布后完成的,这意味着您在网上看到的一些文档可能略有过时,并且包含已弃用的宏名称。

另请注意,为了确保 grade_in_school 是数字,并且在某个范围内,由单个验证行设置的条件有时可能需要多个 Shoulda 宏。在我在此处演示的特定情况下,在检查人员的年级是否在可接受的范围内时,Rails 给 Shoulda 的错误消息与 Shoulda 期望的消息之间存在令人惊讶的不匹配。最后,我通过告诉 Shoulda 从 Rails 期望什么消息来解决了这个问题。尽管这比我可能希望的更冗长,但它证明了 Shoulda 提供的灵活性。

毫不奇怪,Shoulda 的作者让您可以创建自己的宏,就像您可以为 ActiveRecord 类创建自己的验证器方法一样。我在这里不深入介绍如何创建此类宏,但它有相当完善的文档记录,这意味着您可以创建大量测试,将它们打包在一个 Shoulda 宏下,然后在多个项目中使用这些测试(通过宏)。

测试和上下文

您可能已经看到 Shoulda 宏如何减少您需要编写的代码量。Shoulda 还提供了一个类似 RSpec 的工具,它可以让您使用字符串而不是方法名称来命名测试。诚然,这现在已包含在 Test::Unit 中,尽管使用了略有不同的语法。但是,您可以使用 should 关键字而不是 test 来定义测试,这增加了一些可读性——尤其是在与我下面描述的上下文结合使用时。

在这里,我在模型中创建一个名为 fullname 的方法,它返回人员的名字和姓氏的连接

def fullname  # added to app/models/person.rb
  "#{firstname} #{lastname}"
end

接下来,我添加一个新的测试

should "return the concatenation of the first and last name" do
  person = Person.new(:firstname => "First",
                      :lastname => "Last",
                      :email_address => "email@example.com")

  assert_equal person.fullname, "First Last"
end

现在,这个测试没有问题。它不仅通过了,而且还很好地检查了您是否获得了正确的值。也许只是我个人,但我有时最终会得到非常长的测试列表,并最终使用测试文件内部的注释对它们进行分类。Shoulda 提供了上下文,让您可以使用代码而不是注释在文件中对测试进行分组。拥有单个上下文和单个测试显然有点傻,但与 TDD/BDD 世界中的许多事情一样,从一开始就正确地做事是值得的,因为您知道您的代码库会随着时间的推移而增长,使得正确地组织事情变得困难。

要定义上下文,您只需编写

context "Defined methods" do
    # "should" blocks go here
end

换句话说,您现在可以将测试块重写为

context "Defined methods" do
    should "return the concatenation of the first and last name" do
        person = Person.new(:firstname => "First",
                      :lastname => "Last",
                      :email_address => "email@example.com")

        assert_equal person.fullname, "First Last"
    end
end

使用上下文块和 should 块,您现在可以将您的测试读作“定义的方法应返回名字和姓氏的连接”。这不是世界上最令人惊叹的描述,但这是一个不错的开始。此外,现在您可以添加额外的 should 块来测试其他定义的方法。

上下文可能包含其他上下文以及 should 块。这意味着,如果您有一个特别复杂的模型要测试,您可以拥有一个上下文层次结构,底部是 should 块。

此外,使用上下文块意味着您可以编写一个 setup 块,该块定义将在 should 块内部使用的变量并以其他方式分配资源。例如,您现在可以编写

context "Defined methods" do
  setup do
    @person = Person.new(:firstname => "First",
                         :lastname => "Last",
                         :email_address => "email@example.com")

  end

  should "return the concatenation of the first and last name" do
    assert_equal @person.fullname, "#{@person.firstname}
    ↪#{@person.lastname}"
  end
end

如您所见,在 setup 块和 should 块之间共享的变量需要是实例变量,它们的名称前面带有 @ 符号。

当调用测试时,首先调用其所有周围上下文中的所有 setup 块。这意味着,如果一个 should 块位于三个嵌套的上下文中,并且如果每个上下文都有自己的 setup 块,那么所有三个 setup 块都将在测试执行之前触发。

结论

如果您正在使用 Test::Unit 来测试您的 Ruby on Rails 应用程序,Shoulda 是一个自然的搭配,它允许您使用灵活、易于阅读的宏来编写大量常见测试。在本文中,我仅介绍了 Shoulda 在 ActiveRecord 模型中的用法;Shoulda 的其他部分与控制器测试一起工作,提供额外的功能,这些功能可能对测试人员有用。

从我的角度来看,使用 Shoulda 是无需动脑筋的。我已经在许多项目中使用了它,发现它进一步降低了 TDD/BDD 的门槛,帮助使我的代码更加可靠。如果您是测试新手,Shoulda 是一个很好的入门方法,它提供了一种简单的方法来提高代码的稳定性和正确性。总而言之,Shoulda 对于 Ruby 程序员(尤其是 Rails 程序员)来说是一个很好的资源。

资源

Shoulda 的主页是 thoughtbot.com/projects/shoulda。这里的文档是一个很好的起点,但您可能需要自己试用一下才能掌握要领。即使是我上面描述的小问题,在测试人员的最小和最大年龄时,也表明您可能仍然需要仔细阅读文档才能完全理解。

Shoulda 的 PDF 速查表位于 kylebanker.com/assets/content/2008/shoulda_cheat_sheet.pdf,流行的 Ruby 程序员速查表程序也有一个条目:cheat.errtheblog.com/s/shoulda

以下是一些关于 Shoulda 的有趣博客文章,它们也可能提供一些有用的想法:pragdave.blogs.pragprog.com/pragdave/2008/04/shoulda-used-th.htmlgiantrobots.thoughtbot.com/2009/2/3/speculating-with-shouldawww.alexjsharp.com/2008/10/15/shoulda-painless-unit-testing

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

加载 Disqus 评论