Optimize your .NET development workflow with contract testing and Pact. Explore the transformative impact of contract testing on testing cycles, reliability, and integration. Unlock the potential of seamless software development.
In the ever-evolving landscape of software development, maintaining synchronization between backend and frontend teams is crucial. Miscommunication or misalignment can lead to broken functionalities, delayed releases, and ultimately, a poor user experience.
Contracts serve as a formal agreement between the two teams, ensuring that both sides adhere to predefined expectations. When your application exposes a public API, the stakes are even higher. Public APIs are consumed by external developers, and any regression can have far-reaching consequences. Contract tests act as a safety net, catching potential issues before they reach production.
What is a contract test?
Contract testing is a method to ensure that services (or components) interact with each other as expected. Unlike traditional end-to-end tests, which test the entire system, contract tests focus on the interactions between specific components. This makes them faster and more reliable.
Contract tests can be particularly useful in a microservices architecture, where services are developed and deployed independently. By validating the contracts, you can ensure that changes in one service do not break others.
What is Pact?
Pact is a contract testing tool that was created to solve the challenges of testing interactions between microservices. It was initially developed by Beth Skurrie and Matt Fellows in 2013.
Pact allows you to define a contract between a consumer (frontend) and a provider (backend) and then verify that both sides adhere to this contract. The tool has gained significant traction and is now widely used in the industry. Pact supports multiple languages, including Java, JavaScript, Ruby, and C#.
Pact necessitates the creation of two distinct types of tests—consumer tests and provider tests—to establish a valid contract, and it's important to understand the roles of the consumer and provider is crucial.
How to produce a pact test
Before getting started, it's important to point out that the case study is based on the 5.0 version of the library, which is in beta at the time of writing. Don't forget to activate the prereleases to use it.
To perform a contract test, you need an API route. Let's imagine an API for managing a list of goats, with an endpoint for retrieving information about a goat using its identifier.
app.MapGet("/goats/{id}", (int id) =>
{
return new GoatResponse(id, "Billy");
});
To simplify the case, the API returns a predefined goat, but it's quite normal for the endpoint to be wired to a service and a database in the real world. Let's keep the example as simple as possible.
Consumer testing
The consumer is the client that initiates requests to an API, often represented by the frontend application or another dependent microservice.
Consumer tests articulate the consumer's expectations by detailing the requests it will send and the responses it anticipates. These tests are essential for capturing the consumer's viewpoint and ensuring that the provider can meet these demands.
The contract is built with an IPactBuilderV4
.
private readonly IPactBuilderV4 _pactBuilder = PactNet.Pact.V4("Goat API Consumer", "Goat API Provider", new PactConfig()).WithHttpInteractions();
The PactConfig()
method can be used to define the path where the generated contract files will be saved.
The request must contain all the information needed for the HTTP request and response to define the application's expectations. On peut ajouter un peu de contexte avec les méthodes `UponReceiving()` to describe the interaction that the consumer expects to have with the provider et `Given` to set up the provider's state before the interaction takes place.
[Fact]
public async Task GivenHttpRequest_WhenCallGetGoatRoute_ThenReturnTheGoat()
{
_pactBuilder
.UponReceiving("A GET request to retrieve the goat")
.Given("There is a goat with id '1'")
.WithRequest(HttpMethod.Get, "/goats/1")
.WithHeader("Accept", "application/json")
.WillRespond()
.WithStatus(HttpStatusCode.OK)
.WithHeader("Content-Type", "application/json; charset=utf-8")
.WithJsonBody(new GoatResponse(1, "Billy"));
await _pactBuilder.VerifyAsync(async ctx =>
{
// Act
var client = new GoatClient(ctx.MockServerUri);
var goat = await client.GetGoat(1);
// Assert
Assert.Equal("Billy", goat.Name);
});
}
Once the packBuilder
has been configured, the next step is to produce the response expected by the consumer, the contract.
When you run a Consumer test, Pact creates a local mock server. This mock server simulates the responses that the supplier should return according to the defined contract.
It then exposes the MockServerUri
in order to pass it as a parameter to a client producing the call required by the test. In this way, the provider is abstracted.
class GoatClient(Uri baseUri)
{
public async Task<Goat> GetGoat(int id)
{
using var httpClient = new HttpClient { BaseAddress = baseUri };
httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var response = await httpClient.GetAsync($"goats/{id}");
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync();
return JsonConvert.DeserializeObject<Goat>(content);
}
}
To obtain the following contract:
{
"consumer": {
"name": "Goat API Consumer"
},
"interactions": [
{
"description": "A GET request to retrieve the goat",
"pending": false,
"providerStates": [
{
"name": "There is a goat with id '1'"
}
],
"request": {
"headers": {
"Accept": [
"application/json"
]
},
"method": "GET",
"path": "/goats/1"
},
"response": {
"body": {
"content": {
"Id": 1,
"Name": "Billy"
},
"contentType": "application/json",
"encoded": false
},
"headers": {
"Content-Type": [
"application/json; charset=utf-8"
]
},
"status": 200
},
"type": "Synchronous/HTTP"
}
],
"metadata": {
...
},
"provider": {
"name": "Goat API Provider"
}
}
Now that the contract has been generated, let's check the provider.
Provider testing
The provider is the server that responds to these requests, typically the backend service.
Provider tests, on the other hand, confirm that the provider can deliver the expected responses as per the contract.
The test is built using a PactVerifier
, which takes the provider's URI and the contract file as parameters. In this way, it can call the application and ensure that the return corresponds exactly to the consumer's expectations.
[Fact]
public void GivenGoatProvider_ShouldHonourGoatPact()
{
// Arrange
var uri = new Uri("API_URL");
var config = new PactVerifierConfig
{
Outputters = new List<IOutput> { new ConsoleOutput() },
LogLevel = PactLogLevel.Debug
};
var pactPath = Path.Combine("..", "..", "..", "pacts", "PACT-FILE.json");
using var pactVerifier = new PactVerifier("Goat API Provider", config);
pactVerifier
.WithHttpEndpoint(uri)
.WithFileSource(new FileInfo(pactPath))
.WithSslVerificationDisabled()
.Verify();
}
In the same way as the PactBuilder
has a configuration, it's possible to configure the PactVerifier
with a PactVerifierConfig
, notably on the output side to enable Pact error returns to be written not only to the console, but also to the xUnit return with the XunitOutput
class available in the PactNet.Output.Xunit
library, which we won't go into in this article.
A real instance of the provider is required to run a Pact test. It's impossible to use a TestServer
or a WebApplicationFactory
, as Pact won't be able to call this route, as stated in the Pact documentation extract.
-- From : docs.pact.io
Having already tried it, it doesn't work!
For my part, I always use a Docker container to start contract tests to ensure that the application is working exactly as it should.
Once in place, the test returns the following result:
1) Verifying a pact between Goat API Consumer and Goat API Provider Given There
is a goat with id '1' - A GET request to retrieve the goat
1.1) has a matching body
$ -> Actual map is missing the following keys: Id, Name
{
- "Id": 1,
"Name": "Billy"
+ "id": 1,
"name": "Billy"
}
This is excellent news, as the provider doesn't return exactly what the consumer expects, which is exactly why we set up this contract test.
The naming problem is easily solved by adding a JsonPropertyName()
to the properties of the response object.
public record GoatResponse(
[property: JsonPropertyName("id")] int Id,
[property: JsonPropertyName("name")] string Name);
To obtain a validating test.
This dual-testing approach ensures comprehensive validation from both ends of the interaction, thereby reducing the likelihood of integration failures and promoting a stable, dependable API environment.
Experience feedback of Pact Contract Testing
Pact contract testing offers several compelling advantages. One of the primary benefits is the early detection of issues, allowing teams to catch potential problems before they reach production and thereby reducing the risk of costly fixes.
Additionally, Pact improves collaboration between teams by serving as a formal agreement that clearly defines expectations and responsibilities, fostering better communication and teamwork. The faster feedback loop provided by Pact contract tests, compared to traditional end-to-end tests, allows developers to address issues promptly. Moreover, Pact's language-agnostic nature makes it versatile and adaptable to different tech stacks.
However, Pact contract testing is not without its challenges.
One of the main drawbacks is the initial setup complexity, which can be time-consuming and requires a thorough understanding of the tool and its configuration. Another significant challenge is the maintenance overhead, as contracts need to be updated whenever there are changes in the API, adding to the maintenance burden.
Additionally, the limited scope of contract tests means they may miss issues that only surface in end-to-end tests. Lastly, there is a learning curve associated with Pact contract testing, requiring teams to invest time in learning how to write and maintain contract tests effectively.
Conclusion
In the realm of software development, contract testing acts as the meticulous conductor orchestrating harmony between backend and frontend realms.
Through Pact's precision, developers navigate the microservices landscape with finesse, ensuring the integrity of software components.
Embrace this symphony of precision and collaboration, where every line of code resonates with the essence of software perfection.
Let contract testing be your guiding light in the quest for seamless integration and reliability.
Have a goat day 🐐
Join the conversation.