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

テストコードにまつわる5つのエトセトラ

はじめに

ひとつ前のエントリでRSpecの話を書いたので、それにちなんで最近僕の身の回りで起きたテストコードに関する雑多なエピソードをいくつか書いてみます。


その1:テストコードを書いてない処理で見事にバグを出してしまった・・・!!

僕はソニックガーデンの中では「テスト番長」の異名を持っていて、基本的にテストコードはしっかり書くタイプです。
ですが、どうしてもリリースを優先しなければいけないときは、やむを得ずテストコードを後回しにして先にリリースすることもあります。

先日リリースした「テストを後回しにしたコード」は、リリース直後は問題なく動いていました。
その数週間後、別の仕様変更が入り、変更したコードをリリースしました。
「テストは全部パスしているので大丈夫なはず~!」と思ったら、リリースの翌日に変なシステムエラーが発生。

エラーが起きている場所を見て、ガーン。
例の「テストを後回しにしたコード」でしっかりエラーが起きていました。
正常系のフィーチャスペックを書いておくだけで、確実に検知できたエラーです。
やっぱりテストはちゃんと書いておかないとダメだな、とセルフ反面教師になりました。

その2:JavaScriptのテストも書いておかないとRailsのアップグレードはしんどい

結構昔に作った社内向けのツールを先日Rails 5にアップグレードしました。
基本的にテストはしっかり書いていたのですが、ソニックガーデンに入社して間もない頃に作ったRailsアプリで、当時不慣れだったJavaScriptを実行するフィーチャスペック("js: true"を付けるスペック)はひとつも書いていませんでした。

そのため、JSを使った画面の動きはいちいち手作業で確認せねばならず、非常に手間がかかりました。
ちゃんと"js: true"のフィーチャスペックも書いておかなければいけませんね。

ちなみに、テストを全く書いていないRailsアプリだったらもっと悲惨なことになるのは言うまでもありません。

その3:すぐに終わるテストはRailsのアップグレードがラク

これまた先ほどのRails 5アップグレードのエピソードです。
社内向けツールは小規模なRailsアプリでテスト項目が少なく、全部のテストを実行しても20秒以内に終わります。
なので、アップグレードの作業中は頻繁にテストを再実行して、エラーや警告を潰していくことができました。

普段の開発ではここまでテストを再実行することはありませんが、「速いテストのありがたみ」をしみじみと感じました。

昔から開発し続けている大きなRailsアプリだと15分とか20分ぐらいかかるものもあるので、気軽に全部を再実行するのは厳しそうです。
こういう場合はRSpecのフィルタリング機能を使ったりして、頻繁に再実行するのは特定のspecだけに絞り込んだ方がいいかもしれません。

その4:テスト番長は社内でRSpecの質問に何でも答えてくれます

RSpecは僕の得意分野なので、社内で他のメンバーがテストの書き方で困っていたら気軽に質問や相談に答えるようにしています。

「伊藤さん、ここのテストコードがうまく動かないんだけど、一緒に見てくれる?」
「テストコードの共通化で悩んでるから相談に乗って~」

といった質問や相談があれば、だいたい数分で「はい、論破」・・・じゃなくて「はい、解決!」しています。

RSpecって、それなりに動きにクセがあるので、ハマってしまうときはとことんハマってしまうんですよね~。
まあ、僕も昔はよくハマってたんですが、かなりの経験値を積んだので、「こういうときはこう書く」「こういうエラーが出たら、たぶんこれが原因」というテンプレートがだいたい頭の中にできています。

Twitterとかを見ていると「RSpecがうまく書けない!思った通りに動かない!キィ~~ッ!!!」という悲鳴をよく見かけるので、「あ~、僕が見たらすぐに直るんだろうなあ。お手伝いしてあげたいな~」と思ったりします。

RSpec相談コンサルとかやったらいいお小遣い稼ぎになるかも?
1回500円でどうですか??(笑)

その5:雑に書けるときは雑に書くのが最近の趣向

これはRSpecの書き方の話です。
RSpecって結構「RSpecらしく書くこと」を求められたりします。
たとえば、describeやcontextでしっかりグループを分けましょう、再利用するデータはletやsubjectに切り出しましょう、ひとつのexample(it)の中でテストするのはひとつの項目だけにしましょう、等々の方針です。

典型的な例がBetter Specsを読め、Better Specsに書いてあるとおりにspecを書け!っていうパターンですね。

これはもちろんそれぞれに意味があってのことですし、かつては僕もそうあるべきだと思っていました。

しかし、最近は「そこまでがんばってキレイにしなくてもいいのでは?」と考えるようになってきています。
その結果、テストコードがだんだん雑になってきています。

具体例を挙げるとこんな感じです。
まず、RSpecらしく書いたバージョンはこれです。

describe Cloth do
  describe '#price_with_tax' do
    let(:cloth) { Cloth.new('RSpec Tシャツ', price) }
    subject { cloth.half_price }
    context '割り切れる場合' do
      let(:price) { 1000 }
      it { is_expected.to eq 500 }
    end
    context '割り切れる場合・その2' do
      let(:price) { 2000 }
      it { is_expected.to eq 1000 }
    end
    context '端数が出る場合' do
      let(:price) { 999 }
      it { is_expected.to eq 499 }
    end
  end
end

次に、雑に書いたバージョンがこちらです。

describe Cloth do
  describe '#half_price' do
    example do
      # 割り切れる場合
      cloth = Cloth.new('RSpec Tシャツ', 1000)
      expect(cloth.half_price).to eq 500

      # 割り切れる場合
      cloth.price = 2000
      expect(cloth.half_price).to eq 1000

      # 端数が出る場合
      cloth.price = 999
      expect(cloth.half_price).to eq 499
    end
  end
end

ポイントとしては、

  • describeやcontextのグループ分けは必要最小限にする
  • contextに相当するものはコメントで書く
  • ひとつのexampleの中で、まとめて複数のテストを書いてしまう
  • letやsubjectに切り出さず、ローカル変数にしたり、毎回同じようなコードをベタ書きしたりする
  • "it 'returns half price'"とか"it '半額の値を返す'"のような説明文を省略して、"example"だけで済ませる

みたいな感じです。

メリット・デメリットはいろいろありますが、メリットだけを挙げると、

  • 思いついたテストパターンを上から下へさくっと書ける
  • どれをletやsubjectにすべきか、どうcontextを分割すべきか、といった「テストコードの設計」に頭を使わなくて済む
  • 「意図した通りにコードが動いていることを検証できる」という点では、RSpecらしいテストコードと変わりがない

みたいになるかな、と思います。

あと、RSpecらしいコードを追求して「RSpec、かくあるべき」を全面に出してしまうと、初心者の人たちに「RSpec怖い」「RSpec難しい」「RSpecわけわからん」という恐怖心を植え付けてしまわないかな~という懸念も感じたりします。

もちろん、RSpecらしいコードを完全に放棄したわけではありません。
テストコードの内容によってはRSpecらしく書いた方が、読み書きがしやすかったり、保守性が高かったりするケースもあります。
そういう場合はRSpecらしいコードを書くようにしています。

「よくわかんないけど、これが巷で言う守破離の「離」ってやつ?」なんて自分では考えたりしています。(間違ってたらすいません)

2016.9.5 追記

Qiitaに「雑なRSpec」の記事をもう少し詳しく書きました。
よかったらこちらもどうぞ。


まとめ

というわけで、テストコードに関する最近の雑多なエピソードを書いてみました。
まとめると、「テストコードって大事!テストコードって楽しい!」ということですね。

・・・えっ、全然違う?
いや、「テストコードって大事!テストコードって楽しい!」っていう思いは昔から変わってないので、そういうことにしておいてください。

ではではこのへんで!

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

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

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