FactoryGirlでtraitを使うとintegration test書くのが捗るという話

おなじみfactory_girlがちょっと分かってきたので覚え書きがてらメモです.
FactoryGirlって,あちこちで見る事例がシンプルすぎることが多くて手元のある程度複雑なデータ構造のFactoryをどうやって書くか,ピンと来ないことが多い気がします.要試行錯誤ですね.

前提Model

この記事で使うModelの前提です.掲示板(Forum)があり,そこに対して記事(Post)を投稿するのですが,記事には商品(Product)が紐付いている,というものです.
イメージとしては「おまえらのオススメガジェット教えろ」みたいなのがforumで,postには具体的なコメントと商品を選択して評価スコア(100点満点)と一緒に投稿できる,といったものになります.

Rails4前提なので,attr_accessibleは書いていません.

ER図は以下の通り.
Screen Shot 2013-08-22 at 13.23.36

ソースコードは以下の通り.

class CreateSampleTables < ActiveRecord::Migration
  def change
    create_table :product do |t|
      t.string :name
      t.integer :price
    end

    create_table :forum do |t|
      t.string :title
      t.text :description
    end

    create_table :post do |t|
      t.integer :post_id
      t.integer :product_id
      t.string :body
      t.integer :score
    end
  end
end

各modelの定義は以下の通り

class Product < ActiveRecord::Base
  has_many :posts
end
</pre>

<pre class="brush: ruby; toolbar: false;">
class Forum < ActiveRecord::Base
  has_many :posts
end
</pre>

<pre class="brush: ruby; toolbar: false;">
class Post < ActiveRecord::Base
  belongs_to :forum
  belongs_to :product
end

Factoryの作成

さて,とりあえず何も考えずFactoryを書いてみます.良くある記述方法だと以下の様になるかと思います.

実際にはModelごとにfactoryファイルを分けますが,説明の都合上まとめます.

FactoryGirl.define do
  factory :product do |f|
    name 'MacBook Pro Retina 15 Early 2013'
    price 218800
  end

  factory :forum do |f|
    title 'おまいらのオススメガジェット教えれ'
  end

  factory :post do |f|
    forum
    product
    body 'MBP 15 Retina,まじおすすめ.ドヤリングにぜひ!'
    score 100
  end
end

これで,とりあえずFactoryの生成テストを書いてみます.

これも普通はファイルを分けます

require 'spec_helper'

describe Product do
  it 'has a valid factory' do
    expect(FactoryGirl.create(:product)).to be_valid
  end
end

describe Forum do
  it 'has a valid factory' do
    expect(FactoryGirl.create(:forum)).to be_valid
  end
end

describe Post do
  it 'has a valid factory' do
    expect(FactoryGirl.create(:post)).to be_valid
  end
end

ここまでが良くあるFactoryGirlのチュートリアルなどで書いてあるテストになります.
単一モデルのチェックであれば,これとMock/Stubを使えばテストを書いていくことができます.

Integration Testを書く

次に,複数Modelが関連するテストコードを書いてみます.ここでは,ForumとPost,Productが正しく相互に動いているかどうかを書いていきます.
ここでは,Forumの平均スコアを計算するaverage_score(product)のテストを書いてみます.

require 'spec_helper'

describe 'Forum Post integration' do
  context 'ForumにPostが存在しないとき' do
    before(:each) do
      @forum = FactoryGirl.create(:forum)
    end
    it 'Forumの平均スコアは0' do
      expect(@forum.average_score).to eq 0
    end
  end

  context 'Forumに1件のPostがあるとき' do
    before(:each) do
      @post = FactoryGirl.create(:post)
      @forum = @post.forum
      @product = @post.product
    end

    it 'Forumの平均スコアは100(Postのfactoryスコアは100で,post 1件の場合)' do
      expect(@forum.average_score).to eq 100
    end
  end
end

ここまではfactory定義されたデータだけなので,何の問題も無いですね.
では,postを増やして平均スコア計算が正しく動いているかをテストするコードを書いてみます.

# describeブロックやらspec_helperのrequireは省略
context 'Forumに2件以上のPostがあるとき' do
  before(:each) do
    # 一つ目のForum/Postまで作成
    @post = FactoryGirl.create(:post)
    @forum = @post.forum
    @product = @post.product
  end

  it 'Forumに新たに50点のPostが入ったとき,平均スコアは75' do
    FactoryGirl.create(:post, score: 50)
    expect(@forum.average_score).to eq 75
  end
end

これはうまくいきません.以前FactoryGirlでhas_manyな関係を定義する方法でも上がっている通り,これだとFactoryGirl.create(:post, score: 50)したときに,改めてForumとProductが生成されてしまうわけですね.
これに対応するには,一番単純な方法としてfactoryを分けるという方法があり,以下の様になります.

Factory

# 既存のpost定義
factory :post do |f|
  forum
  product
  body 'MBP 15 Retina,まじおすすめ.ドヤリングにぜひ!'
  score 100
end

# product, postは手動で設定してね
factory :post_template, class: Post do |f|
  body 'MBP 15 Retina,まじおすすめ.ドヤリングにぜひ!'
  score 100
end

spec

# describeブロックやらspec_helperのrequireは省略
context 'Forumに2件以上のPostがあるとき' do
  before(:each) do
    # 一つ目のForum/Postまで作成
    @post = FactoryGirl.create(:post)
    @forum = @post.forum
    @product = @post.product
  end

  it 'Forumに新たに50点のPostが入ったとき,平均スコアは75' do
    FactoryGirl.create(:post_template, score: 50, forum: @forum, product: @product)
    expect(@forum.average_score).to eq 75
  end
end

これで期待した通りに動きます.しかし,factoryにbody, scoreの重複記述があってちょっと嫌な感じです.また,post_templateの定義でわざわざクラス名を指定しないといけないのが煩雑ですね.この数ならまだしも,factoryの数が増えてくると面倒な感じです.

traitを使う

ここで,traitを使うと以下の様に書けます.

Factory

factory :post do |f|
  body 'MBP 15 Retina,まじおすすめ.ドヤリングにぜひ!'
  score 100

  trait :with_dependents do |post|
    forum
    product
  end
end

spec

# describeブロックやらspec_helperのrequireは省略
context 'Forumに2件以上のPostがあるとき' do
  before(:each) do
    # 一つ目のForum/Postまで作成
    @post = FactoryGirl.create(:post, :with_dependents)
    @forum = @post.forum
    @product = @post.product
  end

  it 'Forumに新たに50点のPostが入ったとき,平均スコアは75' do
    FactoryGirl.create(:post, score: 50, forum: @forum, product: @product)
    expect(@forum.average_score).to eq 75
  end
end

こんな感じで,factoryブロック内にtraitブロックを書くと,Factory呼び出しの時に引数オプションを付けるイメージで任意のコードを実行させることができます.
また,Factory呼び出しの時に指定するtraitは一つでなくてもよくて,

factory :post do |f|
  body 'MBP 15 Retina,まじおすすめ.ドヤリングにぜひ!'
  score 100

  trait :with_forum do |post|
    forum
  end

  trait :with_product do |post|
    product
  end
end

として,

FactoryGirl.create(:post, :with_forum, :with_product, body: 'BODY上書きしてみる')

みたいなこともできます.

最近のfactory_girlのGETTING_STARTEDは割と充実しているので,改めて読み直してみようと思いました.

Ruby on RailsによるWEBシステム開発、Android/iPhoneアプリ開発、電子書籍配信のことならお任せください この記事を書いた人と働こう! Ruby on Rails の開発なら実績豊富なBPS

この記事の著者

morimorihoge

高校卒業後,学生をやりながらずっとWebアプリ開発に携わってきました.2010くらいまではPHP/Symfonyプログラマでしたが,それ以降のWeb開発はRailsほぼ一本に宗旨替えしました.開発とは別にサーバ構築・運用も10年以上やってきているので,要件定義から設計・実装・環境構築・運用まで一通り何でもこなせます.開発以外では季節により大学でWebサービス開発やプログラミング関連の非常勤講師もしており,技術の啓蒙・教育にも積極的に関わっています.最近はPM的な仕事が増えていますが,現役開発者としていつでも動ける程度にはコードもサーバも弄る日々を送っています.AWS 認定ソリューションアーキテクト – アソシエイトレベル取りました

morimorihogeの書いた記事

開発
RubyのArray(配列)の使い方

2017年03月15日

週刊Railsウォッチ

インフラ

BigBinary記事より

ActiveSupport探訪シリーズ