faster tests with VCR gem in Ruby

Speed Up Your Tests Using VCR Gem in Ruby

HTTP API requests can be slow. They're especially slow if you have to make them repeatedly in your tests. The VCR gem in Ruby speeds up your tests by recording the response the first time and replaying the same response for future runs. Let's understand how it works and how to use it.

5 min read

I am currently working on a client project where the application needs to sync data between two content management systems using their respective APIs (no UI involved).

As you can imagine, there're a lot of API calls and the data needs a lot of massaging after fetching before it's ready to be synced with the other CRM.

Here's the general pattern that's repeated everywhere in the codebase.

class Entity
  def map_data
    resources = Net::HTTP.get("example.com/api/v2/resources")
    
    # lots of data manipulation, JSON / XML Parsing, 
    # mapping, and  conversion logic goes here 
    # to build the final result that will be 'push'ed
    # to the second API.
    
    result
  end
end

Since there is a ton of data manipulation involved, I am writing a ton of tests. This is first project in very long time where I am following test-driven development (and I have to say, I'm really enjoying it!). Having a solid backup of tests really gives you the confidence to do a lot of sharp refactorings without constantly worrying if they broke something in some unrelated part of the codebase.

However, writing tests with each invocation making an API call can be really slow. To fix this, I started using the stub method in Minitest to fake the API calls. However, that meant creating lots and lots of dummy data manually that's in the same format as returned from the external API.

Since the data returned can be very huge, the fake data methods very quickly grew into data files that needed to be read and fed to the tests, and cumbersome to manage.

In my search while looking for a better alternative, I came across the excellent vcr gem. In its essence, it does what I was doing manually.

A video cassette recorder (VCR)
A video cassette recorder (VCR)

How VCR Gem Works

When you run the test first time, the VCR gem records any HTTP interactions during the run (there can be more than one API calls), saves the results in a text file on your filesystem, and then uses that data for the future test runs without making real HTTP requests over the wire.

💡
Just like the good old video cassette recorders, the VCR gem records and replays HTTP requests.

It means that the test is slow only during the first run. For all the future runs, it will use the pre-fetched data (including the same headers and body), making it go super fast.

A side benefit is that your tests will continue to run and pass even if you're offline, since it's using pre-recorded data. This is really great for tests involving calculations or massaging of the fetched data. Your tests also become predictable, and you can focus on the business logic of your application rather than waiting for HTTP responses.

If you want to fetch fresh data (maybe to test API access or authentication), just delete that file and run the test again.

💡
In the wild: The popular octokit.rb gem from Github extensively uses VCR to make API tests faster. Just look at the cassettes directory containing gobs and gobs of data.

Let's give it a try on a fresh Rails application, shall we?

Step 1: Create a new Rails Application

Let's create a brand new Rails application.

rails new vcr-demo

Next, let's add a simple test that makes an API request to Github. Under the test/models directory, create a new test file named vcr_test.rb with the following code in it:

# test/models/vcr_test.rb

require "test_helper"
require "net/http"

class VCRTest < ActiveSupport::TestCase
  test "fetch a github user" do
    response = Net::HTTP.get(github_url)
    user = JSON.parse response
    
    assert_equal "torvalds", user.fetch("login")
  end

  private
    def github_url
      URI "https://api.github.com/users/torvalds"
    end
end

When you run the test, it passes as expected.

$ bin/rails test test/models/vcr_test.rb

# Running:

.

Finished in 0.460076s, 2.1736 runs/s, 2.1736 assertions/s.
1 runs, 1 assertions, 0 failures, 0 errors, 0 skips

Note that the single test took almost half a second to run. Imagine if you have hundreds of similar tests! It would take minutes to run them all, each and every time! That is, if Github doesn't rate-limit you first 🛑

Let's fix this by using the VCR gem.

Step 2: Install the Required Gems

Let's install the vcr gem along with the webmock gem, which is a library for stubbing and setting expectations on HTTP requests in Ruby (VCR uses it internally - I don't know why VCR doesn't include it out-of-box. If you know, please let me know).

$ bundle add vcr

$ bundle add webmock

That's it. The next step is to configure the gem.

Step 3: Setup VCR

The cassette_library_dir configures the location where it will store the video cassettes, I mean, the HTTP requests and responses 😅

# test/test_helper.rb

require "vcr"

VCR.configure do |config|
  config.cassette_library_dir = "test/vcr_cassettes"
  config.hook_into :webmock
end

We're all set! Now let's use it in our tests.

Step 4: Use a Cassette while Testing

With the gem set up and configured, let's update the test method by wrapping the code inside the use_cassette block as follows. Any HTTP requests made within this block will be recorded and replayed during next runs.

test "fetch a github user" do
  VCR.use_cassette("gh_user") do
    response = Net::HTTP.get(github_url)
    user = JSON.parse response
    assert_equal "torvalds", user.fetch("login")
  end
end

Note the gh_user argument passed to the use_cassette method. After you run the test the first time, VCR will save the results in a file named gh_user.yml.

Now run the test. The first time it should take about the same time it took without using VCR, since it makes a real HTTP request to fetch the data. However, once the test finishes, you will have a new YAML file called gh_user.yml under the test/vcr_cassettes directory, containing the request and the response.

Now run the test again.

$ bin/rails test test/models/vcr_test.rb

# Running:

.

Finished in 0.059047s, 16.9357 runs/s, 16.9357 assertions/s.
1 runs, 1 assertions, 0 failures, 0 errors, 0 skips

Note this time it took only 59 milliseconds to run. Pretty nice! And you can run it as many times as you want, and it will use the pre-loaded request and response every time, that is, until you delete gh_user.yml and force it to make a real HTTP request.

Also remember that the HTTP call doesn't need to be inside the test method. It can be anywhere in your Rails application, including models or service classes. VCR watches for each and every HTTP call, no matter where it's coming from, as long as it's called by the code within the use_cassette block, and records it.

Conclusion

So, that was a quick look at the awesome VCR gem. Does that mean you should always use it in your API tests?

I don't think so. If the data you're receiving is quite small, a handful of properties, then manally stubbing it is still a simple and useful technique. However, as the data grows and you find yourself managing data files containing or objects containing fake data, VCR starts making more and more sense.

Also, a word of caution: If the underlying API changes data format, the VCR tests won't catch it. Hence periodically review your tests and the API responses to ensure you're testing what you meant to. Personally, I frequently nuke the test/vcr_cassettes directory entirely and run the tests from scratch to ensure they're still testing the correct things.


That's a wrap. I hope you found this article helpful and you learned something new.

As always, if you have any questions or feedback, didn't understand something, or found a mistake, please leave a comment below or send me an email. I reply to all emails I get from developers, and I look forward to hearing from you.

If you'd like to receive future articles directly in your email, please subscribe to my blog. If you're already a subscriber, thank you.