Skip to content

Commit

Permalink
Experimentally implement t.like() assertion
Browse files Browse the repository at this point in the history
Co-authored-by: Mark Wubben <mark@novemberborn.net>
  • Loading branch information
futpib and novemberborn committed Jun 14, 2020
1 parent 952a017 commit 19c4f35
Show file tree
Hide file tree
Showing 27 changed files with 930 additions and 218 deletions.
49 changes: 49 additions & 0 deletions docs/03-assertions.md
Expand Up @@ -207,6 +207,55 @@ Assert that `value` is deeply equal to `expected`. See [Concordance](https://git

Assert that `value` is not deeply equal to `expected`. The inverse of `.deepEqual()`.

### `.like(value, selector, message?)`

Assert that `value` is like `selector`. This is a variant of `.deepEqual()`, however `selector` does not need to have the same enumerable properties as `value` does.

Instead AVA derives a *comparable* object from `value`, based on the deeply-nested properties of `selector`. This object is then compared to `selector` using `.deepEqual()`.

Any values in `selector` that are not regular objects should be deeply equal to the corresponding values in `value`.

This is an experimental assertion for the time being. You need to enable it:

**`package.json`**:

```json
{
"ava": {
"nonSemVerExperiments": {
"likeAssertion": true
}
}
}
```

**`ava.config.js`**:

```js
export default {
nonSemVerExperiments: {
likeAssertion: true
}
}
```

In the following example, the `map` property of `value` must be deeply equal to that of `selector`. However `nested.qux` is ignored, because it's not in `selector`.

```js
t.like({
map: new Map([['foo', 'bar']]),
nested: {
baz: 'thud',
qux: 'quux'
}
}, {
map: new Map([['foo', 'bar']]),
nested: {
baz: 'thud',
}
})
```

### `.throws(fn, expectation?, message?)`

Assert that an error is thrown. `fn` must be a function which should throw. The thrown value *must* be an error. It is returned so you can run more assertions against it.
Expand Down
11 changes: 11 additions & 0 deletions index.d.ts
Expand Up @@ -45,6 +45,9 @@ export interface Assertions {
/** Assert that `actual` is [deeply equal](https://github.com/concordancejs/concordance#comparison-details) to `expected`. */
deepEqual: DeepEqualAssertion;

/** Assert that `actual` is like `expected`. */
like: LikeAssertion;

/** Fail the test. */
fail: FailAssertion;

Expand Down Expand Up @@ -125,6 +128,14 @@ export interface DeepEqualAssertion {
skip(actual: any, expected: any, message?: string): void;
}

export interface LikeAssertion {
/** Assert that `value` is like `selector`. */
(value: any, selector: object, message?: string): void;

/** Skip this assertion. */
skip(value: any, selector: any, message?: string): void;
}

export interface FailAssertion {
/** Fail the test. */
(message?: string): void;
Expand Down
59 changes: 58 additions & 1 deletion lib/assert.js
Expand Up @@ -3,6 +3,7 @@ const concordance = require('concordance');
const isError = require('is-error');
const isPromise = require('is-promise');
const concordanceOptions = require('./concordance-options').default;
const {CIRCULAR_SELECTOR, isLikeSelector, selectComparable} = require('./like-selector');
const snapshotManager = require('./snapshot-manager');

function formatDescriptorDiff(actualDescriptor, expectedDescriptor, options) {
Expand Down Expand Up @@ -241,7 +242,8 @@ class Assertions {
fail = notImplemented,
skip = notImplemented,
compareWithSnapshot = notImplemented,
powerAssert
powerAssert,
experiments = {}
} = {}) {
const withSkip = assertionFn => {
assertionFn.skip = skip;
Expand Down Expand Up @@ -386,6 +388,61 @@ class Assertions {
}
});

this.like = withSkip((actual, selector, message) => {
if (!experiments.likeAssertion) {
fail(new AssertionError({
assertion: 'like',
improperUsage: true,
message: 'You must enable the `likeAssertion` experiment in order to use `t.like()`'
}));
return;
}

if (!checkMessage('like', message)) {
return;
}

if (!isLikeSelector(selector)) {
fail(new AssertionError({
assertion: 'like',
improperUsage: true,
message: '`t.like()` selector must be a non-empty object',
values: [formatWithLabel('Called with:', selector)]
}));
return;
}

let comparable;
try {
comparable = selectComparable(actual, selector);
} catch (error) {
if (error === CIRCULAR_SELECTOR) {
fail(new AssertionError({
assertion: 'like',
improperUsage: true,
message: '`t.like()` selector must not contain circular references',
values: [formatWithLabel('Called with:', selector)]
}));
return;
}

throw error;
}

const result = concordance.compare(comparable, selector, concordanceOptions);
if (result.pass) {
pass();
} else {
const actualDescriptor = result.actual || concordance.describe(comparable, concordanceOptions);
const expectedDescriptor = result.expected || concordance.describe(selector, concordanceOptions);
fail(new AssertionError({
assertion: 'like',
message,
values: [formatDescriptorDiff(actualDescriptor, expectedDescriptor)]
}));
}
});

this.throws = withSkip((...args) => {
// Since arrow functions do not support 'arguments', we are using rest
// operator, so we can determine the total number of arguments passed
Expand Down
37 changes: 37 additions & 0 deletions lib/like-selector.js
@@ -0,0 +1,37 @@
'use strict';
function isLikeSelector(selector) {
return selector !== null &&
typeof selector === 'object' &&
Reflect.getPrototypeOf(selector) === Object.prototype &&
Reflect.ownKeys(selector).length > 0;
}

exports.isLikeSelector = isLikeSelector;

const CIRCULAR_SELECTOR = new Error('Encountered a circular selector');
exports.CIRCULAR_SELECTOR = CIRCULAR_SELECTOR;

function selectComparable(lhs, selector, circular = new Set()) {
if (circular.has(selector)) {
throw CIRCULAR_SELECTOR;
}

circular.add(selector);

if (lhs === null || typeof lhs !== 'object') {
return lhs;
}

const comparable = {};
for (const [key, rhs] of Object.entries(selector)) {
if (isLikeSelector(rhs)) {
comparable[key] = selectComparable(Reflect.get(lhs, key), rhs, circular);
} else {
comparable[key] = Reflect.get(lhs, key);
}
}

return comparable;
}

exports.selectComparable = selectComparable;
2 changes: 1 addition & 1 deletion lib/load-config.js
Expand Up @@ -7,7 +7,7 @@ const pkgConf = require('pkg-conf');

const NO_SUCH_FILE = Symbol('no ava.config.js file');
const MISSING_DEFAULT_EXPORT = Symbol('missing default export');
const EXPERIMENTS = new Set(['reverseTeardowns']);
const EXPERIMENTS = new Set(['likeAssertion', 'reverseTeardowns']);

// *Very* rudimentary support for loading ava.config.js files containing an `export default` statement.
const evaluateJsConfig = configFile => {
Expand Down
3 changes: 2 additions & 1 deletion lib/test.js
Expand Up @@ -39,7 +39,8 @@ class ExecutionContext extends assert.Assertions {
compareWithSnapshot: options => {
return test.compareWithSnapshot(options);
},
powerAssert: test.powerAssert
powerAssert: test.powerAssert,
experiments: test.experiments
});
testMap.set(this, test);

Expand Down
17 changes: 17 additions & 0 deletions test-d/like.ts
@@ -0,0 +1,17 @@
import test from '..';

test('like', t => {
t.like({
map: new Map([['foo', 'bar']]),
nested: {
baz: 'thud',
qux: 'quux'
}
}, {
map: new Map([['foo', 'bar']]),
nested: {
baz: 'thud'
}
});
});

0 comments on commit 19c4f35

Please sign in to comment.