私がRSpecを書く場合のやり方について

今日は木曜日だったので、ハンバーグの会(Okayama.rb)に参加してきました。

今日は@mako_wisにテストの書き方について相談を受けたので、粒度とかについて説明しましたが、私は説明し始めると早口になってしまうので詰め込みすぎたかもしれないと思ったのでちょっとまとめておこうと思いました。ちなみに、書き方といってもRSpecの始め方とかではないです。その点はあしからず。

Railsプロジェクトのいいところは、テストがとてもしやすいところだと思います。
私は今の会社に入るまで、テストは書きたいけれど、どう書けばいいのかわからなかったのと、頑張って書いてみたものの、成果が周りに評価されなかったのでこのままでいいのだろうか?と思い悩んでいました。しかし、既にテストがあるプロジェクトに入って書き方を学べた事と、同僚とThe RSpec Book読書会を社内で開いて勉強したおかげで、結構綺麗に書けるようになったんじゃないかな?と思ってます。持つべき物はいい仲間と向上心です(キリッ

テスト戦略

まずテスト戦略についてです。
基本的にはテストは『開発者の不安を取り除くため』にあります。裏を返せば、不安でないところはテストを書く必要はありません。テストはやりすぎるとスピードが遅くなるし、ライブラリのテストをやっているようなことになってきます。ありそうなのは、Deviseを使ってユーザー認証作ってて、Deviseのテストになってしまっていたりとか。軽くならいいでしょうが、やりすぎはやめましょう。

どこまでテストするか?ですが、不安なところはテストしますが、重複しそうなところはやりません。以下のようなものをテストします。

  • Model(ビジネスロジックが詰まってる)
  • ActiveDecorator(Modelの延長なので)
  • rake task(定期処理が多いから)
  • インテグレーションテスト(Capybara + PhantomJS + poltergeist)

インテグレーションテストをするので、基本的にControllerとViewのテストはそちらで賄います。ただし、絶対にテストしないわけではありません。不安ならテストします。

どこからテストするのか?Modelでしょ!

Railsでテストが一番書きやすいのは、Modelです。どうしてテストしやすいかというと、

  • テスト対象が明確である
  • 依存関係が少ないので取りかかりやすい
  • 高速である

という点です。まずはモデルのテストから書いて、慣れていく事をオススメします。

テストを定義してみる

では実際にRSpecでモデルのテストを書いてみるとしましょう。私はテストさえ書けば、テストを先に書こうが後に書こうがどっちでもいいと思います。どちらにしても最初に想定してないパターンとか思いつくので。でもモデルはテスト駆動しやすいと思うので、先に設計のつもりで書いたりすることが多いです。

たとえば、Userモデルがあって、名前、メールアドレス、パスワードを持っていて、登録するときには確認用パスワードが必須とします。
そうなると、こういう感じでテストが書けるかと思います。

require 'spec_helper'

describe User do
  context '正しいデータを入れた場合' do
    it "登録できること"
  end
  context '既にデータがある場合' do
    it '編集できること'
    it '削除できること'
  end
  describe 'エラーチェック' do
    context '何も入れない場合' do
      it 'エラーになること'
    end
    context 'メールアドレスでない場合' do
      it 'エラーになること'
    end
    context 'パスワードと確認用パスワードが一致しない場合' do
      it 'エラーになること'
    end
  end
end

とりあえず考えられるパターンを先に書き出しておきます。
it文だけ書いておくとpending状態になるので、思いつくがままに書いていると、仕様がテストに書き出されてきます。ちなみにdescribeとcontextはどちらでもいいのですが、contextを使う事で、テスト対象がどういう場合かを定義しておきます。describeはいくらでもネスト(入れ子)できますが、最後はcontextにするという感じです。よく使うパターンだと、

  1. describe・・・〜で、〜であり
  2. context・・・〜の場合
  3. it・・・〜であること、〜でないこと

で終わります。

テストを実装してみる

では、次に、正しいデータを入れた場合、のテストを実装してみましょう。

context '正しいデータを入れた場合' do
  it "登録できること" do
    user = User.new(name: 'パトラッシュ',
                    email: 'patorash@email.com',
                    password: 'password',
                    password_confirmation: 'password')
    expect(user.save).to be_true
  end
end

普通に書くと、上のようになります。しかし、これから次は編集、削除のテストもあるのに、毎回User.newやUser.createを書くのは面倒です。そこで、テスト用データを作成しておきます。

FactoryGirlを使おう!

テスト用データの作成ですが、私はFactoryGirlを使って作っています。FactoryGirlは多機能なので色々できますが、まずはシンプルに使いましょう。

FactoryGirl.define do
  factory :user do |d|
    d.name 'パトラッシュ'
    d.email 'patorash@email.com'
    d.password 'password'
    d.password_confirmation 'password'
  end
  
  factory :user_invalid_password, parent: :user do |d|
    d.password 'invalid_password'
    d.password_confirmation 'wrong_password'
  end
end

親データを指定して、一部の値を書き換えたりすることができるので、想定できるパターンのユーザーを都度作成しておくと、どういう意図のテストデータかわかりやすくなります。例えばuser_invalid_passwordは、パスワードが違うテストデータという事になります。

FactoryGirlを使ってテストを直してみる

では、FactoryGirlを使って直してみます。FactoryGirl.buildを使うと、モデルにデータを入れて作成した状態(newした状態)になります(DBに保存はされていません)

context '正しいデータを入れた場合' do
  it "登録できること" do
    user = FactoryGirl.build(:user)
    expect(user.save).to be_true
  end
end

とてもシンプルになりました。

テスト対象を明確にしよう

さっきの修正でも十分機能はしていますが、さらに上を目指しましょう。何をテストしているのかを明確にするべきです。RSpecでは、テスト対象のオブジェクトを、subjectに設定することができます。こうすると、テスト内でsubjectと書くと、対象のオブジェクトにアクセスできます。

context '正しいデータを入れた場合' do
  subject { FactoryGirl.build(:user) }
  it "登録できること" do
    expect(subject.save).to be_true
  end
end

なんか、逆に長くない?と思われたかもしれません。そういうこともあります。ありますが、subjectがテスト対象であるというのが明確になりました。また、subjectに設定すると、英語圏の人はテストが書きやすくなります。日本語圏の我々は上の書き方でいいですが、一応紹介しておきます。

context '正しいデータを入れた場合' do
  subject { FactoryGirl.build(:user) }
  its(:save) { should be_true }
end

好きな方で書きましょう。私はどっちも使います。

次のテストをやってみよう!

次のテストは編集と削除です。事前にデータを登録することになりますが、これもFactoryGirlを使えば簡単です。FactoryGirl.createを使うと、対象のモデルをDBに保存までしてくれます。

context '既にデータがある場合' do
  before do
    @user = FactoryGirl.create(:user)
  end
  it '編集できること' do
    @user.name = "ネロ"
    expect(@user.save).to be_true
  end
  it '削除できること' do
    expect(@user.destroy).to be_true
  end
end

beforeを使えばテストの事前処理が、afterを使うとテストの後処理が定義できます。beforeは、:each, :allがあります(:aroundもあった気がする…)。省略すると、:eachになります。:eachは、テストのitが実行される毎に実行されます。:allは、そのフォーカスの中で1度だけ実行されます。基本的には、:eachを使います。:allはRSpecの事前定義とかで使います。

さて、勘のいい人は気付いたかもしれませんが、これ、subjectを使うともっと短くできます。

context '既にデータがある場合' do
  subject { FactoryGirl.create(:user) }
  it '編集できること' do
    subject.name = "ネロ"
    expect(subject.save).to be_true
  end
  it '削除できること' do
    expect(subject.destroy).to be_true
  end
end

テストシナリオを増やす

ここで、ふと気付きました。メールアドレスで認証しようと思っているのに、メールアドレスがユニークかどうかチェックしてないな、と。気付いた時点でとりあえずテストケースを書きましょう。

describe 'エラーチェック' do
  context '何も入れない場合' do
    it 'エラーになること'
  end
  context 'メールアドレスでない場合' do
    it 'エラーになること'
  end
  context 'パスワードと確認用パスワードが一致しない場合' do
    it 'エラーになること'
  end
  context 'メールアドレスが重複する場合' do
    it 'エラーになること'
  end
end

あとで追加しようと思うと、ど忘れしてしまうことが多いので、とりあえず書いて残しておきます。別にすぐテストの実装を書く必要はありません。

エラー系のテストを書く

では、エラー系を書いていきます。正常系が登録できなければならないのは当然ですが、イレギュラーなことをテストで書いておくと、すごく安心します。新しい機能を追加した結果、既存の機能に影響することはよくありますから、思いつくパターンはなるべく書きます。

Userモデルのバリデーションですが、

  • 名前、メールアドレス、パスワードは必須
  • メールアドレスは形式をチェック
  • パスワードはパスワード確認と一致するかチェック

がされているものとします。

まずは、何も入れない場合のテスト。

describe 'エラーチェック' do
  context '何も入れない場合' do
    subject { User.new }
    it 'エラーになること' do
      expect(subject).not_to be_valid
      expect(subject).to have(1).errors_on(:name)
      expect(subject).to have(2).errors_on(:email)
      expect(subject).to have(2).errors_on(:password)
    end
  end
  context 'メールアドレスでない場合' do
    it 'エラーになること'
  end
  context 'パスワードと確認用パスワードが一致しない場合' do
    it 'エラーになること'
  end
  context 'メールアドレスが重複する場合' do
    it 'エラーになること'
  end
end

expect(subject).not_to が初めて出てきました。not_toは、〜でないことを確認します。ここでは、バリデーションの結果が正常でないことを確認しています。その後、各項目のエラー数をチェックしています。

次に、メールアドレスでない場合のテスト。

describe 'エラーチェック' do
  context '何も入れない場合' do
    subject { User.new }
    it 'エラーになること' do
      # 略
    end
  end
  context 'メールアドレスでない場合' do
    subject { FactoryGirl.build(:user, email: 'invalid_email') }
    it 'エラーになること' do
      expect(subject).not_to be_valid
      expect(subject).to have(1).errors_on(:email)
    end
  end
  context 'パスワードと確認用パスワードが一致しない場合' do
    it 'エラーになること'
  end
  context 'メールアドレスが重複する場合' do
    it 'エラーになること'
  end
end

FactoryGirl.buildで、2つめの引数にハッシュを渡しています。これは、FactoryGirlで作られるオブジェクトの項目を一部上書きすることができます。メールアドレスに適当な文字列を渡すようにしています。そして、正常でないことを確認して、emailがエラーを1つ持っていることを確認します。

次に、パスワードが一致しないとエラーになるテスト。

describe 'エラーチェック' do
  context '何も入れない場合' do
    subject { User.new }
    it 'エラーになること' do
      # 略
    end
  end
  context 'メールアドレスでない場合' do
    subject { FactoryGirl.build(:user, email: 'invalid_email') }
    it 'エラーになること' do
      # 略
    end
  end
  context 'パスワードと確認用パスワードが一致しない場合' do
    subject { FactoryGirl.build(:user,
                                password: 'hogehoge',
                                password_confirmation: 'piyopiyo') }
    it 'エラーになること' do
      expect(subject).not_to be_valid
      expect(subject).to have(1).errors_on(:password)
    end
  end
  context 'メールアドレスが重複する場合' do
    it 'エラーになること'
  end
end

ほぼ、メールアドレスの時と同じようなテストです。コピペして該当箇所を書き換えるだけですが、ずいぶん安心感があります。ここで、FactoryGirlのモデル変更項目が2つになりました。2つ程度なら、まぁそんなに苦ではないですが、これからカラム数が増えて複雑化してくると大変です。そこでFactoryGirlで前に作った、パスワードが違うモデルを使ってみましょう。

context 'パスワードと確認用パスワードが一致しない場合' do
  subject { FactoryGirl.build(:user_invalid_password) }
  it 'エラーになること' do
    expect(subject).not_to be_valid
    expect(subject).to have(1).errors_on(:password)
  end
end

いろんな状態のモデルを作るのは、FactoryGirlに任せると楽ですが、さっきのメールアドレスのテストのように、1項目だけの変更だったりすると、逆に面倒だったりするので、そこは使い分けましょう。

最後にメールアドレスの重複のテストです。
肝は、beforeブロックで事前にデータをDBに登録しておくところです。そして、全く同じモデルオブジェクトをFactoryGirl.buildで作ります。そうすると、必然的にメールアドレスが同じモデルができます。これを検証にかけて、エラーになるかどうかを試します。

describe 'エラーチェック' do
  context '何も入れない場合' do
    subject { User.new }
    it 'エラーになること' do
      # 略
    end
  end
  context 'メールアドレスでない場合' do
    subject { FactoryGirl.build(:user, email: 'invalid_email') }
    it 'エラーになること' do
      # 略
    end
  end
  context 'パスワードと確認用パスワードが一致しない場合' do
    subject { FactoryGirl.build(:user_invalid_password) }
    it 'エラーになること' do
      # 略
    end
  end
  context 'メールアドレスが重複する場合' do
    before do
      FactoryGirl.create(:user)
    end
    subject { FactoryGirl.build(:user) }
    it 'エラーになること' do
      expect(subject).not_to be_valid
      expect(subject).to have(1).errors_on(:email)
    end
  end
end

できました。シンプルなデータのマスターとかだと、これくらいのテストで済みます。

新しい制約ができたら?

たとえば、パスワードはこのままだと空文字じゃなければ1文字でもよくなる。せめて8文字以上にしたい。半角英数字と記号のみにしたい。という要望が出たとしたら、それもまたとりあえずテストに落とし込みます。

describe 'エラーチェック' do
  # 略
  context 'パスワードと確認用パスワードが一致しない場合' do
    subject { FactoryGirl.build(:user_invalid_password) }
    it 'エラーになること' do
      # 略
    end
  end
  context 'パスワードが短すぎる場合' do
    it 'エラーになること'
  end
  context 'パスワードに全角が含まれる場合' do
    it 'エラーになること'
  end
  # 略
end

これでもいいですが、パスワードが、パスワードがってうるさいですね。まとめましょう。

describe 'エラーチェック' do
  # 略
  describe 'パスワードが' do
    context '確認用パスワードと一致しない場合' do
      subject { FactoryGirl.build(:user_invalid_password) }
      it 'エラーになること' do
        # 略
      end
    end
    context '短すぎる場合' do
      it 'エラーになること'
    end
    context '全角が含まれる場合' do
      it 'エラーになること'
    end
  end
  # 略
end

こういうふうに、describeをネストすることで、テスト対象のスコープを狭くしていくことができます。テスト対象のスコープは常に意識しておきましょう。
他にも、似たような挙動を纏めるテストの書き方(shared_examples_for)や、遅延評価用変数の定義(let, let!)などありますが、それはおいおい覚えていってください。ググればでてくるでしょう。

正しいテストの書き方とは?

色々書いてきましたが、これが完全に正解かどうかは、わかりません。
私はテストしたいことがテストできていれば、書き方はどうでもいいと思います。上のような書き方は冗長じゃないか?と思う人もいるでしょう。自分で考えたり、チームで話し合って、わかりやすいものを採用すればそれでいいかなと思います。

長くなったので、インテグレーションテストなどについてはまた後で続きを書きます(違う記事にするかも)。


カテゴリー Ruby, Ruby on Rails | タグ | パーマリンク

コメント・トラックバック一覧

  1. Pingback: 私がインテグレーションテストを書く際のやり方について | 自転車で通勤しましょ♪ブログ

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です