diff --git a/.eslintrc.json b/.eslintrc.json index b520fd6..0790003 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -31,6 +31,12 @@ "@typescript-eslint/no-unsafe-member-access": "off", "@typescript-eslint/member-delimiter-style": "warn", "@typescript-eslint/ban-types": "off", - "@typescript-eslint/no-explicit-any": "off" + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-namespace": [ + "error", + { + "allowDeclarations": true + } + ] } } diff --git a/src/awsClientStub.ts b/src/awsClientStub.ts index b62d561..0c9ad67 100644 --- a/src/awsClientStub.ts +++ b/src/awsClientStub.ts @@ -160,6 +160,11 @@ export class AwsStub impl this.send = send; } + /** Returns the class name of the underlying mocked client class */ + clientName(): string { + return this.client.constructor.name; + } + /** * Resets stub. It will replace the stub with a new one, with clean history and behavior. */ @@ -333,7 +338,7 @@ export class CommandBehavior = Command; +export type AwsCommand = Command; type CommandResponse = Partial | PromiseLike>; export interface AwsError extends Partial, Partial { diff --git a/src/index.ts b/src/index.ts index 238500f..55182ca 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,3 @@ export * from './mockClient'; export * from './awsClientStub'; +import './jestMatchers'; diff --git a/src/jestMatchers.ts b/src/jestMatchers.ts new file mode 100644 index 0000000..61ac08c --- /dev/null +++ b/src/jestMatchers.ts @@ -0,0 +1,374 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +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 extends Record { + /** + * Asserts {@link AwsStub Aws Client Mock} received a {@link command} exact number of {@link times} + * + * @param command aws-sdk command constructor + * @param times + */ + toHaveReceivedCommandTimes< + TCmdInput extends object, + TCmdOutput extends MetadataBearer + >( + command: new (input: TCmdInput) => AwsCommand, + times: number + ): R; + + /** + * Asserts {@link AwsStub Aws Client Mock} received a {@link command} at least one time + * + * @param command aws-sdk command constructor + */ + toHaveReceivedCommand< + TCmdInput extends object, + TCmdOutput extends MetadataBearer + >( + command: new (input: TCmdInput) => AwsCommand + ): R; + + /** + * Asserts {@link AwsStub Aws Client Mock} received a {@link command} at leas one time with input + * matching {@link input} + * + * @param command aws-sdk command constructor + * @param input + */ + toHaveReceivedCommandWith< + TCmdInput extends object, + TCmdOutput extends MetadataBearer + >( + command: new (input: TCmdInput) => AwsCommand, + input: Partial + ): R; + + /** + * Asserts {@link AwsStub Aws Client Mock} received a {@link command} as defined {@link call} number + * with matching {@link input} + * + * @param call call number to assert + * @param command aws-sdk command constructor + * @param input + */ + toHaveReceivedNthCommandWith< + TCmdInput extends object, + TCmdOutput extends MetadataBearer + >( + call: number, + command: new (input: TCmdInput) => AwsCommand, + input: Partial + ): R; +} + +export interface AwsSdkJestMockAliasMatchers { + /** + * Asserts {@link AwsStub Aws Client Mock} received a {@link command} exact number of {@link times} + * + * @alias {@link AwsSdkJestMockBaseMatchers.toHaveReceivedCommandTimes} + * @param command aws-sdk command constructor + * @param times + */ + toReceiveCommandTimes< + TCmdInput extends object, + TCmdOutput extends MetadataBearer + >( + command: new (input: TCmdInput) => AwsCommand, + times: number + ): R; + + /** + * Asserts {@link AwsStub Aws Client Mock} received a {@link command} at least one time + * + * @alias {@link AwsSdkJestMockBaseMatchers.toHaveReceivedCommand} + * @param command aws-sdk command constructor + */ + toReceiveCommand< + TCmdInput extends object, + TCmdOutput extends MetadataBearer + >( + command: new (input: TCmdInput) => AwsCommand + ): R; + + /** + * Asserts {@link AwsStub Aws Client Mock} received a {@link command} at leas one time with input + * matching {@link input} + * + * @alias {@link AwsSdkJestMockBaseMatchers.toHaveReceivedCommandWith} + * @param command aws-sdk command constructor + * @param input + */ + toReceiveCommandWith< + TCmdInput extends object, + TCmdOutput extends MetadataBearer + >( + command: new (input: TCmdInput) => AwsCommand, + input: Partial + ): R; + + /** + * Asserts {@link AwsStub Aws Client Mock} received a {@link command} as defined {@link call} number + * with matching {@link input} + * + * @alias {@link AwsSdkJestMockBaseMatchers.toHaveReceivedNthCommandWith} + * @param call call number to assert + * @param command aws-sdk command constructor + * @param input + */ + toReceiveNthCommandWith< + TCmdInput extends object, + TCmdOutput extends MetadataBearer + >( + call: number, + command: new (input: TCmdInput) => AwsCommand, + input: Partial + ): R; +} + +/** + * Provides {@link jest} matcher for testing {@link AwsStub} command calls + * + * @example + * + * ```ts + * import { mockClient } from "aws-sdk-client-mock"; + * import { ScanCommand } from "@aws-sdk/client-dynamodb"; + * + * const awsMock = mockClient(DynamoDBClient); + * + * awsMock.on(ScanCommand).resolves({ + * Items: [{ Info: { S: '{ "val": "info" }' }, LockID: { S: "fooId" } }], + * }); + * + * it("Should call scan command", async () => { + * // check result ... maybe :) + * await expect(sut()).resolves.toEqual({ ... }); + * + * // Assert awsMock to have recevied a Scan Command at least one time + * expect(awsMock).toHaveReceivedCommand(ScanCommand); + * }); + * ``` + */ +export interface AwsSdkJestMockMatchers extends AwsSdkJestMockBaseMatchers, AwsSdkJestMockAliasMatchers, Record { } + +declare global { + namespace jest { + // eslint-disable-next-line @typescript-eslint/no-empty-interface + interface Matchers extends AwsSdkJestMockMatchers { } + } +} + +type ClientMock = AwsStub; +type AnyCommand = AwsCommand; +type AnySpyCall = SinonSpyCall<[AnyCommand]>; +type MessageFunctionParams = { + 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 + )}` + )] : []; +} + +export function processMatch({ + ctx, + mockClient, + command, + check, + input, + message, +}: { + ctx: jest.MatcherContext; + mockClient: ClientMock; + command: new () => AnyCommand; + check: (params: { calls: AnySpyCall[]; commandCalls: AnySpyCall[] }) => { + pass: boolean; + data: CheckData; + }; + input: Record | undefined; + message: (params: MessageFunctionParams) => string[]; +}): jest.CustomMatcherResult { + assert( + command && + typeof command === 'function' && + typeof command.name === 'string' && + command.name.length > 0, + 'Command must be valid AWS Sdk Command' + ); + + const calls = mockClient.calls(); + const commandCalls = mockClient.commandCalls(command, input); + const { pass, data } = check({ calls, commandCalls }); + + const msg = (): string => { + const cmd = ctx.utils.printExpected(command.name); + const client = mockClient.clientName(); + + const msgParams: MessageFunctionParams = { + calls, + client, + cmd, + data, + commandCalls, + notPrefix: ctx.isNot ? 'not ' : '', + }; + + return message(msgParams).join('\n'); + }; + + return { pass, message: msg }; +} + +/* Using them for testing */ +export const baseMatchers: { [P in keyof AwsSdkJestMockBaseMatchers]: jest.CustomMatcher } = { + /** + * implementation of {@link AwsSdkJestMockMatchers.toHaveReceivedCommandTimes} matcher + */ + toHaveReceivedCommandTimes( + 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`, + `${client} received ${cmd} ${this.utils.printReceived(commandCalls.length)} times`, + ...printCalls(this, commandCalls), + ], + }); + }, + /** + * implementation of {@link AwsSdkJestMockMatchers.toHaveReceivedCommand} matcher + */ + toHaveReceivedCommand( + this: jest.MatcherContext, + mockClient: ClientMock, + command: new () => AnyCommand + ) { + 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}`, + `${client} received ${cmd} ${this.utils.printReceived(commandCalls.length)} times`, + ...printCalls(this, commandCalls), + ], + }); + }, + /** + * implementation of {@link AwsSdkJestMockMatchers.toHaveReceivedCommandWith} matcher + */ + toHaveReceivedCommandWith( + this: jest.MatcherContext, + mockClient: ClientMock, + command: new () => AnyCommand, + input: Record + ) { + return processMatch({ + 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), + ], + }); + }, + /** + * implementation of {@link AwsSdkJestMockMatchers.toHaveReceivedNthCommandWith} matcher + */ + toHaveReceivedNthCommandWith( + this: jest.MatcherContext, + mockClient: ClientMock, + call: number, + command: new () => AnyCommand, + input?: Record + ) { + assert( + call && typeof call === 'number' && call > 0, + 'Call number must be a number and greater as 0' + ); + + return processMatch<{ received: AnyCommand; cmd: string }>({ + ctx: this, + mockClient, + command, + check: ({ calls }) => { + const received = calls[call - 1].args[0]; + return { + pass: + received instanceof command && this.equals(received.input, input), + data: { + received, + cmd: this.utils.printReceived(received.constructor.name), + }, + }; + }, + 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), + ], + }); + }, +}; + +/* typing ensures keys matching */ +export const aliasMatchers: { [P in keyof AwsSdkJestMockAliasMatchers]: jest.CustomMatcher } = { + toReceiveCommandTimes: baseMatchers.toHaveReceivedCommandTimes, + toReceiveCommand: baseMatchers.toHaveReceivedCommand, + toReceiveCommandWith: baseMatchers.toHaveReceivedCommandWith, + toReceiveNthCommandWith: baseMatchers.toHaveReceivedNthCommandWith, +}; + + +// Skip registration if jest expect does not exist +if (typeof expect !== 'undefined' && typeof expect.extend === 'function') { + expect.extend({ ...baseMatchers, ...aliasMatchers }); +} diff --git a/test-d/types.ts b/test-d/types.ts index 169cf88..a46a3df 100644 --- a/test-d/types.ts +++ b/test-d/types.ts @@ -30,3 +30,16 @@ expectError(mockClient(SNSClient).on(PublishCommand).resolves({Topics: []})); // Sinon Spy expectType(mockClient(SNSClient).commandCalls(PublishCommand)[0].args[0]); expectType>(mockClient(SNSClient).commandCalls(PublishCommand)[0].returnValue); + +// Matchers +expect(mockClient(SNSClient)).toHaveReceivedCommand(PublishCommand) +expectError(expect(mockClient(SNSClient)).toHaveReceivedCommand(String)) + +expect(mockClient(SNSClient)).toHaveReceivedCommandTimes(PublishCommand, 1) +expectError(expect(mockClient(SNSClient)).toHaveReceivedCommandTimes(PublishCommand)) + +expect(mockClient(SNSClient)).toHaveReceivedCommandWith(PublishCommand, {Message: ''}) +expectError(expect(mockClient(SNSClient)).toHaveReceivedCommandWith(PublishCommand, { Foo: '' })) + +expect(mockClient(SNSClient)).toHaveReceivedNthCommandWith(1, PublishCommand, {Message: ''}) +expectError(expect(mockClient(SNSClient)).toHaveReceivedNthCommandWith(1, PublishCommand, { Foo: '' })) \ No newline at end of file diff --git a/test-e2e/matchers.test.ts b/test-e2e/matchers.test.ts new file mode 100644 index 0000000..bd396ac --- /dev/null +++ b/test-e2e/matchers.test.ts @@ -0,0 +1,17 @@ +import {PublishCommand, SNSClient} from '@aws-sdk/client-sns'; +import {mockClient} from 'aws-sdk-client-mock'; + +it('mocks SNS client', async () => { + const snsMock = mockClient(SNSClient); + snsMock.on(PublishCommand).resolves({ + MessageId: '12345678-1111-2222-3333-111122223333', + }); + + const sns = new SNSClient({}); + const result = await sns.send(new PublishCommand({ + TopicArn: 'arn:aws:sns:us-east-1:111111111111:MyTopic', + Message: 'My message', + })); + + expect(snsMock).toHaveReceivedCommandTimes(PublishCommand, 1); +}); diff --git a/test/jestMatchers.test.ts b/test/jestMatchers.test.ts new file mode 100644 index 0000000..8455a9a --- /dev/null +++ b/test/jestMatchers.test.ts @@ -0,0 +1,222 @@ +import { AwsClientStub, mockClient } from '../src'; +import { PublishCommand, SNSClient } from '@aws-sdk/client-sns'; +import { publishCmd1, publishCmd2, uuid1 } from './fixtures'; +import { baseMatchers, aliasMatchers } from '../src/jestMatchers'; +import { inspect } from 'util'; + +let snsMock: AwsClientStub; + + +const contextMock = { + isNot: false, + equals: jest.fn(), + utils: { + printExpected: jest.fn(), + printReceived: jest.fn(), + printDiffOrStringify: jest.fn(), + }, +}; + +beforeEach(() => { + snsMock = mockClient(SNSClient); + + contextMock.isNot = false, + contextMock.equals.mockReturnValue(true), + contextMock.utils.printExpected.mockImplementation((v) => inspect(v, { compact: true })); + contextMock.utils.printReceived.mockImplementation((v) => inspect(v, { compact: true })); + contextMock.utils.printDiffOrStringify.mockImplementation((a, b) => [ + inspect(a, { compact: true }), + inspect(b, { compact: true }), + ].join('\n') + ); +}); + +afterEach(() => { + snsMock.restore(); +}); + +describe('matcher aliases', ()=>{ + it('adds matcher aliases', ()=> { + expect(aliasMatchers.toReceiveCommand).toBe(baseMatchers.toHaveReceivedCommand); + expect(aliasMatchers.toReceiveCommandTimes).toBe(baseMatchers.toHaveReceivedCommandTimes); + expect(aliasMatchers.toReceiveCommandWith).toBe(baseMatchers.toHaveReceivedCommandWith); + expect(aliasMatchers.toReceiveNthCommandWith).toBe(baseMatchers.toHaveReceivedNthCommandWith); + }); +}); + +describe('toHaveReceivedCommandTimes', () => { + it('matches calls count', async () => { + snsMock.resolves({ + MessageId: uuid1, + }); + + const sns = new SNSClient({}); + await sns.send(publishCmd1); + + + const match = baseMatchers.toHaveReceivedCommandTimes.call(contextMock as any, snsMock, PublishCommand, 2) as jest.CustomMatcherResult; + expect(match.pass).toBeFalsy(); + + expect(match.message()).toEqual(`Expected SNSClient to receive 'PublishCommand' 2 times +SNSClient received 'PublishCommand' 1 times +Calls: + + 1. PublishCommand: { TopicArn: 'arn:aws:sns:us-east-1:111111111111:MyTopic', Message: 'mock message' }`); + + }); + + it('matches not calls count', async () => { + contextMock.isNot = true; + + snsMock.resolves({ + MessageId: uuid1, + }); + + const sns = new SNSClient({}); + await sns.send(publishCmd1); + await sns.send(publishCmd1); + + + const match = baseMatchers.toHaveReceivedCommandTimes.call(contextMock as any, snsMock, PublishCommand, 2) as jest.CustomMatcherResult; + expect(match.pass).toBeTruthy(); + + expect(match.message()).toEqual(`Expected SNSClient to not receive 'PublishCommand' 2 times +SNSClient received 'PublishCommand' 2 times +Calls: + + 1. PublishCommand: { TopicArn: 'arn:aws:sns:us-east-1:111111111111:MyTopic', Message: 'mock message' } + 2. PublishCommand: { TopicArn: 'arn:aws:sns:us-east-1:111111111111:MyTopic', Message: 'mock message' }`); + }); +}); + + +describe('toHaveReceivedCommand', () => { + it('matches received', () => { + snsMock.resolves({ + MessageId: uuid1, + }); + + const match = baseMatchers.toHaveReceivedCommand.call(contextMock as any, snsMock, PublishCommand, 2) as jest.CustomMatcherResult; + expect(match.pass).toBeFalsy(); + + expect(match.message()).toEqual(`Expected SNSClient to receive 'PublishCommand' +SNSClient received 'PublishCommand' 0 times`); + }); + + it('matches not received', async () => { + contextMock.isNot = true; + + snsMock.resolves({ + MessageId: uuid1, + }); + + const sns = new SNSClient({}); + await sns.send(publishCmd1); + + const match = baseMatchers.toHaveReceivedCommand.call(contextMock as any, snsMock, PublishCommand, 2) as jest.CustomMatcherResult; + expect(match.pass).toBeTruthy(); + + expect(match.message()).toEqual(`Expected SNSClient to not receive 'PublishCommand' +SNSClient received 'PublishCommand' 1 times +Calls: + + 1. PublishCommand: { TopicArn: 'arn:aws:sns:us-east-1:111111111111:MyTopic', Message: 'mock message' }`); + }); +}); + +describe('toHaveReceivedCommandWith', () => { + it('matches received', async () => { + snsMock.resolves({ + MessageId: uuid1, + }); + + const sns = new SNSClient({}); + await sns.send(publishCmd1); + + const match = baseMatchers.toHaveReceivedCommandWith.call(contextMock as any, + snsMock, PublishCommand, + publishCmd2.input + ) as jest.CustomMatcherResult; + + expect(match.pass).toBeFalsy(); + + expect(match.message()).toEqual(`Expected SNSClient to receive 'PublishCommand' with { TopicArn: 'arn:aws:sns:us-east-1:111111111111:MyTopic', + Message: 'second mock message' } +SNSClient received 'PublishCommand' 0 times +Calls: + + 1. PublishCommand: { TopicArn: 'arn:aws:sns:us-east-1:111111111111:MyTopic', Message: 'mock message' }`); + }); + + it('matches not received', async () => { + contextMock.isNot = true; + + snsMock.resolves({ + MessageId: uuid1, + }); + + const sns = new SNSClient({}); + await sns.send(publishCmd1); + + const match = baseMatchers.toHaveReceivedCommandWith.call(contextMock as any, snsMock, PublishCommand, publishCmd1.input) as jest.CustomMatcherResult; + expect(match.pass).toBeTruthy(); + + expect(match.message()).toEqual(`Expected SNSClient to not receive 'PublishCommand' with { TopicArn: 'arn:aws:sns:us-east-1:111111111111:MyTopic', Message: 'mock message' } +SNSClient received 'PublishCommand' 1 times +Calls: + + 1. PublishCommand: { TopicArn: 'arn:aws:sns:us-east-1:111111111111:MyTopic', Message: 'mock message' }`); + }); +}); + +describe('toHaveNthReceivedCommandWith', () => { + it('matches received', async () => { + contextMock.equals.mockReturnValue(false); + + snsMock.resolves({ + MessageId: uuid1, + }); + + const sns = new SNSClient({}); + await sns.send(publishCmd1); + await sns.send(publishCmd2); + + const match = baseMatchers.toHaveReceivedNthCommandWith.call(contextMock as any, + snsMock, 1, PublishCommand, + publishCmd2.input + ) as jest.CustomMatcherResult; + + expect(match.pass).toBeFalsy(); + + expect(match.message()).toEqual(`Expected SNSClient to receive 1. 'PublishCommand' +SNSClient received 1. 'PublishCommand' with input +{ TopicArn: 'arn:aws:sns:us-east-1:111111111111:MyTopic', + Message: 'second mock message' } +{ TopicArn: 'arn:aws:sns:us-east-1:111111111111:MyTopic', Message: 'mock message' } +Calls: + + 1. PublishCommand: { TopicArn: 'arn:aws:sns:us-east-1:111111111111:MyTopic', Message: 'mock message' } + 2. PublishCommand: { TopicArn: 'arn:aws:sns:us-east-1:111111111111:MyTopic', + Message: 'second mock message' }`); + }); + + it('matches not received', async () => { + contextMock.isNot = true; + + snsMock.resolves({ MessageId: uuid1 }); + + const sns = new SNSClient({}); + await sns.send(publishCmd1); + + const match = baseMatchers.toHaveReceivedNthCommandWith.call(contextMock as any, snsMock, 1, PublishCommand, publishCmd1.input) as jest.CustomMatcherResult; + expect(match.pass).toBeTruthy(); + + expect(match.message()).toEqual(`Expected SNSClient to not receive 1. 'PublishCommand' +SNSClient received 1. 'PublishCommand' with input +{ TopicArn: 'arn:aws:sns:us-east-1:111111111111:MyTopic', Message: 'mock message' } +{ TopicArn: 'arn:aws:sns:us-east-1:111111111111:MyTopic', Message: 'mock message' } +Calls: + + 1. PublishCommand: { TopicArn: 'arn:aws:sns:us-east-1:111111111111:MyTopic', Message: 'mock message' }`); + }); +});