Testing Network Services in Ruby Is Easier Than You Think

You’ve started a new project and it’s time for your code to depend on a third-party service. It could be something like ElasticSearch, Resque, a billing provider, or just an arbitrary HTTP API. You’re a good developer, so you want this code to be well tested. But how do you test code that fires off requests to a service that’s totally out of your control?

You could skip the tests, but you’ll soon be piling more code on a shaky foundation. Untested code tends to attract more complex code to it, and you’ll eventually feel like the code is too dangerous to refactor because you don’t have the test coverage you need to feel safe. You want to build a stable foundation for your future work, but instead you’ve ended up with an unmaintainable mess.

Avoiding this situation is a lot easier than it seems! With a few tools and a little up-front effort, you can decouple your tests from the services your code depends on, write simpler code, and have the confidence to improve the code you’ve written–without introducing bugs. Instead of procrastinating because you don’t know how to approach writing those next tests, you could look at an interaction between your code and the outside world, and know exactly how to jump right into the middle of it.

Mocha: the quick-and-dirty approach

Mocha is the easiest way to get in between your code and the outside world.

As an example, say you have a Cart object that triggers a credit card charge when it gets checked out. You want to make sure that the cart has an error message attached to it if the charge fails.

You probably won’t want your tests to actually hit the billing system every time the tests run. Even if you did, it might be hard to force that service to return a failure. Here’s what it would look like with Mocha:

def test_error_message_set_on_charge_failure
  cart = Cart.new(items)
  cart.stubs(:charge!).returns(false) # mocha in action
  cart.checkout!
  assert_equal "The credit card could not be charged", cart.credit_card_error
end

Mocha can also fail your tests if methods aren’t called the way you expect them to:

def test_only_bill_once_per_cart
  cart = Cart.new(items)
  cart.expects(:charge!).once # Don't double-bill, no matter how many times we check out
  cart.checkout!
  cart.checkout!
end

Mocha is simple to use, but can be incredibly handy. You have to be careful that you’re mocking out only the behavior you don’t want to happen – it’s easy to mock too much and hide real bugs. You also don’t want to go overboard with this approach: tests full of expects and stubs are hard to read and think about.

Test fakes: my preferred approach

If you mock or stub the same methods on the same objects all the time, you can promote your mocks to full-fledged objects (sometimes called test fakes), like this:

def test_billed_full_amount_minus_discount
  test_payment_provider = TestPaymentProvider.new # A fake payment provider
  cart = Cart.new(items, discount: 30, provider: test_payment_provider)
  cart.checkout!

  assert_equal items.sum(:&price) * 0.7, test_payment_provider.total_charges
end

Fakes are great:

  • Your fake can keep track of its internal state

    The fake can have custom assertion messages and helper functions that make writing your tests easier, like the total_charges method in the example above.

  • As a full-fledged object, you get extra editor and language support

    If you’re using an editor that supports it, you can get autocomplete, inline documentation, and other things you won’t get by stubbing out individual methods with Mocha. You’ll also get better validations, exception handling, and whatever else you want to build into your fake.

  • If you use a fake in development mode, you don’t have to have a connection to the real service

    You can write your app on the bus, you don’t have to have a forest of services running down your laptop’s battery, and you can set these fake services up to return the data you need to work through edge cases without needing a lot of setup.

  • These objects can be used outside of your tests

    This is probably my favorite part of fakes. You can have a logging client log to both a 3rd party service and your fake, backed by an in-memory array. You could then dump the contents of this array in an admin view on your site, making it much easier to verify that you’re logging what you think you’re logging.

You could do something like this:

  fake_backend = FakeBackend.new
  LoggingService.backends = [RealBackend.new, fake_backend]
  LoggingService.debug("TEST MESSAGE PLEASE IGNORE")
  fake_backend.messages.first # => [:debug, "TEST MESSAGE PLEASE IGNORE"]

Writing a fake takes more effort than stubbing out individual methods, but with practice it shouldn’t take more than an hour or two to get a helpful fake built. If you build one that would be useful to other people, share it! I built resque-unit a long time ago, and lots of people still use it today.

How do I get these objects injected, anyway?

You’ll have to get your objects under test to talk to these fakes somehow. Luckily, Ruby is so easy to abuse that injecting fakes usually isn’t hard.

If you control the API of the object under test, it’s best to add a default parameter, an attribute, or a constructor option where you can set your fake:

class Card
  attr_reader :provider
  def initialize(items, options={})
    @provider = options.fetch(:provider) { RealProvider.new }
  end
end

This is clean when you are talking to the real service and gives you a hook to add flexibility later.

If you don’t control the object or don’t want to add the extra parameter, you can always monkey patch:

# if in test mode
Card.class_eval do
  def provider
    @provider ||= TestProvider.new
  end
end

It’s uglier in test, but cleaner in environments that don’t use the fake.

Start building your own fake right now

Building fakes gets easier with practice, so you should give it a try now:

  • Find a test that talks to an external service. Tests that would fail if you disconnected from the internet are good candidates.
  • Figure out what object actually does the communication, and what calls your code makes to that object.
  • Create a mostly empty duplicate of that object’s class, and have it log the calls you make to an array.
  • Add a method to your fake to return the list of calls made.
  • Swap out the real object with your new fake object, and write some assertions against the calls your code makes.

If you give it a try, let me know how it goes!

With these techniques, it won’t be long until you’re able to tame the craziest interactions between your application and the outside world. A simple stub in the right place will let you ship your well-tested code with confidence.

Pushing through tutorials, and still not learning anything?

Have you slogged through the same guide three times and still don't know how to build a real app?

In this free 7-day Rails course, you'll learn specific steps to start your own Rails apps — without giving up, and without being overwhelmed.

You'll also discover the fastest way to learn new Rails features with your 32-page sample of Practicing Rails: Learn Rails Without Being Overwhelmed.

Sign up below to get started:

Powered by ConvertKit

Did you like this article? You should read these:

Comments