Skip to main content

Defining a function call interaction

The most common way to define a function call contract is from the caller's side - the caller is the consumer, and the function implementer will verify the contract later.

A function call interaction is defined with willCallFunction, which describes the arguments the caller will use, and the return value it expects:

import {
willCallFunction,
FunctionExecutorConfig,
} from '@contract-case/contract-case-jest';

await contract.runInteraction(
{
definition: willCallFunction({
arguments: ['example', 2],
returnValue: 'example2',
functionName: 'concatenate',
}),
},
{
// The trigger calls the mock function that ContractCase
// has set up for this interaction (see below)
trigger: async (setup: FunctionExecutorConfig) =>
setup.getFunction(setup.mock.functionHandle)('example', 2),
// The testResponse function asserts on the value
// returned by the trigger
testResponse: (returnValue) => {
expect(returnValue).toEqual('example2');
},
},
);

As with all interactions, the arguments and return value can be literal values (matched exactly), or Test Equivalence Matchers if you want to decouple the test from the specific test data used.

The function name

The functionName is the identifier for the function under test. It doubles as the handle that the provider team will use when registering the real function during verification, so it needs to be shared between the consumer and provider teams - just like state names.

It's best practice to use the name of the function in your code. However, the function name you use in the contract is just a handle - it doesn't need to actually match. Good function names uniquely identify the function in your domain, so that the contract is self-documenting.

Writing the trigger

During contract definition, ContractCase sets up a mock function for each interaction. Your trigger should get this mock with setup.getFunction(setup.mock.functionHandle), and then invoke your real calling code against it.

If your calling code is a thin wrapper, it's fine for the trigger to call the mock function directly (as in the example above). If you have more substantial calling code, inject the mock function into it, the same way you would inject the base URL for an HTTP client interaction.

As usual, the trigger is paired with a testResponse function that asserts your calling code understood the return value. See testing responses for details - the behaviour is the same for function call interactions.

Functions that are expected to throw

If your interaction models a call where the function is expected to fail, define it with the throwing variant of the interaction, and pair it with a testErrorResponse function (in Typescript, this means using runRejectingInteraction; in Java, runThrowingInteraction). Instead of a returnValue, you describe the error you expect with errorClassName (and, optionally, message):

await contract.runRejectingInteraction(
{
definition: willCallThrowingFunction({
arguments: [],
errorClassName: 'UserNotFoundError',
functionName: 'getUser',
}),
},
{
trigger: async (setup: FunctionExecutorConfig) =>
setup.getFunction(setup.mock.functionHandle)(),
// During definition, the mock function throws a
// FunctionCompletedExceptionally carrying the errorClassName
// defined above
testErrorResponse: (e) => {
expect(e).toBeInstanceOf(FunctionCompletedExceptionally);
expect(
(e as FunctionCompletedExceptionally).errorClassName,
).toBe('UserNotFoundError');
},
},
);

The errorClassName is matched against the class name of the error thrown by the real implementation during verification. This lets the contract describe that the function fails (and how the failure is classified), without coupling to the exact error object, which usually can't cross a language or serialisation boundary.

Matching the content of an error

If the caller depends on data carried by the error (for example, an error code), you can additionally describe the serialised content of the error with an errorInternals matcher. The error internals are whatever the implementer's language wrapper produces when it serialises the thrown error:

  • In Typescript, the error internals are the error's own enumerable properties (excluding the standard name, message, stack and cause).
  • In Java, the exception is serialised with Jackson, so public getters on the exception are included by default and you can control the serialisation with Jackson annotations (such as @JsonIgnore or @JsonProperty) on the exception class.
await contract.runRejectingInteraction(
{
definition: willCallThrowingFunction({
arguments: [],
errorClassName: 'UserNotFoundError',
// The errorInternals matcher describes the serialised content of the error.
// Prefer distinct error classes over error internals matching where you can.
errorInternals: shapedLike({ code: 404, detail: 'No such user' }),
responseName: 'throwing a UserNotFoundError with error internals',
functionName: 'getUser',
}),
},
{
trigger: async (setup: FunctionExecutorConfig) =>
setup.getFunction(setup.mock.functionHandle)(),
testErrorResponse: (e) => {
const thrown = e as FunctionCompletedExceptionally;
// The mock throws example error internals that match the contract
expect(thrown.errorInternals).toEqual({
code: 404,
detail: 'No such user',
});
},
},
);
warning

Matching on the error internals should generally be a last resort. It couples the contract to the internal structure of the error, which is easy to change accidentally. It's usually better for the function implementer to provide explicit, distinct error types for each kind of failure that callers might care about, and to match on errorClassName alone.

When an error declares errorInternals, give it a responseName - this is the name the implementer uses to identify the error response in their trigger groups during verification. Without one, the name is generated from the description of the errorInternals matcher.

States

Function call interactions support state definitions in exactly the same way as HTTP interactions - pass an array of inState(...) definitions alongside the interaction definition, and these will be called at the appropriate time during definition.

Next steps

Once your contract is defined, upload it to a broker so that the function implementer can verify it.