At the Forge - 夹具和工厂

作者:Reuven M. Lerner

Ruby 社区引以为豪的一点是开发者对测试的关注程度。正如我上个月所写,在动态语言中,测试比最好的编译器更有潜力纠正更多错误并保持代码的简洁和功能性。Rails 开发者习惯于使用三种不同类型的测试:单元测试(用于数据库模型)、功能测试(用于控制器类)和集成测试(用于从用户的角度测试事物)。结合覆盖率和分析工具,例如我上个月描述的 metric_fu gem,这些测试可以帮助确保您的代码在公开发布之前尽可能可靠。

测试您的代码需要您为其提供输入,然后将这些输入与预期的输出进行匹配。当涉及到 Web 应用程序时,这些输入很可能来自关系数据库或用户的表单提交。测试表单提交并不特别困难,尤其是在像 Rails 这样的框架中,它内置了广泛的测试支持。然而,测试来自数据库的数据可能更具挑战性,因为它意味着您必须以某种方式将数据存储在数据库中,以便测试可以访问它。

当然,一种可能的解决方案是直接使用测试数据预先填充数据库表。但是,尽管该解决方案乍一看可能很简单且显而易见,但它假设您有一个可以从中预先填充数据库的来源。您可以手动完成,但随后您会发现您的程序对数据库所做的任何修改——创建、更新和删除行——要么会持续到下一次测试,要么需要从另一个来源重新加载。

换句话说,您需要一种在开始测试之前将测试数据库置于已知状态的方法。如果您知道这个初始状态,您就可以编写测试来检查后续状态。

问题是,您如何创建该初始状态?从 Rails 首次发布时起,答案就是夹具——包含 YAML 格式的手工数据的文本文件。夹具很好,但正如许多 Rails 开发者多年来所写的那样,它们可能难以编写、难以跟踪并且通常很脆弱。

本月,我将研究将数据加载到测试数据库的当前状态。我首先检查夹具,探索一些您仍然可以在测试中使其有用的方法。然后,我将介绍一种较新的测试数据方法,称为工厂,查看 Factory Girl gem,然后快速了解 Machinist gem,这两者都在 Rails 开发者中广泛使用,并且可能比纯粹的旧夹具更适合您的项目。

创建您的应用程序

正如我上面提到的,夹具是包含可以加载到数据库中的数据的 YAML 文件。Rails 实际上允许您将夹具数据放在 YAML 以外的其他格式中,例如 CSV。但是,我猜 CSV 在很大程度上未使用,而 YAML 是几乎所有使用夹具的人使用的格式。

我在我的计算机上使用以下命令创建了一个简单的 Rails 应用程序(使用 SQLite):

rails --database=sqlite3 appointments

然后,我为 people 生成了一个 RESTful 资源:

./script/generate scaffold person \
      first_name:string last_name:string email:string

这不仅创建了一个用于处理 people 的模型,还创建了一个用于处理基本 RESTful 功能的控制器、所有这些控制器操作的视图、一个使用 Ruby 描述我的模型的数据库迁移,甚至一些基本的测试。我可以使用以下命令导入数据库迁移:

rake db:migrate

瞧!我现在有了一个可用的应用程序,它允许我添加、删除、修改和列出一堆人。您可能已经注意到我将我的 Rails 应用程序命名为 appointments。我的计划是创建一个非常简单的预约日历,以便我可以跟踪我将与谁会面。因此,我创建了另一个资源,名为 meetings:

./script/generate scaffold meeting \
      starting_at:timestamp ending_at:timestamp location:text

(应该不用说,如果我是真正创建这个应用程序,我不会将位置存储为文本字段,而是存储为指向另一个位置表的 ID。以这种规范化的形式保存数据,以便文本出现在一个地方,并使用外键从数据库的其他地方引用,这使应用程序更健壮,也更高效。)

最后,我创建了第三个表 meeting_person,它允许多个人参加会议。如果我愿意将预约限制为单个参与者(或者如果我包括使用此软件的人,则为两个参与者),我只需在 meeting 表中添加一个 person_id 字段即可。为了实现这一点,我创建了一个新模型:

./script/generate model meeting_person \
      person_id:integer meeting_id:integer

现在三个模型已经到位,我可以添加关联——模型类中将它们彼此链接的那些声明。在编辑模型时,我还会添加一些验证,以确保数据符合我的标准。模型的最终版本如清单 1 所示。模型中可能唯一特别有趣的部分是我放在 Meeting 模型中的自定义验证:

def validate
  if starting_at > ending_at
    errors.add_to_base("Starting time is later than ending time!")
  end
end

清单 1. 模型文件,带有关联和验证

class Person < ActiveRecord::Base
  has_many :meeting_people
  has_many :meetings, :through => :meeting_people

  validates_presence_of :first_name, :last_name, :email
  validates_uniqueness_of :email

  def fullname
    "#{first_name} #{last_name}"
  end

end


class Meeting < ActiveRecord::Base
  has_many :meeting_people
  has_many :people, :through => :meeting_people

  validates_presence_of :starting_at, :ending_at, :location

  def validate
    if starting_at > ending_at
      errors.add_to_base("Starting time is later than ending time!")
    end

    if self.people.empty?
      errors.add_to_base("You must meet with at least one person!")
    end
  end

  def people_as_sentence
    return self.people.map { |p| p.fullname}.to_sentence
  end

end

class MeetingPerson < ActiveRecord::Base
  belongs_to :person
  belongs_to :meeting

end

清单 2. views/meetings/new.html.erb,从默认脚手架修改而来,允许用户输入一个或多个人

<h1>New meeting</h1>

<% form_for(@meeting) do |f| %>
 <%= f.error_messages %>

 <p>
  <%= f.label :starting_at %><br />
  <%= f.datetime_select :starting_at %>
 </p>
 <p>
  <%= f.label :ending_at %><br />
  <%= f.datetime_select :ending_at %>
 </p>
 <p>
  <%= f.label :location %><br />
  <%= f.text_area :location %>
 </p>

 <p>With:
   <%= select("person",
              "person_id",
              Person.all.collect { |p| [p.fullname, p.id] },
              {},
              {:multiple => true}) %>
 </p>
 <p>
  <%= f.submit 'Create' %>
 </p>

<% end %>

<%= link_to 'Back', meetings_path %>

我还创建了一个方便的函数,该函数返回一个包含预约人员姓名的数组:

def people_as_sentence
  return self.people.map {|p| p.fullname}.to_sentence
end

此验证在我尝试保存 Meeting 实例时运行,检查以确保开始时间早于结束时间。如果不是这种情况,则验证失败,并且数据不会被存储。(我可以将时间视为成熟的对象,并可以访问 > 和 < 运算符,这一事实是我在 Ruby 和 SQL 中最喜欢的部分之一。)

最后,我将通过修改现有的脚手架控制器操作来增强此应用程序,使其更有用。首先,我修改了 new 和 create 操作,以便它们允许某人创建预约,同时指示预约将与谁进行。然后,我修改了 index 操作,以便用户将获得所有即将到来的预约的列表。

夹具

现在我已经创建了一个简单的应用程序,是时候对其进行测试了。正如我上面所写,测试应用程序需要我有一些示例数据来测试它。默认情况下,Rails 模型的生成器会创建基本夹具,长期以来,夹具一直是将数据导入 Rails 测试的标准方法。我所说的基本是指它们包含一些非常非常基本的数据——实际上,对于我可能想要做的任何实际测试来说都太基本了。例如,这是为 people 自动生成的夹具:

one:
  first_name: MyString
  last_name: MyString
  email: MyString

two:
  first_name: MyString
  last_name: MyString
  email: MyString

即使您是 YAML 的新手,更不用说夹具文件了,格式也应该很容易理解。YAML 由层次结构中的名称-值对组成,缩进表示特定名称-值对在层次结构中的位置。(您还可以通过用逗号分隔值来将值列表与键关联。)因此,夹具中定义了两个人,one 和 two,并且每个人都有三个名称-值对。

但是,这些名称-值对几乎毫无用处。它们可能包含有效数据,也可能包含不符合我的模型验证中规定的标准的数据。如果我为 email 字段定义了一个验证器,确保该字段始终包含有效的电子邮件地址,则测试会立即失败,甚至在它们运行之前。Rails 会将夹具加载到 ActiveRecord 中,数据库会拒绝它们,因为它们无效,我会挠头。

当您开始创建依赖于关联的夹具时,事情会变得更加棘手。我显然希望我的 meeting_people 夹具指向有效的人和会议,但是使用数字 ID 可能会很快变得混乱。幸运的是,最新版本的 Rails 允许我命名对象关联的夹具,而不是它的数字 ID。因此,尽管 meeting_people 的默认夹具是这样的:

one:
  person_id: 1
  meeting_id: 1

two:
  person_id: 1
  meeting_id: 1

相反,我可以这样说:

one:
  person: one
  meeting: one

two:
  person: two
  meeting: two

显然,您会希望为您的夹具选择更具描述性的名称。但是,我现在已经表明会议 #1 是与人 #1 进行的,会议 #2 是与人 #2 进行的。这显然比简单的数字更具描述性。

您甚至可以做得更好,因为夹具理解我在模型中定义的 has_many :through 关联。就像在 Ruby 代码中一样,我可以使用以下命令将一个人添加到会议中:

meeting.people << a_person

我可以将相同类型的信息放在夹具文件中。例如:

one:
  starting_at: 2009-05-10 00:48:12
  ending_at: 2009-05-10 01:48:12
  location: MyText
  people: one, two
two:
  starting_at: 2009-05-10 00:48:12
  ending_at: 2009-05-10 01:48:12
  location: MyText
  people: two

如果您以这种方式做事,您不想在 meeting_people 夹具和 meetings 夹具中都定义事物。否则,您可能会遇到一些非常奇怪的错误。请注意,夹具文件是 ERb(嵌入式 Ruby)文件,因此您可以具有动态生成的条目,例如:

one:
  starting_at: <%= 5.minutes.ago %>
  ending_at: <%= Time.now %>
  location: MyText
  people: one, two

现在,如何在您的测试中使用这些夹具?实际上非常简单。您需要使用 fixtures 方法加载您想要的夹具:

fixtures :meetings

默认情况下,所有夹具都会被导入,这要归功于:

fixtures :all

在 test/test_helper.rb 中,它会自动导入到所有测试中。然后,在您的测试中,您可以说类似这样的话:

get :edit, :id => people(:one).id

此示例(功能测试)将加载在 people.yml 中标识为 one 的 person 对象,调用 edit 方法并传递适当夹具的 ID。

Factory Girl

对于小型站点,或者当您可以将所有内容都记在脑海中时,夹具就足够了。多年来我肯定使用过它们,并且我发现它们是我测试策略中非常宝贵的一部分。但是,工厂是夹具的替代方案,它变得越来越流行,既因为它们是用 Ruby 代码编写的,又因为它们允许您做各种 YAML 夹具难以或不可能做到的事情。

Factory Girl 是最著名的工厂之一,由 Thoughtbot 公司编写和分发,它作为一个 Ruby gem 提供。在您的系统上安装 Factory Girl 并使用以下命令将其引入您的应用程序环境后:

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

在 config/environment.rb 中,您将能够使用它。基本上,Factory Girl 允许您在 Ruby 中创建对象,而不是从夹具文件中加载它们。生成器不会为您创建默认值,但这没什么大不了的,因为使用 Factory Girl 创建测试对象非常容易。

上面,我展示了如何在测试环境中使用夹具,您可以使用 people 方法获取名称为 one 的 person 对象,然后传递一个符号:

get :edit, :id => people(:one).id

people(:one)是一个成熟的 ActiveRecord 对象,具有您可能期望从这样的对象获得的一切。Factory Girl 以不同的方式工作。首先,您需要创建一个 test/factories.rb 文件,在其中定义您的工厂。(您还可以创建一个 test/factories/ 目录,其内容将是定义工厂的 Ruby 文件。)

要为 people 创建一个工厂(即,代替 people.yml),请在 test/factories 中插入 people.rb:

Factory.define :person do |p|
  p.first_name 'Reuven'
  p.last_name  'Lerner'
  p.email 'reuven@lerner.co.il'
end

现在,在测试中,您可以说:

get :edit, :id => Factory.build(:person).id

person = Factory.build(:person)
get :edit, :id => person.id

乍一看,这似乎没什么令人兴奋的。毕竟,您可以使用夹具大致完成相同的事情,对吗?但是工厂允许您覆盖默认值:

person = Factory.build(:person, :first_name => 'Foobar')
get :edit, :id => person.id

但是等等,还有更多。您可以按如下方式设置关联:

Factory.define :person do |p|
  p.first_name 'Reuven'
  p.last_name  'Lerner'
  p.email 'reuven@lerner.co.il'
  p.meetings {|meetings| meetings.association(:meeting)}
end

换句话说,如果您创建了一个 meeting 工厂,您可以将其合并到您的 person 工厂中,利用关联,使用相当自然的语法。

一个更有趣的想法是序列。如果您的应用程序需要创建大量测试人员,您可能希望每个人的电子邮件地址都是唯一的。(没关系电子邮件永远不会被发送。)您可以使用序列来做到这一点:

Factory.define :person do |p|
  p.first_name 'Reuven'
  p.last_name  'Lerner'
  p.sequence(:email) {|n| "person#{n}@example.com" }
end

使用此工厂创建的第一个人的电子邮件地址将是 person1@example.com;第二个将是 person2@example.com,依此类推。

如您所见,Factory Girl 与 YAML 夹具一样易于使用,但它提供了许多在测试 Rails 应用程序时派上用场的强大功能。

Factory Girl 是一个非常棒的工厂库,自首次发布以来,它已变得非常流行。但是,并非所有人都喜欢它的基本语法,其中一人是 Pete Yandell,他认为尽管工厂背后的基本思想是合理的,但他希望为他的工厂使用不同的(且更紧凑的)语法。因此诞生了 Machinist,它使用 Sham 对象来描述对象中的字段,然后将这些字段组装成特定对象的蓝图。例如:

require 'faker'

# Define the fields that we will need
Sham.first_name  { Faker::Name.first_name }
Sham.last_name  { Faker::Name.last_name }
Sham.email { Faker::Internet.email }

# Now use these field definitions to create a blueprint
Person.blueprint do
  first_name
  last_name
  email
end

现在您可以使用这些蓝图来创建测试对象。例如:

person = Person.make()

与 Factory Girl 一样,您也可以覆盖默认值:

person = Person.make(:email => 'foo@example.com')
结论

自 Rails 测试实践开始以来,夹具一直是其中的一部分,它们仍然非常有用。但是,如果您发现自己对 YAML 文件感到沮丧,或者如果您想尝试一些提供更多灵活性和功能的东西,您可能很想尝试研究工厂。本月,我研究了两个不同的用于创建 Rails 工厂的库,这两个库都在广泛使用,并且可能非常适合您的项目。

资源

Ruby on Rails 的主页是 www.rubyonrails.com。有关测试的信息,包括夹具的使用,请参阅优秀的社区编写的 Rails 指南之一,网址为 guides.rubyonrails.org/testing.html

如果您有兴趣了解有关工厂的更多信息,一个好的起点(通常情况下)是 Railscast 站点,其中包含 Ryan Bates 每周的截屏视频。讨论夹具的 Railscast 位于 railscasts.com/episodes/158-factories-not-fixtures

最后,Factory Girl 的主页位于 dev.thoughtbot.com/factory_girl,Machinist 的主页位于 github.com/notahat/machinist/tree/master

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

加载 Disqus 评论