give IT a try

プログラミング、リモートワーク、田舎暮らし、音楽、etc.

僕がRSpecでsubjectを使わない理由

はじめに

僕は折に触れて「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