Pact Erlang
View SourceAn 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 applicationProvider
- 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 bypact: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 bypact: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:
PactRef
- Process ID returned bypact:v4/2
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 bypact: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:
PactRef
- Process ID returned bypact: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.
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 providerProviderOpts
- 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