Defining from the implementer's side
Sometimes it's the function implementer whose expectations should drive the
contract - for example, a framework host that promises "I provide a function that
you are expected to call like this". In that case, the implementer is the
consumer, and defines the contract with willReceiveFunctionCall.
During definition, ContractCase acts as the mock caller: it invokes your real
function with the arguments from the interaction definition, and checks that
the return value matches. This means you don't write a trigger - instead, you
register the function under test with registerFunction before running the
interaction:
- Typescript
- Java
import { willReceiveFunctionCall } from '@contract-case/contract-case-jest';
// This string can be anything you like, as long as it's the same when
// registering the function and when defining the interaction
const FUNCTION_HANDLE = 'HAS ARGS FUNCTION';
beforeAll(() => {
contract.registerFunction(
FUNCTION_HANDLE,
(s: string, n: number) => `${s}${n}`,
);
});
it('succeeds', () =>
contract.runInteraction({
definition: willReceiveFunctionCall({
arguments: ['example', 2],
returnValue: 'example2',
functionName: FUNCTION_HANDLE,
}),
}));
contract.registerFunction("PageNumbers", convertJsonArgs(
(Integer num) -> num + " pages"));
contract.runInteraction(new InteractionDefinition<>(
List.of(),
WillReceiveFunctionCall.builder()
.arguments(List.of(new AnyInteger(2)))
.returnValue("2 pages")
.functionName("PageNumbers")
.build()));
Because ContractCase generates the caller side itself, there's no trigger,
testResponse, or testErrorResponse to write for these interactions. However,
because a function contract is defined expecting string arguments, you may need to
wrap your function with a marshaller and unmarshaller before providing it to ContractCase.
For example, the convertJsonArgs adapter used in the Java example above is:
- Typescript
- Java
// With Typescript/Javascript, arguments and return values
// are marshalled for you, so no adapter is needed
// Because the arguments and return values cross a language boundary,
// registered functions receive JSON strings. A small adapter like this
// parses the arguments and serialises the result of the real function.
@NotNull
private static InvokableFunction1<?> convertJsonArgs(
Function<Integer, String> functionUnderTest) {
return (String a) -> {
try {
var arg1 = mapper.readValue(a, Integer.class);
return mapper.writeValueAsString(functionUnderTest.apply(arg1));
} catch (JsonProcessingException e) {
throw new RuntimeException("Unable to parse argument");
}
};
}
Functions that throw
If the function under test is expected to throw for a given set of arguments,
define the interaction with WillReceiveFunctionCallAndThrow, describing the
error with errorClassName instead of a returnValue:
- Typescript
- Java
contract.registerFunction('throwingFunction', () => {
throw new CustomException('Oh no');
});
await contract.runInteraction({
definition: willReceiveFunctionCallAndThrow({
arguments: [],
errorClassName: 'CustomException',
functionName: 'throwingFunction',
}),
});
contract.registerFunction("throwingFunction", () -> {
throw new CustomException("Oh no");
});
contract.runInteraction(new InteractionDefinition<>(
List.of(),
WillReceiveFunctionCallAndThrow.builder()
.arguments(List.of())
.errorClassName("CustomException")
.functionName("throwingFunction")
.build()));
The errorClassName is compared with the class name of the error your
function actually throws - the error message is not part of the contract
unless you add a message matcher.
Matching the content of an error
If callers depend on data carried by the error, you can also describe the
serialised content of the thrown error with an errorInternals matcher. How the
error is serialised depends on the language:
- 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 (with the standard
Throwableproperties such as the stack trace, cause and message removed), so any public getters on your exception class become part of the error internals. You can control this with Jackson annotations on the exception class.
- Typescript
- Java
// The error's own enumerable properties (here, code and detail)
// become the errorInternals
class ComplexException extends Error {
constructor(
message: string,
readonly code: number,
readonly detail: string,
) {
super(message);
}
}
contract.registerFunction('throwingFunctionWithErrorInternals', () => {
throw new ComplexException('Oh no', 123, 'some detail');
});
await contract.runInteraction({
definition: willReceiveFunctionCallAndThrow({
arguments: [],
errorClassName: 'ComplexException',
errorInternals: shapedLike({ code: 123, detail: 'some detail' }),
responseName: 'throwing a ComplexException with error internals',
functionName: 'throwingFunctionWithErrorInternals',
}),
});
contract.registerFunction("throwingFunctionWithErrorInternals", () -> {
// ComplexException has getCode() and getDetail() accessors,
// which Jackson serialises into the errorInternals
throw new ComplexException("Oh no", 123, "some detail");
});
contract.runInteraction(new InteractionDefinition<>(
List.of(),
WillReceiveFunctionCallAndThrow.builder()
.arguments(List.of())
.errorClassName("ComplexException")
.errorInternals(new ShapedLike(Map.of("code", 123, "detail", "some detail")))
.responseName("throwing a ComplexException with error internals")
.functionName("throwingFunctionWithErrorInternals")
.build()));
Matching on the error internals should generally be a last resort - it couples the
contract to the internal structure of your exception. Prefer explicit,
distinct exception types for each kind of failure that callers might care
about, and match on errorClassName alone where you can.