Managing Externals in Ruby Tests

Brought to you by

Semaphore

Learn More

In this post we’ll explore options for dealing with external resources when writing tests. Generally, a common solution is to use a mock instance of the resource that is not part of your system under test. It is however important to make sure that your mock, or stubbed response is a faithful copy and does not get out of date.

Bending time

With Timecop, it is not necessary to create records and then modify their timestamps manually in order to verify some time-based conditions. Consider the following example:

describe ".recent" do

  before do
    2.times do
      post = create(:post)
      post.update_attribute(:created_at, 2.months.ago)
    end

    @recent_post = create(:post)
    @recent_post.update_attribute(:created_at, 10.days.ago)
  end

  it "returns posts from the past month" do
    Post.recent.count.should eql(1)
    Post.recent.should include(@recent_post)
  end
end

Real-life examples of course get more messy, for example when you’re dealing with more elaborate conditions for a leaderboard. Compare the same spec with a version using Timecop:

describe ".recent" do

  before do
    Timecop.travel(2.months.ago) do
      2.times { create(:post)
    end

    Timecop.travel(10.days.ago) { @recent_post = create(:post) }
  end

  it "returns posts from the past month" do
    Post.recent.count.should eql(1)
    Post.recent.should include(@recent_post)
  end
end

Making HTTP requests

Depending on external resources makes the test suite slow and prone to unexpected errors in case of connectivity issues or service outages. For these reasons you should avoid making real HTTP requests in your tests. A common solution is to stub the requests and, optionally, responses. Ruby has very good tools to achieve this.

Webmock lets you stub and set expectations on HTTP requests:

stub_request(:get, "www.example.com").with(:query => {"a" => ["b", "c"]})

RestClient.get("http://www.example.com/?a[]=b&a[]=c")    # ===> Success

When dealing with many HTTP requests, especially if they are important enough that you want to stub the full response, I recommend using VCR. It works with any test framework and lets you record your test suite’s HTTP interactions in YAML files and “replay” them, ie reuse them as stubbed responses, during future test runs. We are using VCR to test Semaphore’s interaction with the GitHub API, for example.

describe HookCreationService do

  let(:project) { FactoryGirl.build(:project, :hash_id => "1a3b5") }

  vcr_options = { :cassette_name => "HookCreationService/connection_working",
                  :record => :new_episodes }

  context "connection working", vcr: vcr_options do

    it "sets project's github_hook_id" do
      project.should_receive(:update_attribute)
        .with(:github_hook_id, instance_of(Fixnum))

      HookCreationService.execute(project)
    end
  end
end

The first time this test runs (in practice on your development machine), the actual HTTP request is made. VCR stores the full response, including headers in fixtures/cassette_library/HookCreationService/connection_working.yml. Future runs of the test will use Webmock under the hood to stub the request and return the saved response. Not only will they be fast, as they won’t reach for the internet, but you can be sure that they’ll be accurate, since they will use a bit-exact copy of response. If we ever hit that URL with different options, VCR will record a new copy. All this is provided by the :new_episodes option. Configuration can be provided globally of course so you don’t repeat yourself.

You can find more information about VCR on its documentation site.

A word of caution: VCR does not insulate you from changes in external APIs. If an API changes and you are not paying attention, your tests will still be passing but the production system will stop working. Ideally your API provider is responsible and is not making any changes without announcing them. In that case, follow the API changes feed and when something does change, delete your cassette files and re-record them.

Testing an OAuth flow

Developers of applications that use OAuth to authenticate users via a third-party service can find it challenging to test that flow. I will present how we test GitHub authentication for Semaphore.

If you are using the excellent OmniAuth gem and one of its many providers, you have an option to use its facilities for integration testing. In short, you can short-circuit the flow to immediately redirect to the authentication callback (that’s a route in your app) with a provided mock hash. This is good because a fairly simple hash is what OmniAuth distills for you in production as well.

Since we do integration testing with Cucumber, this is our features/support/omniauth.rb:

Before("@omniauth_test") do
  OmniAuth.config.test_mode = true

  OmniAuth.config.mock_auth[:github] = {
    "uid" => "1",
    "provider" => "github",
    "info" => { "nickname" => "darkofabijan", "name" => "Darko Fabijan" },
    "credentials" => { "token" => "63146da137f3612f..." }
  }
end

After("@omniauth_test") do
  OmniAuth.config.test_mode = false
end

The first time this was set up, we authenticated Semaphore (a local development instance or production, does not matter) with GitHub and pasted the credentials token above. The Cucumber suite is using VCR, so the next step was to record cassettes with real API responses. A scenario only needs to include the @omniauth_test tag to apply this token to all requests to GitHub API:

Feature: Building projects

  @omniauth_test @javascript
  Scenario: New user signs up, creates a project without collaborators and builds it
    Given I am on the homepage
    #...

Again, first test runs create YAML files with real responses which we can reuse in developing the feature:

---
http_interactions:
- request:
    method: get
    uri: https://api.github.com/user/repos?access_token=63146da137f3612f...
    body:
      encoding: US-ASCII
      string: ""
    headers:
      Accept-Encoding:
      - gzip;q=1.0,deflate;q=0.6,identity;q=0.3
      Accept:
      - "*/*"
      User-Agent:
      - Ruby
  response:
    status:
      code: 200
      message: OK
    headers:
      Server:
      - GitHub.com
      #...

At this point the test suit can run autonomously. The only thing left, as a security measure, is to revoke access on GitHub in order to invalidate this particular API token.

Related Articles

Subscribe to receive email updates on continuous integration from Semaphore.

comments powered by Disqus