Skip to content

Commit

Permalink
feat(assert): haveResource lists failing properties (#1016)
Browse files Browse the repository at this point in the history
Make the haveResource assertion tell you which fields failed matching.
This helps in case of differences in hard-to-read fields, such as
'10.0.0.0/16' vs '10.10.0.0/16'.
  • Loading branch information
rix0rrr authored Oct 26, 2018
1 parent 5a6ad3c commit 7f6f3fd
Show file tree
Hide file tree
Showing 2 changed files with 112 additions and 20 deletions.
81 changes: 61 additions & 20 deletions packages/@aws-cdk/assert/lib/assertions/have-resource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@ export function haveResource(resourceType: string, properties?: any, comparison?
return new HaveResourceAssertion(resourceType, properties, comparison);
}

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

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

Expand All @@ -33,13 +33,17 @@ class HaveResourceAssertion extends Assertion<StackInspector> {
for (const logicalId of Object.keys(inspector.value.Resources)) {
const resource = inspector.value.Resources[logicalId];
if (resource.Type === this.resourceType) {
this.inspected.push(resource);

const propsToCheck = this.part === ResourcePart.Properties ? resource.Properties : resource;

if (this.predicate(propsToCheck)) {
// Pass inspection object as 2nd argument, initialize failure with default string,
// to maintain backwards compatibility with old predicate API.
const inspection = { resource, failureReason: 'Object did not match predicate' };

if (this.predicate(propsToCheck, inspection)) {
return true;
}

this.inspected.push(inspection);
}
}

Expand All @@ -48,7 +52,15 @@ class HaveResourceAssertion extends Assertion<StackInspector> {

public assertOrThrow(inspector: StackInspector) {
if (!this.assertUsing(inspector)) {
throw new Error(`None of ${JSON.stringify(this.inspected, null, 2)} match ${this.description}`);
const lines: string[] = [];
lines.push(`None of ${this.inspected.length} resources matches ${this.description}.`);

for (const inspected of this.inspected) {
lines.push(`- ${inspected.failureReason} in:`);
lines.push(indent(4, JSON.stringify(inspected.resource, null, 2)));
}

throw new Error(lines.join('\n'));
}
}

Expand All @@ -58,46 +70,75 @@ class HaveResourceAssertion extends Assertion<StackInspector> {
}
}

function indent(n: number, s: string) {
const prefix = ' '.repeat(n);
return prefix + s.replace(/\n/g, '\n' + prefix);
}

/**
* Make a predicate that checks property superset
*/
function makeSuperObjectPredicate(obj: any) {
return (resourceProps: any) => {
return isSuperObject(resourceProps, obj);
return (resourceProps: any, inspection: InspectionFailure) => {
const errors: string[] = [];
const ret = isSuperObject(resourceProps, obj, errors);
inspection.failureReason = errors.join(',');
return ret;
};
}

interface InspectionFailure {
resource: any;
failureReason: string;
}

/**
* Return whether `superObj` is a super-object of `obj`.
*
* A super-object has the same or more property values, recursing into nested objects.
*/
export function isSuperObject(superObj: any, obj: any): boolean {
export function isSuperObject(superObj: any, obj: any, errors: string[] = []): boolean {
if (obj == null) { return true; }
if (Array.isArray(superObj) !== Array.isArray(obj)) { return false; }
if (Array.isArray(superObj) !== Array.isArray(obj)) {
errors.push('Array type mismatch');
return false;
}
if (Array.isArray(superObj)) {
if (obj.length !== superObj.length) { return false; }
if (obj.length !== superObj.length) {
errors.push('Array length mismatch');
return false;
}

// Do isSuperObject comparison for individual objects
for (let i = 0; i < obj.length; i++) {
if (!isSuperObject(superObj[i], obj[i])) {
return false;
if (!isSuperObject(superObj[i], obj[i], [])) {
errors.push(`Array element ${i} mismatch`);
}
}
return true;
return errors.length === 0;
}
if ((typeof superObj === 'object') !== (typeof obj === 'object')) {
errors.push('Object type mismatch');
return false;
}
if ((typeof superObj === 'object') !== (typeof obj === 'object')) { return false; }
if (typeof obj === 'object') {
for (const key of Object.keys(obj)) {
if (!(key in superObj)) { return false; }
if (!(key in superObj)) {
errors.push(`Field ${key} missing`);
continue;
}

if (!isSuperObject(superObj[key], obj[key])) {
return false;
if (!isSuperObject(superObj[key], obj[key], [])) {
errors.push(`Field ${key} mismatch`);
}
}
return true;
return errors.length === 0;
}

if (superObj !== obj) {
errors.push('Different values');
}
return superObj === obj;
return errors.length === 0;
}

/**
Expand Down
51 changes: 51 additions & 0 deletions packages/@aws-cdk/assert/test/test.have-resource.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { Test } from 'nodeunit';
import { expect, haveResource } from '../lib/index';

export = {
'support resource with no properties'(test: Test) {
const synthStack = mkStack({
Resources: {
SomeResource: {
Type: 'Some::Resource'
}
}
});
expect(synthStack).to(haveResource('Some::Resource'));

test.done();
},

'haveResource tells you about mismatched fields'(test: Test) {
const synthStack = mkStack({
Resources: {
SomeResource: {
Type: 'Some::Resource',
Properties: {
PropA: 'somevalue'
}
}
}
});

test.throws(() => {
expect(synthStack).to(haveResource('Some::Resource', {
PropA: 'othervalue'
}));
}, /PropA/);

test.done();
}
};

function mkStack(template: any) {
return {
name: 'test',
template,
metadata: {},
environment: {
name: 'test',
account: 'test',
region: 'test'
}
};
}

0 comments on commit 7f6f3fd

Please sign in to comment.