(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を互いに手作業でコンバートしたりする中でなんとなく分かってきたことをメモ書きしていきます。
2015.06.30 追記「RSpecユーザのためのMinitestチュートリアル」という本を書きました
電子書籍「Everyday Rails - RSpecによるRailsテスト入門」の追加コンテンツとして「RSpecユーザのためのMinitestチュートリアル」を公開しました。
詳しくは以下のエントリをご覧ください。
なぜ研究しようと思ったのか
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: [email protected] password_digest: <%= User.digest('password') %> admin: true activated: true activated_at: <%= Time.zone.now %> archer: name: Sterling Archer email: [email protected] 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のテストコードを自力でコンバートしてみると双方の良いところ、悪いところが見えてくると思います。
一度試してみてください。
今回コンバートしたテストコードはこちら
各コードはGitHubに置いています。
「Everyday Rails - RSpecによるRailsテスト入門」について
「Everyday Rails - RSpecによるRailsテスト入門」は初心者向けにRSpecの書き方を説明した電子書籍です。
Railsチュートリアルの次に読む一冊としてオススメです。
RSpecのDSLを手っ取り早く学習したい方はこちらをどうぞ
「必要最小限の努力で最大限実戦で使える知識を提供するRSpec入門記事」、略して「使えるRSpec入門」です。(全4回)
RSpecについてはこれだけ理解しておけばだいたい何とかなります。
あわせて読みたい
関西Ruby会議でもMinitestとRSpecの比較をしてきました。
このエントリよりも、さらにシンプルに、かつわかりやすくまとめたつもりです。
RSpecの開発者によるRSpecとMinitestの比較記事です。
こちらもなかなか興味深いのでぜひ読んでみてください。