RSpecえかきうた

初めまして、takanekoと申します。
入社して以来2年くらい「ブログ書きます!」と言いつつすっぽかし続けたダメWebエンジニアです。

RSpecえかきうた?

「ぼくのあーるすぺっくのかきじゅん」(僕のRSpecの書き順)です。
RSpecの書き方わからない、という方のために、0から書き上げる際の参考になればと思い書きました。

Specを書き始めましょう

RSpecにはModel Spec、Feature Spec、Request Spec、System Specなど色々種類がありますが、今回はRequest Specを書いていきます。APIとかのテストをする際に使うSpecです。

書き順がメイントピックなので、細かい実装の妥当性とかは無視して、以下のような簡単な仕様のユーザ登録APIでSpecを書きたいと思います。

API仕様

  • URL POST /users(format: :json)
    • email, password, password_confirmationをPOSTしてユーザ作成
    • 作成したら200 OKとユーザのidをjsonで返却する
    • 全てのパラメータが揃わない限り、400 Bad Requestを返却する
    • メールアドレスが重複する場合も、400 Bad Requestを返却する
    • 管理者アカウントでログインしていない場合、401 Unauthorizedを返却する

spec/requests/users_create_spec.rbに空のSpecを書いてスタートしましょう。

require 'rails_helper'

RSpec.describe 'POST /users', type: :request do
  # やるぞ
end

テストケースを羅列します

contextitだけ並べて、自然言語で仕様を書き起こしましょう。

RSpec.describe 'POST /users', type: :request do
  context '全てのパラメータが揃っている場合' do
    it '200 OKを返す'
    it '成功時のJSONレスポンスを返す'
    it 'ユーザを登録する'
  end

  context 'emailパラメータが不足している場合' do
    it '400 Bad Requestを返す'
    it 'パラメータ不正のJSONレスポンスを返す'
    it 'ユーザを登録しない'
  end

  context 'emailが既に登録されている場合' do
    it '400 Bad Requestを返す'
    it 'email重複エラーのJSONレスポンスを返す'
    it 'ユーザを登録しない'
  end

  context '管理者アカウント未ログインの場合' do
    it '401 Unauthorizedを返す'
    it 'ユーザを登録しない'
  end
end

どうでもいいけど、結合テスト仕様書ってこんな感じですよね。

expectを書きましょう

テストを書く上で一番時間のかかる部分は前提条件です。(今回は簡単な仕様のテストなのでさほど感じませんが)
その間テストが形にならず、何かやったという気になりません…。

でも、itに自然言語で記述した内容をexpectに起こすのは流れ作業でできそうです。expectから書いていきましょう。
expectはテストのゴールなので、それが明確になるとちょっとだけやる気が出てくるような気がします(弱気)。

RSpec.describe 'POST /users', type: :request do
  context '全てのパラメータが揃っている場合' do
    it '200 OKを返す' do
      post '/users', params: { email: 'test@bpsinc.jp', password: 'hogehoge', password_confirmation: 'hogehoge' }, headers: { 'ACCEPT' => 'application/json' }
      expect(response).to have_http_status(:ok)
    end

    it '成功時のJSONレスポンスを返す' do
      post '/users', params: { email: 'test@bpsinc.jp', password: 'hogehoge', password_confirmation: 'hogehoge' }, headers: { 'ACCEPT' => 'application/json' }
      expect(JSON.parse(response.body)).to eq(
        'user' => { 'id' => User.find_by(email: 'test@bpsinc.jp').id }
      )
    end

    it 'ユーザを登録する' do
      expect do
        post '/users', params: { email: 'test@bpsinc.jp', password: 'hogehoge', password_confirmation: 'hogehoge' }, headers: { 'ACCEPT' => 'application/json' }
       end.change(User, :count).by(1)

      # パスワードを暗号化していない :masakari:
      expect(User.find_by(email: 'test@bpsinc.jp')).to have_attributes(password: 'hogehoge')
    end
  end

  ...
end

簡単なケースから対応して勢いをつけましょう

ちょっとでも弾みをつけたい時には、書くのが簡単なテストケースから手をつけましょう。
例えば、ログアウトした状態で401を返すテストは楽です。ログインコード書かなくて良いですもんね。

RSpec.describe 'POST /users', type: :request do
  ...

  context '管理者アカウント未ログインの場合' do
    it '401 Unauthorizedを返す' do
      post '/users', params: { email: 'test@bpsinc.jp', password: 'hogehoge', password_confirmation: 'hogehoge' }, headers: { 'ACCEPT' => 'application/json' }
      expect(response).to have_http_status(:unauthorized)
    end

    it 'ユーザを登録しない' do
      expect do
         post '/users', params: { email: 'test@bpsinc.jp', password: 'hogehoge', password_confirmation: 'hogehoge' }, headers: { 'ACCEPT' => 'application/json' }
      end.change(User, :count).by(0)
    end
  end
end

expectを書いた時点で完成していますね。
この時点でSpecを動かしてみて、テスト成功を確認しましょう。細かく満足感を得ていくのは大事です。

bundle exec rspec spec/requests/users_create_spec.rb -e '管理者アカウント未ログインの場合'

いよいよ前提条件を埋めましょう

beforeに前提条件を書いていきます。
例えば、正常系はログインするだけなので簡単ですね。

RSpec.describe 'POST /users', type: :request do
  context '全てのパラメータが揃っている場合' do
    before { sign_in create(:administrator) } # createはFactoryBotのメソッドです

    it '200 OKを返す'

    it '成功時のJSONレスポンスを返す'

    it 'ユーザを登録する'
  end

  ...
end

メール重複による失敗時の場合は、以下のような前提条件になりますね。

RSpec.describe 'POST /users', type: :request do
  ...

  context 'emailが重複している場合' do
    before do
      # email重複ユーザを作る
      create(:user, email: 'test@bpsinc.jp')
      # サインイン
      sign_in create(:administrator)
    end

    it '400 Bad Requestを返す'

    it 'email重複エラーのJSONレスポンスを返す' do
      post '/users', params: { email: 'test@bpsinc.jp', password: 'hogehoge', password_confirmation: 'hogehoge' }, headers: { 'ACCEPT' => 'application/json' }
      expect(JSON.parse(response.body)).to eq(
        'error' => "そのメールアドレスは使用されています(user_id=#{User.find_by(email: 'test@bpsinc.jp').id})"
      )
    end

    it 'ユーザを登録しない'
  end

  ...
end

今回は書きませんが、GET系のAPIだとここでデータを作っていくのが大変です…。頑張りましょう!

あとは繰り返しです

全てのテストを通すまで、繰り返していきましょう。

この記事では成功を期待する時だけ実行していますが、失敗する場合でも、期待する失敗になっているかを随時確認したほうがいいです。

さらなる高みへ(ここからネタです)

贅沢にもテストコードをリファクタリングしていきましょう。

beforeとitをスッキリさせる

beforeitが分かれていると読みやすいですが、ブロックが分かれていてローカル変数アクセスできないので不便ですね(盲信)。
一緒にしちゃいましょう。

RSpec.describe 'POST /users', type: :request do
  ...

  context 'emailが重複している場合' do
    it 'email重複エラーのJSONレスポンスを返す' do
      # email重複ユーザを作る
      user = create(:user, email: 'test@bpsinc.jp')
      # サインイン
      sign_in create(:administrator)

      post '/users', params: { email: 'test@bpsinc.jp', password: 'hogehoge', password_confirmation: 'hogehoge' }, headers: { 'ACCEPT' => 'application/json' }
      expect(JSON.parse(response.body)).to eq(
        'error' => "そのメールアドレスは使用されています(user_id=#{user.id})"
      )
    end

  ...
end

ここで「letを使うのがいいのでは?」という質問が出そうなので先に弁明しておきます。

letは以前ひどい目にあって以来、Model Spec以外では基本的に書かないようになりました。
letの適切な書き方はご説明できないので、別の解説記事を参考になさってください💦。

contextとitをスッキリさせる

contextitは合わせて一つの文章なので、一緒にした方が読みやすい気がします(盲信)。

RSpec.describe 'POST /users', type: :request do
  it '全てのパラメータが揃っている場合, 200 Okを返す'

  it '全てのパラメータが揃っている場合, 成功時のJSONレスポンスを返す'

  it '全てのパラメータが揃っている場合, ユーザを登録する'

  ...
end

これでどんなにSpecが長くなっても、エディタの画面からcontextが出て行って「あれ、これのcontextなんだっけ?」となりません。ファイル分割しろって話ですが。

subjectがないのにitとは

短いのでついitを書いてしまうのですが、主語(subject)がないので何を指しているかわかりませんね。
同じ用途で使えるexampleでもいいけど、testとかの方がわかりやすくないですか?

RSpec::Core::Configuration#alias_example_toexampleに別名をつけられます。

spec/spec_helper.rbに以下を追記します。

RSpec.configure do |config|
  config.alias_example_to :test

  ...
end

今までのテストコードもitからtestに置き換えます。

RSpec.describe 'POST /users', type: :request do
  test '全てのパラメータが揃っている場合, 200 Okを返す'

  test '全てのパラメータが揃っている場合, 成功時のJSONレスポンスを返す'

  test '全てのパラメータが揃っている場合, ユーザを登録する' 

  ...
end

これで完成!!

想定されるいくつかの質問

さてはアンチ(Minitest派)だな、オメー?

Minitest派だけどアンチではないです。ほんとです!

RSpec嫌いなの?

嫌いというより、格好良く書こうとして読みづらいテストコードになることが多いよなぁ、というイメージがあります。
格好良く書きたくなる魔力が、RSpecにはあるんですかね。

Minitestだから必ず良くなるとも思っていないです。単純な書き方しか出来ない方が、単調なテストコードになる確率が高そう、くらいの気持ちです。

let嫌いなの?

正直、嫌いです。

自分がletを書いてしまうことで、そのletが自分以外の人によってcontextの階段を駆け上がっていき、いつしかSpecのtop levelにいる悪夢を見ます(大げさ)。

遅延評価(let)、即時実行(let!)とかもそれに気づかずにテストがfailしたり、前提条件を勘違いしてしまったりして苦手なんですよね…。

私が病的に嫌いなだけなので、使うべき適切な場面はあると思います。
subjectが使いやすいModel Specとか。

それにしても冗長すぎない?

基本的にテストコードの冗長は問題ないと考えてます。

特に前提条件beforeと期待値expectに関しては、むやみに共通化することで、後から書く人がその共通部分にひきずられて窮屈な or 無理のあるテストコードしか書けなくなってしまいがちです。

ただ、この記事は読みやすさ重視で冗長にしているので、全部このままがいいとは思ってません。例えば、post部分とかは便利メソッドがあるといいかも。

最後に

半分が煽りっぽいネタ記事で申し訳ありません。
普段RSpecしか書かないのですが、Minitestを書きたいのでその闇が出てきてしまいました。

ここまで読んでいただき、ありがとうございました。

関連記事

Rails tips: RSpecの`let`ブロックや`before`ブロックは基本避けるべき(翻訳)

TestProf: Ruby/Railsの遅いテストを診断するgem(翻訳)

Rails tips: RSpecテストを遅くする悪い書き方3種(翻訳)

デザインも頼めるシステム開発会社をお探しならBPS株式会社までどうぞ 開発エンジニア積極採用中です! Ruby on Rails の開発なら実績豊富なBPS

この記事の著者

takaneko

Railsエンジニアです。金融界隈のSEから今に至ります。

takanekoの書いた記事

BPSアドベントカレンダー

週刊Railsウォッチ

インフラ

ActiveSupport探訪シリーズ