如何在MiniTest中进行桩测试?

72

在我的测试中,我想为某个类的任何实例存根预设响应。

可能会像这样:

Book.stubs(:title).any_instance().returns("War and Peace")

每当我调用@book.title时,它都会返回“战争与和平”。

在MiniTest中有没有一种方法可以实现这一点?如果有,你能给我一个示例代码片段吗?

还是我需要像mocha这样的东西?

MiniTest确实支持Mocks,但Mocks对我来说有点过头了。

8个回答

47
  # Create a mock object
  book = MiniTest::Mock.new
  # Set the mock to expect :title, return "War and Piece"
  # (note that unless we call book.verify, minitest will
  # not check that :title was called)
  book.expect :title, "War and Piece"

  # Stub Book.new to return the mock object
  # (only within the scope of the block)
  Book.stub :new, book do
    wp = Book.new # returns the mock object
    wp.title      # => "War and Piece"
  end

40
我觉得这个很难读懂,意图不清晰。虽然这更多是对minitest的评论,而不是你的答案。 - Steven Soroka
@StevenSoroka,在我的回答中这里,我详细阐述了panic的答案。 - MarkDBlackwell
@StevenSoroka 同感。我添加了注释,以使不熟悉Minitest语法的人更清楚明白。 - David Moles

29

我使用 minitest 进行所有的 Gems 测试,但是用 mocha 进行所有的 stubs。也许可以使用 minitest 的 Mocks(没有 stubs 或其他任何东西,但是 mocks 很强大)来完成所有的测试,但我发现 mocha 做得非常好。如果有帮助:

require 'mocha'    
Books.any_instance.stubs(:title).returns("War and Peace")

27

如果您希望进行简单的存根(stubbing)而无需使用模拟库,那么在Ruby中很容易实现:

class Book
  def avg_word_count_per_page
    arr = word_counts_per_page
    sum = arr.inject(0) { |s,n| s += n }
    len = arr.size
    sum.to_f / len
  end

  def word_counts_per_page
    # ... perhaps this is super time-consuming ...
  end
end

describe Book do
  describe '#avg_word_count_per_page' do
    it "returns the right thing" do
      book = Book.new
      # a stub is just a redefinition of the method, nothing more
      def book.word_counts_per_page; [1, 3, 5, 4, 8]; end
      book.avg_word_count_per_page.must_equal 4.2
    end
  end
end

如果你想要更复杂的东西,比如模拟一个类的所有实例,那么也很容易做到,只需要稍微有些创造力:

class Book
  def self.find_all_short_and_unread
    repo = BookRepository.new
    repo.find_all_short_and_unread
  end
end

describe Book do
  describe '.find_all_short_unread' do
    before do
      # exploit Ruby's constant lookup mechanism
      # when BookRepository is referenced in Book.find_all_short_and_unread
      # then this class will be used instead of the real BookRepository
      Book.send(:const_set, BookRepository, fake_book_repository_class)
    end

    after do
      # clean up after ourselves so future tests will not be affected
      Book.send(:remove_const, :BookRepository)
    end

    let(:fake_book_repository_class) do
      Class.new(BookRepository)
    end

    it "returns the right thing" do 
      # Stub #initialize instead of .new so we have access to the
      # BookRepository instance
      fake_book_repository_class.send(:define_method, :initialize) do
        super
        def self.find_all_short_and_unread; [:book1, :book2]; end
      end
      Book.find_all_short_and_unread.must_equal [:book1, :book2]
    end
  end
end

7
如果您在测试中重新定义一个方法,那么它会在测试完成后立即恢复原状,还是会保持重新定义的状态直到其他测试调用同一方法时才恢复? - Toby 1 Kenobi

26
你可以在MiniTest中轻松地存根类方法。相关信息可在github上找到。
因此,按照您的示例,并使用Minitest::Spec风格,这是您应该存根方法的方式:
# - RSpec -
Book.stubs(:title).any_instance.returns("War and Peace")

# - MiniTest - #
Book.stub :title, "War and Peace" do
  book = Book.new
  book.title.must_equal "War and Peace"
end

这只是一个非常愚蠢的例子,但至少可以让你知道如何做你想做的事情。我尝试使用Ruby 1.9附带的MiniTest v2.5.1版本进行测试,似乎在该版本中并不支持#stub方法,但是我尝试了MiniTest v3.0,它完美地解决了问题。
祝你好运,并恭喜你使用MiniTest编辑: 还有另一种方法,尽管它看起来有点hackish,但仍然是解决你问题的方法:
klass = Class.new Book do
  define_method(:title) { "War and Peace" }
end

klass.new.title.must_equal "War and Peace"

13
似乎 Book.stub :title, "War and Peace" 只能在 titleBook 的类方法时起作用。我无法复制 any_instance 的相同行为,出现了错误 NameError: undefined method 'title' for Book' - fguillen
4
根据MiniTest的模拟库源代码,stub 操作的是 metaclass = class << self; self; end。因此似乎只能对单例方法进行桩测试;否则请改用完整的模拟对象。 - Michael De Silva
1
在我的Rails应用程序中使用此代码时,我遇到了名称错误。我已经添加了require 'minitest/mock',但仍然出现NoMethodError: undefined method stub'`。 - Tom Rossi
1
@TomRossi 看一下这个:https://github.com/seattlerb/minitest/issues/384 随着1.9.3版本一起发布的minitest版本不包括stub。您需要加载较新版本的minitest/mock。 - ReggieB
你可以使用 Book.stub_any_instance 来替换一个实例方法,而不是使用 Book.stub - Jesse Mignac
显示剩余2条评论

21

为了进一步阐释 @panic的回答,假设你有一个 Book 类:

require 'minitest/mock'
class Book; end

首先,创建一个书籍实例存根,并使其返回您所需的标题(任意次数):

book_instance_stub = Minitest::Mock.new
def book_instance_stub.title
  desired_title = 'War and Peace'
  return_value = desired_title
  return_value
end

然后,在下面的代码块中,让Book类实例化您的Book实例存根(仅且始终如此):

method_to_redefine = :new
return_value = book_instance_stub
Book.stub method_to_redefine, return_value do
  ...

在这个代码块中(仅限此处),Book::new 方法被桩化。让我们试试:

  ...
  some_book = Book.new
  another_book = Book.new
  puts some_book.title #=> "War and Peace"
end

或者,简言之:
require 'minitest/mock'
class Book; end
instance = Minitest::Mock.new
def instance.title() 'War and Peace' end
Book.stub :new, instance do
  book = Book.new
  another_book = Book.new
  puts book.title #=> "War and Peace"
end

如果你愿意,你也可以安装Minitest扩展宝石minitest-stub_any_instance。(注意:使用此方法时,必须存在Book#title方法,然后你才能将其stub。)现在,你可以更简单地说:

require 'minitest/stub_any_instance'
class Book; def title() end end
desired_title = 'War and Peace'
Book.stub_any_instance :title, desired_title do
  book = Book.new
  another_book = Book.new
  puts book.title #=> "War and Peace"
end

如果您想验证Book#title被调用的次数,请执行以下操作:

require 'minitest/mock'
class Book; end

book_instance_stub = Minitest::Mock.new
method = :title
desired_title = 'War and Peace'
return_value = desired_title
number_of_title_invocations = 2
number_of_title_invocations.times do
  book_instance_stub.expect method, return_value
end

method_to_redefine = :new
return_value = book_instance_stub
Book.stub method_to_redefine, return_value do
  some_book = Book.new
  puts some_book.title #=> "War and Peace"
# And again:
  puts some_book.title #=> "War and Peace"
end
book_instance_stub.verify

因此,对于任何特定的实例,在调用存根方法的次数超过指定次数时会引发MockExpectationError: No more expects available异常。

此外,对于任何特定的实例,在调用存根方法的次数少于指定次数时,只有在该实例上调用#verify时,才会引发MockExpectationError: expected title()异常。


18

你不能桩替一个类的所有实例,但你可以像这样桩替给定对象的任何实例方法

你无法桩替一个类的所有实例,但是你可以像这样桩替给定对象的任何实例方法:

require "minitest/mock"

book = Book.new
book.stub(:title, 'War and Peace') do
  assert_equal 'War and Peace', book.title
end

你可以在这里找到文档 => https://www.rubydoc.info/gems/minitest/5.16.3/Object#stub-instance_method - Kevin Robatel

5

您可以在测试代码中创建一个模块,并使用include或extend将其与类或对象进行Monkey-Patch。例如(在book_test.rb中)

module BookStub
  def title
     "War and Peace"
  end
end

现在你可以在你的测试中使用它。
describe 'Book' do
  #change title for all books
  before do
    Book.include BookStub
  end
end

 #or use it in an individual instance
 it 'must be War and Peace' do
   b=Book.new
   b.extend BookStub
   b.title.must_equal 'War and Peace'
 end

这允许您组合比简单的存根更复杂的行为


4
测试应该在结束时撤销所有的存根,以便它们不会影响其他测试。但是,在核心 Ruby 中没有办法取消包含或扩展一个模块。对于 MRI,有一些本地扩展,例如 https://github.com/rosylilly/uninclude。 - Beni Cherniavsky-Paskin

2

我想分享一个基于这里的答案的示例。

我需要在一长串方法的末尾存根一个方法。它始于一个新的PayPal API包装器实例。我需要存根的调用本质上是:

paypal_api = PayPal::API.new
response = paypal_api.make_payment
response.entries[0].details.payment.amount

我创建了一个类,如果方法不是 amount,则返回自身:

paypal_api = Class.new.tap do |c|
  def c.method_missing(method, *_)
    method == :amount ? 1.25 : self
  end
end

然后我将它存根到 PayPal::API 中:

PayPal::API.stub :new, paypal_api do
  get '/paypal_payment', amount: 1.25
  assert_equal 1.25, payments.last.amount
end

您可以通过创建哈希表并返回hash.key?(method) ? hash[method] : self来使其适用于不止一种方法。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接