The Principal Dev – Masterclass for Tech Leads

The Principal Dev – Masterclass for Tech Leads28-29 May

Join

Pact Ruby

pact-ruby v2+ implements support for the latest versions of Pact specifications:

Architecture

Pact tests architecture

Installation

The pact/v2 namespace was introduced in pact-ruby v1.67.0 and moved to pact in v2

It introduces a suite of new depedencies, including a reliance on the pact-ffi and ffi gems.

 gem "pact"

pact-ffi ships prebuilt binary gems, and does not support platforms outside of the released pact_ffi libraries

Version Platform
0.4.28.0 x86_64-darwin
0.4.28.0 arm64-darwin
0.4.28.0 x86_64-linux
0.4.28.0 aarch64-linux
0.4.28.0 x86_64-linux-musl
0.4.28.0 aarch64-linux-musl
0.4.28.0 x64-mingw32
0.4.28.0 x64-mingw-ucrt

If you require a pure ruby gem, you are advised to pin to v1.

Usage

For each type of interaction (due to their specific features), a separate version of DSL has been implemented. However, the general principles remain the same for each type of interaction.

Place your consumer tests under

spec/pact/provider/**

it's not an error: consumer tests contain providers subdirectory (because we're testing against different providers)


# Declaration of a consumer test, always include the :pact tag
# This is used in CI/CD pipelines to separate Pact tests from other RSpec tests
# Pact tests are not run as part of the general RSpec pipeline
RSpec.describe "SomePactConsumerTestForAnyTransport", :pact do
  # declaration of the type of interaction - here we determine which consumer and provider interact on which transport
  has_http_pact_between "CONSUMER-NAME", "PROVIDER-NAME"
  # or
  has_grpc_pact_between "CONSUMER-NAME", "PROVIDER-NAME"
  # or
  has_message_pact_between "CONSUMER-NAME", "PROVIDER-NAME"

  # the context for one of the interactions, for example GET /api/v2/stores
  context "with GET /api/v2/stores" do
      let(:interaction) do
        # creating a new interaction - within which we describe the contract
        new_interaction
          # if you need to save any metadata for subsequent use by the test provider,
          # for example, specify the entity ID that will need to be moved to the database in the test provider
          # we use the provider states, see more at https://docs.pact.io/getting_started/provider_states
          .given("UNIQUE PROVIDER STATE", key1: value1, key2: value2)
          # the description of the interaction, used for identification inside the package binding,
          # is optional in some cases, but it is recommended to always specify
          .upon_receiving("UNIQUE INTERACTION DESCRIPTION")
          # the description of the request using the matchers
          # the name and parameters of the method differ for different transports
          .with_request(...)
          # the description of the response using the matchers
          # the name and parameters of the method differ for different transports
          .will_respond_with(...)
          # further, there are differences for different types of transports,
          # for more information, see the relevant sections of the documentation
      end

      it "executes the pact test without errors" do | mock_server |
        interaction.execute do
          # the url of the started mock server, you should pass this into your api client in the next step
          mock_server_url = mock_server.url
          # here our client is called for the API being tested
          # in this context, the client can be: http client, grpc client, kafka consumer
          expect(make_request).to be_success
        end
      end
    end
  end

Common DSL Methods:

Multiple interactions can be declared within a single rspec example, in order to call the mock server

HTTP consumers

Specific DSL methods:

More at http_client_spec.rb

gRPC consumers

Specific DSL methods:

More at grpc_client_spec.rb

Message consumers

Specific DSL methods:

Next, the specifics are one of two options for describing the format:

JSON (to describe a message in a JSON representation):

PROTO (to describe the message in the protobuf view):

More at message_spec.rb

Kafka consumers

Specific DSL methods:

Next, the specifics are one of two options for describing the format:

JSON (to describe a message in a JSON representation):

PROTO (to describe the message in the protobuf view):

More at kafka_spec.rb

Requires the following gems, to use this wrapper

Matchers

Matchers are special helper methods that allow you to define rules for matching request/response parameters at the level of the pact manifest. The matchers are described in the Pact specifications. In this gem, the matchers are implemented as RSpec helpers.

For details of the implementation, see matchers.rb

See the different uses of the matchers in matchers_spec.rb

Generators

Generators are helper methods that allow you to specify dynamic values in your contract tests. These values are generated at runtime, making your contracts more flexible and robust. Below are the available generator methods:

For details of the implementation, see matchers.rb

These generators can be used in your DSL definitions to provide dynamic values for requests, responses, or messages in your contract tests.

Generator Examples

  .with_request(
    method: :get, 
    path: generate_from_provider_state(
      expression: '/alligators/${alligator_name}',
      example: '/alligators/Mary'),
    headers: headers)

...

  body: {
    _links: {
      :'pf:publish-provider-contract' => {
        href: generate_mock_server_url(
          regex: ".*(\\/provider-contracts\\/provider\\/.*\\/publish)$",
          example: "/provider-contracts/provider/{provider}/publish"
        ),
        boolean: generate_random_boolean,
        integer: generate_random_int(min: 1, max: 100),
        decimal: generate_random_decimal(digits: 2),
        hexidecimal: generate_random_hexadecimal(digits: 8),
        string: generate_random_string(size: 10),
        uuid: generate_uuid,
        date: generate_date(format: "yyyyy.MMMMM.dd GGG"),
        time: generate_time(),
        datetime: generate_datetime(format: "%Y-%m-%dT%H:%M:%S%z")
      }
    }
  }

Provider verification

Place your provider verification file under

spec/pact/consumers/**

it's not an error: provider tests contain consumers subdirectory (because we're verifying against different consumer)

Provider verification options

            @provider_name = provider_name
            @log_level = opts[:log_level] || :info
            @pact_dir = opts[:pact_dir] || nil
            @provider_setup_port = opts[:provider_setup_port] || 9001
            @pact_proxy_port = opts[:pact_proxy_port] || 9002
            @pact_uri = ENV.fetch("PACT_URL", nil) || opts.fetch(:pact_uri, nil)
            @publish_verification_results = ENV.fetch("PACT_PUBLISH_VERIFICATION_RESULTS", nil) == "true" || opts.fetch(:publish_verification_results, false)
            @provider_version = ENV.fetch("PACT_PROVIDER_VERSION", nil) || opts.fetch(:provider_version, nil)
            @provider_build_uri = ENV.fetch("PACT_PROVIDER_BUILD_URL", nil) || opts.fetch(:provider_build_uri, nil)
            @provider_version_branch = ENV.fetch("PACT_PROVIDER_BRANCH", nil) || opts.fetch(:provider_version_branch, nil)
            @provider_version_tags = ENV.fetch("PACT_PROVIDER_VERSION_TAGS", nil) || opts.fetch(:provider_version_tags, [])
            @consumer_version_tags = ENV.fetch("PACT_CONSUMER_VERSION_TAGS", nil) || opts.fetch(:consumer_version_tags, [])
            @consumer_version_selectors = ENV.fetch("PACT_CONSUMER_VERSION_SELECTORS", nil) || opts.fetch(:consumer_version_selectors, nil)
            @enable_pending = ENV.fetch("PACT_VERIFIER_ENABLE_PENDING", nil) == "true" || opts.fetch(:enable_pending, false)
            @include_wip_pacts_since = ENV.fetch("PACT_INCLUDE_WIP_PACTS_SINCE", nil) || opts.fetch(:include_wip_pacts_since, nil)
            @fail_if_no_pacts_found = ENV.fetch("PACT_FAIL_IF_NO_PACTS_FOUND", nil) == "true" || opts.fetch(:fail_if_no_pacts_found, true)
            @consumer_branch = ENV.fetch("PACT_CONSUMER_BRANCH", nil) || opts.fetch(:consumer_branch, nil)
            @consumer_version = ENV.fetch("PACT_CONSUMER_VERSION", nil) || opts.fetch(:consumer_version, nil)
            @consumer_name = opts[:consumer_name]
            @broker_url = ENV.fetch("PACT_BROKER_BASE_URL", nil) || opts.fetch(:broker_url, nil)
            @broker_username = ENV.fetch("PACT_BROKER_USERNAME", nil) || opts.fetch(:broker_username, nil)
            @broker_password = ENV.fetch("PACT_BROKER_PASSWORD", nil) || opts.fetch(:broker_password, nil)
            @broker_token = ENV.fetch("PACT_BROKER_TOKEN", nil) || opts.fetch(:broker_token, nil)
            @verify_only = [ENV.fetch("PACT_CONSUMER_FULL_NAME", nil)].compact || opts.fetch(:verify_only, [])

Single transport providers

# frozen_string_literal: true

require "pact_broker"
require "pact_broker/app"
require "rspec/mocks"
include RSpec::Mocks::ExampleMethods
require_relative "../../service_consumers/hal_relation_proxy_app"

PactBroker.configuration.base_urls = ["http://example.org"]

pact_broker = PactBroker::App.new { |c| c.database_connection = PactBroker::TestDatabase.connection_for_test_database }
app_to_verify = HalRelationProxyApp.new(pact_broker)

require "pact"
require "pact/rspec"
require_relative "../../service_consumers/shared_provider_states"
RSpec.describe "Verify consumers for Pact Broker", :pact do

  http_pact_provider "Pact Broker", opts: { 

    # rails apps should be automatically detected
    # if you need to configure your own app, you can do so here

    app: app_to_verify,
    # start rackup with a different port. Useful if you already have something
    # running on the default port *9292*
    http_port: 9393, 
    
    # Set the log level, default is :info
  
    log_level: :info,
    
    fail_if_no_pacts_found: true,

    # Pact Sources

    # 1. Local pacts from a directory

    # Default is pacts directory in the current working directory
    # pact_dir: File.expand_path('../../../../consumer/spec/internal/pacts', __dir__),
    
    # 2. Broker based pacts

    # Broker credentials
  
    # broker_username: "pact_workshop", # can be set via PACT_BROKER_USERNAME env var
    # broker_password: "pact_workshop", # can be set via PACT_BROKER_PASSWORD env var
    # broker_token: "pact_workshop", # can be set via PACT_BROKER_TOKEN env var
  
    # Remote pact via a uri, traditionally triggered via webhooks
    # when a pact that requires verification is published
  
    # 2a. Webhook triggered pacts
    # Can be a local file or a remote URL
    # Most used via webhooks
    # Can be set via PACT_URL env var
    # pact_uri: File.expand_path("../../../pacts/pact.json", __dir__),
    pact_uri: "https://raw.githubusercontent.com/YOU54F/pact_broker-client/refs/heads/feat/pact-ruby/spec/pacts/Pact%20Broker%20Client%20V2-Pact%20Broker.json",
    # pact_uri: "https://raw.githubusercontent.com/YOU54F/pact_broker-client/refs/heads/feat/pact-ruby/spec/pacts/pact_broker_client-pact_broker.json",
    # pact_uri: "http://localhost:9292/pacts/provider/Pact%20Broker/consumer/Pact%20Broker%20Client/version/96532124f3a53a499276c69ff2df785b8377588e",
    
    # 2b. Dynamically fetched pacts from broker

    # i. Set the broker url
    # broker_url: "http://localhost:9292", # can be set via PACT_BROKER_URL env var

    # ii. Set the consumer version selectors 
    # Consumer version selectors
    # The pact broker will return the following pacts by default, if no selectors are specified
    # For the recommended setup, you dont _actually_ need to specify these selectors in ruby
    # consumer_version_selectors: [{"deployedOrReleased" => true},{"mainBranch" => true},{"matchingBranch" => true}],
 
    # iii. Set additional dynamic selection verification options
    # additional dynamic selection verification options
    enable_pending: true,
    include_wip_pacts_since: "2021-01-01",

    # Publish verification results to the broker
    publish_verification_results: ENV["PACT_PUBLISH_VERIFICATION_RESULTS"] == "true",
    provider_version: `git rev-parse HEAD`.strip,
    provider_version_branch: `git rev-parse --abbrev-ref HEAD`.strip,
    provider_version_tags: [`git rev-parse --abbrev-ref HEAD`.strip],
    # provider_build_uri: "YOUR CI URL HERE - must be a valid url",
    
  }

  before_state_setup do
    PactBroker::TestDatabase.truncate
  end

  after_state_teardown do
    PactBroker::TestDatabase.truncate
  end

  shared_provider_states
  
end

Multiple transport providers

You may have a consumer pact which consumes multiple transport protocols, if they are using pact specification v4.

In order to validate an entire pact in a single test run, you will need to configure each transport as appropriate.

# frozen_string_literal: true

require "pact/rspec"

RSpec.describe "Pact::Consumers::Http", :pact do
  mixed_pact_provider "pact-test-app", opts: {
    http: {
      http_port: 3000,
      log_level: :info,
      pact_dir: File.expand_path('../../pacts', __dir__),
    },
    grpc: {
      grpc_port: 3009
    },
    async: {
      message_handlers: {
        # "pet message as json" => proc do |provider_state|
        #   pet_id = provider_state.dig("params", "pet_id")
        #   with_pact_producer { |client| PetJsonProducer.new(client: client).call(pet_id) }
        # end,
        # "pet message as proto" => proc do |provider_state|
        #   pet_id = provider_state.dig("params", "pet_id")
        #   with_pact_producer { |client| PetProtoProducer.new(client: client).call(pet_id) }
        # end
      }
    }
  }

  handle_message "pet message as json" do |provider_state|
    pet_id = provider_state.dig("params", "pet_id")
    with_pact_producer { |client| PetJsonProducer.new(client: client).call(pet_id) }
  end

  handle_message "pet message as proto" do |provider_state|
    pet_id = provider_state.dig("params", "pet_id")
    with_pact_producer { |client| PetProtoProducer.new(client: client).call(pet_id) }
  end
  
end

Development & Test

Setup

bundle install

Run unit tests

bundle exec rake spec

Run pact tests

The Pact tests are not run within the general rspec pipeline, they need to be run separately, see below

Consumer tests

bundle exec rspec -t pact spec/pact/providers/**/*_spec.rb
or 
bundle exec rake pact:spec

NOTE If you have never run it, you need to run it at least once to generate the pact files that will be used in provider tests (below)

Provider tests

bundle exec rspec -t pact spec/pact/consumers/*_spec.rb
or 
bundle exec rake pact:spec

Examples

Migration

  1. add gem "pact-ffi", "~> 0.4.28" to Gemfile, or Gemspec

  2. pact ruby v2 uses activesupport classes, so you may need to add

    1. gem 'combustion' to load active support during tests

    2. add a pact helper to load it

      require "combustion"
      begin
        Combustion.initialize! :action_controller do
          config.log_level = :fatal if ENV["LOG"].to_s.empty?
        end
      rescue => e
        # Fail fast if application couldn't be loaded
        warn "💥 Failed to load the app: #{e.message}\n#{e.backtrace.join("\n")}"
        exit(1)
      end
      
  3. Add a new rake task

    • require your helper file created above
    • add a tag, we will use pact to namespace away from our existing pact tagged tests
    RSpec::Core::RakeTask.new('spec') do |task|
      task.pattern = 'spec/pact/providers/**/*_spec.rb'
      task.rspec_opts = ['-t pact', '--require rails_helper']
    end
    
  4. File paths have moved for consumer tests, and provider verification tasks

    • consumer test location
      1. pact v1 spec/service_providers
      2. pact v2 - spec/pact/providers
    • provider verification location
      1. pact v1 spec/service_consumers
      2. pact v2 - spec/pact/consumers

The following projects were designed for pact-ruby-v1 and have been migrated to pact-ruby. They can serve as an example of the work required.

Demos

The demos are stored in this codebase for regression test, but exist as standalone in https://github.com/pact-foundation/pact-ruby-e2e-example

Join libs.tech

...and unlock some superpowers

GitHub

We won't share your data with anyone else.