跳至内容 跳至搜索

Active Record 夹具

夹具是一种组织数据的方法,您想用这些数据进行测试;简而言之,就是示例数据。

它们存储在 YAML 文件中,每个模型一个文件,默认情况下放置在 <your-rails-app>/test/fixtures/ 或应用程序任何引擎下的 test/fixtures 文件夹中。

可以通过 ActiveSupport::TestCase.fixture_paths= 更改位置,前提是您的 test_helper.rb 中已经 require "rails/test_help"

夹具文件以 .yml 文件扩展名结尾,例如:<your-rails-app>/test/fixtures/web_sites.yml)。

夹具文件的格式如下:

rubyonrails:
  id: 1
  name: Ruby on Rails
  url: http://www.rubyonrails.org

google:
  id: 2
  name: Google
  url: http://www.google.com

此夹具文件包含两个夹具。每个 YAML 夹具(即记录)都有一个名称,后面跟着一个缩进的键/值对列表,格式为“键: 值”。记录之间用空行分隔,方便查看。

排序

夹具默认是无序的。这是因为 YAML 中的映射是无序的。

如果您想要有序夹具,请使用 omap YAML 类型。有关规范,请参阅 yaml.org/type/omap.html

当您在同一表中的键上有外键约束时,需要有序夹具。这通常是树状结构所必需的。

例如

--- !omap
- parent:
    id:         1
    parent_id:  NULL
    title:      Parent
- child:
    id:         2
    parent_id:  1
    title:      Child

在测试用例中使用夹具

由于夹具是测试的构造,我们在单元测试和功能测试中使用它们。有两种方法可以使用夹具,但首先让我们看一个示例单元测试。

require "test_helper"

class WebSiteTest < ActiveSupport::TestCase
  test "web_site_count" do
    assert_equal 2, WebSite.count
  end
end

默认情况下,test_helper.rb 会将所有夹具加载到您的测试数据库中,因此此测试将成功。

测试环境会在每次测试之前自动将所有夹具加载到数据库中。为了确保数据一致性,环境会在运行加载之前删除夹具。

除了可以在数据库中访问外,还可以通过使用一个特殊的动态方法来访问夹具数据,该方法与模型名称相同。

将夹具名称传递给此动态方法将返回匹配此名称的夹具。

test "find one" do
  assert_equal "Ruby on Rails", web_sites(:rubyonrails).name
end

传递多个夹具名称将返回匹配这些名称的所有夹具。

test "find all by name" do
  assert_equal 2, web_sites(:rubyonrails, :google).length
end

不传递参数将返回所有夹具。

test "find all" do
  assert_equal 2, web_sites.length
end

传递任何不存在的夹具名称将引发 StandardError

test "find by name that does not exist" do
  assert_raise(StandardError) { web_sites(:reddit) }
end

如果模型名称与 TestCase 方法冲突,您可以使用通用的 fixture 访问器。

test "generic find" do
  assert_equal "Ruby on Rails", fixture(:web_sites, :rubyonrails).name
end

或者,您可以启用夹具数据的自动实例化。例如,考虑以下测试。

test "find_alt_method_1" do
  assert_equal "Ruby on Rails", @web_sites['rubyonrails']['name']
end

test "find_alt_method_2" do
  assert_equal "Ruby on Rails", @rubyonrails.name
end

为了在测试用例中使用这些方法访问夹具数据,您必须在您的 ActiveSupport::TestCase 派生类中指定以下之一。

  • 完全启用实例化夹具(启用上面备用方法 #1 和 #2)。

    self.use_instantiated_fixtures = true
    
  • 仅创建夹具的哈希,不要“查找”每个实例(仅启用备用方法 #1)。

    self.use_instantiated_fixtures = :no_instances
    

使用这两种备用方法中的任何一种都会对性能产生影响,因为必须完全遍历数据库中的夹具数据才能创建夹具哈希和/或实例变量。对于大量夹具数据来说,这非常昂贵。

动态夹具与 ERB

有时,您并不太关心夹具的内容,而更关心其数量。在这些情况下,您可以将 ERB 与 YAML 夹具混合使用,以创建大量夹具用于负载测试,例如:

<% 1.upto(1000) do |i| %>
fix_<%= i %>:
  id: <%= i %>
  name: guy_<%= i %>
<% end %>

这将创建 1000 个非常简单的夹具。

使用 ERB,您还可以通过类似 <%= Date.today.strftime("%Y-%m-%d") %> 的插入来注入动态值。但是,这是一个需要谨慎使用的功能。夹具的意义在于它们是稳定、可预测的示例数据单元。如果您觉得需要注入动态值,那么您应该重新审视您的应用程序是否可以正确测试。因此,夹具中的动态值应被视为代码异味。

在夹具中定义的辅助方法将无法在其他夹具中使用,以防止不期望的测试间依赖。多个夹具使用的方法应定义在一个模块中,该模块包含在 ActiveRecord::FixtureSet.context_class 中。

  • test_helper.rb 中定义一个辅助方法。

    module FixtureFileHelpers
      def file_sha(path)
        OpenSSL::Digest::SHA256.hexdigest(File.read(Rails.root.join('test/fixtures', path)))
      end
    end
    ActiveRecord::FixtureSet.context_class.include FixtureFileHelpers
    
  • 在夹具中使用辅助方法。

    photo:
      name: kitten.png
      sha: <%= file_sha 'files/kitten.png' %>

事务测试

测试用例可以使用 begin+rollback 来隔离其对数据库的更改,而不是为每个测试用例执行 delete+insert。

class FooTest < ActiveSupport::TestCase
  self.use_transactional_tests = true

  test "godzilla" do
    assert_not_empty Foo.all
    Foo.destroy_all
    assert_empty Foo.all
  end

  test "godzilla aftermath" do
    assert_not_empty Foo.all
  end
end

如果您预加载了包含所有夹具数据的测试数据库(可能通过运行 bin/rails db:fixtures:load)并使用事务测试,那么您可以省略测试用例中的所有夹具声明,因为所有数据都已存在,并且每个用例都会回滚其更改。

要将实例化夹具与预加载数据一起使用,请将 self.pre_loaded_fixtures 设置为 true。这将为通过夹具加载的每个表提供夹具数据访问(取决于 use_instantiated_fixtures 的值)。

何时使用事务测试。

  1. 您正在测试事务是否正确工作。嵌套事务在所有父事务提交之前不会提交,特别是夹具事务,它在 setup 中开始,在 teardown 中回滚。因此,在 Active Record 支持嵌套事务或保存点(进行中)之前,您将无法验证事务的结果。

  2. 您的数据库不支持事务。除了 MySQL MyISAM 之外,所有 Active Record 数据库都支持事务。请改用 InnoDB、MaxDB 或 NDB。

高级夹具

未指定 ID 的夹具获得了一些额外功能。

  • 稳定的、自动生成的 ID。

  • 关联的标签引用(belongs_tohas_onehas_many)。

  • HABTM 关联作为内联列表。

即使指定了 ID,还有一些更高级的功能可用。

  • 自动填充的时间戳列。

  • 夹具标签插值。

  • 对 YAML 默认值的支持。

稳定的、自动生成的 ID

这里有一个猴子夹具。

george:
  id: 1
  name: George the Monkey

reginald:
  id: 2
  name: Reginald the Pirate

这些夹具中的每一个都有两个唯一的标识符:一个用于数据库,一个用于人类。为什么我们不生成主键呢?对每个夹具的标签进行哈希处理会产生一致的 ID。

george: # generated id: 380982691
  name: George the Monkey

reginald: # generated id: 41001176
  name: Reginald the Pirate

Active Record 会查看夹具的模型类,发现正确的 ​​主键,并在将夹具插入数据库之前生成它。

给定标签的生成 ID 是恒定的,因此我们可以发现任何夹具的 ID 而无需加载任何内容,只要我们知道标签。

关联的标签引用(belongs_tohas_onehas_many

在夹具中指定外键可能会非常脆弱,更不用说难以阅读了。由于 Active Record 可以从标签中找出任何夹具的 ID,因此您可以通过标签而不是 ID 来指定 FK。

belongs_to

让我们来分解一下更多的猴子和海盗。

### in pirates.yml

reginald:
  id: 1
  name: Reginald the Pirate
  monkey_id: 1

### in monkeys.yml

george:
  id: 1
  name: George the Monkey
  pirate_id: 1

再加几只猴子和海盗,然后将其分成多个文件,就会很难弄清楚发生了什么。让我们使用标签而不是 ID。

### in pirates.yml

reginald:
  name: Reginald the Pirate
  monkey: george

### in monkeys.yml

george:
  name: George the Monkey
  pirate: reginald

砰!一切都变得清晰了。Active Record 反射夹具的模型类,查找所有 belongs_to 关联,并允许您指定关联的目标标签(monkey: george),而不是外键的目标IDmonkey_id: 1)。

多态 belongs_to

支持多态关系有点复杂,因为 Active Record 需要知道您的关联指向的类型。类似这样的东西应该很熟悉。

### in fruit.rb

belongs_to :eater, polymorphic: true

### in fruits.yml

apple:
  id: 1
  name: apple
  eater_id: 1
  eater_type: Monkey

我们能做得更好吗?当然!

apple:
  eater: george (Monkey)

只需提供多态目标类型,Active Record 就会处理其余的。

has_and_belongs_to_manyhas_many :through

该给我们的猴子一些水果了。

### in monkeys.yml

george:
  id: 1
  name: George the Monkey

### in fruits.yml

apple:
  id: 1
  name: apple

orange:
  id: 2
  name: orange

grape:
  id: 3
  name: grape

### in fruits_monkeys.yml

apple_george:
  fruit_id: 1
  monkey_id: 1

orange_george:
  fruit_id: 2
  monkey_id: 1

grape_george:
  fruit_id: 3
  monkey_id: 1

让我们移除 fruits_monkeys.yml 文件。

### in monkeys.yml

george:
  id: 1
  name: George the Monkey
  fruits: apple, orange, grape

### in fruits.yml

apple:
  name: apple

orange:
  name: orange

grape:
  name: grape

咻!没有 fruits_monkeys.yml 文件了。我们在 George 的夹具中指定了水果列表,但同样,我们也可以在每个水果上指定猴子列表。与 belongs_to 一样,Active Record 反射夹具的模型类并发现 has_and_belongs_to_many 关联。

自动填充时间戳列

如果您的表/模型指定了 Active Record 的任何标准时间戳列(created_atcreated_onupdated_atupdated_on),它们将自动设置为 Time.now

如果您设置了特定值,它们将保持不变。

夹具标签插值

当前夹具的标签始终可作为列值提供。

geeksomnia:
  name: Geeksomnia's Account
  subdomain: $LABEL
  email: $LABEL@email.com

此外,有时(例如迁移旧的连接表夹具时)您需要获取给定标签的标识符。 ERB 来拯救!

george_reginald:
  monkey_id: <%= ActiveRecord::FixtureSet.identify(:reginald) %>
  pirate_id: <%= ActiveRecord::FixtureSet.identify(:george) %>

如果模型使用 UUID 值作为标识符,请添加 :uuid 参数。

ActiveRecord::FixtureSet.identify(:boaty_mcboatface, :uuid)

对 YAML 默认值的支持

您可以在夹具 YAML 文件中设置和重用默认值。这是 database.yml 文件中用于指定默认值的技术。

DEFAULTS: &DEFAULTS
  created_on: <%= 3.weeks.ago.to_fs(:db) %>

first:
  name: Smurf
  <<: *DEFAULTS

second:
  name: Fraggle
  <<: *DEFAULTS

任何标记为“DEFAULTS”的夹具都会被安全地忽略。

除了使用“DEFAULTS”之外,您还可以通过在“_fixture”部分设置“ignore”来指定要忽略的夹具。

# users.yml
_fixture:
  ignore:
    - base
  # or use "ignore: base" when there is only one fixture that needs to be ignored.

base: &base
  admin: false
  introduction: "This is a default description"

admin:
  <<: *base
  admin: true

visitor:
  <<: *base

在上面的示例中,“base”在创建夹具时将被忽略。这可用于常见的属性继承。

复合主键夹具

复合主键表的夹具与普通表非常相似。使用 id 列时,可以像往常一样省略该列。

# app/models/book.rb
class Book < ApplicationRecord
  self.primary_key = [:author_id, :id]
  belongs_to :author
end

# books.yml
alices_adventure_in_wonderland:
  author_id: <%= ActiveRecord::FixtureSet.identify(:lewis_carroll) %>
  title: "Alice's Adventures in Wonderland"

但是,为了支持复合主键关联,您必须使用 `composite_identify` 方法。

# app/models/book_orders.rb
class BookOrder < ApplicationRecord
  self.primary_key = [:shop_id, :id]
  belongs_to :order, foreign_key: [:shop_id, :order_id]
  belongs_to :book, foreign_key: [:author_id, :book_id]
end

# book_orders.yml
alices_adventure_in_wonderland_in_books:
  author: lewis_carroll
  book_id: <%= ActiveRecord::FixtureSet.composite_identify(
               :alices_adventure_in_wonderland, Book.primary_key)[:id] %>
  shop: book_store
  order_id: <%= ActiveRecord::FixtureSet.composite_identify(
               :books, Order.primary_key)[:id] %>

配置夹具模型类

可以直接在 YAML 文件中设置夹具的模型类。当夹具在测试外部加载且 set_fixture_class 不可用时(例如,在运行 bin/rails db:fixtures:load 时),此功能非常有用。

_fixture:
  model_class: User
david:
  name: David

任何标记为“_fixture”的夹具都会被安全地忽略。

方法
#
C
E
F
I
N
R
S
T

常量

MAX_ID = 2**30 - 1
 

Attributes

[R] config
[R] fixtures
[R] ignored_fixtures
[R] model_class
[R] name
[R] table_name

类公共方法

cache_fixtures(connection_pool, fixtures_map)

# File activerecord/lib/active_record/fixtures.rb, line 576
def cache_fixtures(connection_pool, fixtures_map)
  cache_for_connection_pool(connection_pool).update(fixtures_map)
end

cache_for_connection_pool(connection_pool)

# File activerecord/lib/active_record/fixtures.rb, line 560
def cache_for_connection_pool(connection_pool)
  @@all_cached_fixtures[connection_pool]
end

cached_fixtures(connection_pool, keys_to_fetch = nil)

# File activerecord/lib/active_record/fixtures.rb, line 568
def cached_fixtures(connection_pool, keys_to_fetch = nil)
  if keys_to_fetch
    cache_for_connection_pool(connection_pool).values_at(*keys_to_fetch)
  else
    cache_for_connection_pool(connection_pool).values
  end
end

composite_identify(label, key)

返回一个表示标签与提供的复合键的子组件之间映射的、一致的、平台无关的哈希。

示例

composite_identify("label", [:a, :b, :c]) # => { a: hash_1, b: hash_2, c: hash_3 }
# File activerecord/lib/active_record/fixtures.rb, line 633
def composite_identify(label, key)
  key
    .index_with
    .with_index { |sub_key, index| (identify(label) << index) % MAX_ID }
    .with_indifferent_access
end

context_class()

ERB 夹具使用的评估上下文的超类。

# File activerecord/lib/active_record/fixtures.rb, line 641
def context_class
  @context_class ||= Class.new
end

create_fixtures(fixtures_directories, fixture_set_names, class_names = {}, config = ActiveRecord::Base)

# File activerecord/lib/active_record/fixtures.rb, line 595
def create_fixtures(fixtures_directories, fixture_set_names, class_names = {}, config = ActiveRecord::Base)
  fixture_set_names = Array(fixture_set_names).map(&:to_s)
  class_names.stringify_keys!

  connection_pool = config.connection_pool
  fixture_files_to_read = fixture_set_names.reject do |fs_name|
    fixture_is_cached?(connection_pool, fs_name)
  end

  if fixture_files_to_read.any?
    fixtures_map = read_and_insert(
      Array(fixtures_directories),
      fixture_files_to_read,
      class_names,
      connection_pool,
    )
    cache_fixtures(connection_pool, fixtures_map)
  end
  cached_fixtures(connection_pool, fixture_set_names)
end

fixture_is_cached?(connection_pool, table_name)

# File activerecord/lib/active_record/fixtures.rb, line 564
def fixture_is_cached?(connection_pool, table_name)
  cache_for_connection_pool(connection_pool)[table_name]
end

identify(label, column_type = :integer)

返回 label 的一个一致的、平台无关的标识符。

整数标识符是小于 2^30 的值。UUID 是 RFC 4122 版本 5 SHA-1 哈希。

# File activerecord/lib/active_record/fixtures.rb, line 619
def identify(label, column_type = :integer)
  if column_type == :uuid
    Digest::UUID.uuid_v5(Digest::UUID::OID_NAMESPACE, label.to_s)
  else
    Zlib.crc32(label.to_s) % MAX_ID
  end
end

instantiate_all_loaded_fixtures(object, load_instances = true)

# File activerecord/lib/active_record/fixtures.rb, line 589
def instantiate_all_loaded_fixtures(object, load_instances = true)
  all_loaded_fixtures.each_value do |fixture_set|
    instantiate_fixtures(object, fixture_set, load_instances)
  end
end

instantiate_fixtures(object, fixture_set, load_instances = true)

# File activerecord/lib/active_record/fixtures.rb, line 580
def instantiate_fixtures(object, fixture_set, load_instances = true)
  return unless load_instances
  fixture_set.each do |fixture_name, fixture|
    object.instance_variable_set "@#{fixture_name}", fixture.find
  rescue FixtureClassNotFound
    nil
  end
end

new(_, name, class_name, path, config = ActiveRecord::Base)

# File activerecord/lib/active_record/fixtures.rb, line 713
def initialize(_, name, class_name, path, config = ActiveRecord::Base)
  @name     = name
  @path     = path
  @config   = config

  self.model_class = class_name
  @fixtures = read_fixture_files(path)

  @table_name = model_class&.table_name || self.class.default_fixture_table_name(name, config)
end

reset_cache()

# File activerecord/lib/active_record/fixtures.rb, line 556
def reset_cache
  @@all_cached_fixtures.clear
end

实例公共方法

[](x)

# File activerecord/lib/active_record/fixtures.rb, line 724
def [](x)
  fixtures[x]
end

[]=(k, v)

# File activerecord/lib/active_record/fixtures.rb, line 728
def []=(k, v)
  fixtures[k] = v
end

each(&block)

# File activerecord/lib/active_record/fixtures.rb, line 732
def each(&block)
  fixtures.each(&block)
end

size()

# File activerecord/lib/active_record/fixtures.rb, line 736
def size
  fixtures.size
end

table_rows()

返回要插入的行的哈希。键是表,值是要插入到该表的行列表。

# File activerecord/lib/active_record/fixtures.rb, line 742
def table_rows
  # allow specifying fixtures to be ignored by setting `ignore` in `_fixture` section
  fixtures.except!(*ignored_fixtures)

  TableRows.new(
    table_name,
    model_class: model_class,
    fixtures: fixtures,
  ).to_hash
end