使用 RSpec 为 class 实例模拟实例 variable/accessor

Mock out instance variable/accessor for a class instance using RSpec

在我们的 Rails 应用程序中,我们有一个第三方 API(使用 Thrift),我们用 classes 包装它可以使用多种方法从同一实例检索数据然后将该数据添加到实例 variable/accessor.

例如我们有一个 BookManager class 像这样:

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_idauthor_id 获取书籍列表或单本书,然后 API 将 return 数据和我们的 class实例会有这些书。

这个class是这样构建的主要原因是因为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

class 在我们的应用中是这样使用的:

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 告诉 return 书(里面有书)。

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 访问器总是 returns 它的默认值 [],所以它实际上并没有在 @books 中设置 class 使用我的模拟实例。

相反,我不得不模拟 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... 但我们的其他一些 classes 有很多嵌套的 API 方法,我希望保持模拟更简单,只模拟代码中使用的 classes 而不是下面的嵌套方法...... 我也很好奇我是怎么做到的!

我假设 BookManager 的模拟没有按预期工作,因为我已经模拟了方法(在这种情况下 find_book_by_id) which is what actual sets @booksand therefore the accessor/instance variable is always empty... so in this particular case, using.and_return(books)` 实际上 return 书...

似乎我需要做的是 return 那个 class 的实例而不仅仅是 books 但我不确定我将如何使用 RSpec 嘲笑。

关于为什么您尝试过的存根不起作用,您是正确的。由于您正在模拟设置实例变量的方法,因此每当您通过 attr_accessor 访问实例变量时,您将获得初始化值而不是 [=12= 的模拟 return 值].

你不嘲笑 API 的直觉也是正确的。如果您的目标是测试使用 BookManager 的代码,那么您应该 mock/stub BookManager 接口而不是它的从属对象。事实上,您的测试不应该知道 BookManager 的内部结构,包括它是否保持状态。那将违反得墨忒耳法则。

但是,您的测试确实知道 BookManager 的 public 接口,包括 books attr_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_idwith_authors 的调用将执行,并且 return 空对象(本质上是自我)与您的方法链完美配合。而且,你可以只存根你关心的方法,比如 books.

此外,您将因不使用 allow_any_instance_of 而获得奖励积分,这应该保留用于测试最棘手的遗留代码。

文档:https://relishapp.com/rspec/rspec-mocks/docs/basics/null-object-doubles