diff --git a/docs/features/parameters.md b/docs/features/parameters.md index e7f16500af..df1aadc0a9 100644 --- a/docs/features/parameters.md +++ b/docs/features/parameters.md @@ -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" ``` diff --git a/examples/snippets/parameters/testingYourCodeClientMock.ts b/examples/snippets/parameters/testingYourCodeClientMock.ts index 38e3416215..ca835cae2c 100644 --- a/examples/snippets/parameters/testingYourCodeClientMock.ts +++ b/examples/snippets/parameters/testingYourCodeClientMock.ts @@ -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); diff --git a/package-lock.json b/package-lock.json index 82696a5852..ef9006297a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4309,24 +4309,6 @@ "tslib": "^2.1.0" } }, - "node_modules/aws-sdk-client-mock-vitest": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/aws-sdk-client-mock-vitest/-/aws-sdk-client-mock-vitest-7.1.0.tgz", - "integrity": "sha512-Qjl5cHdTysOfsq7ac0UFchQKW/A4NbVL4uhU1FcPmawAQQ8Bt6xsTJA//gWCkZO9+hHQxcCJfpmWbSYm02po1A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/expect": "^4.1.0" - }, - "engines": { - "node": "^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "peerDependencies": { - "@smithy/types": ">=3.5.0", - "aws-sdk-client-mock": ">=2.2.0", - "vitest": ">=3.2.0" - } - }, "node_modules/aws-xray-sdk-core": { "version": "3.12.0", "resolved": "https://registry.npmjs.org/aws-xray-sdk-core/-/aws-xray-sdk-core-3.12.0.tgz", @@ -8715,9 +8697,9 @@ "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" } }, "packages/tracer": { diff --git a/packages/testing/package.json b/packages/testing/package.json index 85d6a5af66..f0cabdcc02 100644 --- a/packages/testing/package.json +++ b/packages/testing/package.json @@ -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" } } diff --git a/packages/testing/src/setupEnv.ts b/packages/testing/src/setupEnv.ts index b00e9c7e9b..f83c2cd5d7 100644 --- a/packages/testing/src/setupEnv.ts +++ b/packages/testing/src/setupEnv.ts @@ -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(); @@ -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( + received: AwsStub, + command: new (input: Input) => AwsCommand, + expected: Partial + ): MatcherResult { + const inputs = received + .commandCalls(command) + .map((call) => call.args[0].input); + const pass = inputs.some((input) => + this.equals(input, expect.objectContaining(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); @@ -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( + command: new (input: Input) => AwsCommand, + expected: Partial> + ): void; +} + declare module 'vitest' { // biome-ignore lint/suspicious/noExplicitAny: vitest typings expect an any type - interface Assertion extends CustomMatcher { + interface Assertion extends AwsSdkMatchers { /** * Asserts that the logger function has been called with the expected log message * during any call. @@ -360,7 +399,7 @@ declare module 'vitest' { expected: Record ): void; } - interface AsymmetricMatchersContaining extends CustomMatcher {} + interface AsymmetricMatchersContaining extends AwsSdkMatchers {} } // Set up environment variables for testing diff --git a/packages/testing/tests/types/toReceiveCommandWith.test-d.ts b/packages/testing/tests/types/toReceiveCommandWith.test-d.ts new file mode 100644 index 0000000000..290b886d44 --- /dev/null +++ b/packages/testing/tests/types/toReceiveCommandWith.test-d.ts @@ -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', + }); +}); diff --git a/packages/testing/tests/unit/toReceiveCommandWith.test.ts b/packages/testing/tests/unit/toReceiveCommandWith.test.ts new file mode 100644 index 0000000000..932b467e04 --- /dev/null +++ b/packages/testing/tests/unit/toReceiveCommandWith.test.ts @@ -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), + }); + }); +});