RSpecを書く上で意識した方がいいと思うことと少しのTips | Offers Tech Blog
こんにちは!Offersを開発しているバックエンドエンジニアのShunです。
前回「テストは絶対書いた方がいい」という記事を書いたので、今回はテストを書く上で留意していることを書ければと思います。
そもそも、テストは絶対書いた方がいい
単体テスト・結合テスト・UseCase テストを書いておくことで、テストファイルを見るだけで仕様を理解できたりします。
また、複雑なメソッドほどテストを書いておくことで、きちんと想定した出力を保証したメソッドの実装ができます。
さらに、一度書いたテストは追加機能・修正対応・リファクタリングなどを行った際に、影響してるか否かを自動でチェックしてくれます。
これにより、ユーザ様への円滑な価値提供並びにサービス品質を保証する大事な役割を担ってくれるのです。(テスト書いてなかった頃が恐ろしい)
しかし、きちんと書けば書くほどテストのコード量が爆増する
書けば増えます。しかし、コード量が増えることは今のところデメリットしか見当たりません。
- 全体のテスト実行時間が長くなりがち
- 1h 待ってテスト落ち -> 修正 -> また 1h 待機…は辛すぎる
- 既存メソッドの変更やリファクタリングなどによるテストコードのメンテナンス工数が発生
- ぐちゃぐちゃに書いていると、テストコードメンテだけで時間を取られてしまう
- etc..
結果、ユーザ様への価値提供までのリードタイムが増加してしまう事態に陥ります。
故に、テスト実行時間の短縮と、メンテナンスしやすいテストコード記述を意識
上記の通り、「テスト実行時間の短縮」と「メンテナンスしやすいテストコード記述」はチーム内で意識する必要があると思っております。
弊社でこの 2 つの課題に対するアプローチをいくつか紹介できたらと思います。
テスト実行時間の短縮のためにやっていること
そもそもテスト実行時間増加の原因は?
特に大きい要因は DB に対する create, update, destroy などが挙げられます。
テストにおいては特に create の記述が数多なので、ここを改善していくと効果が現れやすくなります。
let_it_be の導入
厳密には test-prof という gem の機能の一部です。
中身を見ると、一度定義した変数を使い回して使えるようにするものとなっています。
インスタンス変数をセットしておいて、"@😸"をプレフィックスにして一意性を持っているみたいですね。可愛い。
OSS 開発者はみんな猫好きなようです。私は犬派です。
# Use uniq prefix for instance variables to avoid collisions
# We want to use the power of Ruby's unicode support)
# And we love cats!)
PREFIX = "@😸"
def let_it_be(identifier, **options, &block)
initializer = proc do
instance_variable_set(:"#{TestProf::LetItBe::PREFIX}#{identifier}", instance_exec(&block))
rescue FrozenError => e
raise e.exception("#{e.message}#{TestProf::LetItBe::FROZEN_ERROR_HINT}")
end
default_options = LetItBe.config.default_modifiers.dup
default_options.merge!(metadata[:let_it_be_modifiers]) if metadata[:let_it_be_modifiers]
options = default_options.merge(options)
before_all(&initializer)
let_accessor = LetItBe.wrap_with_modifiers(options) do
instance_variable_get(:"#{PREFIX}#{identifier}")
end
LetItBe.module_for(self).module_eval do
define_method(identifier) do
# Trying to detect the context
# Based on https://github.com/rspec/rspec-rails/commit/7cb796db064f58da7790a92e73ab906ef50b1f34
if /(before|after)\(:context\)/.match?(@__inspect_output) || @__inspect_output.include?("before_all")
instance_variable_get(:"#{PREFIX}#{identifier}")
else
# Fallback to let definition
super()
end
end
end
let(identifier, &let_accessor)
end
実際に使ってみる
以下のメソッドをテストするとします。
first_name, last_name をカラムに持つ users テーブルを想定しています。
class User < ApplicationRecord
def full_name
"#{self.last_name} #{self.first_name}"
end
end
以下の例は、通常の let を使用したテストです。
これだと、user レコードを 2 回 create していることになり、その分テスト完了までの時間が長くなります。
let(:user) {
create(:user, first_name: '太郎', last_name: '田中')
}
describe '#full_name' do
it 'フルネームが返却されること' do
expect(user.full_name).to eq '田中 太郎' # userが読み込まれた時点でcreate文が走る
end
context '名字が変更された場合' do
it '更新後のフルネームが返却されること' do
user.update_columns(last_name: '佐藤') # userが読み込まれた時点でcreate文が走る
expect(user.full_name).to eq '佐藤 太郎'
end
end
end
これに、let_it_be を利用すると、create 文が 1 回だけ行われ使い回しされるのでテスト実行時間が短縮されます。
# この時点で1度だけcreate文が走る
let_it_be(:user) {
create(:user, first_name: '太郎', last_name: '田中')
}
describe '#full_name' do
it 'フルネームが返却されること' do
expect(user.full_name).to eq '田中 太郎' # 上記で作成されたuserを使用
end
context '名字が変更された場合' do
it '更新後のフルネームが返却されること' do
user.update_columns(last_name: '佐藤') # 上記で作成されたuserを使用
expect(user.full_name).to eq '佐藤 太郎'
end
end
end
余談ですが、猫ちゃんを使って書くこともできます w
let_it_be(:user) {
create(:user, first_name: '太郎', last_name: '田中')
}
describe '#full_name' do
it 'フルネームが返却されること' do
expect(instance_variable_get(:@😸user).full_name).to eq '田中 太郎' # 上記で作成されたuserを使用
end
end
create 文が不要なときは build を使う
テーブル保存が不要なテスト時に有効。build は単にインスタンスを生成するだけなので、create 文は走らない。故に速い!
以下は、ユーザのフルネームを返すインスタンスメソッドですが、わざわざ user レコードを生成しなくても良さそうですね。
class User < ApplicationRecord
def full_name
"#{self.last_name} #{self.first_name}"
end
end
create を使用すると以下のような記述になります。
describe '#full_name' do
let(:user) { create(:user, first_name: '太郎', last_name: '田中') }
it 'should return full name' do
expect(user.full_name).to eq "田中 太郎"
end
end
各実行環境によって異なりますが、少なくとも私のローカルでは 5.48s もかかりました...
build を使用してみます。
describe '#full_name' do
let(:user) { build(:user, first_name: '太郎', last_name: '田中') }
it 'should return title' do
expect(user.full_name).to eq "田中 太郎"
end
end
なんと 0.62281s で完了!
このような小さな積み重ねが大事です。
mock を使う
純粋に「インスタンスメソッドやクラスメソッドを call する」というテストなどでは、わざわざエンティティを作成する必要はないので、以下のような処理のテスト作成時に有効です。
double や spy は既存処理の変更時にエラーを出力してくれないので、できる限り instance_double や class_double を利用するのが良さそうです。
class CreateLogWorker
sidekiq_options retry: 1, queue: :default
def perform(user_id, category_type)
user = User.find(user_id)
user.create_log(category_type: category_type)
end
end
mock を使った記述方法
RSpec.describe CreateLogWorker do
it 'should call instance method' do
Sidekiq::Testing.inline! do
instance_double = instance_double(User) # Userモデルに定義してあるインスタンスメソッドだけを許容する
allow(instance_double).to receive(:create_log)
allow(User).to receive(:find).and_return(instance_double)
CreateLogWorker.perform_async(1, 'information')
expect(instance_double).to have_received(:create_log)
end
end
end
テストの分散実行
これはテスト記述ではないですが、parallel_testsのような gem を利用して、テストファイルを幾つにも分割し、パラレル実行させることで全体のテスト実行時間を短縮が可能です。
テストコード自体をリーダブルにする観点での Tips
まずは記法的なものはrspec-best-practiceを踏襲するのが良さそうです。
プラスで、リーダブルな書き方の補足ができたらと思います。
基本 describe, context, it を使用する
基本的にはこの 3 つでおおよそのテストが書けると思います。
基本的なものを利用することで、新しくジョインした方や、RSpec 初めてという方にもとっつきやすくなります。
Model のテストにて、Scope, Callback, Validates などは一つの describe にまとめておくと見やすい
class User < ApplicationRecord
scope :valid_users, -> { where(valid: true) }
validate :validate_name
after_commit :perform_create_log, on: :create
...
end
RSpec.describe User, type: :model do
describe 'scopes' do
context 'valid_users' do
let_it_be(:valid_user) { create(:user, valid: true) }
let_it_be(:invalid_user) { create(:user, valid: false) }
it '有効なユーザだけ取得すること' do
expect(User.valid_users.first).to eq valid_user
end
end
end
describe 'validates' do
context 'validate_name' do
it 'バリデーションエラーが発生すること' do
user = build(:user, name: nil)
user.valid?
expect(user).to be_invalid
expect(user.errors[:base]).to include(...)
end
end
end
describe 'callbacks' do
context 'after_create_commit' do
it 'ログ作成Workerが登録されること' do
...
end
end
end
end
ネストしすぎないこと
以下の例はめちゃめちゃ読みづらいです。
RSpec.describe User, type: :model do
let(:company) { create(:company) }
describe '#hogehoge' do
before do
@user = create(:user)
end
context 'パターンAの場合' do
before do
@user.post_comment!(comment: 'こんにちは')
end
context 'パターンAかつBの場合' do
before do
@valid = company.valid_contract_term?
end
shared_examples_for 'should return XXX' do |company|
it do
expect(company.get_xxxx).to eq 'XXX'
end
end
it_behaves_like 'should return XXX', company
end
end
end
end
まとめ
リーダブルかつ、高速に、テストカバレッジ(テストカバー率)を向上させていくことが、サービスの品質向上のために必要不可欠だなぁと最近感じております。
今後はフロント側の自動テストも導入して、デザインやレンダリング周りの挙動確認もできたらと思ってます。
関連記事
副業転職の Offers 開発チームがお送りするテックブログです。【エンジニア積極採用中】カジュアル面談、副業からのトライアル etc 承っております💪 jobs.overflow.co.jp
Discussion