在 Forge - 使用 Shoulda 测试 Rails 应用程序
在过去的几个月里,我一直在研究许多工具,这些工具使 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
现在到了困难的部分。您想要编写哪些类型的测试?嗯,这取决于您对模型施加的约束,通常是通过使用 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.html、giantrobots.thoughtbot.com/2009/2/3/speculating-with-shoulda 和 www.alexjsharp.com/2008/10/15/shoulda-painless-unit-testing。
Reuven M. Lerner,一位长期的 Web/数据库开发人员和顾问,是西北大学学习科学博士候选人,研究在线学习社区。在芝加哥地区生活四年后,他最近(与妻子和三个孩子)返回他们在以色列莫迪因的家。