diff --git a/packages/@aws-cdk/assert/README.md b/packages/@aws-cdk/assert/README.md index 71c19f3652a51..c81fac74562e9 100644 --- a/packages/@aws-cdk/assert/README.md +++ b/packages/@aws-cdk/assert/README.md @@ -63,6 +63,7 @@ If you only care that a resource of a particular type exists (regardless of its ```ts haveResource(type, subsetOfProperties) +haveResourceLike(type, subsetOfProperties) ``` Example: @@ -76,7 +77,35 @@ expect(stack).to(haveResource('AWS::CertificateManager::Certificate', { })); ``` -`ABSENT` is a magic value to assert that a particular key in an object is *not* set (or set to `undefined`). +The object you give to `haveResource`/`haveResourceLike` like can contain the +following values: + +- **Literal values**: the given property in the resource must match the given value *exactly*. +- `ABSENT`: a magic value to assert that a particular key in an object is *not* set (or set to `undefined`). +- `arrayWith(...)`/`objectLike(...)`/`deepObjectLike(...)`/`exactValue()`: special matchers + for inexact matching. You can use these to match arrays where not all elements have to match, + just a single one, or objects where not all keys have to match. + +The difference between `haveResource` and `haveResourceLike` is the same as +between `objectLike` and `deepObjectLike`: the first allows +additional (unspecified) object keys only at the *first* level, while the +second one allows them in nested objects as well. + +If you want to escape from the "deep lenient matching" behavior, you can use +`exactValue()`. + +Slightly more complex example with array matchers: + +```ts +expect(stack).to(haveResourceLike('AWS::IAM::Policy', { + PolicyDocument: { + Statement: arrayWith(objectLike({ + Action: ['s3:GetObject'], + Resource: ['arn:my:arn'], + }}) + } +})); +``` ### Check number of resources diff --git a/packages/@aws-cdk/assert/lib/assertions/have-resource.ts b/packages/@aws-cdk/assert/lib/assertions/have-resource.ts index cf7b9c6d15da1..3676f06352068 100644 --- a/packages/@aws-cdk/assert/lib/assertions/have-resource.ts +++ b/packages/@aws-cdk/assert/lib/assertions/have-resource.ts @@ -40,21 +40,24 @@ export function haveResourceLike( return haveResource(resourceType, properties, comparison, true); } -type PropertyPredicate = (props: any, inspection: InspectionFailure) => boolean; +export type PropertyMatcher = (props: any, inspection: InspectionFailure) => boolean; export class HaveResourceAssertion extends JestFriendlyAssertion { private readonly inspected: InspectionFailure[] = []; private readonly part: ResourcePart; - private readonly predicate: PropertyPredicate; + private readonly matcher: any; constructor( private readonly resourceType: string, - private readonly properties?: any, + properties?: any, part?: ResourcePart, allowValueExtension: boolean = false) { super(); - this.predicate = typeof properties === 'function' ? properties : makeSuperObjectPredicate(properties, allowValueExtension); + this.matcher = isCallable(properties) ? properties : + properties === undefined ? anything() : + allowValueExtension ? deepObjectLike(properties) : + objectLike(properties); this.part = part !== undefined ? part : ResourcePart.Properties; } @@ -68,7 +71,7 @@ export class HaveResourceAssertion extends JestFriendlyAssertion // to maintain backwards compatibility with old predicate API. const inspection = { resource, failureReason: 'Object did not match predicate' }; - if (this.predicate(propsToCheck, inspection)) { + if (match(propsToCheck, this.matcher, inspection)) { return true; } @@ -99,7 +102,7 @@ export class HaveResourceAssertion extends JestFriendlyAssertion public get description(): string { // tslint:disable-next-line:max-line-length - return `resource '${this.resourceType}' with properties ${JSON.stringify(this.properties, undefined, 2)}`; + return `resource '${this.resourceType}' with ${JSON.stringify(this.matcher, undefined, 2)}`; } } @@ -108,111 +111,275 @@ function indent(n: number, s: string) { return prefix + s.replace(/\n/g, '\n' + prefix); } -/** - * Make a predicate that checks property superset - */ -function makeSuperObjectPredicate(obj: any, allowValueExtension: boolean) { - return (resourceProps: any, inspection: InspectionFailure) => { - const errors: string[] = []; - const ret = isSuperObject(resourceProps, obj, errors, allowValueExtension); - inspection.failureReason = errors.join(','); - return ret; - }; -} - export interface InspectionFailure { resource: any; failureReason: string; } /** - * Return whether `superObj` is a super-object of `obj`. + * Match a given literal value against a matcher * - * A super-object has the same or more property values, recursing into sub properties if ``allowValueExtension`` is true. + * If the matcher is a callable, use that to evaluate the value. Otherwise, the values + * must be literally the same. */ -export function isSuperObject(superObj: any, pattern: any, errors: string[] = [], allowValueExtension: boolean = false): boolean { +function match(value: any, matcher: any, inspection: InspectionFailure) { + if (isCallable(matcher)) { + // Custom matcher (this mostly looks very weird because our `InspectionFailure` signature is weird) + const innerInspection: InspectionFailure = { ...inspection, failureReason: '' }; + const result = matcher(value, innerInspection); + if (typeof result !== 'boolean') { + return failMatcher(inspection, `Predicate returned non-boolean return value: ${result}`); + } + if (!result && !innerInspection.failureReason) { + // Custom matcher neglected to return an error + return failMatcher(inspection, 'Predicate returned false'); + } + // Propagate inner error in case of failure + if (!result) { inspection.failureReason = innerInspection.failureReason; } + return result; + } + + return matchLiteral(value, matcher, inspection); +} + +/** + * Match a literal value at the top level. + * + * When recursing into arrays or objects, the nested values can be either matchers + * or literals. + */ +function matchLiteral(value: any, pattern: any, inspection: InspectionFailure) { if (pattern == null) { return true; } - if (Array.isArray(superObj) !== Array.isArray(pattern)) { - errors.push('Array type mismatch'); - return false; + + const errors = new Array(); + + if (Array.isArray(value) !== Array.isArray(pattern)) { + return failMatcher(inspection, 'Array type mismatch'); } - if (Array.isArray(superObj)) { - if (pattern.length !== superObj.length) { - errors.push('Array length mismatch'); - return false; + if (Array.isArray(value)) { + if (pattern.length !== value.length) { + return failMatcher(inspection, 'Array length mismatch'); } - // Do isSuperObject comparison for individual objects + // Recurse comparison for individual objects for (let i = 0; i < pattern.length; i++) { - if (!isSuperObject(superObj[i], pattern[i], [], allowValueExtension)) { + if (!match(value[i], pattern[i], { ...inspection })) { errors.push(`Array element ${i} mismatch`); } } - return errors.length === 0; + + if (errors.length > 0) { + return failMatcher(inspection, errors.join(', ')); + } + return true; } - if ((typeof superObj === 'object') !== (typeof pattern === 'object')) { - errors.push('Object type mismatch'); - return false; + if ((typeof value === 'object') !== (typeof pattern === 'object')) { + return failMatcher(inspection, 'Object type mismatch'); } if (typeof pattern === 'object') { + // Check that all fields in the pattern have the right value + const innerInspection = { ...inspection, failureReason: '' }; + const matcher = objectLike(pattern)(value, innerInspection); + if (!matcher) { + inspection.failureReason = innerInspection.failureReason; + return false; + } + + // Check no fields uncovered + const realFields = new Set(Object.keys(value)); + for (const key of Object.keys(pattern)) { realFields.delete(key); } + if (realFields.size > 0) { + return failMatcher(inspection, `Unexpected keys present in object: ${Array.from(realFields).join(', ')}`); + } + return true; + } + + if (value !== pattern) { + return failMatcher(inspection, 'Different values'); + } + + return true; +} + +/** + * Helper function to make matcher failure reporting a little easier + * + * Our protocol is weird (change a string on a passed-in object and return 'false'), + * but I don't want to change that right now. + */ +function failMatcher(inspection: InspectionFailure, error: string): boolean { + inspection.failureReason = error; + return false; +} + +/** + * A matcher for an object that contains at least the given fields with the given matchers (or literals) + * + * Only does lenient matching one level deep, at the next level all objects must declare the + * exact expected keys again. + */ +export function objectLike(pattern: A): PropertyMatcher { + return _objectContaining(pattern, false); +} + +/** + * A matcher for an object that contains at least the given fields with the given matchers (or literals) + * + * Switches to "deep" lenient matching. Nested objects also only need to contain declared keys. + */ +export function deepObjectLike(pattern: A): PropertyMatcher { + return _objectContaining(pattern, true); +} + +export function _objectContaining(pattern: A, deep: boolean): PropertyMatcher { + const ret = (value: any, inspection: InspectionFailure): boolean => { + if (typeof value !== 'object' || !value) { + return failMatcher(inspection, `Expect an object but got '${typeof value}'`); + } + + const errors = new Array(); + for (const [patternKey, patternValue] of Object.entries(pattern)) { if (patternValue === ABSENT) { - if (superObj[patternKey] !== undefined) { errors.push(`Field ${patternKey} present, but shouldn't be`); } + if (value[patternKey] !== undefined) { errors.push(`Field ${patternKey} present, but shouldn't be`); } continue; } - if (!(patternKey in superObj)) { + if (!(patternKey in value)) { errors.push(`Field ${patternKey} missing`); continue; } - const innerErrors = new Array(); - const valueMatches = allowValueExtension - ? isSuperObject(superObj[patternKey], patternValue, innerErrors, allowValueExtension) - : isStrictlyEqual(superObj[patternKey], patternValue, innerErrors); + // If we are doing DEEP objectLike, translate object literals in the pattern into + // more `deepObjectLike` matchers, even if they occur in lists. + const matchValue = deep ? deepMatcherFromObjectLiteral(patternValue) : patternValue; + + const innerInspection = { ...inspection, failureReason: '' }; + const valueMatches = match(value[patternKey], matchValue, innerInspection); if (!valueMatches) { - errors.push(`Field ${patternKey} mismatch: ${innerErrors.join(', ')}`); + errors.push(`Field ${patternKey} mismatch: ${innerInspection.failureReason}`); } } - return errors.length === 0; - } - if (superObj !== pattern) { - errors.push('Different values'); - } - return errors.length === 0; + /** + * Transform nested object literals into more deep object matchers, if applicable + * + * Object literals in lists are also transformed. + */ + function deepMatcherFromObjectLiteral(nestedPattern: any): any { + if (isObject(nestedPattern)) { + return deepObjectLike(nestedPattern); + } + if (Array.isArray(nestedPattern)) { + return nestedPattern.map(deepMatcherFromObjectLiteral); + } + return nestedPattern; + } + + if (errors.length > 0) { + return failMatcher(inspection, errors.join(', ')); + } + return true; + }; + + // Override toJSON so that our error messages print an readable version of this matcher + // (which we produce by doing JSON.stringify() at some point in the future). + ret.toJSON = () => ({ [deep ? '$deepObjectLike' : '$objectLike']: pattern }); + return ret; } -function isStrictlyEqual(left: any, pattern: any, errors: string[]): boolean { - if (left === pattern) { return true; } - if (typeof left !== typeof pattern) { - errors.push(`${typeof left} !== ${typeof pattern}`); - return false; - } +/** + * Match exactly the given value + * + * This is the default, you only need this to escape from the deep lenient matching + * of `deepObjectLike`. + */ +export function exactValue(expected: any): PropertyMatcher { + const ret = (value: any, inspection: InspectionFailure): boolean => { + return matchLiteral(value, expected, inspection); + }; - if (typeof left === 'object' && typeof pattern === 'object') { - if (Array.isArray(left) !== Array.isArray(pattern)) { return false; } - const allKeys = new Set([...Object.keys(left), ...Object.keys(pattern)]); - for (const key of allKeys) { - if (pattern[key] === ABSENT) { - if (left[key] !== undefined) { - errors.push(`Field ${key} present, but shouldn't be`); - return false; - } - return true; + // Override toJSON so that our error messages print an readable version of this matcher + // (which we produce by doing JSON.stringify() at some point in the future). + ret.toJSON = () => ({ $exactValue: expected }); + return ret; +} + +/** + * A matcher for a list that contains all of the given elements in any order + */ +export function arrayWith(...elements: any[]): PropertyMatcher { + if (elements.length === 0) { return anything(); } + + const ret = (value: any, inspection: InspectionFailure): boolean => { + if (!Array.isArray(value)) { + return failMatcher(inspection, `Expect an object but got '${typeof value}'`); + } + + for (const element of elements) { + const failure = longestFailure(value, element); + if (failure) { + return failMatcher(inspection, `Array did not contain expected element, closest match at index ${failure[0]}: ${failure[1]}`); } + } + + return true; + + /** + * Return 'null' if the matcher matches anywhere in the array, otherwise the longest error and its index + */ + function longestFailure(array: any[], matcher: any): [number, string] | null { + let fail: [number, string] | null = null; + for (let i = 0; i < array.length; i++) { + const innerInspection = { ...inspection, failureReason: '' }; + if (match(array[i], matcher, innerInspection)) { + return null; + } - const innerErrors = new Array(); - if (!isStrictlyEqual(left[key], pattern[key], innerErrors)) { - errors.push(`${Array.isArray(left) ? 'element ' : ''}${key}: ${innerErrors.join(', ')}`); - return false; + if (fail === null || innerInspection.failureReason.length > fail[1].length) { + fail = [i, innerInspection.failureReason]; + } } + return fail; } + }; + + // Override toJSON so that our error messages print an readable version of this matcher + // (which we produce by doing JSON.stringify() at some point in the future). + ret.toJSON = () => ({ $arrayContaining: elements.length === 1 ? elements[0] : elements }); + return ret; +} + +/** + * Matches anything + */ +function anything() { + const ret = () => { return true; - } + }; + ret.toJSON = () => ({ $anything: true }); + return ret; +} - errors.push(`${left} !== ${pattern}`); - return false; +/** + * Return whether `superObj` is a super-object of `obj`. + * + * A super-object has the same or more property values, recursing into sub properties if ``allowValueExtension`` is true. + * + * At any point in the object, a value may be replaced with a function which will be used to check that particular field. + * The type of a matcher function is expected to be of type PropertyMatcher. + * + * @deprecated - Use `objectLike` or a literal object instead. + */ +export function isSuperObject(superObj: any, pattern: any, errors: string[] = [], allowValueExtension: boolean = false): boolean { + const matcher = allowValueExtension ? deepObjectLike(pattern) : objectLike(pattern); + + const inspection: InspectionFailure = { resource: superObj, failureReason: '' }; + const ret = match(superObj, matcher, inspection); + if (!ret) { + errors.push(inspection.failureReason); + } + return ret; } /** @@ -231,3 +398,18 @@ export enum ResourcePart { */ CompleteDefinition } + +/** + * Whether a value is a callable + */ +function isCallable(x: any): x is ((...args: any[]) => any) { + return x && {}.toString.call(x) === '[object Function]'; +} + +/** + * Whether a value is an object + */ +function isObject(x: any): x is object { + // Because `typeof null === 'object'`. + return x && typeof x === 'object'; +} \ No newline at end of file diff --git a/packages/@aws-cdk/assert/test/have-resource.test.ts b/packages/@aws-cdk/assert/test/have-resource.test.ts index b523fd2a8bcfc..69ab649433350 100644 --- a/packages/@aws-cdk/assert/test/have-resource.test.ts +++ b/packages/@aws-cdk/assert/test/have-resource.test.ts @@ -2,7 +2,7 @@ import * as cxschema from '@aws-cdk/cloud-assembly-schema'; import * as cxapi from '@aws-cdk/cx-api'; import { writeFileSync } from 'fs'; import { join } from 'path'; -import { ABSENT, expect as cdkExpect, haveResource } from '../lib/index'; +import { ABSENT, arrayWith, exactValue, expect as cdkExpect, haveResource, haveResourceLike } from '../lib/index'; test('support resource with no properties', () => { const synthStack = mkStack({ @@ -138,6 +138,106 @@ describe('property absence', () => { }).toThrowError(/Prop/); }); + test('can use matcher to test for list element', () => { + const synthStack = mkSomeResource({ + List: [ + { Prop: 'distraction' }, + { Prop: 'goal' }, + ], + }); + + expect(() => { + cdkExpect(synthStack).to(haveResource('Some::Resource', { + List: arrayWith({ Prop: 'goal' }), + })); + }).not.toThrowError(); + + expect(() => { + cdkExpect(synthStack).to(haveResource('Some::Resource', { + List: arrayWith({ Prop: 'missme' }), + })); + }).toThrowError(/Array did not contain expected element/); + }); + + test('arrayContaining must match all elements in any order', () => { + const synthStack = mkSomeResource({ + List: ['a', 'b'], + }); + + expect(() => { + cdkExpect(synthStack).to(haveResource('Some::Resource', { + List: arrayWith('b', 'a'), + })); + }).not.toThrowError(); + + expect(() => { + cdkExpect(synthStack).to(haveResource('Some::Resource', { + List: arrayWith('a', 'c'), + })); + }).toThrowError(/Array did not contain expected element/); + }); + + test('exactValue escapes from deep fuzzy matching', () => { + const synthStack = mkSomeResource({ + Deep: { + PropA: 'A', + PropB: 'B', + }, + }); + + expect(() => { + cdkExpect(synthStack).to(haveResourceLike('Some::Resource', { + Deep: { + PropA: 'A', + }, + })); + }).not.toThrowError(); + + expect(() => { + cdkExpect(synthStack).to(haveResourceLike('Some::Resource', { + Deep: exactValue({ + PropA: 'A', + }), + })); + }).toThrowError(/Unexpected keys present in object/); + }); + + /** + * Backwards compatibility test + * + * If we had designed this with a matcher library from the start, we probably wouldn't + * have had this behavior, but here we are. + * + * Historically, when we do `haveResourceLike` (which maps to `objectContainingDeep`) with + * a pattern containing lists of objects, the objects inside the list are also matched + * as 'containing' keys (instead of having to completely 'match' the pattern objects). + * + * People will have written assertions depending on this behavior, so we have to maintain + * it. + */ + test('objectContainingDeep has deep effect through lists', () => { + const synthStack = mkSomeResource({ + List: [ + { + PropA: 'A', + PropB: 'B', + }, + { + PropA: 'A', + PropB: 'B', + }, + ], + }); + + expect(() => { + cdkExpect(synthStack).to(haveResourceLike('Some::Resource', { + List: [ + { PropA: 'A' }, + { PropB: 'B' }, + ], + })); + }).not.toThrowError(); + }); }); function mkStack(template: any): cxapi.CloudFormationStackArtifact {