跳至內容 跳至搜尋

Active Record Fixtures (測試資料)

Fixtures 是一種組織測試資料的方式;簡而言之,就是樣本資料。

它們儲存在 YAML 檔案中,每個模型一個檔案,預設放置在 <您的 Rails 應用程式>/test/fixtures/ 或任何應用程式引擎下的 test/fixtures 資料夾中。

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

Fixture 檔案以 .yml 副檔名結尾,例如:<您的 Rails 應用程式>/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(即記錄)都被賦予一個名稱,後面跟著一個縮排的鍵值對列表,格式為「鍵:值」。為了方便閱讀,記錄之間用空行分隔。

排序

Fixtures 預設是無序的。這是因為 YAML 中的映射是無序的。

如果您需要有序的 Fixtures,請使用 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

在測試案例中使用 Fixtures

由於 Fixtures 是一種測試結構,我們在單元和功能測試中使用它們。有兩種方法可以使用 Fixtures,但首先讓我們看一下單元測試範例

require "test_helper"

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

預設情況下,test_helper.rb 會將所有 Fixture 載入到測試資料庫中,因此此測試將會成功。

測試環境會在每次測試之前自動將所有 Fixture 載入到資料庫中。為了確保資料一致性,環境會在執行載入之前刪除 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

如果模型名稱與 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 交易,它在設定中開始,並在拆解中回滾。因此,在 Active Record 支援巢狀交易或儲存點(正在進行中)之前,您將無法驗證交易的結果。

  2. 您的資料庫不支援交易。除了 MySQL MyISAM 之外,每個 Active Record 資料庫都支援交易。請改用 InnoDB、MaxDB 或 NDB。

進階 Fixture

未指定 ID 的 Fixture 具有一些額外功能

  • 穩定、自動產生的 ID

  • 關聯的標籤參考 (belongs_to、has_one、has_many)

  • HABTM 關聯作為內嵌列表

即使指定了 ID,也有一些更進階的功能可用

  • 自動填入時間戳記欄位

  • 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 關聯,並允許您為關聯指定目標標籤(猴子:george),而不是為FK指定目標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

讓我們移除 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 關聯。

自動填入時間戳記欄位

如果您的表格/模型指定了任何 Active Record 的標準時間戳記欄位(created_atcreated_onupdated_atupdated_on),它們將會自動設定為 Time.now

如果您已設定特定值,它們將會保留不變。

Fixture 標籤插值

目前 Fixture 的標籤始終可作為欄位值使用

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

此外,有時(例如在移植較舊的連接表格 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

常數

最大 ID = 2**30 - 1
 

屬性

[R] 設定
[R] Fixture
[R] 被忽略的 Fixture
[R] 模型類別
[R] 名稱
[R] table_name(表格名稱)

類別公開方法(Class Public methods)

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)

傳回一個一致的、平台獨立的雜湊,表示標籤 (label) 與提供的複合鍵 (composite key) 子組件之間的映射。

範例(Example)

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 Fixtures 使用的評估上下文之父類別。

# 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

實例公開方法(Instance Public methods)

[](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