Skip to content

feat: Add spies #171

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

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
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
400 changes: 234 additions & 166 deletions README.md

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions packages/aws-sdk-client-mock-jest/src/jestMatchers.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-empty-interface */
import assert from 'assert';
import type {MetadataBearer} from '@smithy/types';
import type {AwsCommand, AwsStub} from 'aws-sdk-client-mock';
import type {AwsCommand, AwsStub, AwsSpy} from 'aws-sdk-client-mock';
import type {SinonSpyCall} from 'sinon';

interface AwsSdkJestMockBaseMatchers<R> extends Record<string, any> {
Expand Down Expand Up @@ -147,7 +147,7 @@ interface AwsSdkJestMockAliasMatchers<R> {
* import { mockClient } from "aws-sdk-client-mock";
* import { ScanCommand } from "@aws-sdk/client-dynamodb";
*
* const awsMock = mockClient(DynamoDBClient);
* const awsMock = mockClient(DynamoDBClient); // or `spyClient(DynamoDBClient)`
*
* awsMock.on(ScanCommand).resolves({
* Items: [{ Info: { S: '{ "val": "info" }' }, LockID: { S: "fooId" } }],
Expand All @@ -172,7 +172,7 @@ declare global {
}
}

type ClientMock = AwsStub<any, any, any>;
type ClientMock = AwsStub<any, any, any> | AwsSpy<any, any, any>;
type AnyCommand = AwsCommand<any, any>;
type AnySpyCall = SinonSpyCall<[AnyCommand]>;
type MessageFunctionParams<CheckData> = {
Expand Down Expand Up @@ -218,7 +218,7 @@ const processMatch = <CheckData = undefined>({ctx, mockClient, command, check, m
);

const calls = mockClient.calls();
const commandCalls = mockClient.commandCalls(command);
const commandCalls = (mockClient as AwsSpy<any, any, any>).commandCalls(command);

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

Expand Down
118 changes: 118 additions & 0 deletions packages/aws-sdk-client-mock/src/awsClientSpy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import {Client, MetadataBearer} from '@smithy/types';
import {match, SinonSpyCall, SinonSpy} from 'sinon';
import {AwsCommand} from './commonTypes';
import {spyClient} from './spyClient';

/**
* Type for {@link AwsSpy} class,
* but with the AWS Client class type as the only generic parameter.
*
* @example
* ```ts
* const sns = new SNSCLient({});
* let snsSpy: AwsClientSpy<SNSClient>;
* snsSpy = spyClient(sns);
* ```
*/
export type AwsClientSpy<TClient> =
TClient extends Client<infer TInput, infer TOutput, infer TConfiguration> ? AwsSpy<TInput, TOutput, TConfiguration> : never;

/**
* Wrapper on the mocked `Client#send()` method, allowing you to inspect
* calls to it.
*
* Without any configuration, `Client#send()` invocation returns `undefined`.
*
* To define resulting variable type easily, use {@link AwsClientSpy}.
*/
export class AwsSpy<TInput extends object, TOutput extends MetadataBearer, TConfiguration> {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because Sinon Stub extends the Spy, we could do the same here - AwsStub extend AwsSpy. The call(), calls(), and commandCalls() could be defined only in AwsSpy.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This makes sense in theory, but in practice, the type shenanigans needed to type the send value and the reset call so they return different types makes this extremely difficult (and probably very confusing to downstream users).

I did experiment with this, but I think it's better to have separate definitions and avoid the complexity of inheritance.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That said, I'll take another look as it may make sense from a testing perspective to have these inherited

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, thanks, let me know if you don't find a sensible way to implement this, I will take a look then or we will leave it as it is 👍


/**
* Underlying `Client#send()` method Sinon spy.
*
* Install `@types/sinon` for TypeScript typings.
*/
public send: SinonSpy<[AwsCommand<TInput, TOutput>], Promise<TOutput>>;

constructor(
private client: Client<TInput, TOutput, TConfiguration>,
send: SinonSpy<[AwsCommand<TInput, TOutput>], Promise<TOutput>>,
) {
this.send = send;
}

/** Returns the class name of the underlying spied client class */
clientName(): string {
return this.client.constructor.name;
}

/**
* Resets spy. It will replace the spy with a new one, with clean history.
*/
reset(): AwsSpy<TInput, TOutput, TConfiguration> {
/* sinon.spy.reset() does not remove the fakes which in some conditions can break subsequent spies,
* so instead of calling send.reset(), we recreate the spy.
* See: https://github.com/sinonjs/sinon/issues/1572
* We are only affected by the broken reset() behavior of this bug, since we always use matchers.
*/
const newSpy = spyClient(this.client);
this.send = newSpy.send;
return this;
}

/** Resets spy's calls history. */
resetHistory(): AwsSpy<TInput, TOutput, TConfiguration> {
this.send.resetHistory();
return this;
}

/** Replaces spy with original `Client#send()` method. */
restore(): void {
this.send.restore();
}

/**
* Returns recorded calls to the spy.
* Clear history with {@link resetHistory} or {@link reset}.
*/
calls(): SinonSpyCall<[AwsCommand<TInput, TOutput>], Promise<TOutput>>[] {
return this.send.getCalls();
}

/**
* Returns n-th recorded call to the spy.
*/
call(n: number): SinonSpyCall<[AwsCommand<TInput, TOutput>], Promise<TOutput>> {
return this.send.getCall(n);
}

/**
* Returns recorded calls of given Command only.
* @param commandType Command type to match
* @param input Command payload to match
* @param strict Should the payload match strictly (default false, will match if all defined payload properties match)
*/
commandCalls<TCmd extends AwsCommand<any, any>,
TCmdInput extends TCmd extends AwsCommand<infer TIn, any> ? TIn : never,
TCmdOutput extends TCmd extends AwsCommand<any, infer TOut> ? TOut : never,
>(
commandType: new (input: TCmdInput) => TCmd,
input?: Partial<TCmdInput>,
strict?: boolean,
): SinonSpyCall<[TCmd], Promise<TCmdOutput>>[] {
return this.send.getCalls()
.filter((call): call is SinonSpyCall<[TCmd], Promise<TCmdOutput>> => {
const isProperType = call.args[0] instanceof commandType;
const inputMatches = this.createInputMatcher(input, strict).test(call.args[0]);
return isProperType && inputMatches;
});
}

private createInputMatcher<TCmdInput extends TInput>(input?: Partial<TCmdInput>, strict = false) {
return input !== undefined ?
match.has('input', strict ? input : match(input))
: match.any;
}
}


4 changes: 2 additions & 2 deletions packages/aws-sdk-client-mock/src/awsClientStub.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {Client, Command, MetadataBearer} from '@smithy/types';
import {Client, MetadataBearer} from '@smithy/types';
import {match, SinonSpyCall, SinonStub} from 'sinon';
import { AwsCommand } from './commonTypes';
import {mockClient} from './mockClient';

export type AwsClientBehavior<TClient> =
Expand Down Expand Up @@ -354,7 +355,6 @@ export class CommandBehavior<TInput extends object, TOutput extends MetadataBear
}
}

export type AwsCommand<Input extends ClientInput, Output extends ClientOutput, ClientInput extends object = any, ClientOutput extends MetadataBearer = any> = Command<ClientInput, Input, ClientOutput, Output, any>;
type CommandResponse<TOutput> = Partial<TOutput> | PromiseLike<Partial<TOutput>>;

export interface AwsError extends Partial<Error>, Partial<MetadataBearer> {
Expand Down
3 changes: 3 additions & 0 deletions packages/aws-sdk-client-mock/src/commonTypes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import {Command, MetadataBearer} from '@smithy/types';

export type AwsCommand<Input extends ClientInput, Output extends ClientOutput, ClientInput extends object = any, ClientOutput extends MetadataBearer = any> = Command<ClientInput, Input, ClientOutput, Output, any>;
3 changes: 3 additions & 0 deletions packages/aws-sdk-client-mock/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
export * from './commonTypes';
export * from './mockClient';
export * from './awsClientStub';
export * from './spyClient';
export * from './awsClientSpy';
6 changes: 5 additions & 1 deletion packages/aws-sdk-client-mock/src/sinon.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {SinonStub} from 'sinon';
import {SinonStub, SinonSpy} from 'sinon';

export interface MaybeSinonProxy {
isSinonProxy?: boolean;
Expand All @@ -7,3 +7,7 @@ export interface MaybeSinonProxy {
export const isSinonStub = (obj: unknown): obj is SinonStub =>
((obj as MaybeSinonProxy).isSinonProxy || false)
&& (obj as SinonStub).restore !== undefined;

export const isSinonSpy = (obj: unknown): obj is SinonSpy =>
((obj as MaybeSinonProxy).isSinonProxy || false)
&& (obj as SinonSpy).restore !== undefined;
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Those methods do not distinct between Sinon Stub and Spy. But we don't really care about the distinction, only whether the object is any Sinon Proxy - Stub or Spy. We can get rid of the restore existence check and just make it a single isSinonProxy() function.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll remove the restore check

38 changes: 38 additions & 0 deletions packages/aws-sdk-client-mock/src/spyClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import {Client, Command, MetadataBearer} from '@smithy/types';
import {SinonSpy, spy} from 'sinon';
import {isSinonSpy, isSinonStub} from './sinon';
import {AwsClientSpy, AwsSpy} from './awsClientSpy';

/**
* Creates and attaches a spy of the `Client#send()` method. Only this single method is replaced.
* If method is already a spy, it's replaced.
* @param client `Client` type or instance to replace the method
* @return Stub allowing to configure Client's behavior
*/
export const spyClient = <TInput extends object, TOutput extends MetadataBearer, TConfiguration>(
client: InstanceOrClassType<Client<TInput, TOutput, TConfiguration>>,
): AwsClientSpy<Client<TInput, TOutput, TConfiguration>> => {
const instance = isClientInstance(client) ? client : client.prototype;

const send = instance.send;
if (isSinonSpy(send) || isSinonStub(send)) {
send.restore();
}

const sendStub = spy(instance, 'send') as SinonSpy<[Command<TInput, any, TOutput, any, any>], Promise<TOutput>>;

// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
return new AwsSpy<TInput, TOutput, TConfiguration>(instance, sendStub);
};

type ClassType<T> = {
prototype: T;
};

type InstanceOrClassType<T> = T | ClassType<T>;

/**
* Type guard to differentiate `Client` instance from a type.
*/
const isClientInstance = <TClient extends Client<any, any, any>>(obj: InstanceOrClassType<TClient>): obj is TClient =>
(obj as TClient).send !== undefined;