Pact Erlang
===========

[](https://codecov.io/gh/greyorange-labs/pact_erlang)
[](https://hex.pm/packages/pact_erlang)
[](https://hexdocs.pm/pact_erlang/)
[](https://hex.pm/packages/pact_erlang)
[](https://github.com/greyorange-labs/pact_erlang/blob/develop/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](https://hexdocs.pm/pact_erlang)
📋 **Changelog:** [changelog.html](https://hexdocs.pm/pact_erlang/changelog.html)
🏗️ **Architecture:** [ARCHITECTURE.md](ARCHITECTURE.md)
🤝 **Contributing:** [CONTRIBUTING.md](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`:
~~~erlang
{deps, [pact_erlang]}.
~~~
### Build
~~~bash
make
~~~
API Reference
-------------
### Core Consumer APIs
#### `pact:v4/2` - Create a Pact Contract
Creates a new Pact contract between a consumer and provider.
~~~erlang
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:**
~~~erlang
PactRef = pact:v4(<<"my_app">>, <<"user_service">>).
~~~
#### `pact:interaction/2` - Define HTTP Interaction
Creates an HTTP interaction specification and starts a mock server.
~~~erlang
{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:**
~~~erlang
#{
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:**
~~~erlang
% Simple state
given => <<"user exists">>
% State with parameters
given => #{
state => <<"user exists">>,
params => #{<<"userId">> => <<"123">>}
}
~~~
**Request Specification:**
~~~erlang
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:**
~~~erlang
will_respond_with => #{
status => 200, % HTTP status code
headers => #{ % Optional: Response headers
<<"Content-Type">> => <<"application/json">>
},
body => ResponseBody % Optional: Response body
}
~~~
**Complete Example:**
~~~erlang
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.
~~~erlang
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:**
~~~erlang
#{
given => ProviderState, % Optional: Provider state
upon_receiving => Description, % Required: Message description
with_contents => MessageContents % Required: Message contents
}
~~~
**Example:**
~~~erlang
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.
~~~erlang
Result = pact:verify(PactRef).
~~~
**Parameters:**
- `PactRef` - Process ID returned by `pact:v4/2`
**Returns:**
- `{ok, matched}` - All interactions matched successfully
- `{error, not_matched}` - One or more interactions failed to match
**Example:**
~~~erlang
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.
~~~erlang
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:**
~~~erlang
% 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).
~~~erlang
pact:cleanup(PactRef).
~~~
**Parameters:**
- `PactRef` - Process ID returned by `pact:v4/2`
**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.
~~~erlang
Matcher = pact:like(Value).
~~~
**Example:**
~~~erlang
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.
~~~erlang
Matcher = pact:each_like(Template).
~~~
**Example:**
~~~erlang
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.
~~~erlang
Matcher = pact:regex_match(ExampleValue, RegexPattern).
~~~
**Example:**
~~~erlang
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.
~~~erlang
Matcher = pact:each_key(ExampleValue, KeyPattern).
~~~
**Example:**
~~~erlang
% 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.
~~~erlang
{ok, VerifierRef} = pact_verifier:start_verifier(ProviderName, ProviderOpts).
~~~
**Parameters:**
- `ProviderName` - Binary string identifying the provider
- `ProviderOpts` - Map containing provider configuration
**ProviderOpts Structure:**
~~~erlang
#{
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:**
~~~erlang
% 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:**
~~~erlang
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:**
~~~erlang
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.
~~~erlang
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.
~~~erlang
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.
~~~erlang
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:**
~~~erlang
% 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
~~~erlang
-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
~~~erlang
-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
~~~erlang
-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
~~~erlang
% 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
~~~erlang
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
~~~erlang
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
~~~erlang
% 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