diff --git a/packages/@aws-cdk/assert/README.md b/packages/@aws-cdk/assert/README.md index d3db7d3a96c23..ff04c0e985828 100644 --- a/packages/@aws-cdk/assert/README.md +++ b/packages/@aws-cdk/assert/README.md @@ -79,3 +79,25 @@ expect(stack).to(haveResource('AWS::CertificateManager::Certificate', { // Note: some properties omitted here })); ``` +### Check existence of an output +`haveOutput` assertion can be used to check that a stack contains specific output. +Parameters to check against can be: +- `outputName` +- `outputValue` +- `exportName` + +If `outputValue` is provided, at least one of `outputName`, `exportName` should be provided as well + +Example +```ts +expect(synthStack).to(haveOutput({ + outputName: 'TestOutputName', + exportName: 'TestOutputExportName', + outputValue: { + 'Fn::GetAtt': [ + 'TestResource', + 'Arn' + ] + } +})); +``` diff --git a/packages/@aws-cdk/assert/jest.ts b/packages/@aws-cdk/assert/jest.ts index d5715364652a7..78fa8fab38d90 100644 --- a/packages/@aws-cdk/assert/jest.ts +++ b/packages/@aws-cdk/assert/jest.ts @@ -1,8 +1,11 @@ -import { Stack } from "@aws-cdk/core"; +import { Stack } from '@aws-cdk/core'; import * as cxapi from '@aws-cdk/cx-api'; -import { HaveResourceAssertion, ResourcePart } from "./lib/assertions/have-resource"; -import { MatchStyle, matchTemplate } from "./lib/assertions/match-template"; +import { JestFriendlyAssertion } from './lib/assertion'; +import { haveOutput, HaveOutputProperties } from './lib/assertions/have-output'; +import { HaveResourceAssertion, ResourcePart } from './lib/assertions/have-resource'; +import { MatchStyle, matchTemplate } from './lib/assertions/match-template'; import { expect as ourExpect } from './lib/expect'; +import { StackInspector } from './lib/inspector'; declare global { namespace jest { @@ -17,6 +20,8 @@ declare global { toHaveResourceLike(resourceType: string, properties?: any, comparison?: ResourcePart): R; + + toHaveOutput(props: HaveOutputProperties): R; } } } @@ -50,8 +55,9 @@ expect.extend({ comparison?: ResourcePart) { const assertion = new HaveResourceAssertion(resourceType, properties, comparison, false); - return assertHaveResource(assertion, actual); + return applyAssertion(assertion, actual); }, + toHaveResourceLike( actual: cxapi.CloudFormationStackArtifact | Stack, resourceType: string, @@ -59,11 +65,18 @@ expect.extend({ comparison?: ResourcePart) { const assertion = new HaveResourceAssertion(resourceType, properties, comparison, true); - return assertHaveResource(assertion, actual); + return applyAssertion(assertion, actual); + }, + + toHaveOutput( + actual: cxapi.CloudFormationStackArtifact | Stack, + props: HaveOutputProperties) { + + return applyAssertion(haveOutput(props), actual); } }); -function assertHaveResource(assertion: HaveResourceAssertion, actual: cxapi.CloudFormationStackArtifact | Stack) { +function applyAssertion(assertion: JestFriendlyAssertion, actual: cxapi.CloudFormationStackArtifact | Stack) { const inspector = ourExpect(actual); const pass = assertion.assertUsing(inspector); if (pass) { diff --git a/packages/@aws-cdk/assert/lib/assertion.ts b/packages/@aws-cdk/assert/lib/assertion.ts index d193a6856c27e..57ef3ca759e78 100644 --- a/packages/@aws-cdk/assert/lib/assertion.ts +++ b/packages/@aws-cdk/assert/lib/assertion.ts @@ -20,6 +20,13 @@ export abstract class Assertion { } } +export abstract class JestFriendlyAssertion extends Assertion { + /** + * Generates an error message that can be used by Jest. + */ + public abstract generateErrorMessage(): string; +} + import { AndAssertion } from "./assertions/and-assertion"; function and(left: Assertion, right: Assertion): Assertion { diff --git a/packages/@aws-cdk/assert/lib/assertions/have-output.ts b/packages/@aws-cdk/assert/lib/assertions/have-output.ts new file mode 100644 index 0000000000000..36f76b3e573a0 --- /dev/null +++ b/packages/@aws-cdk/assert/lib/assertions/have-output.ts @@ -0,0 +1,116 @@ +import { JestFriendlyAssertion } from '../assertion'; +import { StackInspector } from '../inspector'; + +class HaveOutputAssertion extends JestFriendlyAssertion { + private readonly inspected: InspectionFailure[] = []; + + constructor(private readonly outputName?: string, private readonly exportName?: any, private outputValue?: any) { + super(); + if (!this.outputName && !this.exportName) { + throw new Error('At least one of [outputName, exportName] should be provided'); + } + } + + public get description(): string { + const descriptionPartsArray = new Array(); + + if (this.outputName) { + descriptionPartsArray.push(`name '${this.outputName}'`); + } + if (this.exportName) { + descriptionPartsArray.push(`export name ${JSON.stringify(this.exportName)}`); + } + if (this.outputValue) { + descriptionPartsArray.push(`value ${JSON.stringify(this.outputValue)}`); + } + + return 'output with ' + descriptionPartsArray.join(', '); + } + + public assertUsing(inspector: StackInspector): boolean { + if (!('Outputs' in inspector.value)) { + return false; + } + + for (const [name, props] of Object.entries(inspector.value.Outputs as Record)) { + const mismatchedFields = new Array(); + + if (this.outputName && name !== this.outputName) { + mismatchedFields.push('name'); + } + + if (this.exportName && JSON.stringify(this.exportName) !== JSON.stringify(props.Export?.Name)) { + mismatchedFields.push('export name'); + } + + if (this.outputValue && JSON.stringify(this.outputValue) !== JSON.stringify(props.Value)) { + mismatchedFields.push('value'); + } + + if (mismatchedFields.length === 0) { + return true; + } + + this.inspected.push({ + output: { [name]: props }, + failureReason: `mismatched ${mismatchedFields.join(', ')}`, + }); + } + + return false; + } + + public generateErrorMessage() { + const lines = new Array(); + + lines.push(`None of ${this.inspected.length} outputs matches ${this.description}.`); + + for (const inspected of this.inspected) { + lines.push(`- ${inspected.failureReason} in:`); + lines.push(indent(4, JSON.stringify(inspected.output, null, 2))); + } + + return lines.join('\n'); + } +} + +/** + * Interface for haveOutput function properties + * NOTE that at least one of [outputName, exportName] should be provided + */ +export interface HaveOutputProperties { + /** + * Logical ID of the output + * @default - the logical ID of the output will not be checked + */ + outputName?: string; + /** + * Export name of the output, when it's exported for cross-stack referencing + * @default - the export name is not required and will not be checked + */ + exportName?: any; + /** + * Value of the output; + * @default - the value will not be checked + */ + outputValue?: any; +} + +interface InspectionFailure { + output: any; + failureReason: string; +} + +/** + * An assertion to check whether Output with particular properties is present in a stack + * @param props properties of the Output that is being asserted against. + * Check ``HaveOutputProperties`` interface to get full list of available parameters + */ +export function haveOutput(props: HaveOutputProperties): JestFriendlyAssertion { + return new HaveOutputAssertion(props.outputName, props.exportName, props.outputValue); +} + +function indent(n: number, s: string) { + const prefix = ' '.repeat(n); + return prefix + s.replace(/\n/g, '\n' + prefix); +} diff --git a/packages/@aws-cdk/assert/lib/assertions/have-resource.ts b/packages/@aws-cdk/assert/lib/assertions/have-resource.ts index d6653f3b64ce6..ce94e152ab5ac 100644 --- a/packages/@aws-cdk/assert/lib/assertions/have-resource.ts +++ b/packages/@aws-cdk/assert/lib/assertions/have-resource.ts @@ -1,4 +1,4 @@ -import { Assertion } from "../assertion"; +import { Assertion, JestFriendlyAssertion } from "../assertion"; import { StackInspector } from "../inspector"; /** @@ -30,8 +30,8 @@ export function haveResourceLike(resourceType: string, type PropertyPredicate = (props: any, inspection: InspectionFailure) => boolean; -export class HaveResourceAssertion extends Assertion { - private inspected: InspectionFailure[] = []; +export class HaveResourceAssertion extends JestFriendlyAssertion { + private readonly inspected: InspectionFailure[] = []; private readonly part: ResourcePart; private readonly predicate: PropertyPredicate; diff --git a/packages/@aws-cdk/assert/lib/index.ts b/packages/@aws-cdk/assert/lib/index.ts index b79d93592affc..ff3516dc2f6fd 100644 --- a/packages/@aws-cdk/assert/lib/index.ts +++ b/packages/@aws-cdk/assert/lib/index.ts @@ -4,6 +4,7 @@ export * from './inspector'; export * from './synth-utils'; export * from './assertions/exist'; +export * from './assertions/have-output'; export * from './assertions/have-resource'; export * from './assertions/have-type'; export * from './assertions/match-template'; diff --git a/packages/@aws-cdk/assert/package.json b/packages/@aws-cdk/assert/package.json index b48d3d5a8e365..2126a8bcaff75 100644 --- a/packages/@aws-cdk/assert/package.json +++ b/packages/@aws-cdk/assert/package.json @@ -23,9 +23,8 @@ ], "coverageThreshold": { "global": { - "statements": 80, - "lines": 80, - "branches": 60 + "statements": 75, + "branches": 65 } }, "preset": "ts-jest", diff --git a/packages/@aws-cdk/assert/test/have-output.test.ts b/packages/@aws-cdk/assert/test/have-output.test.ts new file mode 100644 index 0000000000000..71dfd9015c89c --- /dev/null +++ b/packages/@aws-cdk/assert/test/have-output.test.ts @@ -0,0 +1,201 @@ +import * as cxapi from '@aws-cdk/cx-api'; +import { unlink, writeFileSync } from 'fs'; +import { join } from 'path'; +import '../jest'; + +let templateFilePath: string; +let synthStack: cxapi.CloudFormationStackArtifact; +let noOutputStack: cxapi.CloudFormationStackArtifact; + +beforeEach(done => { + synthStack = mkStack({ + Resources: { + SomeResource: { + Type: 'Some::Resource', + Properties: { + PropA: 'somevalue' + } + }, + AnotherResource: { + Type: 'Some::AnotherResource', + Properties: { + PropA: 'anothervalue' + } + } + }, + Outputs: { + TestOutput: { + Value: { + 'Fn::GetAtt': [ + 'SomeResource', + 'Arn' + ] + }, + Export: { + Name: 'TestOutputExportName' + } + }, + ComplexExportNameOutput: { + Value: { + 'Fn::GetAtt': [ + 'ComplexOutputResource', + 'Arn' + ] + }, + Export: { + Name: { + "Fn::Sub": "${AWS::StackName}-ComplexExportNameOutput" + } + } + } + } + }); + noOutputStack = mkStack({ + Resources: { + SomeResource: { + Type: 'Some::Resource', + Properties: { + PropA: 'somevalue' + } + } + } + }); + done(); +}); + +test('haveOutput should assert true when output with correct name is provided', () => { + expect(synthStack).toHaveOutput({ + outputName: 'TestOutput' + }); +}); + +test('haveOutput should assert false when output with incorrect name is provided', () => { + expect(synthStack).not.toHaveOutput({ + outputName: 'WrongOutput' + }); +}); + +test('haveOutput should assert true when output with correct name and export name is provided', () => { + expect(synthStack).toHaveOutput({ + outputName: 'TestOutput', + exportName: 'TestOutputExportName', + }); +}); + +test('haveOutput should assert false when output with correct name and incorrect export name is provided', () => { + expect(synthStack).not.toHaveOutput({ + outputName: 'TestOutput', + exportName: 'WrongTestOutputExportName', + }); +}); + +test('haveOutput should assert true when output with correct name, export name and value is provided', () => { + expect(synthStack).toHaveOutput({ + outputName: 'TestOutput', + exportName: 'TestOutputExportName', + outputValue: { + 'Fn::GetAtt': [ + 'SomeResource', + 'Arn' + ] + } + }); +}); + +test('haveOutput should assert false when output with correct name and export name and incorrect value is provided', () => { + expect(synthStack).not.toHaveOutput({ + outputName: 'TestOutput', + exportName: 'TestOutputExportName', + outputValue: 'SomeWrongValue' + }); +}); + +test('haveOutput should assert true when output with correct export name and value is provided', () => { + expect(synthStack).toHaveOutput({ + exportName: 'TestOutputExportName', + outputValue: { + 'Fn::GetAtt': [ + 'SomeResource', + 'Arn' + ] + } + }); +}); + +test('haveOutput should assert false when output with correct export name and incorrect value is provided', () => { + expect(synthStack).not.toHaveOutput({ + exportName: 'TestOutputExportName', + outputValue: 'WrongValue' + }); +}); + +test('haveOutput should assert true when output with correct output name and value is provided', () => { + expect(synthStack).toHaveOutput({ + outputName: 'TestOutput', + outputValue: { + 'Fn::GetAtt': [ + 'SomeResource', + 'Arn' + ] + } + }); +}); + +test('haveOutput should assert false when output with correct output name and incorrect value is provided', () => { + expect(synthStack).not.toHaveOutput({ + outputName: 'TestOutput', + outputValue: 'WrongValue' + }); +}); + +test('haveOutput should assert false when asserting against noOutputStack', () => { + expect(noOutputStack).not.toHaveOutput({ + outputName: 'TestOutputName', + exportName: 'TestExportName', + outputValue: 'TestOutputValue' + }); +}); + +test('haveOutput should throw Error when none of outputName and exportName is provided', () => { + expect(() => expect(synthStack).toHaveOutput({ outputValue: 'SomeValue' })) + .toThrow('At least one of [outputName, exportName] should be provided'); +}); + +test('haveOutput should be able to handle complex exportName values', () => { + expect(synthStack).toHaveOutput({ + exportName: {'Fn::Sub': '${AWS::StackName}-ComplexExportNameOutput'}, + outputValue: { + 'Fn::GetAtt': [ + 'ComplexOutputResource', + 'Arn' + ] + } + }); +}); + +afterEach(done => { + if (templateFilePath) { + unlink(templateFilePath, done); + } else { + done(); + } +}); + +function mkStack(template: any): cxapi.CloudFormationStackArtifact { + const templateFileName = 'test-have-output-template.json'; + const stackName = 'test-have-output'; + const assembly = new cxapi.CloudAssemblyBuilder(); + + assembly.addArtifact(stackName, { + type: cxapi.ArtifactType.AWS_CLOUDFORMATION_STACK, + environment: cxapi.EnvironmentUtils.format('123456789012', 'bermuda-triangle-1'), + properties: { + templateFile: templateFileName + } + }); + + templateFilePath = join(assembly.outdir, templateFileName); + writeFileSync(templateFilePath, JSON.stringify(template)); + + return assembly.buildAssembly().getStackByName(stackName); +}