|
I recently helped a friend use FlexMock to do some testing on code
that was written ot use the Google4R checkout APIs. I thought it might be
interesting to share some of the details here. Note that this code uses the
recently released FlexMock version 0.6.0.
Google4R is a simple Ruby wrapper around the Google APIs. In this extended
example, we will use FlexMock to test software that uses the Google APIs,
without every communicating with Google itself.
Purchase.rb
Here is the bit of code that we will be testing…
require 'google4r/checkout'
require 'item'
class Purchase
def initialize(config)
@frontend = Frontend.new(config)
@frontend.tax_table_factory = TaxTableFactory.new
end
# Purchase the +quantity+ items identified by +item_id+. Return the
# confirmation page URL.
def purchase(item_id, quantity=1)
item = Item.find(item_id)
checkout = @frontend.create_checkout_command
checkout.cart.create_item do |cart_item|
cart_item.name = item.name
cart_item.description = item.description
cart_item.unit_price = item.unit_price
cart_item.quantity = quantity
end
response = checkout.send_to_google_checkout
response.redirect_url
end
end
FrontEnd is a Google4R class that provides a lot of the front end
work for talking to the Google APIs. The config object given to the
Purchase initializer is simply a hash of values defining the merchant_id,
merchant_key and sandbox flag. To use the real Google checkout APIs, you
will need to obtains a merchant id and key from Google. Since we will be
mocking the Google interaction, we can use dummy values in our test.
The tax table factory is required by the Google4R software. We provide the
following simplified one. Read the Google API documents for more
information.
class TestTaxTableFactory
def effective_tax_tables_at(time)
tax_free_table = TaxTable.new(false)
tax_free_table.name = "default table"
tax_free_table.create_rule do |rule|
rule.area = UsCountryArea.new(UsCountryArea::ALL)
rule.rate = 0.0
end
return [tax_free_table]
end
end
Item is simply an ActiveRecord class that we are using to hold our
purchase item information. It should respond to the name,
description and unit_price messages.
Testing Without Using External Resources
Our first test attempt will be to run the purchase method without
talking to either the live Google web services, or hitting an actual
ActiveRecord database.
Mocking Active Record
The ActiveRecord part is easy to mock. The following will handle it:
flexmock(Item).should_receive(:find).with(1).and_return(
flexmock("guitar",
:name => "Deschutes",
:description => "Deschutes model Guitar",
:unit_price => Money.new(2400.00)))
We have mocked out the find method on Item so that
whenever we call find with an integer argument of 1, we will return a mock
item that will report its name, description and unit_price. This gives us
an item for testing without actually reading the database.
Mocking the Google Web Services Call
Next we want to prevent the Google4R API from actually talking to the live
web service. Everything that happens in the purchase method is all done
locally except for the final call to send_to_google_checkout. All
we need to do is mock out that one method.
flexmock(Google4R::Checkout::CheckoutCommand).new_instances do |instance|
instance.should_receive(:send_to_google_checkout).once.
and_return(flexmock(:redirect_url => "http://google.response.url"))
end
When we ask FrontEnd to create a check out command, it returns an
instance of Google4R::Checkout::CheckoutCommand. We then use
flexmock to specify that when Google4R::Checkout::CheckoutCommand creates a
new instance, it should actually return a partial mock of that instance.
The block given to the new_instances method allows us to configure
the mocked checkout command. We tell it return a response object (yes,
another mock) that report our dummy response URL.
The Final Result
Here is the complete unit test:
def test_buying_a_guitar
# Setup
flexmock(Item).should_receive(:find).with(1).and_return(
flexmock("guitar",
:name => "Deschutes",
:description => "Deschutes model Guitar",
:unit_price => Money.new(2400.00)))
flexmock(Google4R::Checkout::CheckoutCommand).new_instances do |instance|
instance.should_receive(:send_to_google_checkout).once.
and_return(flexmock(:redirect_url => "http://google.response.url"))
end
# Execute
p = Purchase.new({
:merchant_id => 'dummy_id',
:merchant_key => 'dummy_key',
:use_sandbox => true })
url = p.purchase(1)
# Assert
assert_equal "http://google.response.url", url
end
Testing the Details
The above test is fine as far as it goes. It demonstrates how to use mocks
to avoid talking to external resources such as databases and web services.
But as a unit test, it is sorely lacking in several areas.
All the test really demonstrates is that the
send_to_google_checkout method is called. There are no tests to
ensure that the right item descriptions and prices are correctly stored in
the cart. In fact, if we rewrote the purchase method as follows:
def purchase(item_id, quantity=1)
@frontend.create_checkout_command.send_to_google_checkout.redirect_url
end
it would still pass the unit test we designed, even though the rewrite is
obviously an incorrect implementation.
A more complete test is a bit more complicated. Here are the details.
Mocking Active Record
Our incorrect version of purchase never calls the find method of
Item. We can easily test for that by adding a once constraint one
that mock specification. Since find is a read-only method, we don’t
really care if it is called multiple times, as long as it is called at
least one time, so we will add an at_least modifier as well.
Finally, we are going to break the guitar mock out into its own
declaration. The reason will become obvious in a bit.
mock_guitar = flexmock("guitar",
:name => "Deschutes",
:description => "Deschutes model guitar",
:unit_price => Money.new(2400.00))
flexmock(Item).should_receive(:find).with(1).at_least.once.
and_return(mock_guitar)
Mocking a Cart Item
The next bit is a wee bit complicated, but we will handle it a little bit
at a time so that it doesn’t become overwhelming.
There are three main objects in the Google checkout API that we deal with
in the next section.: (1) the checkout command object returned by the front
end, (2) the cart object returned by the checkout command, and (3) the item
passed to the block in the create_item call.
We will tackle them in reverse order, starting with the item objects given
to the create_item block. The item must respond to four attribute
assignments. This is straightforward to mock, just make sure you include
the once constraint so that the assignments are required.
mock_item = flexmock("item")
mock_item.should_receive(:name=).with(mock_guitar.name).once
mock_item.should_receive(:description=).with(mock_guitar.description).once
mock_item.should_receive(:unit_price=).with(mock_guitar.unit_price).once
mock_item.should_receive(:quantity=).with(1).once
Notice how we used the mock_guitar object defined earlier to provide values
in the with constraint. This way we don’t have to repeat the
explicit strings and values we are checking. (Keep it DRY!).
Mocking the Cart
The mock cart object will pass the mock_item to a block when the
create_item method is called. We specify that with the following:
mock_cart = flexmock("cart")
mock_cart.should_receive(:create_item).with(Proc).once.and_return { |block|
block.call(mock_item)
}
FlexMock objects can handle blocks passed to them by treating them as the
final object in the calling list. Use Proc in the with
constraint to match the block and then invoke the block explicitly via
block.call(…) in the and_return specification.
Mocking the Checkout Command
Finally, we tie it all together by mocking the checkout command. As before,
we use new_instances to force newly created checkout commands to
be stubbed. This time we not only mockout the send_to_google
method, but we also mock the cart command to return the carefully
crafted mock_cart object from the previous section.
flexmock(Google4R::Checkout::CheckoutCommand).new_instances do |instance|
instance.should_receive(:cart).with().once.and_return(mock_cart)
instance.should_receive(:send_to_google_checkout).once.
and_return(flexmock(:redirect_url => "http://google.response.url"))
end
The Final Test Method
Here is the complete detailed version of the test method.
def test_buying_a_guitar_with_details
# Setup
mock_guitar = flexmock("guitar",
:name => "Deschutes",
:description => "Deschutes model guitar",
:unit_price => Money.new(2400.00))
flexmock(Item).should_receive(:find).with(1).at_least.once.
and_return(mock_guitar)
mock_item = flexmock("item")
mock_item.should_receive(:name=).with(mock_guitar.name).once
mock_item.should_receive(:description=).with(mock_guitar.description).once
mock_item.should_receive(:unit_price=).with(mock_guitar.unit_price).once
mock_item.should_receive(:quantity=).with(1).once
mock_cart = flexmock("cart")
mock_cart.should_receive(:create_item).with(Proc).once.and_return { |block|
block.call(mock_item)
}
flexmock(Google4R::Checkout::CheckoutCommand).new_instances do |instance|
instance.should_receive(:cart).with().once.and_return(mock_cart)
instance.should_receive(:send_to_google_checkout).once.
and_return(flexmock(:redirect_url => "http://google.response.url"))
end
# Execute
p = Purchase.new({
:merchant_id => 'dummy_id',
:merchant_key => 'dummy_key',
:use_sandbox => true })
url = p.purchase(1)
# Assert
assert_equal "http://google.response.url", url
end
Summary
Testing with mock objects can get complex. We used seven different mock or
partial mock objects in testing the interaction of our code with the Google
checkout API. Most testing scenarios won’t require that many, but
anytime your code touches something external, it might require a mock
object for testing.
We should stop and ask ourselves: was it worth it? It seems like an awful
lot of work just to test a very simple purchase method. Wouldn’t it
just be easier to just use the Google API directly for testing and forget
about the mocks?
Perhaps, but using mock objects have several definite advantages:
Some might point out that in the final test method we are hardly using
Google4R software at all, most of the code we interact with are mock
objects. Doesn’t that defeat the purpose of testing?
The answer is simple. Always keep in mind what you are testing. The goal of
the TestPurchase test case is not the make sure the Google4R code is
correct, but that our Purchase class correctly interoperates with it. We do
that by carefully stating what methods are called with what arguments and
what they return. The test just checks that we are using to external
software as we expect it to. We don’t actually care about the
Google4R software itself in this test case (presumably we do have tests
that cover Google4R, but those are different tests).
In the end, mock objects are a power tool to have in your testing toolbox.
|