-
Notifications
You must be signed in to change notification settings - Fork 41
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
base: main
Are you sure you want to change the base?
feat: Add spies #171
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
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> { | ||
|
||
/** | ||
* 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; | ||
} | ||
} | ||
|
||
|
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>; |
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'; |
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; | ||
|
@@ -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; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'll remove the |
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; |
There was a problem hiding this comment.
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()
, andcommandCalls()
could be defined only in AwsSpy.There was a problem hiding this comment.
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 thereset
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.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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 👍