【RSpec】同じ shared_contextやshared_examplesを複数箇所で利用する

# この記事のテーマ

Ruby のテスティングフレームワーク RSpec を使ったテストにおいて、 **ひとつの shared_context や shared_examples を複数のファイルやテストで使い回す** ことについての私なりの解法を書いています。

RSpec では、同じ内容のテストを違う対象を相手に実施することが多いです。
特にバリデーションを検証するのに「長さ〇の文字列をセットしてバリデーションを実行」というのを複数のモデル／フィールドで確認することになります。

同じようなテストケースは使い回したいのですが、shared_context や shared_examples を複数のファイルで使い回すための情報が意外に転がってないと感じました。
色々と試行錯誤した結果をこの記事にまとめてみました。同じことを考えている方の一助になれば嬉しいです。

## 最初に要点

このあとに自分なりの試行錯誤を書いていますが、読むのが面倒という方は以下だけ押さえておけばOKだと思います。

1. 共用する shared_context や shared_examples をファイルに定義
1. 使用する側で shared_context のあるファイルを `require`
1. include_context や it_behaves_like で利用

# 参考にしたもの

RSpec そのものの解説や、テストの書き方についての説明については、様々なエントリがあります。個人的に参考になったものを以下に紹介します。

- [RSpec 公式サイト](http://rspec.info/)
- [Relish](https://relishapp.com/rspec)
  RSpec に関するドキュメント群。特に「RSpec Expectations」にあるマッチャの種類やサンプルはとても参考になりました。
- [スはスペックのス 【第 1 回】 RSpec の概要と、RSpec on Rails (モデル編)](https://magazine.rubyist.net/articles/0021/0021-Rspec.html)
- [使えるRSpec入門・その1「RSpecの基本的な構文や便利な機能を理解する」](https://qiita.com/jnchito/items/42193d066bd61c740612)

# 環境（主要なもののみ）

- macOS 10.14.3 Mojave
- ruby 2.6.1p33
- Rails 5.2.2
- rspec-rails 3.8.2

# 検証用アプリケーションについて

記事を書くために作成した rspec_samples というアプリを Github で公開しています。
書籍と会員情報を扱うという想像のもと、 Book と Model という2つのモデルを作成しました。

https://github.com/outerlet/rspec_samples

この記事では presence バリデーションを検証することについてのみ詳しく書きましたが、アプリには length や uniqueness を検証するための shared_context や shared_examples も作ってあります。
Rails.root/spec 以下の support および models ディレクトリにあるファイルをご確認ください。

あくまで動作確認が目的なので、全て scaffold で作成したなど作りが雑です。そのあたりは目をつぶって貰えるとありがたいです。

# テスト内容

この記事では Book モデルの title プロパティに関する presence バリデーションが機能するかを検証する shared_context を作成します。
`presence:true` が機能しているかどうかを確認しますので、検証内容は以下の2つとします。

- nil をセットして検証を実行すると結果が invalid になるか（invalid になるのが正しい）
- 空文字をセットして検証を実行すると結果が invalid になるか（同上）

# テストの作成

Book モデルの title プロパティについて検証ロジックを作成してから、それを他のモデル／プロパティでも使えるように改造していきます。

1. 基本となるテストの作成
1. 複数のテストで使えるよう検証ロジックを独立
1. 修正した検証ロジックをテストとは別のファイルに切り出す
1. 切り出したファイルを別のファイルから読み込む

## 基本となるテストの作成

presence を検証するテストを以下のように定義しました。
`describe:presence` の中で context を2つに分けず、1つの it 内で nil も空文字もテストするなど、方法は色々あると思います。

```ruby
# タイトルのバリデーションを検証
context :title do
  let(:book) { build(:book, title: title) }

  describe :presence do
    context :nil do
      let(:title) { nil }
      it { expect(book).to be_invalid }
    end

    context :blank do
      let(:title) { '' }
      it { expect(book).to be_invalid }
    end
  end

  # 以下略
```

## 複数のテストで使えるよう検証ロジックを独立

上記では nil と空文字それぞれについて別々の context で検証していますが、異なるのは let(:title) の値だけで、それ以外は期待する入力も結果も全く同じです。

そこで、この context を共用できるようにまとめます。
まとめる方法はいくつかありますが shared_context は引数を取ることができますので、以下の2通りを考えます。
（ちなみに shared_context だけでなく shared_examples も引数を取ることができます）

- 引数から値を得る shared_context
- 引数がなく let で値を得る shared_examples

### 引数から値を得る shared_context

- context の文字列を type の値次第で変えて、失敗した時にその箇所がわかるようにしています。
- 第1引数 type が :nil なら title に nil を入れた場合、:empty なら空文字を入れた場合を検証します。
  nil は NG で空文字は OK、という要件にも対応できるよう、引数 expected で nil と空文字のどちらをテストするか指定できるようにしています。
- 第2引数 expected には、期待する結果を true(=valid)/false(=invalid) で指定します。

```ruby
shared_context :presence_validation do |type, expected|
  let(:actual) { build(factory, attribute => value) }

  context (type == :nil ? 'nil' : 'empty') do
    let(:value) { type == :nil ? nil : '' }
    it { expect(actual).to (expected ? be_valid : be_invalid) }
  end
end
```

この shared_context は以下のように使います。

```ruby
let(:factory) { :book }
let(:attribute) { :title }

include_context :presence_validation, :nil, false
include_context :presence_validation, :empty, false
```

### 引数がなく let で値を得る shared_examples

shared_context で引数を取る格好にすると RSpec の DSL とは異質なコードがテストに混在してしまいます。
可能な限り RSpec の DSL だけで完結させられるように shared_examples を以下のように定義しました。

- let(:type) には :nil か :empty かを指定します（意味合いは shared_context の第1引数と同じ）。
- let(:expect) には期待する結果を指定します（shared_context の第2引数と同じ）。

```ruby
shared_examples :presence_example do
  it :validate_presense do
    actual = build(factory, attribute => (type == :nil ? nil : ''))

    if expected
      expect(actual).to be_valid
    else
      expect(actual).to be_invalid
    end
  end
end
```

この shared_examples は以下のように使います。

```ruby
let(:factory) { :book }
let(:attribute) { :title }

describe :presence do
  let(:expected) { false }

  context :nil do
    let(:type) { :nil }
    it_behaves_like :presence_example
  end

  context :empty do
    let(:type) { :empty }
    it_behaves_like :presence_example
  end
end
```

### 修正した検証ロジックをテストとは別のファイルに切り出す

ここまでで定義した shared_context や shared_examples はファイルをまたがって使い回すことはできません。そこでこれを別ファイルに切り出します。
rails_helper.rb にある下記の行を有効にしておいてください。

```ruby
Dir[Rails.root.join('spec', 'support', '**', '*.rb')].each { |f| require f }
```

1. 切り出す先のファイルを作成
  ここでは Rails.root/spec/support/shared_contexts3.rb というファイルに切り出すことにします。
1. shared_context をコピーして以下のように修正
  shared_context を RSpec.shared_context とします。切り出した箇所は元のファイルから削除しておいてください。

  ```ruby
  RSpec.shared_context :presence_validation do |type, expected|
    let(:actual) { build(factory, attribute => value) }

    context (type == :nil ? 'nil' : 'empty') do
      let(:value) { type == :nil ? nil : '' }
      it { expect(actual).to (expected ? be_valid : be_invalid) }
    end
  end
  ```

### 切り出したファイルを元のファイルから読み込む

共用する shared_context があるファイルを `require` して完了です。
切り出した元のファイルだけでなく、別のファイルからでも同じように `require` すれば検証内容がそのまま使えます。

```ruby
require 'support/shared_contexts3'
```

# 最後に

以上、 shared_context や shared_examples を使い回すための方法について、私なりの解法をまとめました。

describe や context の使い分け、shared_context や shared_examples の使い方には様々なバリエーションが考えられると思います。
例えば Relish の [このページ](https://relishapp.com/rspec/rspec-core/v/3-8/docs/example-groups/shared-context) にはメソッドを shared_context に定義して、そのメソッドをテストから呼び出すということもしています。
この記事に書いたのとは違うやり方もあるとか、そのやり方は好ましくないなどがあれば、コメント頂けるととてもありがたいです。