【Rails】Rspecでスタブを使用したテストを書く

## はじめに
Rspecで初めてスタブを使用したテストを書いたのでメモとして残します。

## 実現したいこと
メモに紐づくコメントの削除に失敗したときのテストをしたい
```Ruby
  def destroy
    memo = Memo.find(params[:memo_id])
    comment = memo.comments.find(params[:id])
    if comment.destroy
      head :no_content
    else
      render json: { errors: comment.errors.full_messages }, status: :unprocessable_entity
    end
  end
```

存在しないメモで404を返すケースなどは、idを0のコメントに対してリクエストを送るなどすれば良いが、422のケースはどうすれば良いのか？

## スタブを使う
#### スタブとは？
> 「スタブ」とは、テスト対象から見た下位モジュール(呼び出される側)に成り代わる中身をもたないテスト専用のダミー部品です。

https://service.shiftinc.jp/column/8057/

簡単にいうと、テストに使用するダミー部品(メソッドorモジュール)のことのようです。

今回で言うと
commentオブジェクトのdestroyメソッドをスタブ(ダミー部品)にすることで実際の処理は行わず、失敗した時の戻り値である「False」を返すダミー部品に置き換えることで失敗時のテストを行えるようにしたいのです。

## Rspecでスタブを作成するには？
以下のメソッドを使用する

### 引用元(Rspec公式)
https://rspec.info/documentation/3.7/rspec-mocks/RSpec/Mocks/ExampleMethods.html#allow-instance_method

#### allowメソッド

> - (Object) allow
Note: If you disable the :expect syntax this method will be undefined.
Used to wrap an object in preparation for stubbing a method on it.

allowメソッドは、オブジェクトに対して特定のメソッドをスタブ（モック）する準備をするために使用する。
スタブすることで、テスト中にそのメソッドが呼ばれたときに、実際の処理をせずに指定した動作を返すようにする

注意書きにあるようにexpect構文が有効でないと使用できないようです

引数にはスタブにしたいオブジェクトを渡します。
```Ruby
#メモオブジェクトをスタブしたい場合
allow(Memo)
```

#### receiveメソッド
> - (Object) receive
Used to specify a message that you expect or allow an object to receive. The object returned by receive supports the same fluent interface that should_receive and stub have always supported, allowing you to constrain the arguments or number of times, and configure how the object should respond to the message.

receiveメソッドは、引数に渡したメソッドの呼び出しに対して、どういう振る舞いをするかを設定できるオブジェクトが返されます。

##### 例
```Ruby
#findメソッドの振る舞いを指定したい場合
allow(Memo).to receive(:find)
```


#### and_returnメソッド
> - (nil) and_return(value)
> - (nil) and_return(first_value, second_value)
Tells the object to return a value when it receives the message. Given more than one value, the first value is returned the first time the message is received, the second value is returned the next time, etc, etc.
If the message is received more times than there are values, the last value is received for every subsequent call.

and_returnメソッドはreceiveメソッドで返されたオブジェクトから呼び出します。
対象のメソッドが呼ばれたときに返す値を設定するために使います。
返す値は引数で指定します。
複数指定することもできて、複数指定した場合、1回目の実行では第1引数の値、2回目の実行では第2引数の値が返されます。指定した数より多く呼び出された場合、一番後ろの引数の値が返され続けます。

##### 例
```Ruby
#findメソッドの戻り値を指定する場合
receive(:find).and_return(memo)
```

#### 引用元2(Rspec公式)
戻り値以外にも例外を返すようにするなど様々なメソッドが用意されていました

https://rspec.info/documentation/3.5/rspec-mocks/RSpec/Mocks/MessageExpectation.html

## 実際に使ってみる
コメントオブジェクトのdestroyアクションに対してfalseを返すようにしてみる

```Ruby
describe 'DELETE /memos/:memo_id/comments/:id' do
  context 'コメントの削除に失敗した場合' do
    let!(:memo) { create(:memo) }
    let!(:comment) { create(:comment, memo: memo) }

    before do
      allow(comment).to receive(:destroy).and_return(false)
    end

    it '422が返ることを確認する' do
      aggregate_failures do
        expect do
          delete "/memos/#{memo.id}/comments/#{comment.id}", as: :json
        end.not_to change(Comment, :count)
        expect(response).to have_http_status(:unprocessable_entity)
      end
    end
  end
end
```

## 結果
何かうまくスタブされてませんでした・・・
普通にcountが1減って、204が返却されて削除が成功していることがわかります。

```Ruby
     1.1) Failure/Error:
            expect do
              delete "/memos/#{memo.id}/comments/#{comment.id}", as: :json
            end.not_to change(Comment, :count)

            expected `Comment.count` not to have changed, but did change from 1 to 0
          # ./spec/requests/comments_spec.rb:90:in `block (5 levels) in <top (required)>'

     1.2) Failure/Error: expect(response).to have_http_status(:unprocessable_entity)
            expected the response to have status code :unprocessable_entity (422) but it was :no_content (204)
          # ./spec/requests/comments_spec.rb:93:in `block (5 levels) in <top (required)>'
```

## 原因
別のcommentオブジェクトに対してスタブされているため、正常に動作しなかったようです。
コントローラの処理に合わせて正しくスタブを設定する必要があります。

### 具体的にどういうことか？

#### コントローラの処理
```Ruby
  def destroy
    memo = Memo.find(params[:memo_id])
    comment = memo.comments.find(params[:id])
    if comment.destroy
      head :no_content
    else
      render json: { errors: comment.errors.full_messages }, status: :unprocessable_entity
    end
  end
```
destroyアクションの処理は以下の通り
1. メモクラスのfindメソッドを呼び出す
1. 取得したmemoオブジェクトのcommentsオブジェクトのfindメソッドを呼び出す
1. 上記で取得したcommentオブジェクトのdestroyメソッドを呼び出す

最後のcommentオブジェクトのみスタブすれば良いと思ってましたが、特定のオブジェクトに対して正常にスタブを設定するには、1から順番に行う必要がありました。
理由としては、テストの中でdestroyメソッドに渡されるcommentオブジェクトが、Memo.findやmemo.comments.findの結果であることを保証するためです

before句を以下のように変更
```Ruby
      before do
      #1のMemoクラスのfindメソッドの戻り値をletで宣言したmemoオブジェクトにする
        allow(Memo).to receive(:find).and_return(memo)
      #2のmemo.commentsオブジェクトのfindメソッドの戻り値をletで宣言したcommentオブジェクトにする  
        allow(memo.comments).to receive(:find).and_return(comment)
      #3のcommentオブジェクトのdestroyメソッドの戻り値をfalseにする  
        allow(comment).to receive(:destroy).and_return(false)
      end
```

このようにスタブして返却されたオブジェクトを使用して設定していくことで特定のオブジェクトに対して行うことができました。

## もう一度実行
```Ruby

CommentsController
  DELETE /memos/:memo_id/comments/:id
    コメントが存在する場合
      コメントが削除され、204になる
    コメントが存在しない場合
      404が返ることを確認する
    メモが存在しない場合
      404が返ることを確認する
    コメントの削除に失敗した場合
      422が返ることを確認する
Finished in 0.55512 seconds (files took 1.18 seconds to load)
33 examples, 0 failures

Coverage Report
======================
Lines: 57/57 (100.0%)
Branch coverage: 8 / 8 branches (100.0%) covered.
```

無事スタブを使用して失敗時のテストを行うことができました