In this article we will have a look on how to write Explicit Contract tests for Ruby on Rails application with RSpec that consume external 3rd party HTTP JSON API.

In October 2015 Platformatec released an article “Mocks and explicit contracts” related to testing Phoenix (Elixir lang.) framework. I’m pretty much covering the same thing from perspective of Ruby on Rails framework. If you already read that article you will not find anything new here.

Gateway objects and Gateway mocks

Imagine we need to consume 3rd party API in order to import students to our system. So we need to build a controller that will pull the students and save them to our DB.

Let say this 3rd party API endpoint can be accessed with GET https://third-party-provider.org/v1/students and the response looks like this:

{
  "students": [
    { "name": "Tomi", "age": "10" },
    { "name": "Zdenka", "age": "12" },
    { "name": "Majko", "age": "15" },
    # ... and more
  ]
}

Now what we can do is create an Gateway object that will be responsible for fetching this data:

For simplicity we will use HTTParty gem for making HTTP requests

# lib/third_party_gateway.rb
require 'httparty'

module ThirdPartyGateway
  extend self

  def fetch_students
    resp = HTTParty.get('https://third-party-provider.org/v1/students')
    JSON.parse(resp.body)
  end
end

So now our controller for pulling and saving Students could look like this:

class StudentsController < ApplicationController

  # ...

  def pull
    pulled_student_list = ThirdPartyGateway.fetch_students['students']

    pulled_student_list.each do |pulled_student|
      student = Student.new
      student.name = pulled_student['name']
      student.age = pulled_student['age']
      student.save!
    end
  end
end

Of course this can be a Service Object or maybe a ActiveJob background job, or anything really. The point is just that our Gateway module is only responsible providing the data as a simple hash structure.

How would we test this ?

Well one idea would be to mock the Gateway class:

# spec/controllers/students_controller

RSpec.describe StudentsController do
  describe 'POST /studnets/pull' do
    def trigger
      post :pull
    end

    let(:mock_data_from_3rd_party) do
      "students" => [
        { "name" => "Tomi", "age"=> "10" },
        { "name" => "Zdenka", "age" => "12" }
      ]
    end

    before do
      expect(ThirdPartyGateway)
        .to receive(:fetch_students)
        .and_return(mock_data_from_3rd_party)
    end

    it 'should pull and store the student information from 3rd party provider' do
      expect(trigger).to change { Student.count }.by(2)

      student = Student.first
      expect(student.name).to eq 'Tomi'
      expect(student.age).to eq 10
    end
  end
end

Now this may look like good enough solution for this simple example but when it comes to more complicated scenarios it may get out of hand pretty quickly.

What if you need to pull the data in multiple parts of your application? What if you are dealing with multiple other endpoints (e.g. GET teachers, POST student works, …)

Although it is possible to deal with this scenarios by shared mocks, they quickly may get out of sync between different cases and suddenly your tests may tell a lie.

You see mocking individual endpoint requests are more like Factories. They are good for unit tests. But with 3rd party APIs we really looking for a solution similar to Fixtures. So one source of truth for all scenarios that may happen.

Gateway contracts

Lets refactor our Gateway module a bit. We will introduce ThirdPartyGateway::HTTP and ThirdPartyGateway::Test modules:

# lib/third_party_gateway/http.rb
require 'httparty'

module ThirdPartyGateway
  module HTTP
    extend self

    def fetch_students
      resp = HTTParty.get('https://third-party-provider.org/v1/students')
      JSON.parse(resp.body)
    end
  end
end
# lib/third_party_gateway/test.rb

module ThirdPartyGateway
  module Test
    extend self

    def fetch_students
      {
        "students": [
          { "name": "Tomi", "age": "10" },
          { "name": "Zdenka", "age": "12" }
        ]
      }
    end
  end
end

now we are able to call:

ThirdPartyGateway::Test.fetch_students  # data in our hash/file (like fixture)
ThirdPartyGateway::HTTP.fetch_students  # real HTTP call

NOTE ! If you are consuming multiple different 3rd party APIs, then every API should have own contract class/module (E.g. PayPal::Test/ PayPall::HTTP, Stripe::Test/Stripe::HTTP, …)

Lets configure our Production & Developmennt enviroment to use HTTP Gataway and our Test environment to use Test Gateway.

# config/environments/development.rb

require 'lib/third_party_gateway/http.rb
Rails.application.configure do
  # ...
 config.x.third_party_contract = ThirdPartyGateway::HTTP
  # ...
end
# config/environments/production.rb

require 'lib/third_party_gateway/http.rb
Rails.application.configure do
  # ...
 config.x.third_party_contract = ThirdPartyGateway::HTTP
  # ...
end
# config/environments/test.rb

require 'lib/third_party_gateway/test.rb
Rails.application.configure do
  # ...
 config.x.third_party_contract = ThirdPartyGateway::Test
  # ...
end

Prefixing the confix with x is a Rails standard way to define custom confix values in enviroment files (config.x.anything_i_like = true) Read more here

Now our controller can look like this:

class StudentsController < ApplicationController

  # ...

  def pull
    pulled_student_list = third_party_contract.fetch_students['students']

    pulled_student_list.each do |pulled_student|
      student = Student.new
      student.name = pulled_student['name']
      student.age = pulled_student['age']
      student.save!
    end
  end

  private

  def third_party_contract
    Rails.configuration.x.third_party_contract
  end
end

So our test may look like this now:

# spec/controllers/students_controller

RSpec.describe StudentsController do
  describe 'POST /studnets/pull' do
    def trigger
      post :pull
    end

    it 'should pull and store the student information from 3rd party provider' do
      expect(trigger).to change { Student.count }.by(2)

      student = Student.first
      expect(student.name).to eq 'Tomi'
      expect(student.age).to eq 10
    end
  end
end

Testing Test contracts themself

Now what about a situation when 3rd party changed their API ?

This should never happen (In theory) but sometime you may be consuming API from 3rd parties that are less diligent and they do mistakes that may leave your pull script do harm to your product.

With test contracts you can write one “slow” integration test that will ensure nothing has changed on the 3rd party API and ensure that your Test contract is valid.

# spec/lib/third_party_gateway

RSpec.describe "ensure the test contract don't tell a lie" do
  it do
    http_student = ThirdPartyGateway::HTTP.fetch_student.fetch('students').first
    test_student = ThirdPartyGateway::Test.fetch_student.fetch('students').first

    expect(http_student.keys).to match_array(test_student.keys)
  end
end

To learn more about JSON API testing with native RSpec you can read my other article Testing API with RSpec

This will ensure that that the keys of the students fields didn’t changed. It maybe a case where required key was removed from 3rd party API that could cause our internal system serious damage (e.g. if ID is missing we are deleting rows in our DB)

It’s a not bullet proof solution, but better than blind mocks / request recordings (discussed in “Other Solutions section)

Dealing with variants

Imagine your gateway needs to POST some data to 3rd party API resulting in successful response with body {result: 'ok'} or bad request with body {result: 'error', errors: ['invalid format']}

With mocks this would be easy. We would just mock the request and return result for every scenario needed.

# app/controllers/students_controller.rb
class StudentsController < ApplicationController
  # ...

  def create
    resp = HTTParty.post('https://third-party-provider.org/v1/students', name: params[:name], age: params[:age].to_i)
    case JSON.parse(resp)['result']
    when 'ok'
      # ... do some further processing
    when 'error'
      # ... render some error to FE
    else
      raise 'unknown edgecase'
    end
  end
end
# spec/controllers/students_controller
RSpec.describe StudentsController do

  # ...
  describe 'POST create' do
    def trigger
      post :create, age: age, name: "Rene"
    end

    context 'when proper age for a student' do
      let(:age) { 12 }

      before do
        expect(HTTParty)
          .to receive(:post)
          .with(name: "Rene", age: age)
          .and_return({result: 'ok'})
      end

      it do
        trigger
        # ..
      end
    end

    context 'when too old for a student' do
      let(:age) { 31 }

      before do
        expect(HTTParty)
          .to receive(:post)
          .with(name: "Rene", age: age)
          .and_return({result: 'error', errors: ['Student too old']})
      end

      it do
        trigger
        # ....
      end
    end
  end
end

How would you write a test for this with test contracts ?

Now remember, contract tests tests behave similar way how would DB fixtures behave. That means that there are variants built within the contract:

# lib/third_party_gateway/http.rb
require 'httparty'

module ThirdPartyGateway
  module HTTP
    # ...

    def create_student(name:, age:)
      resp = HTTParty.post('https://third-party-provider.org/v1/students', name: name, age: age)
      JSON.parse(resp.body)
    end
  end
end
# lib/third_party_gateway/test.rb

module ThirdPartyGateway
  module Test
    # ...

    def create_student(name:, age:)
      if age < 18
        {result: 'ok'}
      else
        {result: 'error', errors: ['Student too old']}
      end
    end
  end
end
class StudentsController < ApplicationController
  # ...

  def create
    resp_hash = Rails.configuration.x.third_party_contract.create_student(name: params[:name], age: params[:age].to_i)
    case resp_hash['result']
    when 'ok'
      # ... do some further processing
    when 'error'
      # ... render some error to FE
    else
      raise 'unknown edgecase'
    end
  end
end
# spec/controllers/students_controller
RSpec.describe StudentsController do

  # ...
  describe 'POST create' do
    def trigger
      post :create, age: age, name: "Rene"
    end

    context 'when proper age for a student' do
      let(:age) { 12 }

      it do
        trigger
        # ..
      end
    end

    context 'when too old for a student' do
      let(:age) { 31 }

      it do
        trigger
        # ....
      end
    end
  end
end

Now this may not be the best example as probably you want to rather create scenarios around HTTP status codes. So on success 201 or bad request 400 you may raise exception from within contact and capture it in the code. Really this is not the important part and it’s really up to you or your team/product definition what you need / like.

Conclusion (on Contracts)

Contracts are much better representation of behavior of an external APIs. They may be bit inconvinient when dealing with several different scenarios but that’s the whole point. Your application is consuming 3rd party application => you are suddenly introducing “something” to your application that you have not full controll of. Mocks may be easier for writing but also easier to introduce a lie. Contracts are representation of this inflexible reality.

Other Solutions

Now there are many ways in Ruby on Rails world how to write the same test. You can:

  • Mock/Stub the data (expect(HTTParty).to receive('get').with('https://blog.eq8.eu/feed.xml').and_return(mock_data))
  • Webmock gem, that will allow you to write more HTTP mocks
  • use VCR gem to record the real HTTP calls and next time the tests will just use data recorded during first run

These approaches are valid but in lot of cases they are hard to maintain. Test contracts are my favorite approach in 70% of situations when it comes to dealing with 3rd party API.

sources