Learnings and observations from implementing contract testing
Will Herring
Full Stack Software Engineer @ Banked : | Mod @ indie-testing.community | GoLang | TypeScript | React
Recently, I've been working to put together a proof of concept for adding contract tests to our automated testing repertoire here at Banked. It's a tool that I'd heard of and discussed regularly over the last few years, but never had the opportunity to build from scratch myself, so when the opportunity arose here, I was excited to take it on.
What is contract testing, and why choose it?
For those that don't know, contract testing is a type of testing (frequently mentioned alongside Pact ) that mainly focusses on the integration and communication between different services. As a lot of organisations have transitioned to a micro-services architecture, we've come across issues where Team A will make a change in Service A, that has unexpected impacts on a consumer of that service, Service B, owned by a completely different team, who were unaware this change was happening, let alone that it would also impact them. Suddenly alerts are firing, customers are impacted, and stakeholders are frustrated. Contract testing aims to help teams be more aware of their dependencies and to talk about their changes before problems are hitting production.
In any given contract, you have two parties: the consumer, who will be making a request to an API and expecting a specific response; and the provider, who provides the API and will be responding to the request. The contract then contains all the expected interactions from the consumer's perspective, and the provider will then have all these interactions replayed against it to validate that it responds as expected. If, when testing against the provider, we find a discrepancy between what's expected in the contract and what the provider actually responds with, then we know some changes are needed somewhere in order to get these two services aligned again.
At Banked, our architecture fits in with what I described earlier: A number of micro-services, all owned by various different teams, and written in multiple different languages. As we've rapidly grown our engineering teams and the product itself, it's become more challenging to be aware of all of the potential impacts a change could introduce. With that in mind, we felt like contract testing would help provide some additional confidence when we want to merge in changes.
Getting our POC up and running
Our services are mainly built in either Go, Ruby, or Javascript/Typescript, all languages that Pact has libraries for (You can see the full list in the side panel here ), and I identified two Go services that had a single interaction between them; Perfect to use for a POC with pact-go .
As a sidebar, pact-go is currently undergoing quite a lot of changes from it's main branch to the 2.x.x branch, and I initially had a lot of challenges getting the main branch version to work on my M1 Mac, and never quite figured out how to get around them. So if you're working on an ARM machine like me, I'd recommend using the 2.x.x version as well.
Pact tests follow a relatively simple flow:
Pact then handles intercepting the actual request and returning the response you specify.
Consumer Testing
In my example below, I'm looking to validate a request made from our refunds-service, the consumer, to our mas-rest-api service, the provider. It's a POST request, with a JSON body in the request and the response. With that example in mind, lets take a look at how the above steps play out:
mockProvider, err := consumer.NewV2Pact(consumer.MockHTTPProviderConfig{
Consumer: "refunds-services",
Provider: "mas-rest-api",
Host: "127.0.0.1",
Port: 55532,
})
First up, we have our mock provider config. Adding the consumer and provider names will allow the provider half of the tests to know which contracts relate to it. I've added a specific host and port just so I know where exactly the mock can be located.
requestBodyMatcher := Like(map[string]interface{}{
"externalReferenceId": Like("test-refund-1000"),
"merchantAccountId": UUID(),
"amount": Like(map[string]interface{}{
"amount": Like("400"),
"currency": Term("GBP", "GBP|EUR|PLN"),
}),
"reference": Like("Refund."),
"individualBankAccount": Like(map[string]interface{}{
"firstName": Like("Paw"),
"lastName": Like("Patrol"),
"sortCodeAccountNumber": Like(map[string]interface{}{
"sortCode": Like("123456"),
"accountNumber": Like("12345678"),
}),
}),
})
responseBodyMatcher := Like(map[string]interface{}{
"payout": Like(map[string]interface{}{
"id": UUID(),
"externalReferenceId": Like("test-refund-1000"),
"merchantAccountId": UUID(),
"amount": Like(map[string]interface{}{
"amount": Like("400"),
"currency": Term("GBP", "GBP|EUR|PLN"),
}),
"reference": Like("Refund."),
"status": Like("PAYOUT_STATUS_PENDING"),
"createdAt": Like("2023-01-25T13:32:30.791103985Z"),
"updatedAt": Like("2023-01-25T13:32:30.791103985Z"),
}),
})
After declaring our mock provider, we've then got our two body matchers for the request and response. Pact provides several different matcher functions that allow you to match on various types. For example, for all our different IDs we use the UUID() matcher, and whenever we have fields that have specific set values, like currency, we can use the Term() matcher and a regex to specify the accepted values.
领英推荐
err = mockProvider.
AddInteraction().
Given("We want to create a payout").
UponReceiving("A valid request").
WithRequest("POST", "/v1/payouts", func(b *consumer.V2RequestBuilder) {
b.JSONBody(requestBodyMatcher)
}).
WillRespondWith(201, func(b *consumer.V2ResponseBuilder) {
b.JSONBody(responseBodyMatcher)
b.Header("Content-Type", S("application/json;charset=UTF-8"))
}).
ExecuteTest(t, func(config consumer.MockServerConfig) error {
client := mas.New("https://127.0.0.1:55532")
_, err := client.CreatePayout(context.TODO(), createPayoutRequestObject)
assert.NoError(t, err)
return err
})
assert.NoError(t, err)
Finally, we specify our actual test. Pact is quite legible with a BDD-like syntax. The WithRequest() method outlines what we expect our consumer to send to the provider, and the WillRespondWith() method outlines our expectations for the providers response. In both of these, we make use of the matcher objects we created above.
Lastly, the ExecuteTest() method is what we use to actually call the HTTP method itself. In my test, I've not been too bothered about what comes back from that method currently, but we could do some additional assertions here against the return value of the method if we wanted to. For this, I just wanted to validate that we've gotten our successful response and handled it without error.
Running this test will generate the contract, containing all the information required in order to replay the same request against our actual provider to ensure it responds as expected.
Using a Pact Broker
It's not specifically required to make use of contract testing, but within this context, a Pact Broker is basically an intermediary service that provides the ability to share contracts amongst your different services and to store validation data. You can self-host Pact Broker using the supplied Docker image , or you can also use the SaaS version called PactFlow . As it's so easy to get up and running with, and prevents me from needing to manually pass around the contract files myself, I decided to use PactFlow's free tier for our Pact Broker.
Some of the language specific Pact libraries provide built-in functionality for publishing contracts to the Pact Broker, but since the pact-go one does not, I needed to make use of the pact-cli Docker image to do so. Once the contract is published, the consumer half of the testing is complete.
Testing our Provider
The last step in all of this is to validate our contract against the provider. The code for this is incredibly simple:
err := verifier.VerifyProvider(t, provider.VerifyRequest{
ProviderBaseURL: "https://mas-rest-api",
Provider: "mas-rest-api",
BrokerURL: os.Getenv("PACTFLOW_URL"),
BrokerToken: os.Getenv("PACTFLOW_AUTH_TOKEN"),
ConsumerVersionSelectors: []provider.Selector{
&provider.ConsumerVersionSelector{
Branch: "main",
Latest: true,
},
},
PublishVerificationResults: true,
ProviderVersion: os.Getenv("VERSION"),
})
assert.NoError(t, err)
So there's a number of values here we've needed to use. Most of them are pretty self explanatory, but let's go through them all.
Having run the provider test, we now know whether our services are aligned, and if we have a failure, we know a conversation needs to happen.
Challenges and learnings
Getting the tests themselves running locally wasn't too difficult, but I ended up scratching my head a few times when trying to get this up and running in our CI. Here are a couple of issues and observations I made throughout the POC:
So that's a quick round up of putting together our proof of concept with Pact, and why it feels like it's going to be a great tool for us. I'm looking forward to expanding on the proof of concept further and seeing the impact it can have in our company. If you have any questions about Pact, feel free to drop me a message on here or find me in the Pact Slack.
Software engineering leader
1 年Great article Will Herring. Are you using can-i-deploy in your deployment pipelines https://docs.pact.io/pact_broker/can_i_deploy ?