Defining service states
Since each Example in ContractCase is defined and verified independently, you may need to indicate preconditions that must be true for the example to be valid. This is done using service states.
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 keyed by the state name that can be called to set up the service in that state.
Whether or not you also need to provide a state handler depends on the Example type that you're defining. For example, with HTTP Examples, state handlers are only required at the server side. So:
- A contract defined by an HTTP client will need state handlers during contract verification.
- A contract defined by an HTTP server will need state handlers during contract definition.
For a full list of the Example types and when state handlers are required, see the Example types reference. ContractCase will warn helpfully if you need state handlers and do not provide them.
Order of execution
States are guaranteed to be executed in the order that the states are defined in the Example:
- Typescript
- Java
await contract.runExample(
{
states: [
inState('Server is up'), // This one runs first
inState('A user with id "foo" exists'), // This one runs second
],
/* .... */
},
{ logLevel: 'debug' },
);
contract.runExample(
new ExampleDefinition<>(
List.of(
// This one runs first
new InState("Server is up"),
// This one runs second
new InState("A user with id \"foo\" exists")
),
/* ... */
);
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 Example 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 example 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 not possible to predict the result of a precondition - for example, it's not always possible to know what user ID the next created user will have. In these cases, cases, the state handler can return variables.
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, where the values are the default values for the variables:
- 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 variable returned by the state
handler later, so a default value of "foo"
will not match 12
, as a number
does not match the string provided in the variable. Defaults are not constrained
to be primitive types, but generally.
These state variables are available via the stateVariable()
test equivalence matcher:
- Typescript
- Java
await contract.runExample({
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.runExample(
new ExampleDefinition<>(
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())
),
/* ... */
);
Next steps
Next, read about how to relax the test constraints to make it easy for your contract to be verified.