Defining service states
Some interactions return different responses in different states. For example, an http request to get a user's details would return differently depending on whether or not the user exists.
In order to indicate these differences, state definitions are used.
Each state is identified with a name string. The best practice is to use a human readable, descriptive string - for example:
- Typescript
- Java
inState('Server is up'),
inState('A user with id "foo" exists'),
new InState("Server is up"),
new InState("A user with id \"foo\" exists")
There are two parts to service states:
- State definitions - A state definition indicates the need for a precondition with the
inState()
DSL function at contract definition time (like the example above) - State handlers - A state handler is a function that can be called to set up the service in a particular state.
During HTTP client contract definition, you only need to reference the state definition.
Later, when the provider team go to verify the contract, they'll need to implement state handlers (see the http contract verification section for details).
Order of execution
State handlers are guaranteed to be executed in the order that the states are defined in the interaction:
- Typescript
- Java
await contract.runInteraction(
{
states: [
inState('Server is up'), // This one runs first
inState('A user with id "foo" exists'), // This one runs second
],
/* .... */
},
{ logLevel: 'debug' },
);
contract.runInteraction(
new InteractionDefinition<>(
List.of(
// This one runs first
new InState("Server is up"),
// This one runs second
new InState("A user with id \"foo\" exists")
),
/* ... */
);
This means the best practice is to describes your states with the most important precondition first - eg, the server is up, a user exists, and that user is an admin.
You will need to communicate with the provider team to find out what order is most appropriate for your case.
State definition types
There are two types of state definitions - name-only states and states with variables.
Name-only states
The state name describes the precondition needed for your interaction to be valid. It is used as a key between the consumer and the provider to set up the preconditions.
Good state names are short, but descriptive. It should be possible to read the
state name and know what the precondition is. For example, the state "a user
with id='foo' exists" implies that there's a user object retrievable with
id='foo'
. If your interaction needs additional properties on that user, you
should ideally include it in the state name (eg "a user with id 'foo' who has purchased some books"
).
State names need to be shared between the consumer and provider teams.
States with variables
Sometimes it's challenging to predict specific server state data that is needed to run a test. For example, the user ID for a valid user might not be known when you're writing your contract (say, in a situation where the server generates random user ids each time a new user is created).
To make verification of these cases convenient, the state handler can return variables. When you define a state variable, you specify a default value. This default value will be used during contract definition.
States with variables are an extension of name-only states - they have all of the same properties as name-only states, but can return variables too:
To define a state with variables, provide an object keyed by variable names:
- Typescript
- Java
inState('A user exists', { userId: 'foo' }),
new InStateWithVariables("A user exists", Map.of("userId", "foo"));
This defines a state with the name "A user exists"
, which is expected to
return a variable called userId
. The default value for this variable is
"foo"
, which will be used when state handlers are not used (for example, in
HTTP contracts, state handlers are only used to set up preconditions for the
server. When the HTTP client is under test, the default state variables are
used).
Defaults are not constrained to be primitive types, but it is generally best practice to avoid passing complicated objects.
The default variable value is also a matcher, automatically wrapped in
shapedLike()
. This will be checked against the value returned by the state
handler. This ensures that the test data on both sides of the contract meet the same expectations.
If you need to use the state variable in a part of your interaction definition, you can use the StateVariable test equivalence matcher:
- Typescript
- Java
await contract.runInteraction({
states: [
inState('Server is up'),
inState('A user exists', { userId: '123' }),
],
definition: willSendHttpRequest({
request: {
method: 'GET',
path: stringPrefix('/users/', stateVariable('userId')),
},
response: {
status: 200,
body: {
userId: stateVariable('userId'),
name: anyString('John Smith'),
},
},
}),
/* ... */
});
});
contract.runInteraction(
new InteractionDefinition<>(
List.of(
new InState("Server is up"),
new InStateWithVariables(
"A user exists",
Map.of("userId", "123")
)
),
new WillSendHttpRequest(
HttpExample.builder()
.request(
new HttpRequest(HttpRequestExample.builder()
.method("GET")
.path(
new StringPrefix(
"/users",
new StateVariable("userId")
)
).build()
)
)
.response(new HttpResponse(HttpResponseExample.builder()
.status(200)
.body(
Map.ofEntries(
Map.entry("userId", new StateVariable("userId")),
Map.entry("name", new AnyString("john smith"))
)
)
.build()))
.build())
),
/* ... */
);