Skip to main content

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:

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

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:

// With Typescript/Javascript, arguments and return values
// are marshalled for you, so no adapter is needed

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:

contract.registerFunction('throwingFunction', () => {
throw new CustomException('Oh no');
});

await contract.runInteraction({
definition: willReceiveFunctionCallAndThrow({
arguments: [],
errorClassName: 'CustomException',
functionName: 'throwingFunction',
}),
});

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, stack and cause).
  • In Java, the exception is serialised with Jackson (with the standard Throwable properties 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.
// 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',
}),
});
warning

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.