やわらかテック

興味のあること。業務を通して得られた発見。個人的に試してみたことをアウトプットしています🍵

コントローラーの単体テストをファットにしないRspecテストの書き方

Rails製のアプリケーションのコントローラーの単体テストを見ていると内部で使用しているクラスやモジュールの観点・パターンまでを網羅したファットな単体テストが書かれているものを見かけます。しかしながらコントローラーの単体テストはあくまでコントローラーの責務について注目して書かれるべきであって、内部で使用しているクラスやモジュールの観点・パターンにまで踏み込む必要はありません。

ではコントローラーの単体テストで内部で使用しているクラス・モジュールがある場合に、どのように単体テストを書けば良いのでしょうか。 自分がよくやるのは、呼び出し時の引数を確認するという方法です。

サンプルコード

以下のようなRails製のアプリケーションに、データ同期用のコントローラーがあるとします。
このコントローラーでは同期用クラスのインスタンスを作成して、適当なメソッドを呼び出します。

controllers/sync_controller.rb

require 'sync_request'

class SyncController < ApplicationController
  def create
    options = { option_a: true, option_b: false }
    ins = SyncRequest.new(
      permitted_params[:table_name],
      permitted_params[:column_name],
      permitted_params[:value],
      options,
    )

    ins.exec
    render status: :ok, json: {}
  end

  def permitted_params
    params.permit(:table_name, :column_name, :value)
  end
end

lib/sync_request.rb

class SyncRequest
  def initialize(table_name, column_name, value, options)
    @table_name = table_name
    @column_name = column_name
    @value = value
    @options = options
  end

  def exec
    # do something...
    puts 'exec!'
  end
end

ファイルの全体像はgithubにて公開しているので、合わせてご覧ください。

github.com

前提条件について

今回、紹介していく引数を確認する単体テストですが以下の前提の元で採用をしています。

  • クラス・モジュールの関数呼び出しにおいて副作用がないこと
  • 引数によって関数の振る舞いが決定されること

「呼び出し時の引数を確認する」単体テスト

先ほど書いた「呼び出し時の引数を確認する」単体テストについて紹介します。
rspecのallowreceiveのブロックを組み合わせることで、特定の関数が呼び出された時の振る舞いをオーバーライドすることが可能なのですが、このブロックの引数には呼び出し時に指定された値が格納されています。
引数の数だけdo |a, b, c|と書くことができますが、面倒なのでdo |*args|と受け取ると楽です。指定された値が配列で受け取れるので、ここにexpectを書けば、値が想定通りに指定されているかを確認する単体テストが出来上がります。

require 'rails_helper'
require 'sync_request'

RSpec.describe "SyncController", type: :request do
  describe 'POST: /sync' do
    let(:request_params) do
      {
        table_name: 'users',
        column_name: 'name',
        value: '有馬かな',
      }
    end

    context 'SyncRequest' do
      it 'インスタンス作成時に引数に受け取ったパラメーターが正しい順序で指定されること' do
        orignal_method = SyncRequest.method(:new)
        allow(SyncRequest).to receive(:new) do |*args|
          expect(args.size).to eq 4
          expect(args[0]).to eq request_params[:table_name]
          expect(args[1]).to eq request_params[:column_name]
          expect(args[2]).to eq request_params[:value]
          expect(args[3].class).to eq Hash

          orignal_method.call(*args)
        end

        post sync_path, params: request_params
      end
    end
  end
end

補足: orignal_methodについて

allowreceiveで振る舞いをオーバーライドすると、本来の振る舞いが失われるため戻り値が変わってしまったり、インスタンス変数の更新がされなかったり...などの問題が発生します。
今回の場合だとorignal_method.call(*args)を記述しないと、newからSyncRequestクラスのインスタンスが返らないためexecの呼び出しに失敗します。

Failure/Error: ins.exec
  NoMethodError:
    private method `exec' called for true:TrueClass

      ins.exec

戻り値の作成が複雑な時や、newをオーバーライドした状態でブロック内部でSyncRequest.newを実行するとスタックを食い潰してしまったりと考慮しないといけない事が意外と多いです。
なので、汎用的に使えるorignal_method.call(*args)を採用しています。

allow(SyncRequest).to receive(:new) do |*args|
  SyncRequest.new(*args)
end

# Failure/Error: SyncRequest.new(*args)
     
# SystemStackError:
#   stack level too deep

最後に

冒頭でコントローラーの責務外の単体テストをコントローラーの単体テストに書くべきではないと書きましたが、それ以外にもコントローラーの単体テストをファットにしない方が良い理由の1つにパフォーマンスの問題があります。

ライブラリやモジュールの単体テストはシンプルにプログラムを呼び出すだけなので、軽量な処理だと考えることができます。 しかしコントローラーの場合、バックグラウンドでサーバーを立ち上げているかもしれないし、大量のモックを生成しているかもしれません。 そのため、ライブラリやモジュールの単体テストと比べると重い処理であり、一般的にはパフォーマンスが悪いと考えられます。

少しでも「ええな〜」と思ったらはてなスター・はてなブックマーク・シェアを頂けると励みになります。