Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support Jest asymmetric matchers #111

Merged
merged 4 commits into from
Aug 15, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"prepare": "husky install",
"ci": "yarn install --frozen-lockfile",
"pretest": "rimraf coverage/",
"test": "jest --coverage",
"test": "jest --coverage --colors",
"test-types": "tsd",
"test-e2e": "ts-node test-e2e/simple/run.ts",
"lint": "eslint .",
Expand Down Expand Up @@ -67,6 +67,7 @@
"husky": "7.0.4",
"jest": "28.1.1",
"lint-staged": "11.1.2",
"pretty-format": "28.1.1",
"rimraf": "3.0.2",
"size-limit": "5.0.3",
"standard-version": "9.3.1",
Expand All @@ -84,6 +85,9 @@
"modulePathIgnorePatterns": [
"test-e2e",
"verdaccio-storage"
],
"snapshotSerializers": [
"./node_modules/pretty-format/build/plugins/ConvertAnsi.js"
]
},
"lint-staged": {
Expand Down
197 changes: 101 additions & 96 deletions src/jestMatchers.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-empty-interface */
import assert from 'assert';
import type {MetadataBearer} from '@aws-sdk/types';
import type {AwsCommand, AwsStub} from './awsClientStub';
import type {SinonSpyCall} from 'sinon';

export interface AwsSdkJestMockBaseMatchers<R> extends Record<string, any> {
interface AwsSdkJestMockBaseMatchers<R> extends Record<string, any> {
/**
* Asserts {@link AwsStub Aws Client Mock} received a {@link command} exact number of {@link times}
*
Expand All @@ -28,8 +28,7 @@ export interface AwsSdkJestMockBaseMatchers<R> extends Record<string, any> {
): R;

/**
* Asserts {@link AwsStub Aws Client Mock} received a {@link command} at leas one time with input
* matching {@link input}
* Asserts {@link AwsStub Aws Client Mock} received a {@link command} at least one time with matching {@link input}
*
* @param command aws-sdk command constructor
* @param input
Expand All @@ -56,7 +55,7 @@ export interface AwsSdkJestMockBaseMatchers<R> extends Record<string, any> {
): R;
}

export interface AwsSdkJestMockAliasMatchers<R> {
interface AwsSdkJestMockAliasMatchers<R> {
/**
* Asserts {@link AwsStub Aws Client Mock} received a {@link command} exact number of {@link times}
*
Expand All @@ -82,8 +81,7 @@ export interface AwsSdkJestMockAliasMatchers<R> {
): R;

/**
* Asserts {@link AwsStub Aws Client Mock} received a {@link command} at leas one time with input
* matching {@link input}
* Asserts {@link AwsStub Aws Client Mock} received a {@link command} at least one time with matching {@link input}
*
* @alias {@link AwsSdkJestMockBaseMatchers.toHaveReceivedCommandWith}
* @param command aws-sdk command constructor
Expand Down Expand Up @@ -141,7 +139,6 @@ export interface AwsSdkJestMockMatchers<R> extends AwsSdkJestMockBaseMatchers<R>

declare global {
namespace jest {
// eslint-disable-next-line @typescript-eslint/no-empty-interface
interface Matchers<R = void> extends AwsSdkJestMockMatchers<R> {
}
}
Expand All @@ -153,124 +150,106 @@ type AnySpyCall = SinonSpyCall<[AnyCommand]>;
type MessageFunctionParams<CheckData> = {
cmd: string;
client: string;
calls: AnySpyCall[];
commandCalls: AnySpyCall[];
data: CheckData;
notPrefix: string;
};

/**
* Prettyprints command calls for message
*
* @param ctx
* @param calls
* @returns
*/
function printCalls(ctx: jest.MatcherContext, calls: AnySpyCall[]): string[] {
return calls.length > 0 ? [
'Calls:',
'',
...calls.map(
(c, i) =>
` ${i + 1}. ${c.args[0].constructor.name}: ${ctx.utils.printReceived(
c.args[0].input,
)}`,
)] : [];
}
const printCalls = (ctx: jest.MatcherContext, calls: AnySpyCall[]): string[] =>
calls.length > 0
? [
'',
'Calls:',
...calls.map(
(c, i) =>
` ${i + 1}. ${c.args[0].constructor.name}: ${ctx.utils.printReceived(
c.args[0].input,
)}`,
)]
: [];

export function processMatch<CheckData>({
ctx,
mockClient,
command,
check,
input,
message,
}: {
const processMatch = <CheckData = undefined>({ctx, mockClient, command, check, message}: {
ctx: jest.MatcherContext;
mockClient: ClientMock;
command: new () => AnyCommand;
check: (params: { calls: AnySpyCall[]; commandCalls: AnySpyCall[] }) => {
pass: boolean;
data: CheckData;
};
input: Record<string, unknown> | undefined;
message: (params: MessageFunctionParams<CheckData>) => string[];
}): jest.CustomMatcherResult {
}): jest.CustomMatcherResult => {
assert(
command &&
typeof command === 'function' &&
typeof command.name === 'string' &&
command.name.length > 0,
'Command must be valid AWS Sdk Command',
'Command must be valid AWS SDK Command',
);

const calls = mockClient.calls();
const commandCalls = mockClient.commandCalls(command, input);
const commandCalls = mockClient.commandCalls(command);

const {pass, data} = check({calls, commandCalls});

const msg = (): string => {
const cmd = ctx.utils.printExpected(command.name);
const client = mockClient.clientName();

const msgParams: MessageFunctionParams<CheckData> = {
calls,
client,
cmd,
data,
commandCalls,
notPrefix: ctx.isNot ? 'not ' : '',
};

return message(msgParams).join('\n');
return [
...message({
client,
cmd,
data,
commandCalls,
notPrefix: ctx.isNot ? 'not ' : '',
}),
...printCalls(ctx, calls),
].join('\n');
};

return {pass, message: msg};
}
};

/* Using them for testing */
export const baseMatchers: { [P in keyof AwsSdkJestMockBaseMatchers<unknown>]: jest.CustomMatcher } = {
const baseMatchers: { [P in keyof AwsSdkJestMockBaseMatchers<unknown>]: jest.CustomMatcher } = {
/**
* implementation of {@link AwsSdkJestMockMatchers.toHaveReceivedCommandTimes} matcher
* implementation of {@link AwsSdkJestMockMatchers.toHaveReceivedCommand} matcher
*/
toHaveReceivedCommandTimes(
toHaveReceivedCommand(
this: jest.MatcherContext,
mockClient: ClientMock,
command: new () => AnyCommand,
expectedCalls: number,
) {
return processMatch({
ctx: this,
mockClient,
command,
input: undefined,
check: ({commandCalls}) => ({pass: commandCalls.length === expectedCalls, data: {}}),
message: ({client, cmd, commandCalls, notPrefix}) => [
`Expected ${client} to ${notPrefix}receive ${cmd} ${this.utils.printExpected(
expectedCalls,
)} times`,
check: ({commandCalls}) => ({pass: commandCalls.length > 0, data: undefined}),
message: ({client, cmd, notPrefix, commandCalls}) => [
`Expected ${client} to ${notPrefix}receive ${cmd}`,
`${client} received ${cmd} ${this.utils.printReceived(commandCalls.length)} times`,
...printCalls(this, commandCalls),
],
});
},
/**
* implementation of {@link AwsSdkJestMockMatchers.toHaveReceivedCommand} matcher
* implementation of {@link AwsSdkJestMockMatchers.toHaveReceivedCommandTimes} matcher
*/
toHaveReceivedCommand(
toHaveReceivedCommandTimes(
this: jest.MatcherContext,
mockClient: ClientMock,
command: new () => AnyCommand,
expectedCalls: number,
) {
return processMatch({
ctx: this,
mockClient,
command,
input: undefined,
check: ({commandCalls}) => ({pass: commandCalls.length > 0, data: {}}),
message: ({client, cmd, notPrefix, commandCalls}) => [
`Expected ${client} to ${notPrefix}receive ${cmd}`,
check: ({commandCalls}) => ({pass: commandCalls.length === expectedCalls, data: undefined}),
message: ({client, cmd, commandCalls, notPrefix}) => [
`Expected ${client} to ${notPrefix}receive ${cmd} ${this.utils.printExpected(expectedCalls)} times`,
`${client} received ${cmd} ${this.utils.printReceived(commandCalls.length)} times`,
...printCalls(this, commandCalls),
],
});
},
Expand All @@ -283,18 +262,30 @@ export const baseMatchers: { [P in keyof AwsSdkJestMockBaseMatchers<unknown>]: j
command: new () => AnyCommand,
input: Record<string, unknown>,
) {
return processMatch({
return processMatch<{ matchCount: number }>({
ctx: this,
mockClient,
command,
input,
check: ({commandCalls}) => ({pass: commandCalls.length > 0, data: {}}),
message: ({client, cmd, calls, notPrefix, commandCalls}) => [
`Expected ${client} to ${notPrefix}receive ${cmd} with ${this.utils.printExpected(
input,
)}`,
`${client} received ${cmd} ${this.utils.printReceived(commandCalls.length)} times`,
...printCalls(this, calls),
check: ({commandCalls}) => {
const matchCount = commandCalls
.map(call => call.args[0].input) // eslint-disable-line @typescript-eslint/no-unsafe-return
.map(received => {
try {
expect(received).toEqual(
expect.objectContaining(input),
);
return true;
} catch (e) {
return false;
}
})
.reduce((acc, val) => acc + Number(val), 0);

return {pass: matchCount > 0, data: {matchCount}};
},
message: ({client, cmd, notPrefix, data}) => [
`Expected ${client} to ${notPrefix}receive ${cmd} with ${this.utils.printExpected(input)}`,
`${client} received matching ${cmd} ${this.utils.printReceived(data.matchCount)} times`,
],
});
},
Expand All @@ -310,43 +301,57 @@ export const baseMatchers: { [P in keyof AwsSdkJestMockBaseMatchers<unknown>]: j
) {
assert(
call && typeof call === 'number' && call > 0,
'Call number must be a number and greater as 0',
'Call number must be a number greater than 0',
);

return processMatch<{ received: AnyCommand; cmd: string }>({
return processMatch<{ received: AnyCommand | undefined }>({
ctx: this,
mockClient,
command,
check: ({calls}) => {
if (calls.length < call) {
return {pass: false, data: {received: undefined}};
}

const received = calls[call - 1].args[0];

let pass = false;
if (received instanceof command) {
try {
expect(received.input).toEqual(
expect.objectContaining(input),
);
pass = true;
} catch (e) { // eslint-disable-line no-empty
}
}

return {
pass:
received instanceof command && this.equals(received.input, input),
data: {
received,
cmd: this.utils.printReceived(received.constructor.name),
},
pass,
data: {received},
};
},
input,
message: ({cmd, client, calls, data, notPrefix}) => [
`Expected ${client} to ${notPrefix}receive ${call}. ${cmd}`,
`${client} received ${call}. ${data.cmd} with input`,
this.utils.printDiffOrStringify(
input,
data.received.input,
'Expected',
'Received',
false,
),
...printCalls(this, calls),
message: ({cmd, client, data, notPrefix}) => [
`Expected ${client} to ${notPrefix}receive ${call}. ${cmd} with ${this.utils.printExpected(input)}`,
...(data.received
? [
`${client} received ${this.utils.printReceived(data.received.constructor.name)} with input:`,
this.utils.printDiffOrStringify(
input,
data.received.input,
'Expected',
'Received',
false,
),
]
: []),
],
});
},
};

/* typing ensures keys matching */
export const aliasMatchers: { [P in keyof AwsSdkJestMockAliasMatchers<unknown>]: jest.CustomMatcher } = {
const aliasMatchers: { [P in keyof AwsSdkJestMockAliasMatchers<unknown>]: jest.CustomMatcher } = {
toReceiveCommandTimes: baseMatchers.toHaveReceivedCommandTimes,
toReceiveCommand: baseMatchers.toHaveReceivedCommand,
toReceiveCommandWith: baseMatchers.toHaveReceivedCommandWith,
Expand Down
Loading