With more and more software moving from monolith to microservices, we are swamped in the world of more APIs than ever. How can we write APIs that are meaningful, precise , testable and maintainable? Once an API is out there we are obligated not to break them or alternatively we have to know before hand that we have breaking changes.
In this post I try to put together my experience on how Pact (https://docs.pact.io) helps us in writing better APIs in our microservices architecture.
What is a Pact?
Pact (noun):
A formal agreement between individuals or parties.
According to Pact Foundation
Pact is a
contract testing tool
. Contract testing is a way to ensure that services (such as an API provider and a consumer) can communicate with each other. Without contract testing, the only way to know that services can communicate is by using expensive and brittle integration tests.
Parties
Any API(Application Programming Interface) consists of two important parties:
Provider: One who exposes one or more API(s)
Consumer(s): One or more client(s) who uses the API(s) exposed by the Provider
How does a Pact work?
Consumer captures an expectation
as a separate pact, and provider agrees to it
.
Advantages of using Pacts
Isolation:
The Ability to test the provider and consumer in Isolation but in conjunction, i.e. Pacts gives us the sophistication of
- testing a consumer with provider's API from the comfort of a developer's machine Without having to make an actual call.
- testing a provider's API changes against one or more consumers to make sure that the changes do not accidentally break the existing consumers
Quick Feedback
We do not have to wait for our End to End tests to fail, the same feedback can be unit tested.
User Centred API design
Pacts are essentially Consumer Driven Contracts
, so the consumers are laying out the expectation which leads to better usable APIs
Overview of existing APIs
- Pacts provides
Network Graph
of dependant services - Pacts helps us understand, if
one or more APIs are similar
- Pacts can also show
unused APIs
, when the consumers are not using them anymore.
Language independent
Pact is a specification, which makes it perfect for microservices testing. You can find Pact consumer and provider libraries implemented in many programming languages here
Pact in Action:
An Example Pact
Let us take the most simple use case of authentication as a service.
Consumer: I need to log the user in, I have user credentials
Provider: I can authenticate a user given credentials
For demonstration, we use the same pact as described above. Complete implementation can be found here
Consumer Pact in Go:
import (
"fmt"
"github.com/pact-foundation/pact-go/dsl"
"net/http"
"testing"
)
func TestClient_AuthenticateUser(t *testing.T) {
var username = "alice"
var password = "s3cr3t"
t.Run("user exists", func(t *testing.T) {
pact := &dsl.Pact{
Consumer: "Quoki",
Provider: "UserManager",
PactDir: "../pacts",
LogDir: "../pacts/logs",
}
defer pact.Teardown()
pact.
AddInteraction().
Given("user exists").
UponReceiving("a request to authenticate").
WithRequest(dsl.Request{
Method: "POST",
Path: dsl.String(fmt.Sprintf("/users/%s/authentication", username)),
Headers: dsl.MapMatcher{"Content-Type": dsl.Like("application/x-www-form-urlencoded")},
Body: dsl.MapMatcher{
"password": dsl.Like(password),
},
}).
WillRespondWith(dsl.Response{
Status: http.StatusNoContent,
})
pact.Verify(func() error {
subject := New(fmt.Sprintf("http://localhost:%d", pact.Server.Port))
ok := subject.AuthenticateUser(username, password)
if !ok {
t.Fail()
}
return nil
})
})
}
Running this successfully will generate,
{
"consumer": {
"name": "Quoki"
},
"provider": {
"name": "UserManager"
},
"interactions": [
{
"description": "a request to authenticate",
"providerState": "user exists",
"request": {
"method": "POST",
"path": "/users/alice/authentication",
"headers": {
"Content-Type": "application/x-www-form-urlencoded"
},
"body": "password=s3cr3t",
"matchingRules": {
"$.headers.Content-Type": {
"match": "type"
},
"$.body.password": {
"match": "type"
}
}
},
"response": {
"status": 204,
"headers": {
}
}
}
],
"metadata": {
"pactSpecification": {
"version": "2.0.0"
}
}
}
Provider verification of Pact in Kotlin
The Provider then takes this as requirement, then verifies by running this test
package com.shyamz.provider.authenticate
import au.com.dius.pact.provider.junit.Consumer
import au.com.dius.pact.provider.junit.Provider
import au.com.dius.pact.provider.junit.State
import au.com.dius.pact.provider.junit.loader.PactFolder
import au.com.dius.pact.provider.junit.target.HttpTarget
import au.com.dius.pact.provider.junit.target.Target
import au.com.dius.pact.provider.junit.target.TestTarget
import au.com.dius.pact.provider.spring.SpringRestPactRunner
import org.junit.Before
import org.junit.runner.RunWith
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
@RunWith(SpringRestPactRunner::class)
@Provider("UserManager")
@Consumer("Quoki")
@PactFolder("../consumer/src/consumer/http/pacts")
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT,
properties = ["server.port=8601"])
class QuokiUserAuthenticatePactItTest {
@Suppress("unused")
@JvmField
@TestTarget
final val target: Target = HttpTarget(port=8601)
@Autowired
private lateinit var userRepository: UserRepository
@Before
fun setUp() {
userRepository.deleteAll()
}
@State("user exists")
fun userExists() {
userRepository.save(QuokiUser(userName = "alice", password = "s3cr3t"))
}
}
Running this will provide the result
Verifying a pact between Quoki and UserManager
Given user exists
a request to authenticate
returns a response which
has status code 204 (OK)
has a matching body (OK)
Best Practices
A Bad Pact
A bad pact, dictates what it considers as a precondition for an invalid username and tightly couples the exact error message that is expected.
This is then unit testing the provider's implementation detail rather than the contract itself. So the provider cannot change the validation rule or the error message without breaking the consumer.
Given : Alice does not exist
Upon receiving: A request to create user, with user name that has special characters other than underscores
Response: Is 400 Bad Request
Response Body: {"error": "username cannot contain special characters"}
A Good Pact
A Good pact from a consumer hides the provider's implementation details. It must only capture expectation from consumer point of view. In the below example a consumer shall not dictate what it considers an invalid username.
Given : Alice does not exist
Upon receiving: A request to create user with invalid username
Response: Is 400 Bad Request
Response Body: {"error": "[a non empty string]"}
Conclusion
If you have more questions or if you need more information, you can find all the information here
Top comments (0)