跳至内容 跳至搜索

Active Record Fixtures

Fixtures 是一种组织测试数据的方案,简单来说,就是示例数据。

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

位置也可以使用 ActiveSupport::TestCase.fixture_paths= 更改,前提是在您的 test_helper.rb 中包含 require "rails/test_help"

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

Fixture 文件的格式如下

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

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

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

排序

Fixture 默认情况下是不排序的。这是因为 YAML 中的映射是不排序的。

如果您想要排序的 Fixture,请使用 omap YAML 类型。有关规范,请参见 yaml.org/type/omap.html

当您对同一表中的键有外键约束时,您将需要排序的 Fixture。这通常用于树结构。

例如

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

在测试用例中使用 Fixture

由于 Fixture 是一个测试结构,因此我们在单元测试和功能测试中使用它们。使用 Fixture 有两种方法,但首先让我们来看一个单元测试示例

require "test_helper"

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

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

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

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

将 Fixture 名称传递给此动态方法将返回与该名称匹配的 Fixture

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

传递多个 Fixture 名称将返回所有与这些名称匹配的 Fixture

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

不传递任何参数将返回所有 Fixture

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

传递任何不存在的 Fixture 名称将引发 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

或者,您可以启用 Fixture 数据的自动实例化。例如,看看下面的测试

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

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

  • 完全启用实例化的 Fixture(启用上面的替代方法 #1 和 #2)

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

    self.use_instantiated_fixtures = :no_instances
    

使用这两种替代方法之一都会导致性能下降,因为必须在数据库中完全遍历 Fixture 数据才能创建 Fixture 哈希和/或实例变量。对于大型 Fixture 数据集来说,这很昂贵。

使用 ERB 的动态 Fixture

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

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

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

使用 ERB,您还可以使用像 <%= Date.today.strftime("%Y-%m-%d") %> 这样的插入将动态值注入您的 Fixture 中。但这项功能需要谨慎使用。Fixture 的重点在于它们是稳定的可预测样本数据单元。如果您觉得需要注入动态值,那么您可能应该重新考虑您的应用程序是否可测试。因此,Fixture 中的动态值被认为是代码异味。

在 Fixture 中定义的帮助方法在其他 Fixture 中不可用,以防止意外的测试间依赖。多个 Fixture 使用的方法应该定义在包含在 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
    
  • 在 Fixture 中使用帮助方法

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

事务性测试

测试用例可以使用 begin+rollback 来隔离它们对数据库的更改,而不必对每个测试用例进行删除+插入操作。

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

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

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

何时使用事务性测试

  1. 您正在测试事务是否正常工作。嵌套事务只有在所有父事务提交后才会提交,特别是 Fixture 事务,它在 setup 中开始并在 teardown 中回滚。因此,您将无法验证事务的结果,直到 Active Record 支持嵌套事务或保存点(正在进行中)。

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

高级 Fixture

不指定 ID 的 Fixture 有一些额外的功能

  • 稳定的、自动生成的 ID

  • 关联(belongs_to、has_one、has_many)的标签引用

  • HABTM 关联作为内联列表

即使指定了 id,也有一些更高级的功能可用

  • 自动填充的 timestamp 列

  • Fixture 标签插值

  • 支持 YAML 默认值

稳定的、自动生成的 ID

这里有一只猴子 Fixture

george:
  id: 1
  name: George the Monkey

reginald:
  id: 2
  name: Reginald the Pirate

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

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

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

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

为给定标签生成的 ID 是常量,因此只要知道标签,我们就可以在不加载任何内容的情况下发现任何 Fixture 的 ID。

关联(belongs_tohas_onehas_many)的标签引用

在 Fixture 中指定外键可能非常脆弱,更不用说难以阅读了。由于 Active Record 可以从标签中找出任何 Fixture 的 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 反映了 Fixture 的模型类,找到所有 belongs_to 关联,并允许您为关联(monkey: george)指定一个目标标签,而不是为FKmonkey_id: 1)指定一个目标id

多态 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

让我们让 HABTM Fixture 消失。

### 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 的 Fixture 上指定了水果列表,但我们也同样可以在每个水果上指定猴子列表。与 belongs_to 一样,Active Record 会反映 Fixture 的模型类,并发现 has_and_belongs_to_many 关联。

自动填充的 Timestamp 列

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

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

Fixture 标签插值

当前 Fixture 的标签始终可用作列值

geeksomnia:
  name: Geeksomnia's Account
  subdomain: $LABEL
  email: [email protected]

此外,有时(例如,在移植旧的联接表 Fixture 时),您需要能够获取给定标签的标识符。ERB 来拯救!

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

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

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

支持 YAML 默认值

您可以在 Fixture YAML 文件中设置和重用默认值。这与在 database.yml 文件中指定默认值使用的技术相同

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

first:
  name: Smurf
  <<: *DEFAULTS

second:
  name: Fraggle
  <<: *DEFAULTS

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

除了使用“DEFAULTS”之外,您还可以通过在“_fixture”部分设置“ignore”来指定哪些 Fixture 将被忽略。

# 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

在上面的示例中,创建 Fixture 时将忽略‘base’。这可用于继承常用属性。

复合主键 Fixture

复合主键表的 Fixture 与普通表非常相似。使用 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] %>

配置fixture模型类

可以在YAML文件中直接设置fixture的模型类。当fixture在测试之外加载并且 `set_fixture_class` 不可使用时,这很有帮助(例如,在运行 `bin/rails db:fixtures:load` 时)。

_fixture:
  model_class: User
david:
  name: David

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

方法
#
C
E
F
I
N
R
S
T

常量

MAX_ID = 2**30 - 1
 

属性

[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 fixture 使用的评估上下文的超类。

# 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