Skip to main content

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:

              inState('Server is up'),
inState('A user with id "foo" exists'),

There are two parts to service states:

  1. State definitions - A state definition indicates the need for a precondition with the inState() DSL function at contract definition time (like the example above)
  2. 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:

      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' },
);

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:

            inState('A user exists', { 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.

tip

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:

      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'),
},
},
}),
/* ... */
});
});