Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Rails 7 API: ActiveRecord::FixtureSet(翻訳)

概要

MITライセンスに基づいて翻訳・公開いたします。


  • 2019/07/11: 初版公開
  • 2021/12/17: 5.2.3->7.0.0に更新

以下も参考にどうぞ。

週刊Railsウォッチ(20190708-1/2前編)ActiveRecord::FixtureSetがめちゃ強くなってた、MacだとRubyが遅い理由、Puma 4登場ほか

Rails API: ActiveRecord::FixtureSet(翻訳)

fixtureとは、テストの対象としたいデータ(つまりサンプルデータ)を組み立てる手法のひとつです。

fixtureはYAMLファイルに保存されます。1つのYAMLファイルが1つのモデルに対応し、ActiveSupport::TestCase.fixture_path=(パス)で指定されたディレクトリに保存されます(このディレクトリはRailsで自動設定されますので、自分のRailsアプリ/test/fixtures/に保存できます)。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が2件含まれています。各YAML fixture(レコードなどを表す)に名前が与えられ、続く行をインデントして、キーバリューペアを「キー: 値」の形式で記述します。レコードとレコードの間には見やすくするための空行が入ります。

注意: fixture同士の間には順序関係がありません。fixtureを順序付けしたい場合は、omapというYAMLをお使いください。omapの仕様については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は単体テストや機能テストで用いられます。fixtureの利用法は2とおりありますが、まずは単体テストのサンプルを見てみましょう。

require "test_helper"

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

test_helper.rbはデフォルトですべてのfixtureをテストデータベースに読み込みます。したがって、このテストは成功します。

test環境では、各テストの実行前にすべての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

別の方法として、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の派生クラスで以下のいずれか1つを指定しなければなりません。

  • インスタンス化されたfixtureをすべて有効にする(上のalt_method_1とalt_method_2を有効にする)には以下のようにします。
self.use_instantiated_fixtures = true
  • fixtureのハッシュだけを作成し、各インスタンスをfindしない(alt_method_1のみを有効にする)ようにするには以下のようにします。
self.use_instantiated_fixtures = :no_instances

これらの別の方法のいずれかを使うと、パフォーマンスに悪影響が生じます。理由は、fixtureのハッシュやインスタンス変数を作成するために、データベース内のfixture化されたデータをフルスキャンしなければならないためです。そのため、fixture化されたデータセットが大規模になるとコストがかさみます。

ERBで動的fixtureを使う

fixtureの量が重要だが、fixtureの中身はさほど重要ではない場合があります。そのような場合はYAMLのfixtureでERBを併用することで、以下のようにテストの読み込みで大量のfixtureを作成できます。

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

上はきわめてシンプルなfixtureを1000件作成します。

ERBを用いることで、<%= Date.today.strftime("%Y-%m-%d") %>などをのような動的な値をfixtureに注入できます。ただしこの機能には若干の注意点があります。fixtureは、それがfixtureの形であれば予測可能なサンプルデータの安定的な単位となりますが、そこに動的な値を注入する必要があると思えてきた場合は、おそらくアプリケーションが正しくテスト可能な状態であるかどうかを再点検する必要があるでしょう。つまり、fixtureの中で動的な値を使うことはすなわち「コードの匂い」と考えるべきです。

あるfixture内で定義されたヘルパーメソッドは、他のfixtureでは利用できません。これは、テスト同士をまたがる依存関係を意図に反して作り出さないためです。複数のfixtureで用いるメソッドは、ActiveRecord::FixtureSet.context_classincludeされる何らかのモジュール内で定義すべきです。

  • 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' %>

トランザクショナルテスト

テストケースでは、テストケースごとにdeleteとinsertを使う代わりに、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を実行して)すべてのfixtureデータをテストデータベースにプリロードしてトランザクショナルテスト(transactional test)を行うと、テストケース内のすべてのfixture宣言を省略できることがあります。理由は、全データが既に存在し、すべてのケースの変更がロールバックされるからです。

インスタンス化されたfixtureでプリロード済みデータを用いるには、self.pre_loaded_fixturesをtrueに設定してください。これにより、fixture経由で読み込まれたすべてのテーブルのfixtureデータにアクセスできるようになります(この挙動はuse_instantiated_fixturesの値によって変わります)。

以下の場合はトランザクショナルテストを使いません

  1. トランザクションが正しく動いているかどうかをテストする場合。ネストしたトランザクションは、すべての親トランザクションがコミットされるまでコミットされません(特にfixtureのトランザクションがセットアップで開始され、ティアダウン(teardown)でロールバックする場合)。つまり、Active Recordがネステッドトランザクションまたはsavepointsをサポートするまではトランザクションの結果を検証できません(これについては作業中)。

  2. データベースがトランザクションをサポートしていない場合。MySQLのMyISAMをのぞくあらゆるActive Recordデータベースはトランザクションをサポートしています。その場合はInnoDBかMaxDBかNDBをお使いください。

アドバンストfixture

IDを指定しないfixtureにはいくつかの機能を利用できます。

  • 安定な自動生成ID
  • 関連付けをラベルで参照する(belongs_to、has_one、has_many)
  • HABTM関連付けをインラインリストとして用いる

訳注: 現在のRailsでHABTM(has_and_belongs_to_many)を使ったリレーションは一般に悪手とされています。現在のRailsでは代わりにhas_many :through関連付けを使うのが一般的です。
* 参考: HABTMリレーションシップは悪であるという論争 | A-Listers
* 参考: has_and_belongs_to_many関連付け -- Active Record の関連付け - Rails ガイド
* 参考: has_many :through関連付け -- Active Record の関連付け - Rails ガイド

IDを指定した場合でも利用できる機能がいくつかあります。

  • Timestampカラム値の自動入力
  • fixtureラベルの式展開(interpolation)
  • YAML defaultsのサポート

安定な自動生成ID

以下のおさるfixtureがあるとします。

george:
  id: 1
  name: George the Monkey

reginald:
  id: 2
  name: Reginald the Pirate

それぞれのfixtureには一意のIDが2種類あります。2つのIDのうち、1つはデータベース用で、もう1つは人間用です。そうする代わりに、主キーを生成できるとよいでしょう。それぞれのfixtureのラベルがハッシュ化されて一貫したIDが生成されます。

george: # 生成されたID: 503576764
  name: George the Monkey

reginald: # 生成されたID: 324201669
  name: Reginald the Pirate

Active Recordはこのfixtureのモデルクラスを参照して正しい主キーを見つけ、fixtureがデータベースに挿入される直前に主キーを生成します。

指定したラベルで生成されるIDは定数なので、ラベルがわかっていれば、読み込みなしに任意のfixture IDを見つけられます。

関連付けをラベルで参照する(belongs_tohas_onehas_many

fixtureで外部キーを指定すると非常にもろくなる可能性があり、しかも読みにくくなります(言うまでもありませんが)。Active Recordは任意のfixtureのIDをラベルで特定できるので、外部キーを(IDではなく)ラベルで指定できます。

belongs_to

おさると海賊でもう少しやってみましょう。

### pirates.yml

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

### monkeys.yml

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

この調子でおさると海賊を増やして複数のファイルに分割すると、追いかけるのがつらくなってきます。IDをやめてラベルを使ってみましょう。

### pirates.yml

reginald:
  name: Reginald the Pirate
  monkey: george

### monkeys.yml

george:
  name: George the Monkey
  pirate: reginald

見事にすっきりしました。このfixtureのモデルクラスはActive Recordに反映されてすべてのbelongs_to関連付けが検索されるようになり、(外部キーのターゲットIDではなく)関連付けmonkey: george)のターゲットラベルを指定できるようになります。

ポリモーフィックbelongs_to

ポリモーフィックなリレーションシップのサポートは少し複雑です。理由は、関連付けが指す先の型をActive Recordが認識する必要があるためです。次のような例ならわかりやすいでしょう。

### fruit.rb

belongs_to :eater, polymorphic: true
### fruits.yml

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

もっとよくできそうですね。

apple:
  eater: george (Monkey)

ポリモーフィック関連付けのターゲットの型を指定するだけで、後はActive Recordが面倒を見てくれます。

has_and_belongs_to_manyまたはhas_many :through

おさるにフルーツをあげる時間になりました。

### monkeys.yml

george:
  id: 1
  name: George the Monkey

### fruits.yml

apple:
  id: 1
  name: apple

orange:
  id: 2
  name: orange

grape:
  id: 3
  name: grape

### 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を消し去りましょう。

### monkeys.yml

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

### fruits.yml

apple:
  name: apple

orange:
  name: orange

grape:
  name: grape

fruits_monkeys.ymlファイルが不要になりました!ここではジョージのfixtureでフルーツのリストを指定しましたが、フルーツごとにおさるのリストを指定することも簡単です。fixtureのモデルクラスがbelongs_toによってActive Recordに反映され、has_and_belongs_to_many関連付けが見つかるようになります。

Timestampカラム値の自動入力

Active Recordの標準的なタイムスタンプカラム(created_atcreated_onupdated_atupdated_on)のいずれかがテーブルやモデルで指定される場合は、自動的にTime.nowで設定されます。

特定の値が設定済みの場合は何もしません。

fixtureラベルの式展開

現在のfixtureのラベルは、カラムの値としていつでも参照できます。

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

また、指定のラベルでIDを保持できるようにする必要が生じることがあります(古い結合テーブルfixtureを移植する場合など)。そんなときはERBが役に立ちます。

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

YAML defaultsのサポート

fixtureのYAMLファイルでデフォルト値を設定して再利用できます。これはdatabase.ymlでデフォルトを指定するのに使われる手法と同じです。

訳注: この場合DEFAULTSはすべて大文字にする必要があります。

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

first:
  name: Smurf
  <<: *DEFAULTS

second:
  name: Fraggle
  <<: *DEFAULTS

ラベルが「DEFAULTS」のfixtureはすべて安全に無視されます。

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

In the above example, 'base' will be ignored when creating fixtures. This can be used for common attributes inheriting.

fixtureのモデルクラスを設定する

fixtureのモデルクラスをYAMLファイルで直接指定できます。これは、fixtureがテストの外部で読み込まれ、かつset_fixture_classが利用できない場合(bin/rails db:fixtures:loadの実行時など)に有用です。

_fixture:
  model_class: User
david:
  name: David

ラベルが「_fixture」のfixtureはすべて安全に無視されます。

関連記事

Rails 5.1〜7.2: 'form_with' APIドキュメント(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。