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:
- Typescript
- Java
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');
},
},
);
contract.runInteraction(
new InteractionDefinition<>(
List.of(),
WillCallFunction.builder()
.arguments(List.of(new AnyInteger(2)))
.returnValue("2 pages")
.functionName("PageNumbers")
.build()),
IndividualSuccessTestConfigBuilder.<String>builder()
// The trigger calls the mock function that ContractCase
// has set up for this interaction (see below)
.withTrigger((setupInfo) ->
parse(setupInfo.getFunction(setupInfo.getMockSetup("functionHandle"))
.apply(List.of("2"))))
// The testResponse function asserts on the value
// returned by the trigger
.withTestResponse((result, setupInfo) -> {
assertThat(result).isEqualTo("2 pages");
}));
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):
- Typescript
- Java
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');
},
},
);
contract.runThrowingInteraction(
new InteractionDefinition<>(
List.of(),
WillCallThrowingFunction.builder()
.arguments(List.of())
.errorClassName("CustomException")
.functionName("throwingFunction")
.build()),
IndividualFailedTestConfigBuilder.<String>builder()
.withTrigger((setupInfo) ->
parse(setupInfo.getFunction(setupInfo.getMockSetup("functionHandle"))
.apply(List.of())))
// During definition, the mock function throws an exception
// matching the errorClassName defined above
.withTestErrorResponse((exception, setupInfo) -> {
assertThat(exception).isInstanceOf(FunctionCompletedExceptionally.class);
}));
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,stackandcause). - 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
@JsonIgnoreor@JsonProperty) on the exception class.
- Typescript
- Java
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',
});
},
},
);
contract.runThrowingInteraction(
new InteractionDefinition<>(
List.of(),
WillCallThrowingFunction.builder()
.arguments(List.of())
.errorClassName("ComplexException")
// The errorInternals matcher describes the serialised content of the exception.
// Prefer distinct exception classes over error internals matching where you can.
.errorInternals(new ShapedLike(Map.of("code", 123, "detail", "some detail")))
.responseName("throwing a ComplexException with error internals")
.functionName("throwingFunctionWithErrorInternals")
.build()),
IndividualFailedTestConfigBuilder.<String>builder()
.withTrigger((setupInfo) ->
parse(setupInfo.getFunction(setupInfo.getMockSetup("functionHandle"))
.apply(List.of())))
.withTestErrorResponse((exception, setupInfo) -> {
var thrown = (FunctionCompletedExceptionally) exception;
// The mock throws example error internals that match the contract
assertThat(thrown.getErrorInternals())
.isEqualTo(Map.of("code", 123, "detail", "some detail"));
}));
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.