Skip to content

Commit

Permalink
feat(assert): cdk assert: implement 'haveOutput' assertion (#5366)
Browse files Browse the repository at this point in the history
Implements an ability to assert presence of stack outputs with specific
properties being present.

Fixes #1906
  • Loading branch information
ilya-v-trofimov authored and mergify[bot] committed Jan 13, 2020
1 parent 9cdf713 commit ee6decb
Show file tree
Hide file tree
Showing 8 changed files with 371 additions and 12 deletions.
22 changes: 22 additions & 0 deletions packages/@aws-cdk/assert/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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'
]
}
}));
```
25 changes: 19 additions & 6 deletions packages/@aws-cdk/assert/jest.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -17,6 +20,8 @@ declare global {
toHaveResourceLike(resourceType: string,
properties?: any,
comparison?: ResourcePart): R;

toHaveOutput(props: HaveOutputProperties): R;
}
}
}
Expand Down Expand Up @@ -50,20 +55,28 @@ 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,
properties?: any,
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<StackInspector>, actual: cxapi.CloudFormationStackArtifact | Stack) {
const inspector = ourExpect(actual);
const pass = assertion.assertUsing(inspector);
if (pass) {
Expand Down
7 changes: 7 additions & 0 deletions packages/@aws-cdk/assert/lib/assertion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ export abstract class Assertion<InspectorClass extends Inspector> {
}
}

export abstract class JestFriendlyAssertion<InspectorClass extends Inspector> extends Assertion<InspectorClass> {
/**
* Generates an error message that can be used by Jest.
*/
public abstract generateErrorMessage(): string;
}

import { AndAssertion } from "./assertions/and-assertion";

function and<I extends Inspector>(left: Assertion<I>, right: Assertion<I>): Assertion<I> {
Expand Down
116 changes: 116 additions & 0 deletions packages/@aws-cdk/assert/lib/assertions/have-output.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { JestFriendlyAssertion } from '../assertion';
import { StackInspector } from '../inspector';

class HaveOutputAssertion extends JestFriendlyAssertion<StackInspector> {
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<string>();

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<string, any>)) {
const mismatchedFields = new Array<string>();

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<string>();

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<StackInspector> {
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);
}
6 changes: 3 additions & 3 deletions packages/@aws-cdk/assert/lib/assertions/have-resource.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Assertion } from "../assertion";
import { Assertion, JestFriendlyAssertion } from "../assertion";
import { StackInspector } from "../inspector";

/**
Expand Down Expand Up @@ -30,8 +30,8 @@ export function haveResourceLike(resourceType: string,

type PropertyPredicate = (props: any, inspection: InspectionFailure) => boolean;

export class HaveResourceAssertion extends Assertion<StackInspector> {
private inspected: InspectionFailure[] = [];
export class HaveResourceAssertion extends JestFriendlyAssertion<StackInspector> {
private readonly inspected: InspectionFailure[] = [];
private readonly part: ResourcePart;
private readonly predicate: PropertyPredicate;

Expand Down
1 change: 1 addition & 0 deletions packages/@aws-cdk/assert/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
5 changes: 2 additions & 3 deletions packages/@aws-cdk/assert/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,8 @@
],
"coverageThreshold": {
"global": {
"statements": 80,
"lines": 80,
"branches": 60
"statements": 75,
"branches": 65
}
},
"preset": "ts-jest",
Expand Down
Loading

0 comments on commit ee6decb

Please sign in to comment.