はじめに
僕は折に触れて「RSpecではなるべくsubjectを使わない方がいい」という発言をしています。
ただ、その理由をあまり明確に明文化していなかったので、ここらでちょっとブログにまとめてみたいと思います。
RSpecをよく知らない方へ
そもそもRSpecって何?subjectって何?という方は以下の記事を読んでから戻ってくることをお勧めします。
qiita.com
subjectの使いどころ = メソッドの戻り値をテストするケース
subjectの使いどころについては、以前僕が書いたQiita記事に投稿された、こちらのコメントが的を射ていると思います。
副作用が目的の(procedural)メソッドと、返り値が目的の(functional)メソッド。
返り値が目的のメソッドは、subjectがうまくハマる。
by @akihitofujiwara 2017-09-15 14:36
上のコメントにあるとおりなのですが、メソッドの戻り値をテスト対象にするときはまだ収まりが良いです。たとえば、イメージとしてはこんな感じです。
def greet
'Hello!'
end
describe '#greet' do
subject { greet }
it { is_expected.to eq 'Hello' }
end
上のテストコードはgreet
メソッドをテストしています。このメソッドはHello!
という文字列を返すメソッドです。このように戻り値を返すメソッドをsubjectにすると、あまり大きな問題はありません。
subjectが向いていないケース = メソッドの副作用をテストするケース
一方で、次のようなメソッドはsubjectには向いていません。
class Counter
attr_reader :count
def initialize
@count = 0
end
def increment
@count += 1
end
end
describe 'Counter#increment' do
let(:counter) { Counter.new }
subject { counter.increment }
it do
subject
expect(counter.count).to eq 1
end
end
上のテストコードはCounterクラスのincrement
メソッドをテストしています。そのため、subjectにはcounter.increment
をセットしました。しかし、increment
メソッドはあくまでインスタンス内で保持しているカウント値を増やすことが目的です(注:Rubyの言語仕様上、加算後の値がメソッドの戻り値になりますが、説明の都合上、戻り値はvoid、つまり戻り値無しとします)。つまりこのメソッドは副作用を起こすことを目的としています。subjectは副作用を起こすメソッドのテストには向いていないため、次のような不自然なテストコードが生まれます。
it do
subject
expect(counter.count).to eq 1
end
もちろん、ひとひねりすれば次のようなコードを書くこともできます。
describe 'Counter#increment' do
let(:counter) { Counter.new }
subject { ->{ counter.increment } }
it { is_expected.to change(counter, :count).by(1) }
end
これを「美しい」と思う人もいるかもしれませんが、僕からすると「無理矢理がんばってるなあ」感が強いです。あくまで主観の問題かもしれませんが、こういったテストコードは「自己満足」の要素が強い気がするんですよね。で、その自己満足と引き換えに、以下のようなデメリットが発生します。
- subjectをがんばって使うための、試行錯誤の工数が発生する
- そのがんばりの結果、摩訶不思議なテストコードが生まれる(RSpec初心者に優しくないし、熟練者が見ても一瞬考えてしまう)
そのため、もしチーム内で「必ずsubjectを使うこと」というコーディング規約があったりすると、上のような問題が頻発しそうな気がします(個人の推測です)。
僕の主張:そもそもsubjectなしで書けばいいじゃない
上で示したテストコードはどちらもsubjectなしで書いても全然問題ないと思います。実際にsubjectなしのテストコードに書き直してみましょう。
最初はメソッドの戻り値をテストする場合です。
describe '#greet' do
it 'returns "Hello!"' do
expect(greet).to eq 'Hello!'
end
end
次に、メソッドの副作用をテストする場合です。
describe 'Counter#increment' do
let(:counter) { Counter.new }
it 'increments count' do
counter.increment
expect(counter.count).to eq 1
end
end
別解としてこういう書き方もあります。
describe 'Counter#increment' do
let(:counter) { Counter.new }
it 'increments count' do
expect { counter.increment }.to change(counter, :count).by(1)
end
end
僕はこういうテストコードでも何も問題を感じません。むしろシンプルかつ素直なので、subjectを使うときよりもコードが読みやすいと感じます。
subjectを好む人の主張に、「テスト対象のメソッドがわかりやすくなる」というものがありますが、describe '#greet'
やdescribe 'Counter#increment'
でテスト対象を明示しておけば、それで十分明確になるはずです。よって、「テスト対象をわかりやすくする」という目的において、subjectは必ずしも必要ないと考えています。
もうひとつの問題:subjectを使うと視線を上下させなければならない
subjectを使うもう一つの問題点は、subjectを使うタイミングで視線を上下させなければならないところです。上で示したような単純なサンプルコードとは異なり、実務で書くテストコードはどうしても大きくなりがちです。そうすると、いちいち「このsubjectっていったい何だっけ?」というのを確認しに行く作業が発生します。
イメージとしてはこんな感じです。
describe 'Foo#bar' do
let(:foo) { Foo.new }
subject { foo.bar }
context 'when A' do
before do
end
it { is_expected.to eq 'abc' }
end
context 'when B' do
before do
end
it { is_expected.to eq 'def' }
end
context 'when C' do
before do
end
it { is_expected.to eq 'xyz' }
end
end
こんなテストコードになっていると、context 'when C'
を読む頃には「あれ?subjectって何だっけ??」ということになってしまいます。
テストコードは人間が読むドキュメントのように上から下に読めるようにすべきです。subjectをなくせば、視線を上下させる必要がなくなります。
context 'when C' do
before do
end
it 'returns "xyz"' do
expect(foo.bar).to eq 'xyz'
end
end
プルリクエストをレビューするときもsubjectの実体がdiffに出てこない(ことが起こりえる)
プルリクエストをコードレビューするときはこんなふうに新規に追加したテストケースだけがdiffに上がってきて、subjectの実体はdiffに出てこないこともあるかもしれません。
end
+ context 'when C' do
+ before do
+ # 何行にも渡ってセットアップが続く
+ # .
+ # .
+ # .
+ end
+ it { is_expected.to eq 'xyz' }
+ end
end
subjectを使わなければそのテストで何の結果を検証しているのか、diffを見るだけで理解できます。
end
+ context 'when C' do
+ before do
+ # 何行にも渡ってセットアップが続く
+ # .
+ # .
+ # .
+ end
+ it 'returns "xyz"' do
+ expect(foo.bar).to eq 'xyz'
+ end
+ end
end
応用:subjectの代わりにrspec-parameterizedを使う
かつては僕もたまにsubjectを使っていました。それは次のようなテストコードを書く場合です。
describe 'Ticket.calc_fee' do
subject { Ticket.calc_fee(age) }
context '子どもの場合' do
let(:age) { 10 }
it { is_expected.to eq 500 }
end
context '大人の場合' do
let(:age) { 20 }
it { is_expected.to eq 1000 }
end
context '老人の場合' do
let(:age) { 65 }
it { is_expected.to eq 0 }
end
end
これはメソッドに渡す引数をcontextごとにいろいろ変えながら、メソッドの戻り値を検証するテストコードです(「同値分割・境界値分析」みたいなテスト技法の話はこのエントリの本題ではないため、ここでは無視します)。
ですが、こういったテストコードはrspec-parameterized gemを使った方がシンプルに書けます(余談:gemなしで使えるようにRSpecの標準機能に取り込んでほしい〜)。
describe 'Ticket.calc_fee' do
where(:age, :fee) do
[
[10, 500],
[20, 1000],
[65, 0]
]
end
with_them do
it 'returns fee by age' do
expect(Ticket.calc_fee(age)).to eq fee
end
end
end
rspec-parameterizedすら使わず雑に書く
上のような例であればそもそもsubjectもrspec-parameterizedもいらないかもしれません。
「デグレを防止できる」「APIドキュメント代わりになる」という要件を満たすのであれば次のようなテストコードでも必要十分だったりします。
describe 'Ticket.calc_fee' do
it 'returns fee by age' do
expect(Ticket.calc_fee(10)).to eq 500
expect(Ticket.calc_fee(20)).to eq 1000
expect(Ticket.calc_fee(65)).to eq 0
end
end
最近はこういうスタイルで書くことが多いので、僕が書くRSpecのコードではsubjectが登場する機会がほとんどなくなりました。
参考:RSpecのメンテナから見たsubject
日本語には翻訳されていませんが、RSpecの元メンテナ・Myron Marston氏が執筆した"Effective Testing with RSpec 3"という本があります。
本書を読むとsubjectについて次のような注意書きが書いてあります。
We recommend you use this one-liner syntax sparingly. It’s possible to over-emphasize brevity and rely too much on one-liners.
(僕の日本語訳)
ワンライナー構文(訳注 it { is_expected.to eq 'Hello!' }
のような構文)は控えめに使用することをお勧めします。この構文を使うと簡潔さを過剰に求めてしまい、大量のワンライナーが量産されてしまう恐れがあります。
本書に書かれているsubjectに関する注意点は、以下の翻訳記事でもほぼ同じ内容が語られています。
qiita.com
私はsubject
やワンライナー構文(例: it { is_expected...}
)を使うことはほとんどありません。特定の分野のプロジェクト(例:mustermann)ではワンライナー構文はうまく機能しますが、大半のプロジェクトではあまり有益なテクニックではないと考えています。
【翻訳】RSpecのリードメンテナだけど何か質問ある? - Qiita
まとめ
というわけで、このエントリでは僕がRSpecでsubjectを使わない理由をつらつらと書いてみました。
このあたりは個人の好みで大きく分かれるというか、もはや宗教に近いものを感じることもよくあります。「絶対subject使うマン」から見ると、僕の主張はどれも眉をひそめる話ばかりだったんじゃないかと思います。なので、「subjectを使った方がいい!」と思っている人はそのまま使い続けても構いません。
「subjectを使いたくない」と思っていても、既存のプロジェクトにあとから参加した場合は悩ましいですね。既存のテストコードが「subject使いまくり」だと、テストコードの一貫性の面でsubjectを無くすのは難しいかもしれません。その場合はチーム内でテストコードの方針を話し合ってみてください。
あわせて読みたい
テストコードをテクニカルに書くために時間をかけすぎるのは不毛だと僕は考えています。
qiita.com
テクニカルなテストコードを避け、上から下に読めるテストコードを書きましょう、という話をここに書いています。
qiita.com
本文でも引用した"Effective Testing with RSpec 3"という書籍の書評です。
blog.jnito.com
[PR] RailsですらすらとRSpecのコードが書けるようになりたい!という方はこちらの電子書籍をどうぞ💁♂️(僕が翻訳しています)
leanpub.com