give IT a try

プログラミング、リモートワーク、田舎暮らし、音楽、etc.

MinitestとRSpec、FixturesとFactoryGirlの良いところ悪いところをコードを書いて比較してみた

(2022.5.4追記)
FactoryGirlはFactoryBotという名前に変更されています(参考)。この記事は昔の名前である「FactoryGirl」を使っています。

はじめに

今年のゴールデンウイークはMinitestとRSpec、FixturesとFactoryGirlについていろいろ研究(?)していました。
具体的にはこんなことをやっていました。

  • Rails Tutorial 第3版を写経した(第3版ではMinitestとFixturesを使っている)
  • Rails TutorialのテストコードをRSpecとFactoryGirlで書き直した
  • Everyday RailsのテストコードをRSpec + FactoryGirlからMinitest + Fixturesに書き直した
  • The Minitest Cookbookを読んだ


今回のエントリではMinitestとRSpec、FixturesとFactoryGirlを互いに手作業でコンバートしたりする中でなんとなく分かってきたことをメモ書きしていきます。


https://www.flickr.com/photos/38417869@N00/11701578825
photo by xelipe

2015.06.30 追記「RSpecユーザのためのMinitestチュートリアル」という本を書きました

電子書籍「Everyday Rails - RSpecによるRailsテスト入門」の追加コンテンツとして「RSpecユーザのためのMinitestチュートリアル」を公開しました。
詳しくは以下のエントリをご覧ください。

blog.jnito.com


なぜ研究しようと思ったのか

RubyやRailsを使い始めて以来、RSpecとFatoryGirlをメインで使い続けています。
というかこれ以外のテスティングツールはほとんど使ったことがありません。

しかし、最近は「MinitestとFixturesも意外といいよ」という話をときどき耳にします。
そこで百聞は一見にしかず、自分の手と頭を使ってMinitestとFixturesのコードを書き、良いところ、悪いところを比較してみようと思いました。

これが今回の研究の動機です。

備考

このエントリの内容はあくまで僕個人の現時点での所感です。
将来的には僕の中の意見が変わる可能性もあります。

MinitestやFixturesは使ってみたものの、試したのはあくまで単純なサンプルアプリケーションレベルなので、実務で長年使い込んでる人からすると「わかってないなー」と思う部分も多少あるかもしれません。
その場合はコメント欄やご自身のブログ等で優しく指摘してやってください。

Minitestについて

良い点
  • 簡単なテストは簡単に書ける。
require 'test_helper'

class RelationshipTest < ActiveSupport::TestCase
  
  def setup
    @relationship = Relationship.new(follower_id: 1, followed_id: 2)
  end

  test "should be valid" do
    assert @relationship.valid?
  end
  
  test "should require a follower_id" do
    @relationship.follower_id = nil
    assert_not @relationship.valid?
  end

  test "should require a followed_id" do
    @relationship.followed_id = nil
    assert_not @relationship.valid?
  end
end
  • 独自のアサーションを作るのが楽。(好きなように検証用のメソッドを定義できる)
# test/test_helper.rb でアサーションを定義する
class ActiveSupport::TestCase
  # ...

  # Returns true if a test user is logged in.
  def is_logged_in?
    !session[:user_id].nil?
  end

  # ...
end
# 検証用メソッドを使う
require 'test_helper'

class SessionsHelperTest < ActionView::TestCase
  
  def setup
    @user = users(:michael)
    remember(@user)
  end
  
  test "current_user returns right user when session is nil" do
    assert_equal @user, current_user
    assert is_logged_in?
  end

  # ...
end
悪い点
  • RSpecで実現できていたことを実現しようとすると凝ったロジックを書く必要がある。
    • RSpecで実現できていたこと = shared_examplesや構造化された(親子関係に応じて実行される)beforeブロックなど
# shared_examples相当のことをやろうとしたMinitestのコード
require 'test_helper'
require 'mocha/mini_test'

class ContactsControllerTest < ActionController::TestCase
  module PublicAccessToContacts
    extend ActiveSupport::Concern
    included do
      test "GET #index / with params[:letter]" do
        smith = create(:contact, lastname: 'Smith')
        jones = create(:contact, lastname: 'Jones')
        get :index, letter: 'S'
        assert_equal [smith], assigns(:contacts).to_a
        assert_template :index
      end

      # ...
    end
  end

  module FullAccessToContacts
    extend ActiveSupport::Concern
    included do
      test "GET #new" do
        get :new
        assert_be_a_new Contact, assigns(:contact)

        phones = assigns(:contact).phones.map do |p|
          p.phone_type
        end
        assert_matched_arrays %w(home office mobile), phones

        assert_template :new
      end

      # ...
    end
  end

  class AdministratorAccessTest < ContactsControllerTest
    include PublicAccessToContacts
    include FullAccessToContacts

    setup do
      admin = build_stubbed(:admin)
      @controller.stubs(:current_user).returns(admin)
    end
  end

  class UserAccessTest < ContactsControllerTest
    include PublicAccessToContacts
    include FullAccessToContacts

    setup do
      user = build_stubbed(:user)
      @controller.stubs(:current_user).returns(user)
    end
  end

  class GuestAccessTest < ContactsControllerTest
    include PublicAccessToContacts

    test "with guest access, requires login for GET #new" do
      get :new
      assert_require_login
    end

    # ...
  end
end
  • 「Minitestのテストコードは純粋なRubyのコードである」といった利点を強調されることが多いが、「Rubyでできること = テストコードでできること」という自由さがかえって「オレオレ実装」を生み出してしまうリスクが出てくる。
    • ベストプラクティスや定石となる実装パターンが開発者コミュニティに浸透する必要がある
  • テストコードをDRYにしようとすると「ロジックロジックしたテストコード」が生まれやすい。下手な実装をすると「他の開発者が簡単に読み下せないテストコード」ができあがる。
    • ロジックロジックしたテストコード = includeとかprependとかクラスの継承とかsuperの呼び出しとか、Rubyの言語機能を多用しがちになる
# test/test_helper.rb
module UseJavaScript
  def setup
    Capybara.current_driver = Capybara.javascript_driver
    super
  end
end
# RSpecのフィーチャスペックでいうところの js: true と同じことを実現したくて書いたコード
require "test_helper"

class UsersTest < Capybara::Rails::TestCase
  # Use JS for test purpose
  prepend UseJavaScript

  # Add setup method to check prepend behavior
  def setup
    @admin = create(:admin)
  end

  test "adds a new user" do
    # Assert for prepend
    assert_equal Capybara.javascript_driver, Capybara.current_driver

    sign_in @admin

    visit root_path
    # ...
  • MinitestでRSpec相当のことを簡単に実現したい場合はSpec形式の記述を使うことができる。しかし、一方で「純粋なRubyのコード」からどんどん離れていくし、RSpecに近づけば近づくほど「じゃあRSpecを使えばいいじゃない」という気がしてならない。
# Spec形式でshared_examples相当のことをやろうとしたMinitestのコード
require 'test_helper'
require 'mocha/mini_test'

describe ContactsController do
  module PublicAccessToContacts
    extend ActiveSupport::Concern

    included do
      describe 'GET #index' do
        context 'with params[:letter' do
          it "populates an array of contacts starting with the letter" do
            smith = contacts(:smith)
            get :index, letter: 'S'
            assigns(:contacts).to_a.must_equal [smith]
          end

          it "renders the :index template" do
            get :index, letter: 'S'
            must_render_template :index
          end
        end

        # ...
      end
    end
  end

  module FullAccessToContacts
    extend ActiveSupport::Concern

    # Without included, tests run before calling before block
    included do
      describe 'GET #new' do
        it "assigns a new Contact to @contact" do
          get :new
          assigns(:contact).must_be_a_new Contact
        end

        it "assigns a home, office, and mobile phone to the new contact" do
          get :new
          phones = assigns(:contact).phones.map do |p|
            p.phone_type
          end
          phones.must_match_arrays %w(home office mobile)
        end

        # ...
      end
    end
  end
end

describe ContactsController, 'Admin access' do
  include PublicAccessToContacts
  include FullAccessToContacts

  before do
    admin = users(:admin)
    @controller.stubs(:current_user).returns(admin)
  end
end

describe ContactsController, 'User access' do
  include PublicAccessToContacts
  include FullAccessToContacts

  before do
    user = users(:non_admin)
    @controller.stubs(:current_user).returns(user)
  end
end

describe ContactsController, 'Guest access' do
  include PublicAccessToContacts

  describe 'GET #new' do
    it "requires login" do
      get :new
      must_require_login
    end
  end

  # ...
end
その他
  • そもそもMinitestでRSpecと同じ事をやろうとする発想を捨てなければいけないのかもしれない。(が、今はまだその境地には至っていない)
  • とりあえず、Hello worldレベルの単純なテストコードを見て「わお!Minitestってシンプル!すてき!!」と飛びつくのはちょっと違う気がする。
  • (RSpecのshould記法が変わったのと同様)Minitest 6になるとSpec形式の記法が変わるらしい。(参考サイト
# Minitest 6でmust_xxxを使う場合
class Minitest::Expectation
  alias_method :must, :must_be
end

describe "Lebowski", "new syntax" do
  let(:the_dude)  { Lebowski.new(name: "Jeffrey") }

  it "should abide" do
    _(the_dude).must :abide?
    expect(the_dude).must :abide?
    value(the_dude).must :abide?
  end
end

RSpecについて

良い点
  • RSpecのルールを覚えてそのルールに従えば、テストコードを書くことだけに集中できる。
  • テストコードを読む人間もRSpecのルールや機能を知っていれば比較的テストコードを理解しやすい。
  • describe や context を使ってうまく構造化してやれば、テストコードの意図を理解しやすい。
  • shared_examples やグループの親子関係に従って実行される before ブロックなどテストコードをDRYにする機能が提供されている。(使い方を間違えるとカオス化するけど)
悪い点
  • 良くも悪くも独自のDSL(ドメイン固有言語)を使っているために学習コストが大きい。RSpecという新しいテスト用言語を学習する必要がある。
  • 裏側の仕組みがブラックボックスになっていてなおかつ複雑(という話)。独自に拡張しようとすると地獄を見る(という噂)。(参考サイト
  • 独自のマッチャを1つ作るのもちょっと面倒。(ヘルパーメソッドを作って呼び出せばOK、というMinitestの手軽さには及ばない)
require 'rails_helper'

# わかる人にはわかる、わからない人には全く分からない(?)RSpecのテストコード
# 文法的にはRubyで書かれているがRuby感がかなり希薄
describe ContactsController do
  shared_examples_for 'public access to contacts' do
    describe 'GET #index' do
      context 'with params[:letter]' do
        it "populates an array of contacts starting with the letter" do
          smith = create(:contact, lastname: 'Smith')
          jones = create(:contact, lastname: 'Jones')
          get :index, letter: 'S'
          expect(assigns(:contacts)).to match_array([smith])
        end
        # ...
      end
      # ...
    end
    # ...
  end

  shared_examples 'full access to contacts' do
    describe 'GET #new' do
      it "assigns a new Contact to @contact" do
        get :new
        expect(assigns(:contact)).to be_a_new(Contact)
      end
      # ...
    end
    # ...
  end

  describe "administrator access" do
    let(:admin) { build_stubbed(:admin) }

    before :each do
      allow(controller).to receive(:current_user).and_return(admin)
    end

    it_behaves_like 'public access to contacts'
    it_behaves_like 'full access to contacts'
  end

  describe "user access" do
    let(:user) { build_stubbed(:user) }

    before :each do
      allow(controller).to receive(:current_user).and_return(user)
    end

    it_behaves_like 'public access to contacts'
    it_behaves_like 'full access to contacts'
  end

  describe "guest access" do
    it_behaves_like 'public access to contacts'

    describe 'GET #new' do
      it "requires login" do
        get :new
        expect(response).to require_login
      end
    end
    # ...
  end
end
その他
  • RSpecのテストコードはプログラム(ロジック)というよりも、テスト仕様書(プログラムの振る舞いを宣言的に記述したドキュメント)であるという感覚で読み書きした方が習得が速い気がする。
# RSpecで記述したfizz buzzメソッドのコード例
def fizz_buzz(number)
  if number % 15 == 0
    "FizzBuzz"
  elsif number % 5 == 0
    "Buzz"
  elsif number % 3 == 0
    "Fizz"
  else
    number.to_s
  end 
end

# あえてわざとらしい日本語を使っているが、Rubyのプログラムというよりも
# メソッドの動作仕様が書かれていると見なした方が理解しやすい
describe "fizz_buzzメソッドの仕様を記述します" do
  # この実行結果について記述します
  subject { fizz_buzz(number) }

  context "1が入力された場合" do
    let(:number) { 1 }
    # "1"になります(以下コメント省略)
    it { is_expected.to eq "1" }
  end
  context "2が入力された場合" do
    let(:number) { 2 }
    it { is_expected.to eq "2" }
  end
  context "3が入力された場合" do
    let(:number) { 3 }
    it { is_expected.to eq "Fizz" }
  end
  context "4が入力された場合" do
    let(:number) { 4 }
    it { is_expected.to eq "4" }
  end
  context "5が入力された場合" do
    let(:number) { 5 }
    it { is_expected.to eq "Buzz" }
  end
  context "6が入力された場合" do
    let(:number) { 6 }
    it { is_expected.to eq "Fizz" }
  end
  context "15が入力された場合" do
    let(:number) { 15 }
    it { is_expected.to eq "FizzBuzz" }
  end
end
  • 一方、こちらはMinitestで書いた場合。
require 'minitest/autorun'

# Minitestで記述したfizz buzzメソッドのコード例
def fizz_buzz(number)
  if number % 15 == 0
    "FizzBuzz"
  elsif number % 5 == 0
    "Buzz"
  elsif number % 3 == 0
    "Fizz"
  else
    number.to_s
  end
end

# Minitestのテストコードは見た目的にRSpecよりも「プログラム感」が強い(手続き的)
# DSL=よくわかんない、覚えるのが面倒、という人にはこっちの方が安心するのも分かる
class FizzBuzzTest < Minitest::Test
  def test_fizz_buzz
    assert_equal "1", fizz_buzz(1)
    assert_equal "2", fizz_buzz(2)
    assert_equal "Fizz", fizz_buzz(3)
    assert_equal "4", fizz_buzz(4)
    assert_equal "Buzz", fizz_buzz(5)
    assert_equal "Fizz", fizz_buzz(6)
    assert_equal "FizzBuzz", fizz_buzz(15)
  end

  # 本来であれば以下のように1テストメソッドに付き1アサーションとするのが理想的だが、
  # 簡単に読み書きできる構文(DSL)がないのでつい上のようにまとめて書いてしまいがち
  def test_fizz_buzz_when_1
    assert_equal "1", fizz_buzz(1)
  end

  def test_fizz_buzz_when_2
    assert_equal "2", fizz_buzz(2)
  end

  def test_fizz_buzz_when_3
    assert_equal "Fizz", fizz_buzz(3)
  end

  def test_fizz_buzz_when_4
    assert_equal "4", fizz_buzz(4)
  end

  def test_fizz_buzz_when_5
    assert_equal "Buzz", fizz_buzz(5)
  end

  def test_fizz_buzz_when_6
    assert_equal "Fizz", fizz_buzz(6)
  end
  
  def test_fizz_buzz_when_15
    assert_equal "FizzBuzz", fizz_buzz(15)
  end
end

続いて以下はFixturesとFactoryGirlに関する感想です。

Fixtures

良い点
  • FactoryGirlに比べるとコードの実行速度が速い。
  • users(:michael) のような簡潔な記述でテストデータを取得できる。
    • テストデータは毎回自動的に全件投入されるため、FactoryGirlのようにテストデータを投入するコードは書かなくてもよい。
# fixtures/users.yml
michael:
  name: Michael Example
  email: michael@example.com
  password_digest: <%= User.digest('password') %>
  admin: true
  activated: true
  activated_at: <%= Time.zone.now %>

archer:
  name: Sterling Archer
  email: duchess@example.gov
  password_digest: <%= User.digest('password') %>
  activated: true
  activated_at: <%= Time.zone.now %>
require 'test_helper'

class MicropostTest < ActiveSupport::TestCase
  def setup
    @user = users(:michael)
    @micropost = @user.microposts.build(content: "Lorem ipsum")
  end
  
  # ...
悪い点
  • テストデータ投入時にモデルのvalidationを通らないのでおかしなデータを作るリスクがある。
    • テストはパスしてるけど実はバグってた、というリスクがあるのは怖い。
  • テーブルのカラムが増えたりするとYAMLの全面的な書き直しが必要。
    • & や << のようなYAML特有の共通化テクニックが使えなくもないが。。。
  • 「このテストケースのときだけここの値をこう変えたい」という柔軟さがない。
  • 「管理者かつ開発者かつテニスサークルに入っているユーザー」のようなテストデータを作るときに名前付けに困りそう。
    • users(:manager_and_developer_and_tennis_member) みたいに書くのか?
  • 動的に値を変えたい場合はERB形式で activated_at: <%= Time.zone.now %> のように書くのがちょっとまどろっこしい。
その他
  • システムが大きくなってきたときにうまくFixturesを管理できている自信がない。
  • 「もうFixturesは限界!!」ってなったときにFactoryGirlに移行するのは地獄を見そう。

FactoryGirl

  • テストコード側で動的にテストデータを変更できる。
  • モデルのvalidationを通るのでおかしなデータを作るリスクがない。(Validationが適切であれば)
  • Factoryの継承やTraitなど(使いこなせれば)便利な機能がたくさん用意されている。
FactoryGirl.define do
  factory :contact do
    firstname { Faker::Name.first_name }
    lastname { Faker::Name.last_name }
    email { Faker::Internet.email }

    after(:build) do |contact|
      [:home_phone, :work_phone, :mobile_phone].each do |phone|
        contact.phones << FactoryGirl.build(:phone,
          phone_type: phone,
          contact: contact
        )
      end
    end

    factory :invalid_contact do
      firstname nil
    end
  end
end
it "returns a contact's full name as a string" do
  contact = create(:contact,
    firstname: 'Jane',
    lastname: 'Smith'
  )
  expect(contact.name).to eq 'Jane Smith'
end
悪いところ
  • 遅い。(毎回Validationを通ったりするので)
  • FactoryGirlの仕様を覚えるための学習コストがかかる。
  • テストコードで毎回テストデータのセットアップを書く必要がある。
    • 特に親子孫のような深い階層のデータや多対多で関連するデータを用意するのがちょっと面倒。

総括

MinitestとRSpec

この研究をするまでは「もしかしたらMinitestの方がRSpecよりも優れているのかな~」とも思っていたのですが、実際にコードを書いてみると「僕にはRSpecの方が合ってるな」と思いました。


今回の研究では巷でよく耳にする「Minitestの方がシンプルだ!ピュアRubyだ!」という評価は利点であるのと同時に欠点でもある、というのが一番大きな発見でした。


仕様がシンプルであることは、裏を返すと複雑さの管理に苦労するという意味になります。
describeやcontextを使ってテストを意味のある単位でグループ化し、グループの親子関係を利用しながらbeforeブロックを順に呼び出す、といったことが簡単に実現できるのはやはりRSpecです。


また「ピュアRuby」であることは、「おのおのの開発者が好きなようにロジックを組めてしまう」というメリットともデメリットとも言える結果をもたらします。
一方、RSpecが用意しているDSLは開発者にRSpecの流儀に従うことを「強制」します。
不自由であることはデメリットしかないように思えますが、RSpecのDSLという共通言語を強制されることで「ルールさえ理解していれば、誰もが迷わずに読み書きできる」という利点をもたらします。(DSLの学習コストがかかるという問題も当然ありますが)


「いや、MinitestにはSpec形式で書けるようにもなっているから大丈夫!」と思う人がいるかもしれませんが、前述の通りわざわざMinitestを使って「RSpecにそっくりなテストコード」を書く理由が僕にはよくわかりません。
もちろん「RSpecより速いから」とか、「アサーション形式のテストコードと混在できるから」とか、いろんな理由はあるとは思いますが、Spec形式のテストコードについてはやはりRSpecに一日の長がある(Minitestよりも便利な機能が多い)のでRSpecで書くのが一番効率がいいと思います。


ただ、学習コストが高い、という最初のハードルはいかんともしがたいので、Railsチュートリアルのような初心者向けのサービスがMinitestを採用するのは仕方がないという気もします。(初心者の人はRailsの仕組みを覚えるだけで精一杯なはずです)


また、グループ化や構造化されたbeforeブロック呼び出しといった高度な機能が特に必要でないシンプルなアプリケーションや(Railsチュートリアルなんかがそう)、そもそも最初からそういった機能を使わずにテストコードを書けばいいと考えている開発者はMinitestでも良いと思います。
「(拡張しやすいから、というような理由で)RSpecよりもMinitestを使ってSpec形式で書く方が好き」という人も中にはいるでしょう。
このあたりはケースバイケース、もしくは人それぞれで好きなツールを使えば良いと思います。

FixturesとFactoryGirl

FixturesとFactoryGirlに関しては、実務レベルの複雑なアプリケーションではFactoryGirlの柔軟性がないとしんどいかな、という気がします。
実際の本番環境で登場する様々な条件のデータを静的なYAMLでパシッと定義できればFixturesの方が便利だと思いますが、僕が実際に携わっている案件を想像すると「ちょっと無理そうだな」というのが正直な感想です。


もちろんプロジェクトの初期はFixturesもうまく機能すると思いますが、システムがどんなふうに成長していくのか(どんなふうに複雑化していくのか)は予想できないので、Fixturesを使うとあとが怖いです。


また怖いと言えば、FixturesはValidationを通らない点も怖いですね。本番環境ではあり得ない状態のデータをうっかり放り込んでしまいそうです。
もしかすると、Fixturesを長年使っている人は様々なノウハウやベストプラクティスを持っているのかもしれません。
しかし僕の場合は現時点ではそこまで深く使いこなせていないので、FactoryGirlを使うのが無難かな、という気がします。(テストは遅くなるけど・・・)

まとめ

というわけで、今回はMinitestとFixtures、RSpecとFactoryGirlについて個人的な感想をいろいろと書いてみました。

繰り返しになりますが、これは現時点での僕の感想です。
経験の長短や考え方の違いによっては、全く違う意見を持つ人もいると思います。
「いや、自分はこう思う!」というご意見、ご感想があればコメント欄かご自身のブログで詳しく述べてもらえると僕も勉強になるので、どうぞよろしくお願いします。

また、僕と同じようにRSpecだけ、もしくはMinitestだけしか使ってこなかったという人はRailsチュートリアルやEveryday Railsのテストコードを自力でコンバートしてみると双方の良いところ、悪いところが見えてくると思います。
一度試してみてください。

「Everyday Rails - RSpecによるRailsテスト入門」について

「Everyday Rails - RSpecによるRailsテスト入門」は初心者向けにRSpecの書き方を説明した電子書籍です。
Railsチュートリアルの次に読む一冊としてオススメです。

leanpub.com

RSpecのDSLを手っ取り早く学習したい方はこちらをどうぞ

「必要最小限の努力で最大限実戦で使える知識を提供するRSpec入門記事」、略して「使えるRSpec入門」です。(全4回)
RSpecについてはこれだけ理解しておけばだいたい何とかなります。

あわせて読みたい

関西Ruby会議でもMinitestとRSpecの比較をしてきました。
このエントリよりも、さらにシンプルに、かつわかりやすくまとめたつもりです。

blog.jnito.com


RSpecの開発者によるRSpecとMinitestの比較記事です。
こちらもなかなか興味深いのでぜひ読んでみてください。

blog.jnito.com