Simplifying Microservice testing with Pacts

“Captain! All the integration tests have failed again! Could it be just another false negative?”

“Well, you just never know, Son. Fire all the torpedoes anyway”

I feel the way we do integration testing is sort of like driving your car from Melbourne to Sydney each time your mechanic makes a change to it. Or setting your house on fire to test your smoke alarm. It is excessive, tiresome and way too costly.

In a previous blog I talked about using the Pact gem to validate integration points in your architecture (see Testing interactions with web services without integration tests in ruby).

Since then Pact has improved a lot, and the original example was a bit contrived. Well, totally contrived. I thought it time to provide a more concrete example using the latest version of Pact.

What is Pact?

From the Pact website:

Enables consumer driven contract testing, providing a mock service and DSL for the consumer project, and interaction playback and verification for the service provider project.

Essentially, Pact provides a mechanism for creating a contract between a service consumer and a service provider, and then providing the tools to validate that the consumer and provider adhere to the contact independently of each other.

Pact, a tale of two parts – courtesy Bethany Skurrie

An example: Splitting out events to an Event Service

For one of our clients, we were required to create a RESTful Web API for customers to get a feed of events that have occurred in their web application. In this particular case, the web application was extremely large and complex, as we did not want to add more to it. We decided to create a new small service in Ruby that would house the API, and we would push events to it via HTTP as they occurred.

Event Architecture

The source system would do a POST request to an event resource URI, and this would create a new event in the event store. This new event would then be available via a feed to users of the API. The API would use a HMAC authentication scheme, and the events would be sent and stored in JSON format. For the Ruby HMAC library I used, refer to https://github.com/Asquera/warden-hmac-authentication.

Testing the interactions between the source system and the event API

The source system is a large web application that requires a lot of running components to function. And our new web API required a data store to store the events. We sure didn’t want to create a test environment with all of the services running in it just to make sure we haven’t broken the interaction between the two applications.

The Consumer

For this particular interaction, the consumer is the source system which is going to push the events to the event API. It will use a HTTP Client library (HTTParty in this case) to make the request and include all the necessary headers for HMAC authentication scheme. Here is a very simple version that posts a JSON version of an event and returns a boolean.

require 'httparty'
require 'hmac/signer'
require 'digest'

class SourceSystemClient
  include HTTParty
  base_uri 'http://events-service'

  attr_reader :hmac_secret

  def save_event(event)
    json_body = event.to_json
    response = self.class.post('/events', body: json_body, headers: hmac_headers(json_body))
    response.code == 200
  end

  def hmac_headers(json_body)
    hmac = HMAC.new
    md5 = Digest::MD5.hexdigest json_body
    now = Time.now
    headers = { 'Content-Md5' => md5 }
    signature => hmac.generate_signature(secret: @hmac_secret, method: 'POST', date: now, path: '/events', headers: headers)
    headers.merge 'X-Hmac-Date' => now.httpdate, 'Authorization' => "HMAC #{signature}", 'Content-Type' => 'application/json'
  end

end

view rawclient.rb hosted with ❤ by GitHub

The event model looks something like this:

require 'securerandom'

class Event

  attr_accessor :event_id, :timestamp, :host, :remote_address, :event_type, :user

  def initialize
    @event_id = SecureRandom.uuid
    @timestamp = Time.now
  end

  def to_hash
    {
      eventId: @event_id,
      timestamp: @timestamp.gmtime,
      host: @host,
      remoteAddress: @remote_address,
      eventType: @event_type,
      user: @user
    }
  end

end

view rawevent.rb hosted with ❤ by GitHub

This event generates the following JSON (running on my laptop):

{
  "eventId": "13ec9646-a33b-4319-a941-dbb924a60130",
  "timestamp": "2014-05-13 01:06:38 UTC",
  "host": "rholshausen.local",
  "remoteAddress": "0:0:0:0:0:0:0:1%0",
  "eventType": "LOGIN",
  "user": "rholshausen"
}

view rawevent.json hosted with ❤ by GitHub

The actual request that gets sent is:

POST /events HTTP/1.1
Content-Md5: CHEFLZW9taJ4sH6fxPN82Q==
X-Hmac-Date: Tue, 13 May 2014 01:21:17 GMT
Authorization: HMAC 1f5ca8529707d870bb993b10fd17ddf53971e4b4
Content-Type: application/json
Connection: close
Host: localhost:9292
Content-Length: 192
    
{"eventId":"13ec9646-a33b-4319-a941-dbb924a60130","timestamp":"2014-05-13 01:06:38 UTC","host":"rholshausen.local","remoteAddress":"0:0:0:0:0:0:0:1%0","eventType":"LOGIN","user":"rholshausen"}

view rawexample post hosted with ❤ by GitHub

The Pact

We will start with the Consumer and create a Pact test which will define the contract between the source system and the RESTful web API. We’ll use the Pact DSL which integrates nicely into rspec. First we create a pact helper:

require 'pact/consumer/rspec'             # Require the pact rspec helper

Pact.service_consumer "Source System" do  # register a consumer with pact
  has_pact_with "Event API" do            # register the provider that has the pact
    mock_service :event_api do            # register the mock service that will run and pretend to be the provider
      port 1234
    end
  end
end

view rawpact_helper.rb hosted with ❤ by GitHub

When the spec runs, this will start a mock HTTP server listening on port 1234. The spec file follows:

require 'pact_helper'
require_relative '../lib/client'
require_relative '../lib/event'

describe SourceSystemClient, :pact => true do

  before do
    subject.class.base_uri 'http://localhost:1234'
  end

  describe 'a request to store an event' do

    let(:event) { Event.new }
    let(:event_json) { event.to_hash }

    before do
      event_api.upon_receiving('A POST request with an event').
        with(method: :post, path: '/events', headers: {'Content-Type' => 'application/json'}, body: event_json).
        will_respond_with(status: 200, headers: {'Content-Type' => 'application/json'})
    end

    it 'is successful' do
      expect(subject.save_event(event)).to be_true
    end

  end

end

view rawconsumer_spec.rb hosted with ❤ by GitHub

Running this test passes, and it generates a pact file of the interaction.

{
  "provider": {
    "name": "Event API"
  },
  "consumer": {
    "name": "Source System"
  },
  "interactions": [
    {
      "description": "A POST request with an event",
      "request": {
        "method": "post",
        "path": "/events",
        "body": {
          "eventId": "7622cb68-3ee5-4f93-a32b-da82f3ff95b5",
          "timestamp": "2014-05-13 02:03:33 UTC",
          "host": null,
          "remoteAddress": null,
          "eventType": null,
          "user": null
        },
        "headers": {
          "Content-Type": "application/json"
        }
      },
      "response": {
        "status": 200,
        "headers": {
          "Content-Type": "application/json"
        }
      }
    }
  ],
  "metadata": {
    "pactSpecificationVersion": "1.0.0"
  }
}

view rawpact.json hosted with ❤ by GitHub

The Provider

Our little Ruby web service is going to provide an event resource that can accept POST requests containing the events. It will validate the request and authenticate it using a HMAC scheme. We’re using Grape for the API and Warden HMAC gem for the HMAC verification . We will also use the ROAR gem for converting the JSON into an Event model.

require 'grape'
require 'models/event_repository'
require 'models/events_decorator'
require 'digest/md5'

class EventsApi < Grape::API
  version 'v1', using: :accept_version_header
  
  helpers do

    def body_matches_content_md5(body)
      content_md5 = request.env['HTTP_CONTENT_MD5']
      body_md5 = Base64.encode64(Digest::MD5.digest(body)).chomp
      body_md5 == content_md5
    end
    
  end

  content_type :json, 'application/json'
  default_format :json
  parser :json, nil

  # enable warden HMAC authentication
  before do
  env['warden'].authenticate! :scope => :hmac
  end

  desc 'singleEvent: Store a single Event'
  post do
    # Check that the MD5 for the body actually matches the header. This is important as the HMAC signature includes the MD5 checksum header, but the
    # warden HMAC does not verify it matches the body
    error!('Unauthorized - Content-MD5 header is required for POST', 401) unless body_matches_content_md5(env['api.request.body'])
    event = Models::Event.new.extend(Models::EventDecorator).from_json(env['api.request.body'])
    Models::EventRepository.instance.add_event(event)
  end

end

view rawprovider.rb hosted with ❤ by GitHub

We can now verify the pact file generated in our consumer test against our provider. Pact has a very convenient rake task to do this (see the section in Pact service provider project in the pact documentation). We just need to setup a helper in spec/service_consumers/pact_helper.rb to configure the verification task with the path to the pact file.

Running the pact verify task gives us two errors:

The second error is easy to fix. In our client we don’t actual expect a response body, only the success (200) HTTP code. So expecting a content type header is incorrect. We can fix that in our consumer pact test by removing the header. Also, a POST to a Grape API returns a 201, not a 200. So we should change that too.

The first error is due to the missing authentication headers, so we got an unauthenticated (401) response. We’ll need to add those to our consumer spec.

Getting the Pact tests to pass

I’ll update the client to expect a 201 instead of a 200.

require 'httparty'
require 'hmac/signer'
require 'digest'
require 'json'
 
class SourceSystemClient
  include HTTParty
  debug_output $stderr
  base_uri 'http://localhost:9292'
 
  attr_accessor :hmac_secret
 
  def save_event(event)
    json_body = JSON.generate(event.to_hash)
    response = self.class.post('/events', body: json_body, headers: hmac_headers(json_body))
    response.code == 201
  end
 
  def hmac_headers(json_body)
    hmac = HMAC::Signer.new
    md5 = Digest::MD5.base64digest json_body
    now = Time.now
    headers = { 'Content-Md5' => md5 }
    signature = hmac.generate_signature(secret: @hmac_secret, method: 'POST', date: now.httpdate, path: '/events', headers: headers)
    headers.merge 'X-Hmac-Date' => now.httpdate, 'Authorization' => "HMAC #{signature}", 'Content-Type' => 'application/json'
  end
 
end

view rawclient_updated.rb hosted with ❤ by GitHub

We just need to get the HMAC Authentication headers added to our consumer pact test. Our client class already calculates these headers, so they will be easy to add. Here is the updated spec:

require 'pact_helper'
require_relative '../lib/client'
require_relative '../lib/event'

describe SourceSystemClient, :pact => true do

  let!(:now) { Time.now }

  before do
    subject.class.base_uri 'http://localhost:1234'
    subject.hmac_secret = '01234567890'
    Time.stub(:now).and_return(now)
  end

  describe 'posting an event to the event service' do

    let(:event) { Event.new }
    let(:event_json) { event.to_hash }

    before do
      event_api.upon_receiving('A POST request with an event').
        with(method: :post, path: '/events', headers: subject.hmac_headers(JSON.generate(event_json)), body: event_json).
        will_respond_with(status: 201)
    end

    it 'is successful' do
      expect(subject.save_event(event)).to be_true
    end

  end

end

view rawconsumer_spec_updated.rb hosted with ❤ by GitHub

Running this generates the following pact file:

{
  "provider": {
    "name": "Event API"
  },
  "consumer": {
    "name": "Source System"
  },
  "interactions": [
    {
      "description": "A POST request with an event",
      "request": {
        "method": "post",
        "path": "/events",
        "headers": {
          "Content-Md5": "QRYlWyL4AwKkqs2sfV6WBA==",
          "X-Hmac-Date": "Tue, 13 May 2014 05:57:15 GMT",
          "Authorization": "HMAC a59a8cb934fa910d62a61fd19bbfbb23ba1c4caa",
          "Content-Type": "application/json"
        },
        "body": {
          "eventId": "80573745-eaee-4e5e-8000-d0eb938c826b",
          "timestamp": "2014-05-13 05:57:15 UTC",
          "host": null,
          "remoteAddress": null,
          "eventType": null,
          "user": null
        }
      },
      "response": {
        "status": 201
      }
    }
  ],
  "metadata": {
    "pactSpecificationVersion": "1.0.0"
  }
}   

view rawupdated_pact.json hosted with ❤ by GitHub

Now, coping this latest pact over to the provider project and re-running the verification task, we get a success, Yay!

$ rake pact:verify
Using spec/service_consumers/pact_helper.rb

Verifying a pact between Source System and Event API
  A post request with an event
    returns a response which
      has status code 201

Finished in 0.10356 seconds
1 example, 0 failures

view rawspec run pass hosted with ❤ by GitHub

In Conclusion

Instead of having to build a test environment running with two servers and databases to test that our interaction between our source system and our event API works, we now have two fast tests that achieve the same result. As long as we maintain the pact file between the two systems, we can be reasonably sure that our integration is working correctly.

There is a number of ways of maintaining the pact file. We can get our consumer build to publish the pact file to the CI server as a build artefact. The pact verification task supports loading the pact file from a URL. That way we can get it to always use the pact file from the latest successful build.

There is also a Pact Broker project that provides a repository of pact files. It allows you to be able to verify your providers against pact files from different versions of your consumer, statically check the compatibility of consumers and providers, and automate your CI and CD workflows.

NOTE: The Pact Broker is an open source tool that requires you to deploy, administer and host it yourself. If you would prefer a plug-and-play option, we’ve created Pactflow, a fully managed Pact Broker with additional features to simplify teams getting started and scaling with Pact and contract testing. You can get started quickly and for free on our Developer Plan

Want to know more about how DiUS can help you?

Offices

Melbourne
Level 3, 31 Queen St Melbourne, Victoria, 3000

Phone: 03 9008 5400

Sydney
The Commons

32 York St Sydney,

New South Wales, 2000

DiUS wishes to acknowledge the Traditional Custodians of the lands on which we work and gather at both our Melbourne and Sydney offices. We pay respect to Elders past, present and emerging and celebrate the diversity of Aboriginal peoples and their ongoing cultures and connections to the lands and waters of Australia.

Subscribe to updates from DiUS

Sign up to receive the latest news, insights and event invites from DiUS straight into your inbox.

© 2024 DiUS®. All rights reserved.

Privacy  |  Terms