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를 호출하지 않도록 이를 조롱하고 싶습니다 .

예를 들어 여기에서는 책의 이중을 만들고 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내 mock을 사용하여 클래스 인스턴스 내부를 설정하지 않는다는 것 입니다.

대신 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 @books and therefore the accessor/instance variable is always empty... so in this particular case, using.and_return (books)`는 실제로 책을 반환하지 않습니다 ...

내가해야 할 일은 해당 클래스의 인스턴스를 반환하는 것이 아니라 booksRSpec 모의를 사용하여 정확히 어떻게 할 것인지 잘 모르겠습니다.

답변

1 aridlehoover Aug 21 2020 at 13:57

시도한 스텁이 작동하지 않는 이유에 대해 정확합니다. 인스턴스 변수를 설정하는 메서드를 모의하기 때문에를 통해 인스턴스 변수에 액세스 할 때마다 모의 attr_accessor반환 값이 아닌 초기화 된 값을 얻게 find_books_by_id됩니다.

API를 조롱하지 않는 본능도 정확합니다. 를 사용하는 코드를 테스트하는 것이 목표 인 경우 종속 개체가 아닌 인터페이스를 BookManager모의 / 스텁해야 BookManager합니다. 실제로 테스트는 BookManager상태를 유지하는지 여부를 포함하여 의 내부 구조에 대해 알면 안됩니다 . 그것은 데메테르의 법칙을 위반하는 것입니다.

그러나 테스트는 attr_accessor를 BookManager포함하여 의 공용 인터페이스에 대해 알고 books있습니다. 문제에 대한 해결책은 그것을 스텁하고 다른 모든 메소드를 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

이제 메서드 체인과 완벽하게 작동하는 null 개체 (본질적으로 self)를 호출 find_book_by_id하고 with_authors실행하고 반환합니다. 그리고 .NET과 같이 관심있는 메서드 만 스텁 할 수 있습니다 books.

또한 allow_any_instance_of가장 까다로운 레거시 코드를 테스트하기 위해 예약해야하는 사용하지 않으면 보너스 포인트를 받게 됩니다.

문서 : https://relishapp.com/rspec/rspec-mocks/docs/basics/null-object-doubles