RSpecを使用して、クラスインスタンスのインスタンス変数/アクセサをモックアウトします。

Aug 20 2020

RailsアプリケーションにはサードパーティのAPI(Thriftを使用)があり、同じインスタンスからデータを取得してそのデータをインスタンス変数/アクセサーに追加する複数のメソッドを使用できるクラスでラップします。

たとえば、次のBookManagerようなクラスがあります。

class BookManager
  attr_accessor :token, :books, :scope, :total_count

  def initialize(token, scope, attrs={})
    @token = token
    @scope = scope
    @books = []
    @total_count = 0
  end

  # find all books
  def find_books
    @books = API.find_books(@token, @scope)
    @total_count = @books.count
  
    self
  end

  # find a single book by book_id
  def find_book_by_id(book_id)
    @books = API.find_book_by_id(@token, @scope, book_id)

    self
  end

  # find a single book by author_id
  def find_book_by_author_id(author_id)
    @books = API.find_book_by_author_id(@token, @scope, author_id)

    self
  end
end

そこでここでは、書籍のリスト、または単一の本を得ることができbook_idたりauthor_idして、APIは、これらの本を持っていますデータと私たちのクラスのインスタンスを返します。

このクラスがこのように構築される主な理由は、APIがデータの各エンティティのエンドポイントで設計されており、データセット全体を取得するために複数のメソッドを使用する必要があるためです。たとえば、本の著者を取得する場合などです。次のような方法を使用します。

def with_authors(&block)
  books.each do |book|
    book.author = API.find_author_by_id(@token, @scope, book.author_id, &block)
  end

  self
end

このクラスは、次のようにアプリケーションで使用されます。

book_manger = BookManager.new(current_user.token, params[:scope])
                         .find_book_by_id(params[:id])
@book = book_manger.books.first

または、作成者も必要な場合は、メソッドをチェーンします。

book_manger = BookManager.new(current_user.token, params[:scope])
                         .find_book_by_id(params[:id])
                         .with_authors
@book = book_manger.books.first

そして、次のようにデータにアクセスできます。

@book.book_name
@book.author.author_name

うまくいけば、これは今まですべて理にかなっています...


そのため、アプリのRSpecテストを作成するときはBookManager、実際のAPIを呼び出さないようにこれをモックアウトする必要があります。

たとえば、ここでは2冊の本を作成し、find_book_by_idメソッドが呼び出されたときに本を(本を中に入れて)返すようにRSpecに指示しました。

book = double('book', book_id: 1, book_name: 'Book Name')
books = double('books', books: [book])
allow_any_instance_of(BookManager).to receive(:find_book_by_id).and_return(books)

ただし、booksアクセサは常にデフォルト値のを返す[]ため@books、モックを使用してクラスインスタンス内を実際に設定しているわけではありません。

代わりに、API自体をモックする必要がありました。

book = double('book', book_id: 1, book_name: 'Book Name')
books = double('books', books: [book])
allow(API).to receive(:find_book_by_id).and_return(books)

これにより、BookManager...を使用できるようになります。これは、モックが必要なAPIであるため、より良い方法であると言えます...しかし、他のクラスのいくつかには、ネストされたAPIメソッドが多数あり、モックをより単純に保つことを望んでいました。以下のネストされたメソッドではなく、コードで使用されているクラスのみをモックします...どうすればそれができるのかも興味があります!

BookManagerメソッドをモックしたため、のモックは期待どおりに機能しないと思います(この場合、find_book_by_id) which is what actual sets @ and therefore the accessor/instance variable is always empty... so in this particular case, usingbooks .and_return(books) `は実際には本を返しません...

私がする必要があるのは、単にではなくそのクラスのインスタンスを返すbooksことのようですが、RSpecモックを使用してそれをどのように行うのか正確にはわかりません。

回答

1 aridlehoover Aug 21 2020 at 13:57

試したスタブが機能しない理由については正しいです。インスタンス変数を設定するメソッドをモックしているので、attr_accessorを介してインスタンス変数にアクセスするときはいつでも、のモックされた戻り値ではなく、初期化された値を取得しますfind_books_by_id

また、APIをモックしないという本能も正しいです。を使用するコードをテストすることが目標である場合は、従属オブジェクトではなくBookManagerBookManagerインターフェースをモック/スタブする必要があります。実際、テストではBookManager、状態を維持するかどうかなど、の内部構造について何も知らないはずです。それはデメテルの法則の違反になります。

ただし、テストではBookManagerbooksattr_accessorを含むのパブリックインターフェイスについては知っています。問題の解決策は、それをスタブ化し、他のすべてのメソッドをnullオブジェクトでモックすることです。

このような:

let(:book_manager) { double(BookManager).as_null_object }
let(:book) { double('book', book_id: 1, book_name: 'Book Name') }
let(:books) { [book] }

before do
  allow(BookManager).to receive(:new).and_return(book_manager)
  allow(book_manager).to receive(:books).and_return(books)
end

さて、への呼び出しfind_book_by_idwith_authors、あなたのメソッドチェーンで完全に動作している実行し、(基本的に、自己)ヌルオブジェクトを返します。また、のように、気になるメソッドだけをスタブ化できますbooks

さらに、使用しないことでボーナスポイントを獲得できます。allow_any_instance_ofこれは、最も厄介なレガシーコードのテスト用に予約する必要があります。

ドキュメント: https://relishapp.com/rspec/rspec-mocks/docs/basics/null-object-doubles