Skip to content
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
4 changes: 2 additions & 2 deletions docs/features/parameters.md
Original file line number Diff line number Diff line change
Expand Up @@ -522,10 +522,10 @@ A similar pattern can be applied also to any of the built-in provider classes -
--8<-- "examples/snippets/parameters/testingYourCodeProvidersHandler.ts"
```

For when you want to mock the AWS SDK v3 client directly, we recommend using the [`aws-sdk-client-mock`](https://www.npmjs.com/package/aws-sdk-client-mock) and [`aws-sdk-client-mock-vitest`](https://www.npmjs.com/package/aws-sdk-client-mock-vitest) libraries. This is useful when you want to test how your code behaves when the AWS SDK v3 client throws an error or a specific response.
For when you want to mock the AWS SDK v3 client directly, we recommend using the [`aws-sdk-client-mock`](https://www.npmjs.com/package/aws-sdk-client-mock) library. This is useful when you want to test how your code behaves when the AWS SDK v3 client throws an error or a specific response.

=== "handler.test.ts"
```typescript hl_lines="2-7 12 16 21-28"
```typescript hl_lines="2-7 11 15 20-27"
--8<-- "examples/snippets/parameters/testingYourCodeClientMock.ts"
```

Expand Down
1 change: 0 additions & 1 deletion examples/snippets/parameters/testingYourCodeClientMock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {
import { mockClient } from 'aws-sdk-client-mock';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { handler } from './testingYourCodeFunctionsHandler.js';
import 'aws-sdk-client-mock-vitest';

describe('Function tests', () => {
const client = mockClient(SecretsManagerClient);
Expand Down
22 changes: 2 additions & 20 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions packages/testing/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -137,8 +137,8 @@
"promise-retry": "^2.0.1"
},
"devDependencies": {
"@smithy/types": "^4.18.0",
"@types/promise-retry": "^1.1.6",
"aws-sdk-client-mock": "^4.1.0",
"aws-sdk-client-mock-vitest": "^7.1.0"
"aws-sdk-client-mock": "^4.1.0"
}
}
57 changes: 48 additions & 9 deletions packages/testing/src/setupEnv.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
import {
type CustomMatcher,
toReceiveCommandWith,
} from 'aws-sdk-client-mock-vitest';
import { expect, vi } from 'vitest';

expect.extend({ toReceiveCommandWith });
import type { MetadataBearer } from '@smithy/types';
import type { AwsCommand, AwsStub } from 'aws-sdk-client-mock';
import { expect, type MatcherResult, vi } from 'vitest';

// Mock console methods to prevent output during tests
vi.spyOn(console, 'error').mockReturnValue();
Expand All @@ -14,6 +10,33 @@ vi.spyOn(console, 'info').mockReturnValue();
vi.spyOn(console, 'log').mockReturnValue();

expect.extend({
/**
* Matches recorded AWS SDK command inputs using Vitest's partial object matching.
*
* @param received - The mocked AWS SDK client
* @param command - The AWS SDK command constructor
* @param expected - The expected subset of the command input
*/
toReceiveCommandWith<Input extends object, Output extends MetadataBearer>(
received: AwsStub<Input, Output, unknown>,
command: new (input: Input) => AwsCommand<Input, Output>,
expected: Partial<Input>
): MatcherResult {
const inputs = received
.commandCalls(command)
.map((call) => call.args[0].input);
const pass = inputs.some((input) =>
this.equals(input, expect.objectContaining<object>(expected))
);

return {
pass,
message: () =>
`Expected ${received.clientName()} ${command.name} ${this.isNot ? 'not ' : ''}to receive input containing ${this.utils.printExpected(expected)}\nReceived inputs (call count: ${inputs.length}): ${this.utils.printReceived(inputs)}`,
actual: inputs,
expected,
};
},
toHaveLogged(received, expected) {
const calls = received.mock.calls;
const messages = new Array(calls.length);
Expand Down Expand Up @@ -232,9 +255,25 @@ expect.addEqualityTesters([
},
]);

/**
* Describes the AWS SDK matchers available in test assertions.
*/
interface AwsSdkMatchers {
/**
* Asserts that at least one call to the command contains the expected input.
*
* @param command - The AWS SDK command constructor
* @param expected - The expected subset of the command input
*/
toReceiveCommandWith<Input extends object, Output extends MetadataBearer>(
command: new (input: Input) => AwsCommand<Input, Output>,
expected: Partial<NoInfer<Input>>
): void;
}

declare module 'vitest' {
// biome-ignore lint/suspicious/noExplicitAny: vitest typings expect an any type
interface Assertion<T = any> extends CustomMatcher<T> {
interface Assertion<T = any> extends AwsSdkMatchers {
/**
* Asserts that the logger function has been called with the expected log message
* during any call.
Expand Down Expand Up @@ -360,7 +399,7 @@ declare module 'vitest' {
expected: Record<string, unknown>
): void;
}
interface AsymmetricMatchersContaining extends CustomMatcher {}
interface AsymmetricMatchersContaining extends AwsSdkMatchers {}
}

// Set up environment variables for testing
Expand Down
27 changes: 27 additions & 0 deletions packages/testing/tests/types/toReceiveCommandWith.test-d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { DescribeStacksCommand } from '@aws-sdk/client-cloudformation';
import { expect, expectTypeOf, it } from 'vitest';
import '../../src/setupEnv.js';

it('infers the expected input from the command constructor', () => {
// Prepare
const assertion = expect({});

// Act & Assess
expectTypeOf(
assertion.toReceiveCommandWith(DescribeStacksCommand, {
StackName: 'stack',
})
).toBeVoid();
assertion.toReceiveCommandWith(DescribeStacksCommand, {});
assertion.toReceiveCommandWith(DescribeStacksCommand, {
StackName: expect.any(String),
});
assertion.toReceiveCommandWith(DescribeStacksCommand, {
// @ts-expect-error StackName must be a string
StackName: 123,
});
assertion.toReceiveCommandWith(DescribeStacksCommand, {
// @ts-expect-error UnknownField is not a DescribeStacks input
UnknownField: 'value',
});
});
170 changes: 170 additions & 0 deletions packages/testing/tests/unit/toReceiveCommandWith.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import {
CloudFormationClient,
CreateStackCommand,
DeleteStackCommand,
DescribeStacksCommand,
} from '@aws-sdk/client-cloudformation';
import { mockClient } from 'aws-sdk-client-mock';
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
import '../../src/setupEnv.js';

describe('toReceiveCommandWith', () => {
const client = new CloudFormationClient({});
const clientMock = mockClient(client);

beforeEach(() => {
clientMock.reset();
clientMock.resolves({});
});

afterAll(() => {
clientMock.restore();
client.destroy();
});

it.each([
{ StackName: 'my-stack', NextToken: 'next' },
{ StackName: 'my-stack' },
{ StackName: expect.stringContaining('stack') },
{},
])('matches the expected input %j', async (expected) => {
// Prepare
const command = new DescribeStacksCommand({
StackName: 'my-stack',
NextToken: 'next',
});

// Act
await client.send(command);

// Assess
expect(clientMock).toReceiveCommandWith(DescribeStacksCommand, expected);
});

it('matches nested asymmetric matchers', async () => {
// Prepare
const command = new CreateStackCommand({
StackName: 'my-stack',
Tags: [{ Key: 'service', Value: 'my-service' }],
});

// Act
await client.send(command);

// Assess
expect(clientMock).toReceiveCommandWith(CreateStackCommand, {
Tags: expect.arrayContaining([
expect.objectContaining({ Value: expect.stringContaining('service') }),
]),
});
});

it('requires nested objects to match unless an asymmetric matcher is used', async () => {
// Prepare
const command = new CreateStackCommand({
StackName: 'my-stack',
Parameters: [{ ParameterKey: 'service', ParameterValue: 'my-service' }],
});

// Act
await client.send(command);

// Assess
expect(clientMock).not.toReceiveCommandWith(CreateStackCommand, {
Parameters: [{ ParameterKey: 'service' }],
});
});

it('finds a matching input among multiple calls', async () => {
// Prepare
const stackNames = ['first', 'matching', 'last'];

// Act
for (const StackName of stackNames) {
await client.send(new DescribeStacksCommand({ StackName }));
}

// Assess
expect(clientMock).toReceiveCommandWith(DescribeStacksCommand, {
StackName: 'matching',
});
});

it('ignores matching inputs sent to a different command', async () => {
// Prepare
const input = { StackName: 'my-stack' };

// Act
await client.send(new DeleteStackCommand(input));

// Assess
expect(clientMock).not.toReceiveCommandWith(DescribeStacksCommand, input);
expect(() =>
expect(clientMock).toReceiveCommandWith(DescribeStacksCommand, input)
).toThrow('Received inputs (call count: 0):');
});

it('reports the expected input and recorded inputs on a mismatch', async () => {
// Prepare
const command = new DescribeStacksCommand({ StackName: 'actual-stack' });

// Act
await client.send(command);

// Assess
expect(clientMock).not.toReceiveCommandWith(DescribeStacksCommand, {
StackName: 'expected-stack',
});
expect(() =>
expect(clientMock).toReceiveCommandWith(DescribeStacksCommand, {
StackName: 'expected-stack',
})
).toThrow(
/CloudFormationClient DescribeStacksCommand to receive input containing.*expected-stack.*\nReceived inputs \(call count: 1\):.*actual-stack/s
);
});

it('reports no calls for an unused client', () => {
// Prepare
const expected = { StackName: 'my-stack' };

// Act
const assertCommand = () =>
expect(clientMock).toReceiveCommandWith(DescribeStacksCommand, expected);

// Assess
expect(assertCommand).toThrow('Received inputs (call count: 0):');
expect(clientMock).not.toReceiveCommandWith(
DescribeStacksCommand,
expected
);
});

it('reports a matching call when a negated assertion fails', async () => {
// Prepare
const input = { StackName: 'my-stack' };

// Act
await client.send(new DescribeStacksCommand(input));

// Assess
expect(() =>
expect(clientMock).not.toReceiveCommandWith(DescribeStacksCommand, input)
).toThrow(
/DescribeStacksCommand not to receive input containing.*my-stack/s
);
});

it('supports asymmetric command assertions', async () => {
// Prepare
const input = { StackName: 'my-stack' };

// Act
await client.send(new DescribeStacksCommand(input));

// Assess
expect({ client: clientMock }).toEqual({
client: expect.toReceiveCommandWith(DescribeStacksCommand, input),
});
});
});