Skip to content

Commit 19c4f35

Browse files
futpibnovemberborn
andauthored
Experimentally implement t.like() assertion
Co-authored-by: Mark Wubben <[email protected]>
1 parent 952a017 commit 19c4f35

27 files changed

+930
-218
lines changed

docs/03-assertions.md

+49
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,55 @@ Assert that `value` is deeply equal to `expected`. See [Concordance](https://git
207207

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

210+
### `.like(value, selector, message?)`
211+
212+
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.
213+
214+
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()`.
215+
216+
Any values in `selector` that are not regular objects should be deeply equal to the corresponding values in `value`.
217+
218+
This is an experimental assertion for the time being. You need to enable it:
219+
220+
**`package.json`**:
221+
222+
```json
223+
{
224+
"ava": {
225+
"nonSemVerExperiments": {
226+
"likeAssertion": true
227+
}
228+
}
229+
}
230+
```
231+
232+
**`ava.config.js`**:
233+
234+
```js
235+
export default {
236+
nonSemVerExperiments: {
237+
likeAssertion: true
238+
}
239+
}
240+
```
241+
242+
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`.
243+
244+
```js
245+
t.like({
246+
map: new Map([['foo', 'bar']]),
247+
nested: {
248+
baz: 'thud',
249+
qux: 'quux'
250+
}
251+
}, {
252+
map: new Map([['foo', 'bar']]),
253+
nested: {
254+
baz: 'thud',
255+
}
256+
})
257+
```
258+
210259
### `.throws(fn, expectation?, message?)`
211260

212261
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.

index.d.ts

+11
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ export interface Assertions {
4545
/** Assert that `actual` is [deeply equal](https://github.com/concordancejs/concordance#comparison-details) to `expected`. */
4646
deepEqual: DeepEqualAssertion;
4747

48+
/** Assert that `actual` is like `expected`. */
49+
like: LikeAssertion;
50+
4851
/** Fail the test. */
4952
fail: FailAssertion;
5053

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

131+
export interface LikeAssertion {
132+
/** Assert that `value` is like `selector`. */
133+
(value: any, selector: object, message?: string): void;
134+
135+
/** Skip this assertion. */
136+
skip(value: any, selector: any, message?: string): void;
137+
}
138+
128139
export interface FailAssertion {
129140
/** Fail the test. */
130141
(message?: string): void;

lib/assert.js

+58-1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ const concordance = require('concordance');
33
const isError = require('is-error');
44
const isPromise = require('is-promise');
55
const concordanceOptions = require('./concordance-options').default;
6+
const {CIRCULAR_SELECTOR, isLikeSelector, selectComparable} = require('./like-selector');
67
const snapshotManager = require('./snapshot-manager');
78

89
function formatDescriptorDiff(actualDescriptor, expectedDescriptor, options) {
@@ -241,7 +242,8 @@ class Assertions {
241242
fail = notImplemented,
242243
skip = notImplemented,
243244
compareWithSnapshot = notImplemented,
244-
powerAssert
245+
powerAssert,
246+
experiments = {}
245247
} = {}) {
246248
const withSkip = assertionFn => {
247249
assertionFn.skip = skip;
@@ -386,6 +388,61 @@ class Assertions {
386388
}
387389
});
388390

391+
this.like = withSkip((actual, selector, message) => {
392+
if (!experiments.likeAssertion) {
393+
fail(new AssertionError({
394+
assertion: 'like',
395+
improperUsage: true,
396+
message: 'You must enable the `likeAssertion` experiment in order to use `t.like()`'
397+
}));
398+
return;
399+
}
400+
401+
if (!checkMessage('like', message)) {
402+
return;
403+
}
404+
405+
if (!isLikeSelector(selector)) {
406+
fail(new AssertionError({
407+
assertion: 'like',
408+
improperUsage: true,
409+
message: '`t.like()` selector must be a non-empty object',
410+
values: [formatWithLabel('Called with:', selector)]
411+
}));
412+
return;
413+
}
414+
415+
let comparable;
416+
try {
417+
comparable = selectComparable(actual, selector);
418+
} catch (error) {
419+
if (error === CIRCULAR_SELECTOR) {
420+
fail(new AssertionError({
421+
assertion: 'like',
422+
improperUsage: true,
423+
message: '`t.like()` selector must not contain circular references',
424+
values: [formatWithLabel('Called with:', selector)]
425+
}));
426+
return;
427+
}
428+
429+
throw error;
430+
}
431+
432+
const result = concordance.compare(comparable, selector, concordanceOptions);
433+
if (result.pass) {
434+
pass();
435+
} else {
436+
const actualDescriptor = result.actual || concordance.describe(comparable, concordanceOptions);
437+
const expectedDescriptor = result.expected || concordance.describe(selector, concordanceOptions);
438+
fail(new AssertionError({
439+
assertion: 'like',
440+
message,
441+
values: [formatDescriptorDiff(actualDescriptor, expectedDescriptor)]
442+
}));
443+
}
444+
});
445+
389446
this.throws = withSkip((...args) => {
390447
// Since arrow functions do not support 'arguments', we are using rest
391448
// operator, so we can determine the total number of arguments passed

lib/like-selector.js

+37
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
'use strict';
2+
function isLikeSelector(selector) {
3+
return selector !== null &&
4+
typeof selector === 'object' &&
5+
Reflect.getPrototypeOf(selector) === Object.prototype &&
6+
Reflect.ownKeys(selector).length > 0;
7+
}
8+
9+
exports.isLikeSelector = isLikeSelector;
10+
11+
const CIRCULAR_SELECTOR = new Error('Encountered a circular selector');
12+
exports.CIRCULAR_SELECTOR = CIRCULAR_SELECTOR;
13+
14+
function selectComparable(lhs, selector, circular = new Set()) {
15+
if (circular.has(selector)) {
16+
throw CIRCULAR_SELECTOR;
17+
}
18+
19+
circular.add(selector);
20+
21+
if (lhs === null || typeof lhs !== 'object') {
22+
return lhs;
23+
}
24+
25+
const comparable = {};
26+
for (const [key, rhs] of Object.entries(selector)) {
27+
if (isLikeSelector(rhs)) {
28+
comparable[key] = selectComparable(Reflect.get(lhs, key), rhs, circular);
29+
} else {
30+
comparable[key] = Reflect.get(lhs, key);
31+
}
32+
}
33+
34+
return comparable;
35+
}
36+
37+
exports.selectComparable = selectComparable;

lib/load-config.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ const pkgConf = require('pkg-conf');
77

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

1212
// *Very* rudimentary support for loading ava.config.js files containing an `export default` statement.
1313
const evaluateJsConfig = configFile => {

lib/test.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,8 @@ class ExecutionContext extends assert.Assertions {
3939
compareWithSnapshot: options => {
4040
return test.compareWithSnapshot(options);
4141
},
42-
powerAssert: test.powerAssert
42+
powerAssert: test.powerAssert,
43+
experiments: test.experiments
4344
});
4445
testMap.set(this, test);
4546

test-d/like.ts

+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import test from '..';
2+
3+
test('like', t => {
4+
t.like({
5+
map: new Map([['foo', 'bar']]),
6+
nested: {
7+
baz: 'thud',
8+
qux: 'quux'
9+
}
10+
}, {
11+
map: new Map([['foo', 'bar']]),
12+
nested: {
13+
baz: 'thud'
14+
}
15+
});
16+
});
17+

0 commit comments

Comments
 (0)