Pact Erlang

View Source

Build Status Code Coverage Hex Version Hex Docs Total Download License

An Erlang library for contract testing using Pact FFI, supporting both HTTP APIs and asynchronous messaging systems. This library enables Erlang applications to participate in consumer-driven contract testing by generating and verifying Pact contracts.

📚 Documentation: hexdocs.pm/pact_erlang
📋 Changelog: changelog.html
🏗️ Architecture: ARCHITECTURE.md
🤝 Contributing: CONTRIBUTING.md

Features

  • HTTP Contract Testing: Create and verify HTTP API contracts
  • Message Contract Testing: Test asynchronous messaging interactions
  • Consumer & Provider Testing: Support for both sides of contract testing
  • Flexible Matching: Type matching, regex matching, and custom matchers
  • Pact Broker Integration: Publish and fetch contracts from Pact Broker
  • Multiple Platforms: Support for Linux (x86_64, aarch64) and macOS (x86_64, arm64)
  • Built on Pact FFI: Leverages the mature Rust Pact FFI library

Quick Start

Installation

Add pact_erlang as a dependency in your rebar.config:

{deps, [pact_erlang]}.

Build

make

API Reference

Core Consumer APIs

pact:v4/2 - Create a Pact Contract

Creates a new Pact contract between a consumer and provider.

PactRef = pact:v4(Consumer, Provider).

Parameters:

  • Consumer - Binary string identifying the consumer application
  • Provider - Binary string identifying the provider application

Returns: Process ID (PactRef) representing the Pact contract

Example:

PactRef = pact:v4(<<"my_app">>, <<"user_service">>).

pact:interaction/2 - Define HTTP Interaction

Creates an HTTP interaction specification and starts a mock server.

{ok, Port} = pact:interaction(PactRef, InteractionSpec).

Parameters:

  • PactRef - Process ID returned by pact:v4/2
  • InteractionSpec - Map containing interaction details

Returns: {ok, Port} where Port is the mock server port

InteractionSpec Structure:

#{
    given => ProviderState,              % Optional: Provider state
    upon_receiving => Description,       % Required: Interaction description  
    with_request => RequestSpec,         % Required: Request specification
    will_respond_with => ResponseSpec    % Required: Response specification
}

Provider State Options:

% Simple state
given => <<"user exists">>

% State with parameters  
given => #{
    state => <<"user exists">>,
    params => #{<<"userId">> => <<"123">>}
}

Request Specification:

with_request => #{
    method => <<"GET">>,                % HTTP method
    path => <<"/users/123">>,           % Request path
    headers => #{                       % Optional: Request headers
        <<"Authorization">> => <<"Bearer token">>,
        <<"Content-Type">> => <<"application/json">>
    },
    query_params => #{                  % Optional: Query parameters
        <<"active">> => <<"true">>
    },
    body => RequestBody                 % Optional: Request body
}

Response Specification:

will_respond_with => #{
    status => 200,                      % HTTP status code
    headers => #{                       % Optional: Response headers
        <<"Content-Type">> => <<"application/json">>
    },
    body => ResponseBody                % Optional: Response body
}

Complete Example:

PactRef = pact:v4(<<"my_app">>, <<"user_service">>),

{ok, Port} = pact:interaction(PactRef,
    #{
        given => #{
            state => <<"user exists">>,
            params => #{<<"userId">> => <<"123">>}
        },
        upon_receiving => <<"get user by ID">>,
        with_request => #{
            method => <<"GET">>,
            path => <<"/users/123">>,
            headers => #{
                <<"Authorization">> => <<"Bearer token">>
            }
        },
        will_respond_with => #{
            status => 200,
            headers => #{
                <<"Content-Type">> => <<"application/json">>
            },
            body => #{
                <<"id">> => <<"123">>,
                <<"name">> => <<"John Doe">>,
                <<"email">> => <<"john@example.com">>
            }
        }
    }),

% Make request to mock server
Response = http_client:get("http://127.0.0.1:" ++ integer_to_list(Port) ++ "/users/123").

pact:msg_interaction/2 - Define Message Interaction

Creates a message interaction for asynchronous messaging contracts.

TestMessage = pact:msg_interaction(PactRef, MessageSpec).

Parameters:

  • PactRef - Process ID returned by pact:v4/2
  • MessageSpec - Map containing message interaction details

Returns: Map containing the reified message contents

MessageSpec Structure:

#{
    given => ProviderState,              % Optional: Provider state
    upon_receiving => Description,       % Required: Message description
    with_contents => MessageContents     % Required: Message contents
}

Example:

PactRef = pact:v4(<<"weather_consumer">>, <<"weather_service">>),

Message = pact:like(#{
    weather => #{
        temperature => 23.0,
        humidity => 75.5,
        wind_speed_kmh => 29
    },
    timestamp => <<"2024-03-14T10:22:13+05:30">>
}),

TestMessage = pact:msg_interaction(PactRef, #{
    given => <<"weather data available">>,
    upon_receiving => <<"weather update message">>,
    with_contents => Message
}),

#{<<"contents">> := MessageContents} = TestMessage,
% Process the test message
ok = my_message_handler:process(MessageContents).

pact:verify/1 - Verify Interactions

Verifies that all defined interactions were successfully matched during testing.

Result = pact:verify(PactRef).

Parameters:

Returns:

  • {ok, matched} - All interactions matched successfully
  • {error, not_matched} - One or more interactions failed to match

Example:

case pact:verify(PactRef) of
    {ok, matched} ->
        io:format("All interactions verified successfully~n");
    {error, not_matched} ->
        io:format("Some interactions failed verification~n")
end.

pact:write/1 and pact:write/2 - Write Pact Files

Writes the Pact contract to a file after successful verification.

pact:write(PactRef).
pact:write(PactRef, Directory).

Parameters:

  • PactRef - Process ID returned by pact:v4/2
  • Directory - Optional: Custom directory path (defaults to "./pacts")

Returns: ok

Example:

% Write to default directory (./pacts)
pact:write(PactRef).

% Write to custom directory
pact:write(PactRef, <<"/tmp/my_pacts">>).

pact:cleanup/1 - Cleanup Resources

Cleans up resources used by the Pact contract (does not remove Pact files).

pact:cleanup(PactRef).

Parameters:

Returns: ok

Matching Functions

These functions create matching rules for flexible contract testing.

pact:like/1 - Type Matching

Matches values based on their type rather than exact value.

Matcher = pact:like(Value).

Example:

Body = #{
    <<"user_id">> => pact:like(123),           % Matches any integer
    <<"name">> => pact:like(<<"John">>),       % Matches any string  
    <<"active">> => pact:like(true),           % Matches any boolean
    <<"metadata">> => pact:like(#{             % Matches map with same structure
        <<"created">> => <<"2023-01-01">>
    })
}.

pact:each_like/1 - Array Type Matching

Matches arrays where each element matches the provided template.

Matcher = pact:each_like(Template).

Example:

UsersArray = pact:each_like(#{
    <<"id">> => 1,
    <<"name">> => <<"User Name">>,
    <<"email">> => <<"user@example.com">>
}),
% Matches arrays of user objects with the same structure

pact:regex_match/2 - Regular Expression Matching

Matches values against a regular expression pattern.

Matcher = pact:regex_match(ExampleValue, RegexPattern).

Example:

EmailMatcher = pact:regex_match(
    <<"test@example.com">>, 
    <<"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$">>
),

PhoneMatcher = pact:regex_match(
    <<"555-1234">>,
    <<"^\\d{3}-\\d{4}$">>
).

pact:each_key/2 - Key Pattern Matching

Matches map keys against a regular expression pattern.

Matcher = pact:each_key(ExampleValue, KeyPattern).

Example:

% Match dynamic keys that follow a pattern
DynamicData = pact:each_key(#{
    <<"user-123">> => <<"John Doe">>,
    <<"user-456">> => <<"Jane Doe">>
}, <<"^user-\\d+$">>).

Provider/Verifier APIs

pact_verifier:start_verifier/2 - Start Provider Verifier

Starts a verifier instance for provider-side contract testing.

{ok, VerifierRef} = pact_verifier:start_verifier(ProviderName, ProviderOpts).

Parameters:

  • ProviderName - Binary string identifying the provider
  • ProviderOpts - Map containing provider configuration

ProviderOpts Structure:

#{
    name => ProviderName,                % Provider name
    version => ProviderVersion,          % Provider version
    scheme => <<"http">>,                % URL scheme (http/https)
    host => <<"localhost">>,             % Provider host
    port => 8080,                        % Provider port (optional)
    base_url => <<"/">>,                 % Base URL path
    branch => <<"main">>,                % Git branch name
    protocol => <<"http">>,              % Protocol type (http/message)
    pact_source_opts => SourceOpts,      % Pact source configuration
    message_providers => MessageProviders, % Message provider mappings
    state_change_url => StateChangeUrl   % Provider state change URL
}

Pact Source Options:

% File-based verification
pact_source_opts => #{
    file_path => <<"./pacts">>
}

% Broker-based verification
pact_source_opts => #{
    broker_url => <<"http://pact-broker.example.com"\>\>,
    broker_username => <<"username">>,
    broker_password => <<"password">>,
    enable_pending => 1,
    consumer_version_selectors => []
}

For possible values of consumer_version_selectors, check https://docs.pact.io/pact_broker/advanced_topics/consumer_version_selectors

Message Providers:

message_providers => #{
    <<"weather update message">> => {weather_service, generate_weather, []},
    <<"user notification">> => {notification_service, create_notification, [user_id]}
},
fallback_message_provider => {default_service, generate_default, []}

Example:

ProviderOpts = #{
    name => <<"user_service">>,
    version => <<"1.0.0">>,
    scheme => <<"http">>,
    host => <<"localhost">>,
    port => 8080,
    base_url => <<"/">>,
    branch => <<"main">>,
    protocol => <<"http">>,
    pact_source_opts => #{
        broker_url => <<"http://localhost:9292"\>\>,
        broker_username => <<"pact_user">>,
        broker_password => <<"pact_pass">>,
        enable_pending => 1,
        consumer_version_selectors => [#{<<"matchingBranch">> => true}]
    }
},

{ok, VerifierRef} = pact_verifier:start_verifier(<<"user_service">>, ProviderOpts).

pact_verifier:verify/1 - Run Verification

Executes the verification process against the provider.

Result = pact_verifier:verify(VerifierRef).

Returns: Integer result code (0 = success, non-zero = failure)

pact_verifier:stop_verifier/1 - Stop Verifier

Stops the verifier and releases resources.

pact_verifier:stop_verifier(VerifierRef).

Utility APIs

pact:enable_logging/1 and pact:enable_logging/2 - Enable Logging

Enables Pact FFI logging for debugging purposes.

pact:enable_logging(LogLevel).
pact:enable_logging(FilePath, LogLevel).

Parameters:

  • LogLevel - Atom: off, error, warn, info, debug, trace
  • FilePath - Binary: Custom log file path (defaults to "./pact_erlang.log")

Example:

% Enable debug logging to default file
pact:enable_logging(debug).

% Enable trace logging to custom file
pact:enable_logging(<<"/tmp/pact_debug.log">>, trace).

Complete Workflow Examples

Consumer Test Example

-module(user_service_consumer_test).
-include_lib("eunit/include/eunit.hrl").

user_service_test() ->
    % Setup Pact contract
    PactRef = pact:v4(<<"my_app">>, <<"user_service">>),
    
    % Define interaction
    {ok, Port} = pact:interaction(PactRef, #{
        given => <<"user 123 exists">>,
        upon_receiving => <<"get user request">>,
        with_request => #{
            method => <<"GET">>,
            path => <<"/users/123">>,
            headers => #{
                <<"Authorization">> => <<"Bearer token123">>
            }
        },
        will_respond_with => #{
            status => 200,
            headers => #{
                <<"Content-Type">> => <<"application/json">>
            },
            body => #{
                <<"id">> => pact:like(123),
                <<"name">> => pact:like(<<"John Doe">>),
                <<"email">> => pact:regex_match(
                    <<"john@example.com">>,
                    <<"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$">>
                )
            }
        }
    }),
    
    % Test your code against the mock server
    BaseUrl = "http://127.0.0.1:" ++ integer_to_list(Port),
    {ok, User} = my_user_client:get_user(BaseUrl, "123", "token123"),
    
    % Verify interaction was matched
    ?assertEqual({ok, matched}, pact:verify(PactRef)),
    
    % Write Pact file
    pact:write(PactRef),
    
    % Cleanup
    pact:cleanup(PactRef).

Message Consumer Test Example

-module(weather_consumer_test).
-include_lib("eunit/include/eunit.hrl").

weather_message_test() ->
    % Setup Pact contract
    PactRef = pact:v4(<<"weather_app">>, <<"weather_service">>),
    
    % Define expected message structure
    Message = pact:like(#{
        weather => #{
            temperature => 23.0,
            humidity => 75.5,
            conditions => <<"sunny">>
        },
        location => #{
            city => <<"San Francisco">>,
            country => <<"US">>
        },
        timestamp => pact:regex_match(
            <<"2024-03-14T10:22:13Z">>,
            <<"^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z$">>
        )
    }),
    
    % Create message interaction
    TestMessage = pact:msg_interaction(PactRef, #{
        given => <<"weather data is available">>,
        upon_receiving => <<"weather update message">>,
        with_contents => Message
    }),
    
    % Extract and test message contents
    #{<<"contents">> := MessageContents} = TestMessage,
    ?assertMatch(ok, weather_handler:process_weather_update(MessageContents)),
    
    % Write Pact file
    pact:write(PactRef),
    
    % Cleanup
    pact:cleanup(PactRef).

Provider Verification Example

-module(user_service_provider_test).
-include_lib("eunit/include/eunit.hrl").

verify_contracts_test() ->
    % Start your provider service
    {ok, _} = application:start(user_service),
    
    % Configure verifier
    ProviderOpts = #{
        name => <<"user_service">>,
        version => <<"1.0.0">>,
        scheme => <<"http">>,
        host => <<"localhost">>,
        port => 8080,
        base_url => <<"/">>,
        branch => <<"main">>,
        protocol => <<"http">>,
        pact_source_opts => #{
            file_path => <<"./pacts">>
        },
        state_change_url => <<"http://localhost:8080/provider-states"\>\>
    },
    
    % Start verifier
    {ok, VerifierRef} = pact_verifier:start_verifier(<<"user_service">>, ProviderOpts),
    
    % Run verification
    % This will also log the pact verification results to stdout
    Result = pact_verifier:verify(VerifierRef),
    
    % Check results
    ?assertEqual(0, Result),  % 0 means success
    
    % Stop verifier
    pact_verifier:stop_verifier(VerifierRef),
    
    % Stop provider service
    application:stop(user_service).

Advanced Usage

Complex Matching Example

% Define pact consumer and producer
PactRef = pact:v4(<<"consumer">>, <<"producer">>).

% Define the interaction, returns running mock server port
{ok, Port} = pact:interaction(PactRef,
    #{
        given => #{
            state => <<"a user ranjan exists">>
        },
        upon_receiving => <<"get all users">>,
        with_request => #{
            method => <<"GET">>,
            path => <<"/users">>
        },
        will_respond_with => #{
            status => 200,
            headers => #{
                <<"Content-Type">> => <<"application/json">>
            },
            body => #{users => [#{user_id => 1, user_name => <<"ranjan">>, age => 26}]}
        }
    }).

% test your code which calls the api
Users = user:get_users(<<"127.0.0.1">>, Port).

% Verify if everything matched successfully
assertEqual({ok, matched}, pact:verify(PactRef)).

% Should write pact file if matched, creates a new folder `pacts'
% and writes the pact file inside it.
pact:write(PactRef).

% Alternatively, one can override the default pacts directory path
pact:write(PactRef, "/path/to/pacts").

% Cleanup test setup
% This won't cleanup the pact files, only the pact ref you created in the test setup
pact:cleanup(PactRef).

Message Pacts Usage

PactRef = pact:v4(<<"animal_service">>, <<"weather_service">>),

Message = pact:like(#{
    weather => #{
        temperature => 23.0,
        humidity => 75.5,
        wind_speed_kmh => 29
    },
    timestamp => <<"2024-03-14T10:22:13+05:30">>
}),
TestMessage = pact:msg_interaction(PactRef,
#{
    given => <<"weather data for animals">>,
    upon_receiving => <<"a weather data message">>,
    with_contents => Message
}),
#{<<"contents">> := TestMessageContents} = TestMessage,
?assertMatch(ok, animal_service:process_weather_data(TestMessageContents)),
pact:write(PactRef).

Pact Verification

Name = <<"weather_service">>,
Version =  <<"default">>,
Scheme = <<"http">>,
Host = <<"localhost">>,
Path = <<"/message_pact/verify">>,
Branch = <<"develop">>,
FilePath = <<"./pacts">>,
WrongFilePath = <<"./pactss">>,
BrokerUrl = <<"http://localhost:9292/"\>\>,
WrongBrokerUrl = <<"http://localhost:8282/"\>\>,
Protocol = <<"message">>,
BrokerConfigs = #{
    broker_url => BrokerUrl,
    broker_username => <<"pact_workshop">>,
    broker_password => <<"pact_workshop">>,
    enable_pending => 1,
    consumer_version_selectors => []
},
ProviderOpts = #{
    name => Name,
    version => Version,
    scheme => Scheme,
    host => Host,
    base_url => Path,
    branch => Branch,
    pact_source_opts => BrokerConfigs,
    message_providers => #{
        <<"a weather data message">> => {weather_service, generate_message, [23.5, 20, 75.0]}
    },
    fallback_message_provider => {weather_service, generate_message, [24.5, 20, 93.0]},
    protocol => Protocol,
    publish_verification_results => 1
    %% 1 = publish verification results to broker, otherwise dont publish
},
{ok, VerifierRef} = pact_verifier:start_verifier(Name, ProviderOpts),
Output = pact_verifier:verify(VerifierRef).

Matching Request Path and Request/Response Headers, and Body Values

% Alternatively, you can also match things inside each request/response
pact:interaction(PactRef,
    #{
        upon_receiving => <<"a request to create an animal: Lazgo">>,
        with_request => #{
            method => <<"POST">>,
            path => <<"/animals">>,
            headers => #{
                <<"Content-Type">> => <<"application/json">>
            },
            body => #{
                <<"name">> => pact:like(<<"Lazgo">>),
                <<"type">> => pact:like(<<"dog">>)
            }
        },
        will_respond_with => #{
            status => 201
        }
    })

Release Checklist

  • Update version in src/pact_erlang.app.src
  • Update CHANGELOG.md
  • Run rebar3 hex publish --dry-run and make sure there are no un-intended files in included files
  • Commit files, add a git tag matching the new version, and push to remote
  • Run rebar3 hex publish to publish