跳至内容 跳至搜索

Active Record Fixtures

Fixtures 是一种组织测试数据的方案,简而言之,就是示例数据。

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

test_helper.rb 中添加 require "rails/test_help" 后,也可以使用 ActiveSupport::TestCase.fixture_paths= 更改位置。

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(即记录)都给定一个名称,后面跟着一个缩进的键值对列表,格式为“键:值”。为了便于查看,记录之间用空行隔开。

排序

默认情况下,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 会将所有 fixtures 加载到您的测试数据库中,因此此测试将成功。

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

除了在数据库中可用之外,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

或者,您可以启用 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 派生类中指定以下内容之一

  • 完全启用实例化的 fixtures(启用上面的备用方法 #1 和 #2)

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

    self.use_instantiated_fixtures = :no_instances
    

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

使用 ERB 的动态 fixtures

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

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

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

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

在 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

如果您使用所有固定数据预加载测试数据库(可能通过运行 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_to、has_one、has_many)

  • HABTM 关联作为内联列表

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

  • 自动填充时间戳列

  • 固定数据标签插值

  • 支持 YAML 默认值

稳定、自动生成的 ID

这里有一个猴子固定数据

george:
  id: 1
  name: George the Monkey

reginald:
  id: 2
  name: Reginald the Pirate

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

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

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

Active Record 查看固定数据的模型类,发现正确的 primary key,并在将固定数据插入数据库之前生成它。

给定标签的生成 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 关联,并允许您为 **关联** 指定目标 **标签**(猴子:乔治),而不是为 **外键** 指定目标 **id**(monkey_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

让我们让 HABTM 固定装置消失。

### 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 文件。我们在乔治的固定装置上指定了水果列表,但我们也可以在每种水果上指定猴子列表。与 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, query_constraints: [:shop_id, :order_id]
  belongs_to :book, query_constraints: [: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
 

属性

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

类公共方法

cache_fixtures(connection, fixtures_map)

# File activerecord/lib/active_record/fixtures.rb, line 572
def cache_fixtures(connection, fixtures_map)
  cache_for_connection(connection).update(fixtures_map)
end

cache_for_connection(connection)

# File activerecord/lib/active_record/fixtures.rb, line 556
def cache_for_connection(connection)
  @@all_cached_fixtures[connection]
end

cached_fixtures(connection, keys_to_fetch = nil)

# File activerecord/lib/active_record/fixtures.rb, line 564
def cached_fixtures(connection, keys_to_fetch = nil)
  if keys_to_fetch
    cache_for_connection(connection).values_at(*keys_to_fetch)
  else
    cache_for_connection(connection).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 631
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 639
def context_class
  @context_class ||= Class.new
end

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

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

  # FIXME: Apparently JK uses this.
  connection = block_given? ? block : lambda { ActiveRecord::Base.connection }

  fixture_files_to_read = fixture_set_names.reject do |fs_name|
    fixture_is_cached?(connection.call, fs_name)
  end

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

fixture_is_cached?(connection, table_name)

# File activerecord/lib/active_record/fixtures.rb, line 560
def fixture_is_cached?(connection, table_name)
  cache_for_connection(connection)[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 617
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 585
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 576
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 709
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 552
def reset_cache
  @@all_cached_fixtures.clear
end

实例公共方法

[](x)

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

[]=(k, v)

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

each(&block)

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

size()

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

table_rows()

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

# File activerecord/lib/active_record/fixtures.rb, line 738
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