Pact test DSL for writing consumer pact tests in Rust
This library provides a test DSL for writing consumer pact tests in Rust. It supports the V3 pact specification and V4 pact specification.
To use it
To use it, add it to your dev-dependencies in your cargo manifest:
[dev-dependencies]
pact_consumer = "0.9.8"
You can now write a pact test using the consumer DSL.
use pact_consumer::prelude::*;
use pact_consumer::*;
#[test]
fn a_service_consumer_side_of_a_pact_goes_a_little_something_like_this() {
// Define the Pact for the test (you can setup multiple interactions by chaining the given or upon_receiving calls)
let pact_runner = ConsumerPactBuilder::consumer("Consumer".to_string()) // Define the service consumer by name
.has_pact_with("Alice Service".to_string()) // Define the service provider that it has a pact with
.given("there is some good mallory".to_string()) // defines a provider state. It is optional.
.upon_receiving("a retrieve Mallory request".to_string()) // upon_receiving starts a new interaction
.path(s!("/mallory")) // define the request, a GET (default) request to '/mallory'
.will_respond_with() // define the response we want returned
.status(200)
.headers(hashmap!{ "Content-Type".to_string() => "text/html".to_string() })
.body(OptionalBody::Present("That is some good Mallory.".to_string()))
.build();
// Execute the run method to have the mock server run (the URL to the mock server will be passed in).
// It takes a closure to execute your requests and returns a Pact VerificationResult.
let result = pact_runner.run(&|url| {
let client = Client { url: url.clone(), .. Client::default() }; // You would use your actual client code here
let result = client.fetch("/mallory"); // we get our client code to execute the request
expect!(result).to(be_ok().value("That is some good Mallory."));
Ok(())
});
expect!(result).to(be_equal_to(VerificationResult::PactVerified)); // This means it is all good
}
Changing the output directory
By default, the pact files will be written to target/pacts
. To change this, set the environment variable PACT_OUTPUT_DIR
.
Forcing pact files to be overwritten
Pacts are merged with existing pact files when written. To change this behaviour so that the files
are always overwritten, set the environment variable PACT_OVERWRITE
to true
.
Testing messages
Testing message consumers is supported. There are two types: asynchronous messages and synchronous request/response.
Asynchronous messages
Asynchronous messages are you normal type of single shot or fire and forget type messages. They are typically sent to a message queue or topic as a notification or event. With Pact tests, we will be testing that our consumer of the messages works with the messages setup as the expectations in test. This should be the message handler code that processes the actual messages that come off the message queue in production.
The generated Pact file from the test run can then be used to verify whatever created the messages adheres to the Pact file.
use pact_consumer::prelude::*;
use pact_consumer::*;
#[tokio::test]
async fn a_message_consumer_side_of_a_pact_goes_a_little_something_like_this() {
// Define the Pact for the test (you can setup multiple interactions by chaining the given or message_interaction calls)
// For messages we need to use the V4 Pact format.
let pact_builder = PactBuilder::PactBuilder::new_v4("message-consumer", "message-provider"); // Define the message consumer and provider by name
pact_builder
// defines a provider state. It is optional.
.given("there is some good mallory".to_string())
// Adds an interaction given the message description and type.
.message_interaction("Mallory Message", "core/interaction/message", |mut i| async move {
// Can set the test name (optional)
i.test_name("a_message_consumer_side_of_a_pact_goes_a_little_something_like_this");
// Set the contents of the message. Here we use a JSON pattern, so that matching rules are applied
i.json_body(json_pattern!({
"mallory": like!("That is some good Mallory.")
}));
// Need to return the mutated interaction builder
i
})
.await;
// This will return each message configured with the Pact builder. We need to process them
// with out message handler (it should be the one used to actually process your messages).
for message in pact_builder.messages() {
let bytes = message.contents.contents.value().unwrap();
// Process the message here as it would if it came off the queue
let message: Value = serde_json::from_slice(&bytes);
// Make some assertions on the processed value
expect!(message.as_object().unwrap().get("mallory")).to(be_some().value());
}
}
Synchronous request/response messages
Synchronous request/response messages are a form of message interchange were a request message is sent to another service and one or more response messages are returned. Examples of this would be things like Websockets and gRPC.
use pact_consumer::prelude::*;
use pact_consumer::*;
use expectest::prelude::*;
use serde_json::{Value, from_slice};
#[tokio::test]
async fn a_synchronous_message_consumer_side_of_a_pact_goes_a_little_something_like_this() {
// Define the Pact for the test (you can setup multiple interactions by chaining the given or message_interaction calls)
// For synchronous messages we also need to use the V4 Pact format.
let mut pact_builder = PactBuilder::new_v4("message-consumer", "message-provider"); // Define the message consumer and provider by name
pact_builder
// Adds an interaction given the message description and type.
.synchronous_message_interaction("Mallory Message", "core/interaction/synchronous-message", |mut i| async move {
// defines a provider state. It is optional.
i.given("there is some good mallory".to_string());
// Can set the test name (optional)
i.test_name("a_synchronous_message_consumer_side_of_a_pact_goes_a_little_something_like_this");
// Set the contents of the request message. Here we use a JSON pattern, so that matching rules are applied.
// This is the request message that is going to be forwarded to the provider
i.request_json_body(json_pattern!({
"requestFor": like!("Some good Mallory, please.")
}));
// Add a response message we expect the provider to return. You can call this multiple times to add multiple messages.
i.response_json_body(json_pattern!({
"mallory": like!("That is some good Mallory.")
}));
// Need to return the mutated interaction builder
i
})
.await;
// For our test we want to invoke our message handling code that is going to initialise the request
// to the provider with the request message. But we need some mechanism to mock the response
// with the resulting response message so we can confirm our message handler works with it.
for message in pact_builder.synchronous_messages() {
// the request message we must make
let request_message_bytes = message.request.contents.value().unwrap();
// the response message we expect to receive from the provider
let response_message_bytes = message.response.first().unwrap().contents.value().unwrap();
// We use a mock here, assuming there is a Trait that controls the response message that our
// mock can implement.
let mock_provider = MockProvider { message: response_message_bytes };
// Invoke our message handler to send the request message from the Pact interaction and then
// wait for the response message. In this case it will be the response via the mock provider.
let response = MessageHandler::process(request_message_bytes, &mock_provider);
// Make some assertions on the processed value
expect!(response).to(be_ok().value("That is some good Mallory."));
}
}
Using Pact plugins
The consumer test builders support using Pact plugins. Plugins are defined in the Pact plugins project. To use plugins requires the use of Pact specification V4 Pacts.
To use a plugin, first you need to let the builder know to load the plugin and then configure the interaction based on
the requirements for the plugin. Each plugin may have different requirements, so you will have to consult the plugin
docs on what is required. The plugins will be loaded from the plugin directory. By default, this is ~/.pact/plugins
or
the value of the PACT_PLUGIN_DIR
environment variable.
There are generic functions that take JSON data structures and pass these on to the plugin to
setup the interaction. For request/response HTTP interactions, there is the contents
function on the request and
response builders. For message interactions, the function is called contents_from
.
For example, if we use the CSV plugin from the plugins project, our test would look like:
#[tokio::test]
async fn test_csv_client() {
// Create a new V4 Pact
let csv_service = PactBuilder::new_v4("CsvClient", "CsvServer")
// Tell the builder we are using the CSV plugin
.using_plugin("csv", None).await
// Add the interaction for the CSV request
.interaction("request for a CSV report", "core/interaction/http", |mut i| async move {
// Path to the request we are going to make
i.request.path("/reports/report001.csv");
// Response we expect back
i.response
.ok()
// We use the generic "contents" function to send the expected response data to the plugin in JSON format
.contents(ContentType::from("text/csv"), json!({
"csvHeaders": false,
"column:1": "matching(type,'Name')",
"column:2": "matching(number,100)",
"column:3": "matching(datetime, 'yyyy-MM-dd','2000-01-01')"
})).await;
i.clone()
})
.await
// Now start the mock server
.start_mock_server_async()
.await;
// Now we can make our actual request for the CSV file and validate the response
let client = CsvClient::new(csv_service.url().clone());
let data = client.fetch("report001.csv").await.unwrap();
let columns: Vec<&str> = data.trim().split(",").collect();
expect!(columns.get(0)).to(be_some().value(&"Name"));
expect!(columns.get(1)).to(be_some().value(&"100"));
let date = columns.get(2).unwrap();
let re = Regex::new("\\d{4}-\\d{2}-\\d{2}").unwrap();
expect!(re.is_match(date)).to(be_true());
}