give IT a try

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

「RSpec で example の外で定義したローカル変数を使うのはアリか?」に対する僕の見解と解決策

はじめに

先日、「RSpec で example の外で定義したローカル変数を使うのはアリか?」というブログ記事を拝見しました。

ブログの作者である「きいあむ」さんは、「exampleの外で定義したローカル変数を使うのもアリなのでは?」というスタンスで記事を書かれています。

ですが、僕は「うーん、これはちょっと・・・」という感が否めません。
そこでこのエントリでは上記ブログ記事の内容に対する、僕の見解と考えられる解決策を書いていきます。
RSpecでテストを書くことがある人は参考にしてみてください。

上記ブログの簡単なまとめ

さくっと要点を把握したい、という方のために、きいあむさんの意見を簡単にまとめておきます。

RSpecでは値を共通化するために、インスタンス変数やletを使います。
たとえば、何も考えずに愚直テストを書くとこんな感じになります。

describe User do
  it 'is valid' do
    user = User.create(name: 'Alice')
    expect(user).to be_valid?
  end
  it 'sets name' do
    user = User.create(name: 'Alice')
    expect(user.name).to eq 'Alice'
  end
end

上のコードでは"user = User.create(name: 'Alice')"が重複しているので、beforeブロックとインスタンス変数を使って共通化することができます。

describe User do
  before do
    @user = User.create(name: 'Alice')
  end
  it 'is valid' do
    expect(@user).to be_valid?
  end
  it 'sets name' do
    expect(@user.name).to eq 'Alice'
  end
end

ですが、RSpecの場合はインスタンス変数よりもletを使う方が一般的にベターです。

describe User do
  let(:user) { User.create(name: 'Alice') }
  it 'is valid' do
    expect(user).to be_valid?
  end
  it 'sets name' do
    expect(user.name).to eq 'Alice'
  end
end

インスタンス変数よりもletを使うメリットについては以前こちらのQiita記事に書いたので、ここでは省略します。

だいたいはこんなふうに書くのがRSpecの世界では一般的なのですが、先ほどのきいあむさんのブログの中では、「example(it)の外でローカル変数を使ってもいいんじゃないか?」という話が書いてあります。
たとえばこんな感じです。

describe User do
  # userをローカル変数として宣言する
  user = User.create(name: 'Alice')
  it 'is valid' do
    expect(user).to be_valid?
  end
  it 'sets name' do
    expect(user.name).to eq 'Alice'
  end
end

具体的なユースケースや詳しい挙動についてはきいあむさんのブログで説明されているので、そちらをご覧ください。

その発想は無かった・・・が!

僕は長年RSpecを使っていましたが、データを共通化するためにローカル変数を使うという発想は全くありませんでした。
言われてみれば、これでも確かに動きます。
きいあむさんは「(元々)RSpec の作法をよく知らなかった」とのことですが、非常に面白いコード例だなと思いました。

が!

確かに動くことは動くものの、これを「アリ」として広めてしまうのはちょっと問題がある気がします。
具体的にどのあたりが問題なのかを以下に書いていきます。

問題点1:一般的ではない(DSLから逸脱している)ため、他の人が困惑する

きいあむさん自身もブログで「このような書き方を全く見かけない」と書いているとおり、exampleの外で宣言したローカル変数を使い回すというのは一般的ではありません。
そのため、このコードを見た人が「なんでこんなことをしてるんだろう?どういう意図なんだろう?」と困惑してしまいます。

特に、RSpecはDSLを強調するテスティングフレームワークなので、DSLから外れたコードを書くと違和感がかなり強くなります。

チームで開発したりするときは毎回質問が飛んで、元の作者が毎回それに答える、というような非効率なやりとりが発生するかもしれません。
なので、共通言語としてのDSLからはなるべく逸脱しないようにする方が良いです。

問題点2:RSpecのDSLとRuby言語の仕様で脳みそを切り替える必要がある

ローカル変数にした場合、そこだけRuby言語のルールでコードを読む必要があります。
下記のコードでuserがletと(一見)同じように動くのは、「ブロックの外で宣言されたローカル変数はローカル内部で参照可能」というRuby言語の仕様に従っているためです。

describe User do
  # ブロックの外で宣言したローカル変数は・・・
  user = User.create(name: 'Alice')
  it 'is valid' do
    # ブロック内でも参照できる
    expect(user).to be_valid?
  end
  it 'sets name' do
    # ブロック内でも参照できる
    expect(user.name).to eq 'Alice'
  end
end

上のようなシンプルなコードであればまだ動きを把握しやすいです。
しかし、テストコードが大きくなってdescribeやcontextがたくさんネストしたり、ローカル変数とletが混在したりしはじめると、RSpecのDSLとして理解すべき部分とRuby言語の仕様として理解すべき部分が入り交じって、動きを把握しづらくなります。

問題点3:変数の寿命が長くなり、予期しないタイミングでデータの状態が変わるリスクがある

beforeとインスタンス変数を使った場合やletを使った場合は、exampleのたびにデータ(@userやuser)が作り直され、exampleごとにテストデータが独立しています。

しかし、exampleの外のローカル変数はRSpecがテストコードを読み込んでいくタイミングで作成され、それがずっと保持されます。
そのため、次のように変数の状態をうっかり変えてしまうと他のテストが失敗します。

describe User do
  # この変数はずっと保持される
  user = User.create(name: 'Alice')
  it 'is valid' do
    expect(user).to be_valid?
    # テスト実行中にわざとnameを変更する
    user.name = 'Bob'
  end
  it 'sets name' do
    # user.nameが'Bob'になっているので、テストが失敗する
    expect(user.name).to eq 'Alice'
  end
end

これはきいあむさんもブログの中で「テスト実行中に変数の状態が変わったりしたら、なんかまずそうなので、定数にしたり freeze した方が安全かも」と書いていて、まさにその通りです。
上のコード例はちょっとわざとらしいですが、配列やハッシュであれば要素の追加や削除で状態を変えられてしまう可能性は結構あると思います。
なので、不必要に変数の寿命を延ばすのは避けるべきです。


・・・と、僕がぱっと思いつく問題点はこんな感じです。
では、どういうふうに書き直すのがいいでしょうか?

解決策あれこれ

解決策はいくつか考えられます。
なお、きいあむさん自身もブログ内で「RSpecの流儀に合わせるなら」という別解を示しているので、重複する部分もあるかもしれません。

その1:お作法を知らなかったのなら、素直にletやインスタンス変数を使う

「letやインスタンス変数を使うのがRSpecのお作法だとは知らなかった」というのが理由であれば、素直にletやインスタンス変数を使うspecに書き直す方がいいです。
理由は先ほど述べたような問題点があるからです。

その2:固定値を使い回したいのなら、定数化する

どのテストでも変化しない共通の固定値を宣言するならローカル変数ではなく、定数にした方が意図が伝わりやすいと思います。

describe SomeController do
  # 共通の値を定数化する
  IN_SALE = 'in_sale'.freeze
  context 'ログイン前' do
    it 'リダイレクトされる' do
      get :index, params: { status: IN_SALE }
      expect(response).to redirect_to root_path
    end
  end
  context 'ログイン後' do
    before do
      sign_in
    end
    it 'リダイレクトされない' do
      get :index, params: { status: IN_SALE }
      expect(response).not_to redirect_to root_path
    end
  end
end

とはいえ、他のspecで同名の定数を宣言していると"warning: already initialized constant IN_SALE"のような警告が出て既存の定数を上書きしてしまうので、それほど安全な解決策ではありません。

その3:パフォーマンスが問題になるならbefore :allを使う

userを作成するのに非常に長い時間がかかるので、絶対に1回しか実行したくない、という場合は"before :all"を使います。

describe User do
  before :all do
    # 1回しか実行されない
    @user = User.create(name: 'Alice')
  end
  it 'is valid' do
    expect(@user).to be_valid?
  end
  it 'sets name' do
    expect(@user.name).to eq 'Alice'
  end
end

インスタンス変数だとtypoが怖い、という場合は、別途letを宣言するのもいいかもしれません。(こんなコードは実際に書いたことはないですが)

describe User do
  let(:user) { @user }
  before :all do
    @user = User.create(name: 'Alice')
  end
  it 'is valid' do
    expect(user).to be_valid?
  end
  it 'sets name' do
    expect(user.name).to eq 'Alice'
  end
end

ただし、example外のローカル変数と同様、作成した変数(user)はテスト実行中保持されるので、うかつに状態を変えないように注意しなければなりません。

その4:タイピング量の多さが問題なら、雑にspecを書く

「"let(:user) { User.create(name: 'Alice') }"だとタイピング量が多くてしんどい、"user = User.create(name: 'Alice')"の方がラクだ」という場合は、いっそのこと「雑なspec」を書くのもひとつの手です。

たとえば、無理にletにしなくてもexample内のローカル変数で書けばいいです。(最初に示したコード例がそれ)

describe User do
  it 'is valid' do
    user = User.create(name: 'Alice')
    expect(user).to be_valid?
  end
  it 'sets name' do
    user = User.create(name: 'Alice')
    expect(user.name).to eq 'Alice'
  end
end

なんならexampleをまとめちゃうのもアリです。

describe User do
  example do
    user = User.create(name: 'Alice')
    expect(user).to be_valid?
    expect(user.name).to eq 'Alice'
  end
end

exampleをまとめると途中でテストが失敗したときに後のテストがパスするのかしないのか分からない、というデメリットがあります。
また、毎回example内にローカル変数を宣言するとテストコードがDRYにならない、というデメリットもあります。
ですが、さくっとテストを書けるメリットと天秤にかけて、あえて早く書ける方を選ぶのもいいと思います。(最近僕はこの傾向が強いです)

複数のdescribeやcontextにまたがるのであれば、beforeブロックの中でインスタンス変数を作るという手もあります。

describe User do
  before do
    # letをやめてインスタンス変数で宣言してしまう
    @alice = User.create(name: 'Alice')
    @bob = User.create(name: 'Bob')
    @carol = User.create(name: 'Carol')
    @dave = User.create(name: 'Dave')
    @ellen = User.create(name: 'Ellen')
  end
  context 'Aの場合' do
    it 'xxxすること' do
      # @aliceや@bobを使ったテストを書く
    end
  end
  context 'Bの場合' do
    it 'xxxすること' do
      # @aliceや@bobを使ったテストを書く
    end
  end
end

もちろん、インスタンス変数だとtypoしたときにNameErrorではなくnilが返ってきてしまう、という問題がありますが、たいていの場合はテストの結果がおかしくなって問題に気づくはずなので、そこまで致命的ではないと思います。

その5:DSLの制約が苦痛なら、Minitestやtest-unitを使う

「RSpecの流儀に従うのは苦痛だ、自分はもっと自由にRuby言語の機能を活用したい!」という場合は、Minitestやtest-unitを使うのがいいと思います。
Minitestやtest-unitは「Rubyらしさ」を重視したテスティングフレームワークで、「Ruby言語でできることはテストコード内でも自由に利用すればよい」というスタンスを持っています。
とはいえ、情報が少なかったり、RSpecで簡単にできていたことができなかったりすることもあるので、一長一短な面はあります。

まとめ

というわけで、このエントリでは「RSpec で example の外で定義したローカル変数を使うのはアリか?」というきいあむさんのブログに対する僕の見解と解決策を書いてみました。
「exampleの外にローカル変数を宣言する」というアイデアはこれまで思いつかなかったし、実験レベルではなかなか面白いなと思います。
ですが、積極的に実務のコードに取り入れていくのはちょっとNGかな、というのが僕の見解です。

みなさんはどうお考えでしょうか?
「僕はこう思う」という意見があれば、みなさんもご自身のブログ等で公開してみてください!

あわせて読みたい

「そもそもRSpecがよくわからないんだけど?」という方はこちらのQiita記事をご覧ください。

「雑にspecを書く」場合をもう少し詳しく説明しています。

RSpecとMinitestってどう違うの?という方はこちらのスライドが参考になるかもし得ません。

PR:RSpecの電子書籍を販売しています

すでにご存知の方も多いかもしれませんが、僕が翻訳した「Everyday Rails - RSpecによるRailsテスト入門」という技術書を電子書籍で販売しています。
RSpec初心者の方でも十分わかりやすい内容になっているので、まだ読んでない方はぜひ読んでみてください!

Everyday Rails - RSpecによるRailsテスト入門
f:id:JunichiIto:20160424081240p:plain