官术网_书友最值得收藏!

Matchers

We've been using RSpec's eq matcher to make assertions so far. We don't absolutely need this or any of RSpec's other matchers. We could use standard Ruby or define our own helper methods, like so:

describe 'no matchers' do
  it "valid? returns false for incomplete address" do
    expected = AddressValidator.valid?(address)
    if expected != false
      # RSpec's fail method allows us to manually fail an example 
      fail "Expected #{expected} to have value of false"
    end
  end
end

There are a few problems with this approach. First, it is clumsy to write and read. Second, without a standard way of handling assertions, we're likely to wind up with a bunch of variations on the code above, making our output confusing. Finally, it is very easy to make mistakes with this kind of code, leading to invalid test results.

RSpec's matchers offer a simple and elegant syntax for making assertions. This makes it easy to write tests and also makes the intent of the test much clearer, allowing us to leverage our tests as documentation for our code.

Built-in matchers

We've used the eq matcher in many of our examples so far. RSpec comes with many other built-in matchers and allows us to define our own custom matchers as well. Some of the more common matchers are listed below (the full list can be found at http://www.relishapp.com/rspec/rspec-expectations/docs/built-in-matchers):

expect([]).to respond_to(:size)
expect([]).to be_empty
expect([].first).to be_nil


expect("foo bar").to match(/^f.+r$/)
expect([1,2]).to include(2)
expect([1,2,3]).to match_array([3,2,1])

Each example above is a successful assertion. We don't absolutely need any matchers except for eq, since we could rewrite any of the preceding assertions to use that instead, for example:

expect([1,2].include?(2)).to eq(true)

The various matchers offer us two main benefits. First, they make the tests much clearer and easier to understand. Second, they generate helpful error messages tailored to the specific situation. For example, compare the output from these two failing assertions:

expect([1,2,3].include?(4)).to eq(true)    
# => expected: true
#         got: false

expect([1,2,3]).to include(4)
# => expected [1, 2, 3] to include 4

The first assertion's error message has no context at all, but the second one, which uses a specialized matcher, tells us exactly what the error is.

We can leverage this and make our testing much more effective by creating our own custom matchers for assertions that we use over and over. We'll learn how to do that in the following section.

Custom matchers

When a complex test assertion is used multiple times, it may be helpful to extract it into a custom matcher that can be reused. Let's say we have the following assertion:

expect(customer.discount_amount_for(product)).to eq(0.1)

This is a bit hard to read and the error message won't provide context. We could make it easier to read, like so:

actual = customer.discount_amount_for(product)
expect(actual).to eq(0.1)

To get a precise error message, we could do the following:

  actual = customer.discount_amount_for(product)
  if actual != 0.1
    fail "Expected discount amount to equal 0.1 not #{actual}"
  end

However, what if we had a bunch of tests that had the same assertion? It would be tedious to redo all this for each test. More importantly, it is likely that each test case will have slight differences that are not easy to spot, allowing errors to slip in. RSpec's custom matchers allow us to encapsulate a custom assertion and error message, allowing us to write the following assertion:

expect(customer).to have_discount_of(0.1).for(product)

To make this work, we'll need to define a custom matcher. Actually, RSpec, by default, creates custom matchers for Boolean methods. For example, if we had the following spec, we could use the have_discount_for assertion:

expect(customer.has_discount_for?(product)).to eq(true)

expect(customer).to have_discount_for(product)

RSpec automatically matches have_discount_for to has_discount_for? by replacing has with have and removing the question mark. So custom matchers are a logical extension for cases where the assertion is more complex. In the case of discount_amount_for, we need to define a matcher that accepts an argument.

Let's work with code for an e-commerce site to find a user's discounts for a product. For example, a user may have earned a special discount by signing up for a promotion plan, or that product could be on sale for all users. We'll use a na?ve implementation that we can run the specs against:

class Customer
  def initialize(opts)
    # assume opts = { discounts: { "a" => 0.1, "b" => 0.2 } }
    @discounts = opts[:discounts]
  end

  def has_discount_for?(product_code)
    @discounts.has_key?(product_code)
  end

  def discount_amount_for(product_code)
    @discounts[product_code] || 0
  end
end

We'd want a lot more from a real implementation, with maximum flexibility for defining simple and complex discounts based on customer attributes, product attributes, or any combination of the two. We'd need additional classes and modules to encapsulate behavior for products and the discounts themselves. The actual method that checks whether a customer has a discount may not be defined on the Customer class at all. It may be defined as Product#discounted_for?(customer) or Discount#valid_for?(product, customer). We will return to the implementation details of the application code at the end of this section. The important thing to note is that we'll be able to use the same specs for different implementations by using custom matchers that can call the right method on the right class. Our goal here it to keep our specs logical and easy to read, regardless of implementation.

Let's start with a simple spec for the discount detection feature that will illustrate the use case for a custom matcher:

describe "product discount" do
  let(:product)      { "foo123"                           }
  let(:discounts)    { { product => 0.1 }                 }
  subject(:customer) { Customer.new(discounts: discounts) }

  it "detects when customer has a discount" do
    actual = customer.discount_amount_for(product)
    expect(actual).to eq(0.1)
  end
end

Because the assertion uses the generic eq matcher, it is hard to read and easy to make mistakes. Also, when an assertion fails, we see a generic error message:

     Failure/Error: expect(actual).to eq(0.1)
       expected: 0.1
            got: 0.2

This does not provide any meaningful context, forcing us to read the spec to figure out what went wrong.

Enhanced context in matcher output

We could write some extra code within the spec to give more context:

describe "product discount" do
  let(:product)      { "foo123"                           }
  let(:discounts)    { { product => 0.1 }                 }
  subject(:customer) { Customer.new(discounts: discounts) }
  it "detects when customer has a discount" do
    actual = customer.discount_amount_for(product)
    if actual != 0.1
      fail "Expected discount amount to equal 0.1 not #{actual}"
    end
  end
end

This results in a better error message, but we had to generate it ourselves. Also, we would have to copy this code every time we wanted to make a similar assertion. There are a number of ways to make this spec easier to understand and maintain. We'll focus on using a custom matcher, which is a good fit for this scenario. Let's start with a very simple custom matcher and enhance it step by step:

require 'rspec/expectations'

RSpec::Matchers.define :be_discounted do |product, expected|
  match do |customer|
    customer.discount_amount_for(product) == discount
  end
end

describe "product discount" do
  let(:product)      { "foo123"                           }
  let(:discounts)    { { product => 0.1 }                 }
  subject(:customer) { Customer.new(discounts: discounts) }

  it "detects when customer has a discount" do
    expect(customer).to be_discounted(product, 0.1)
  end
end

Now the test code is a lot simpler. RSpec now allows us to use the be_discounted matcher just like any of the built-in matchers (for example, eq , eq(true) and have_key).

We use RSpec::Matchers.define to create the custom matcher. The block arguments we pass to RSpec::Matchers.define are name and discount. These are the arguments we will pass to be_discounted. Within this block, we have access to a DSL that will allow us to specify the match criteria as well as other options. We use match with a block whose return value determines whether the assertion will pass (the block returns true) or fail (the block returns false). The block argument (customer in this case) is the value that we pass to expect; that is, the subject about which we are making an assertion.

When the spec fails, the error message will look like this:

 Failure/Error: expect(customer).to be_discounted(product, 0.2)
   expected #<Customer:0x007f9d14df4bd0 @discounts={"foo123"=>0.1}> to be discounted "foo123" and 0.2

This message looks messy, although it does include all of the context information about the customer, product, and discount. We can improve the error message by using failure_message, one of the DSL methods provided by RSpec::Matchers:

RSpec::Matchers.define :be_discounted do |product, discount|
  match do |customer|
    customer.discount_amount_for(product) == discount
  end

  failure_message do |customer|
    actual = customer.discount_amount_for(name)
    "expected #{product} discount of #{discount}, got #{actual}"
  end
end

Now a failing spec will provide us with the relevant info in a clean, organized format:

  Failure/Error: expect(customer).to be_discounted(product, 0.2)
  expected foo123 discount of 0.2, got 0

We can simplify our matcher a bit by using an instance variable to store the actual value in the match block, and referencing it in failure_message:

RSpec::Matchers.define :be_discounted do |product, discount|
  match do |customer|
    @actual = customer.discount_amount_for(product)
    customer.discount_amount_for(product) == discount
  end

  failure_message do |actual|
    "expected #{product} discount of #{discount}, got #{actual}"
  end
end

Notice that failure_message now receives @actual as the block argument. Previously, failure_message received the customer object, but now that we've set an explicit value for @actual in match, it receives that new value instead of customer. Every DSL method in a custom matcher will always be called with the latest value of the @actual instance variable, which we set in this case to the value of the actual discount.

Creating a good custom error message

In this case, using an instance variable just helps us remove a line of duplication. For more complex matchers, however, instance variables can be very helpful in creating a good error message. Let's say we wanted to check for multiple discounts with a single assertion. Our error message would then need to show info for multiple mismatches:

RSpec::Matchers.define :be_discounted do |hsh|
  match do |customer|
    @customer = customer
    @actual   = hsh.keys.inject({}) do |memo, product, _|
      memo[product] = @customer.discount_amount_for(product)
      memo
    end

    differ = RSpec::Expectations::Differ.new

    @difference = differ.diff_as_object(hsh, @actual)
    @difference == "" # blank diff means equality
  end

  failure_message do |actual|
    "Expected #{@customer} to have discounts:\n"  +
    "  #{actual.inspect}.\n"                      +
    "Diff: "                                      +
    @difference
  end
end

To use this matcher, we could write a spec like this:

describe 'discounts' do
  let(:customer) { Customer.new }
  it 'is discounted by some amount' do
    expect(customer).to be_discounted('a', 0.1)
  end
end

Here we use RSpec::Expectations::Differ to give us a message with information on the differences between the actual and expected discounts, which are both instances of Hash. We store a reference to the customer object we pass to expect so that we can reference it in our failure message, which now looks like this:

     Failure/Error: expect(customer).to be_discounted(discounts)
       Expected #<Customer:0x007fc592dcf550> to have discounts:
         {"a"=>0.2, "b"=>0.2, "c"=>0.2}.
       Diff:
       @@ -1,4 +1,4 @@
       -"a" => 0.2,
       -"b" => 0.2,
       -"c" => 0.2
       +"a" => 0.1,
       +"b" => 0.1,
       +"c" => 0.1

We can continue to improve our custom matcher. The assertion syntax we are using is a bit unnatural:

expect(customer).to be_discounted('a', 0.1)

We also used a Hash instead of two arguments:

expect(customer).to be_discounted('a' => 0.1, 'b' => 0.2)

But that was only to help pass in multiple discounts, and is not any more natural. What if we could use a natural syntax in which we specified the product and discount separately? RSpec has a built-in matcher, be_within, that works like this:

expect(1.5).to be_within(0.5).of(1.8)

A nice syntax for our custom matcher would be as follows:

expect(customer).to have_discount_of(0.1).for(product)

This is not hard to achieve. We simply need to define a plain Ruby class with the following methods:

  • matches?
  • failure_message
  • for

We can then use an instance of this class along with a simple helper method to define our custom matcher:

class HaveDiscountOf
  def initialize(expected_discount)
    @expected = expected_discount
  end

  def matches?(customer)
    @actual = customer.discount_amount_for(@product)

    @actual == @expected
  end
  alias == matches? # only for deprecated customer.should syntax

  def for(product)
    @product = product
    self
  end

  def failure_message
    "expected #{@product} discount of #{@expected}, got #{@actual}"
  end
end

describe "product discount" do
  # no need for the RSpec::Matchers.define DSL
  def have_discount_of(discount)
    HaveDiscountOf.new(discount)
  end

  let(:product)      { "foo123"                           }
  let(:discounts)    { { product => 0.1 }                 }
  subject(:customer) { Customer.new(discounts: discounts) }

  it "detects when customer has a discount" do
    expect(customer).to have_discount_of(0.1).for(product)
  end
end

Improving application code

Finally, let's get back to our application code. Now what if we changed our application code so that the actual check for a discount was done in a product class? We can simply alter the match block and keep the same specs:

RSpec::Matchers.define :be_discounted do |product, discount|
  match do |customer|
    product.discount_amount_for(customer) == discount
  end
end

describe "product discount" do
  let(:product)      { "foo123"                           }
  let(:discounts)    { { product => 0.1 }                 }
  subject(:customer) { Customer.new(discounts: discounts) }
  it "detects when customer has a discount" do
    expect(customer).to be_discounted(product, 0.1)
  end
end

Actually, the implementation is arbitrary as long as our specs pass all the required information. We could encapsulate the entire check in a Discount class, perhaps following the Data, Context and Interaction (DCI) pattern (see http://www.artima.com/articles/dci_vision.html), like so:

RSpec::Matchers.define :be_discounted do |product, expected|
  match do |customer|
    @discount = Discount.find(
      product:  product, 
      customer: customer
    )
    @discount.amount == expected
  end
end
The above would require some changes to our spec setup, but the actual assertion would be the same:

describe "product discount" do
  let(:product)      { "foo123"     }
  let(:amount)       {  0.1         }
  subject(:customer) { Customer.new }

  before do
    Discount.create(
      product:  product,
      customer: customer,
      amount:   amount
    )
  end

  it "detects when customer has a discount" do
    expect(customer).to be_discounted(product, amount)
  end
end

Putting it all together, we can not only improve our specs, but improve our application code so that the Customer class is orthogonal to (that is, has no knowledge of) the Discount class:

class Customer
  # ... no discount info here!
end

class Discount
  attr_reader :amount, :customer, :product

  def initialize(opts={})
    @customer = opts[:customer]
    @product  = opts[:product]
    @amount   = opts[:amount]
  end

  STORE = []
  class << self
    def create(opts={})
      STORE << self.new(opts)
    end

    def find(opts={})
      STORE.select do |discount|
        opts.each do |k, v|
          discount.send(k) == v
        end
      end.first
    end
  end
end

Our final specs have only slightly changed and are clearer:

class HaveDiscountOf
  def initialize(expected_discount)
    @expected = expected_discount
  end

  def matches?(customer)
    @actual = Discount.find(product: @product, customer: customer)
    @amt    = @actual && @actual.amount
    @amt == @expected
  end
  alias == matches? # only for deprecated customer.should syntax

  def for(product)
    @product = product
    self
  end

  def failure_message
    if @actual
      "Expected #{@product} discount of #{@expected}, got #{@amt}"
    else
      "#{@customer} has no discount for #{@product}"
    end
  end
end

describe "product discount" do
  def have_discount_of(discount)
    HaveDiscountOf.new(discount)
  end

  let(:product)      { "foo123"     }
  let(:amount)       {  0.1         }
  subject(:customer) { Customer.new }

  before do
    Discount.create(
      product:  product,
      customer: customer,
      amount:   amount
    )
  end

  it "detects when customer has a discount" do
    expect(customer).to have_discount_of(amount).for(product)
  end
end

We've developed some sophisticated custom matchers. However, we should keep in mind that this level of effort is not usually worth the benefit we get. The simpler custom matchers we started with will be seen much more often. Only when our application code is complex does it make sense to start considering sophisticated custom matchers like this, which, as we have just seen, not only ease our testing effort, but contribute to improving the modularity of our code.

So far, all of our tests have been testing success or failure. We haven't dealt at all with errors, which are extremely important to test for in any application.

主站蜘蛛池模板: 廊坊市| 龙江县| 新余市| 奎屯市| 中西区| 嘉定区| 神木县| 阿坝县| 凤凰县| 开封市| 运城市| 孟连| 布尔津县| 涪陵区| 冷水江市| 平利县| 双江| 团风县| 河东区| 历史| 馆陶县| 灵寿县| 青浦区| 长治市| 皋兰县| 周宁县| 安化县| 乐陵市| 东台市| 来安县| 习水县| 新绛县| 辉南县| 江城| 肃宁县| 江山市| 分宜县| 内丘县| 和田县| 涡阳县| 明水县|