Matching test data
By default, the data used in an interaction is strictly matched - if the contract
says the userId should be "foo"
, then it must be "foo"
during the
verification too.
However, it's not always convenient to have the exact same test data between the consumer test and the provider verification. Sometimes, there are even technical constraints preventing the test data from being known ahead of time (for example, where user IDs are autogenerated on insertion into a database).
Test Equivalence Matchers
To address this, we use Test Equivalence Matchers. These tell the framework "this test is equivalent to any other test where the data passes this matcher".
In the examples in the earlier sections, we used john smith
as the example username.
During provider verification, it might be that the test user was set up to be
called steve jenkins
. This would still be a valid username - we don't want to fail
the test just because the specific test data happens to be different. However, we do want to check that the name still conforms to
any expectations that the consumer had.
For example, the consumer might expect that the user name was a valid, non-empty string.
We can express the non-empty string state with the anyString(example?: string)
Test Equivalence Matcher:
body: {
userId: 'foo',
// You can read this as
// "this test covers any name property that is a string (eg 'john smith').
name: anyString('john smith'),
},
This means that the test will pass if any non-empty string is returned as the name
. The test would fail if a complex object or other data type was returned there.
The example passed in to the matcher here is used as the specific data during contract definition - when the provider returns this object, it will reify it into:
{
userId: 'foo',
name: 'john smith',
}
Do I need to use matchers?
Test-equivalence matchers are just a mechanism for making it convenient to do contract tests in an environment where it's inconvenient to keep the test data identical.
If it's convenient for you to keep the test data in sync between the provider and the consumer, there's no need to use test-equivalence matchers at all. However, our experience is that most teams find test-equivalence matchers very convenient.
Parametrising the test with state variables
Sometimes, there are constraints on the provider team that are hard to know from
the consumer side. For example, perhaps their test data is constrained in some
way, and so it is hard to set up a user with the ID 'foo'
. To allow them to
specify some of the state, state variables can be used. Instead of:
- Typescript
- Java
inState('A user with id "foo" exists'),
new InState("A user with id \"foo\" exists"),
you can provide a state variable (and a default value) by providing an object keyed by the variable name:
- Typescript
- Java
inState('A user exists', { userId: 'foo' }),
new InStateWithVariables(
"A user exists",
Map.ofEntries(
Map.entry("userId", "123")
)
),
This can later be accessed using the stateVariable(name: string)
Test
Equivalence Matcher. You can also access the variables using the config object passed
to the trigger
and testResponse
functions:
- Typescript
- Java
await contract.runInteraction(
{
states: [
inState('Server is up'),
// Here we tell ContractCase that there's
// a userId variable returned by this state.
// We give the example "foo", to be used
// during the contract definition.
//
// During contract verification, the value of userId
// value will be provided by the state setup function
// in the provider test.
inState('A user exists', { userId: 'foo' }),
],
definition: willSendHttpRequest({
request: {
method: 'GET',
path: '/users',
// This matcher tells ContractCase that
// the id in the query will be whatever
// the userId is
query: { id: stateVariable('userId') },
},
response: {
status: 200,
body: {
// In the response body, we expect the
// userId to be present
userId: stateVariable('userId'),
// and the name may be any non-empty string
name: anyString('John Smith'),
},
},
}),
},
{
trigger: (interactionSetup: HttpRequestConfig) =>
new YourApi(interactionSetup.mock.baseUrl).getUser(
interactionSetup.getStateVariable('userId'),
),
testResponse: (user, interactionSetup: HttpRequestConfig) => {
expect(user).toEqual({
userId: interactionSetup.getStateVariable('userId'),
name: 'John Smith',
});
},
},
);
contract.runInteraction(
new InteractionDefinition<>(
List.of(
new InStateWithVariables(
"A user exists",
Map.ofEntries(
Map.entry("userId", "123")
)
)
),
new WillSendHttpRequest(HttpExample.builder()
.request(
new HttpRequest(HttpRequestExample.builder()
.method("GET")
.path("/users")
// The StateVariable matcher tells ContractCase that
// the id in the query will be the userId from the
// state setup.
.query(Map.of("id", new StateVariable("userId")))
.build())
)
.response(new HttpResponse(HttpResponseExample.builder()
.status(200)
.body(
Map.ofEntries(
// In the response body, we expect the
// userId to be the same as the one set up
// during state setup
Map.entry("id", new StateVariable("userId")),
// and the name may be any non-empty string
// (but during the contract definition, it will be "John Smith")
Map.entry("name", new AnyString("john smith"))
)
)
.build()))
.build())
),
IndividualSuccessTestConfigBuilder.builder()
.withTrigger(
(interactionSetup) ->
new YourApiClient(interactionSetup.getMockSetup("baseUrl"))
.getUserQuery(interactionSetup.getStateVariable("userId")))
.withTestResponse(
(user, config) -> {
assertThat(user).isEqualTo(new User("foo", ""));
})
);
The default value passed to the state variable can be a Test Equivalence Matcher, so you could add additional constraints:
inState('a user has a score that is an integer', { userScore: anyInteger(10) });
This would prevent the state handler from returning any userScore
value that doesn't pass the anyInteger
constraint.
Note: Remember that the goal here is not to describe the API, it's to describe the constraints of the service defining the contract. So, if the
userScore
field is an integer, but the client would actually be happy with any number, you don't need to constrain it.
As a convenience, all default values passed to state variables are automatically
wrapped in a shapedLike()
constraint. This means that userId: 'foo'
will
accept any string
value, and not just the exact string 'foo'
.
Anti-pattern: Describing the type system with matchers
Sometimes, first time users try to use matchers to describe the whole type system.
For example, a User
payload might describe either an admin
or a member
,
where they may have slightly different fields in the two response payloads.
In this situation, these are probably separate interactions:
- When user
123
is an admin, the get user request should return a user payload describing an admin - When user
123
is a member, the get user request should return a user payload describing a member
It's important to keep these tests separate, because if a service erroneously
could never produce an admin
payload, then a test that passed if either response was returned
would give false confidence.
Be careful not to use matchers to fully realise your type system - you should use matchers only to widen the definition of responses that would also be covered by the current test.
Anti-pattern: Optional fields and empty arrays
Similarly, a test which allows optional fields would still pass even if the field was never present. You wouldn't be sure that your provider could ever generate that missing field.
For a situation where you have an optional field that your consumer relies on, write tests for two interactions - one with the field present, and one without.
To provide the best deployment confidence, a contract test needs specific examples.
Matcher overview
In the reference section, you can find all Test Equivalence Matchers documented.