- RSpec Essentials
- Mani Tadayon
- 2521字
- 2021-07-09 19:33:38
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.
- C++程序設計(第3版)
- Angular UI Development with PrimeNG
- SQL Server 2016從入門到精通(視頻教學超值版)
- 跟“龍哥”學C語言編程
- 深入淺出Java虛擬機:JVM原理與實戰
- Hadoop+Spark大數據分析實戰
- Java Web基礎與實例教程
- Mastering Ext JS
- 軟件品質之完美管理:實戰經典
- SQL Server實用教程(SQL Server 2008版)
- The Professional ScrumMaster’s Handbook
- 用案例學Java Web整合開發
- Elastix Unified Communications Server Cookbook
- ASP.NET本質論
- Mastering Responsive Web Design