Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(assertions): capture matching value #16426

Merged
merged 5 commits into from
Sep 9, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 70 additions & 1 deletion packages/@aws-cdk/assertions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,9 @@ assert.hasResourceProperties('Foo::Bar', {
The `Match.objectEquals()` API can be used to assert a target as a deep exact
match.

In addition, the `Match.absentProperty()` can be used to specify that a specific
### Presence and Absence

The `Match.absentProperty()` matcher can be used to specify that a specific
property should not exist on the target. This can be used within `Match.objectLike()`
or outside of any matchers.

Expand Down Expand Up @@ -218,6 +220,42 @@ assert.hasResourceProperties('Foo::Bar', {
});
```

The `Match.anyValue()` matcher can be used to specify that a specific value should be found
at the location. This matcher will fail if when the target location has null-ish values
(i.e., `null` or `undefined`).

This matcher can be combined with any of the other matchers.

```ts
// Given a template -
// {
// "Resources": {
// "MyBar": {
// "Type": "Foo::Bar",
// "Properties": {
// "Fred": {
// "Wobble": ["Flob", "Flib"],
// }
// }
// }
// }
// }

// The following will NOT throw an assertion error
assert.hasResourceProperties('Foo::Bar', {
Fred: {
Wobble: [Match.anyValue(), "Flip"],
},
});

// The following will throw an assertion error
assert.hasResourceProperties('Foo::Bar', {
Fred: {
Wimble: Match.anyValue(),
},
});
```

### Array Matchers

The `Match.arrayWith()` API can be used to assert that the target is equal to or a subset
Expand Down Expand Up @@ -283,6 +321,37 @@ assert.hasResourceProperties('Foo::Bar', Match.objectLike({
}});
```

## Capturing Values

This matcher APIs documented above allow capturing values in the matching entry
(Resource, Output, Mapping, etc.). The following code captures a string from a
matching resource.

```ts
// Given a template -
// {
// "Resources": {
// "MyBar": {
// "Type": "Foo::Bar",
// "Properties": {
// "Fred": ["Flob", "Cat"],
// "Waldo": ["Qix", "Qux"],
// }
// }
// }
// }

const fredCapture = new Capture();
const waldoCapture = new Capture();
assert.hasResourceProperties('Foo::Bar', {
Fred: fredCapture,
Waldo: ["Qix", waldoCapture],
});

fredCapture.asArray(); // returns ["Flob", "Cat"]
waldoCapture.asString(); // returns "Qux"
Comment on lines +351 to +352
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would appreciate a more "useful" example here that demonstrates the benefit of using capturing. We can use the assertion found in the pipelines module like expect(captured.asString().length()).toBe(3) or something

```

## Strongly typed languages

Some of the APIs documented above, such as `templateMatches()` and
Expand Down
98 changes: 98 additions & 0 deletions packages/@aws-cdk/assertions/lib/capture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { Matcher, MatchResult } from './matcher';
import { Type, getType } from './private/type';

/**
* Capture values while matching templates.
* Using an instance of this class within a Matcher will capture the matching value.
* The `as*()` APIs on the instance can be used to get the captured value.
*/
export class Capture extends Matcher {
public readonly name: string;
private value: any = null;

constructor() {
super();
this.name = 'Capture';
}

public test(actual: any): MatchResult {
this.value = actual;

const result = new MatchResult(actual);
if (actual == null) {
result.push(this, [], `Can only capture non-nullish values. Found ${actual}`);
}
return result;
}

/**
* Retrieve the captured value as a string.
* An error is generated if no value is captured or if the value is not a string.
*/
public asString(): string {
this.checkNotNull();
if (getType(this.value) === 'string') {
return this.value;
}
this.reportIncorrectType('string');
}

/**
* Retrieve the captured value as a number.
* An error is generated if no value is captured or if the value is not a number.
*/
public asNumber(): number {
this.checkNotNull();
if (getType(this.value) === 'number') {
return this.value;
}
this.reportIncorrectType('number');
}

/**
* Retrieve the captured value as a boolean.
* An error is generated if no value is captured or if the value is not a boolean.
*/
public asBoolean(): boolean {
this.checkNotNull();
if (getType(this.value) === 'boolean') {
return this.value;
}
this.reportIncorrectType('boolean');
}

/**
* Retrieve the captured value as an array.
* An error is generated if no value is captured or if the value is not an array.
*/
public asArray(): any[] {
this.checkNotNull();
if (getType(this.value) === 'array') {
return this.value;
}
this.reportIncorrectType('array');
}

/**
* Retrieve the captured value as a JSON object.
* An error is generated if no value is captured or if the value is not an object.
*/
public asObject(): { [key: string]: any } {
this.checkNotNull();
if (getType(this.value) === 'object') {
return this.value;
}
this.reportIncorrectType('object');
}

private checkNotNull(): void {
if (this.value == null) {
throw new Error('No value captured');
}
}

private reportIncorrectType(expected: Type): never {
throw new Error(`Captured value is expected to be ${expected} but found ${getType(this.value)}. ` +
`Value is ${JSON.stringify(this.value, undefined, 2)}`);
}
}
1 change: 1 addition & 0 deletions packages/@aws-cdk/assertions/lib/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './capture';
export * from './template';
export * from './match';
export * from './matcher';
36 changes: 30 additions & 6 deletions packages/@aws-cdk/assertions/lib/match.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Matcher, MatchResult } from './matcher';
import { getType } from './private/type';
import { ABSENT } from './vendored/assert';

/**
Expand Down Expand Up @@ -63,6 +64,13 @@ export abstract class Match {
public static not(pattern: any): Matcher {
return new NotMatch('not', pattern);
}

/**
* Matches any non-null value at the target.
*/
public static anyValue(): Matcher {
return new AnyMatch('anyValue');
}
}

/**
Expand Down Expand Up @@ -141,22 +149,22 @@ interface ArrayMatchOptions {
* Match class that matches arrays.
*/
class ArrayMatch extends Matcher {
private readonly partial: boolean;
private readonly subsequence: boolean;

constructor(
public readonly name: string,
private readonly pattern: any[],
options: ArrayMatchOptions = {}) {

super();
this.partial = options.subsequence ?? true;
this.subsequence = options.subsequence ?? true;
}

public test(actual: any): MatchResult {
if (!Array.isArray(actual)) {
return new MatchResult(actual).push(this, [], `Expected type array but received ${getType(actual)}`);
}
if (!this.partial && this.pattern.length !== actual.length) {
if (!this.subsequence && this.pattern.length !== actual.length) {
return new MatchResult(actual).push(this, [], `Expected array of length ${this.pattern.length} but received ${actual.length}`);
}

Expand All @@ -166,10 +174,16 @@ class ArrayMatch extends Matcher {
const result = new MatchResult(actual);
while (patternIdx < this.pattern.length && actualIdx < actual.length) {
const patternElement = this.pattern[patternIdx];

const matcher = Matcher.isMatcher(patternElement) ? patternElement : new LiteralMatch(this.name, patternElement);
if (this.subsequence && matcher instanceof AnyMatch) {
// array subsequence matcher is not compatible with anyValue() matcher. They don't make sense to be used together.
throw new Error('The Matcher anyValue() cannot be nested within arrayWith()');
}

const innerResult = matcher.test(actual[actualIdx]);

if (!this.partial || !innerResult.hasFailed()) {
if (!this.subsequence || !innerResult.hasFailed()) {
result.compose(`[${actualIdx}]`, innerResult);
patternIdx++;
actualIdx++;
Expand Down Expand Up @@ -271,6 +285,16 @@ class NotMatch extends Matcher {
}
}

function getType(obj: any): string {
return Array.isArray(obj) ? 'array' : typeof obj;
class AnyMatch extends Matcher {
constructor(public readonly name: string) {
super();
}

public test(actual: any): MatchResult {
const result = new MatchResult(actual);
if (actual == null) {
result.push(this, [], 'Expected a value but found none');
}
return result;
}
}
2 changes: 2 additions & 0 deletions packages/@aws-cdk/assertions/lib/matcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ export abstract class Matcher {

/**
* Test whether a target matches the provided pattern.
* Every Matcher must implement this method.
* This method will be invoked by the assertions framework. Do not call this method directly.
* @param actual the target to match
* @return the list of match failures. An empty array denotes a successful match.
*/
Expand Down
5 changes: 5 additions & 0 deletions packages/@aws-cdk/assertions/lib/private/type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export type Type = 'string' | 'number' | 'bigint' | 'boolean' | 'symbol' | 'undefined' | 'object' | 'function' | 'array';

export function getType(obj: any): Type {
return Array.isArray(obj) ? 'array' : typeof obj;
}
69 changes: 69 additions & 0 deletions packages/@aws-cdk/assertions/test/capture.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { Capture, Match } from '../lib';

describe('Capture', () => {
test('uncaptured', () => {
const capture = new Capture();
expect(() => capture.asString()).toThrow(/No value captured/);
});

test('nullish', () => {
const capture = new Capture();
const matcher = Match.objectEquals({ foo: capture });

const result = matcher.test({ foo: null });
expect(result.failCount).toEqual(1);
expect(result.toHumanStrings()[0]).toMatch(/Can only capture non-nullish values/);
});

test('asString()', () => {
const capture = new Capture();
const matcher = Match.objectEquals({ foo: capture });

matcher.test({ foo: 'bar' });
expect(capture.asString()).toEqual('bar');

matcher.test({ foo: 3 });
expect(() => capture.asString()).toThrow(/expected to be string but found number/);
});

test('asNumber()', () => {
const capture = new Capture();
const matcher = Match.objectEquals({ foo: capture });

matcher.test({ foo: 3 });
expect(capture.asNumber()).toEqual(3);

matcher.test({ foo: 'bar' });
expect(() => capture.asNumber()).toThrow(/expected to be number but found string/);
});

test('asArray()', () => {
const capture = new Capture();
const matcher = Match.objectEquals({ foo: capture });

matcher.test({ foo: ['bar'] });
expect(capture.asArray()).toEqual(['bar']);

matcher.test({ foo: 'bar' });
expect(() => capture.asArray()).toThrow(/expected to be array but found string/);
});

test('asObject()', () => {
const capture = new Capture();
const matcher = Match.objectEquals({ foo: capture });

matcher.test({ foo: { fred: 'waldo' } });
expect(capture.asObject()).toEqual({ fred: 'waldo' });

matcher.test({ foo: 'bar' });
expect(() => capture.asObject()).toThrow(/expected to be object but found string/);
});

test('nested within an array', () => {
const capture = new Capture();
const matcher = Match.objectEquals({ foo: ['bar', capture] });

matcher.test({ foo: ['bar', 'baz'] });
expect(capture.asString()).toEqual('baz');
});
});
Loading