Макет переменной / средства доступа экземпляра для экземпляра класса с помощью 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.

Например, здесь я создал двойники книг и сказал RSpec возвращать книги (с книгой внутри) при find_book_by_idвызове метода.

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, usingобразом, потому что я издевался над методом (в данном случае @books .and_return (books) `на самом деле не возвращает книги ...

Кажется, что мне нужно сделать, так это вернуть экземпляр этого класса, а не просто, booksно я не уверен, как именно я это сделаю, используя макеты RSpec.

Ответы

1 aridlehoover Aug 21 2020 at 13:57

Вы правы в том, почему заглушка, которую вы пробовали, не работает. Поскольку вы имитируете метод, который устанавливает переменную экземпляра, каждый раз, когда вы обращаетесь к переменной экземпляра через метод, attr_accessorвы получите инициализированное значение, а не имитируемое возвращаемое значение find_books_by_id.

Вы также правы в своем чутье и не высмеиваете API. Если ваша цель - протестировать код, который использует. BookManager, То вам следует имитировать / заглушить BookManagerинтерфейс, а не его подчиненные объекты. Фактически, ваши тесты не должны ничего знать о внутренней структуре BookManager, в том числе о том, поддерживает ли она состояние. Это было бы нарушением Закона Деметры.

Но ваши тесты знают об общедоступном интерфейсе BookManager, включая booksattr_accessor. Решение вашей проблемы - заглушить это и издеваться над всеми другими методами с помощью нулевого объекта.

Как это:

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_idи with_authorsбудут выполнять и возвращать нулевой объект (по сути, self), который отлично работает с цепочкой ваших методов. И вы можете заглушить только те методы, которые вам нравятся, например books.

Кроме того, вы получите бонусные баллы за неиспользование, allow_any_instance_ofкоторые следует зарезервировать для тестирования самого сложного из устаревшего кода.

Документы: https://relishapp.com/rspec/rspec-mocks/docs/basics/null-object-doubles