Structured comparison
See original GitHub issueIāve come up with something incredibly useful that Iād like to share. š
I have to write tons of integration/functional tests for JSON APIs, and I found this to be extremely cumbersome - you get these big, structured JSON responses, and for the most part, you just want to check them for equality with your expected JSON data. But then there are all these little annoying exceptions - for example, some of the APIs Iām testing return date or time values, and in those cases, all I care is that the API returned a date or a number. There is no right or wrong value, just a type-check, sometimes a range-check, etc.
Simply erasing the differences before comparison is one way to go - but itās cumbersome (for one, because Iām using TypeScript, and so itās not easy to get the types to match) and misses the actual type/range-check, unless I hand-code those before erasing them.
It didnāt spark joy, and so I came up with this, which works really well:
Now I can have a code layout for expected
that matches the actual
data structure Iām testing - just sprinkle with check
, anywhere in the expected
data structure, and those checks will override the default comparison for values in the actual
data structure.
Itās an extension to Assert.equal
, along with a helper function check
, which basically just creates a āmarker objectā, so that the comparison function internally can distinguish checks on the expected
side from other objects/values.
In the example here, Iām using the is
NPM package - most of the functions in this library (and similar libraries) work out of the box, although those that require more than one argument need a wrapper closure, e.g. check(v => is.within(v, 0, 100))
. (This might could look nicer with some sort of wrapper library, e.g. check.within(0, 100)
- but it works just fine as it is.)
Iām posting the code, in case you find this interesting:
const Compare = Symbol("Compare");
type Comparison = (value: any) => boolean;
interface Check {
[Compare]: Comparison;
}
function isCheck(value: any): value is Check {
return (value !== null) && (typeof value === "object") && (Compare in value);
}
/**
* Creates a {@see Check} for use with {@see equal} - works well with packages such as `is`.
*
* @link https://www.npmjs.com/package/is
*/
export function check(comparison: Comparison): Check {
return {
[Compare]: comparison
};
}
/**
* Compares `actual` against `expected`.
*
* You can put comparators (created by {@see check}) anywhere in your `expected` object graph
* to override the way actual values are compared against expected values - for example:
*
* const same = equal(
* { foo: 123, bar: new Date() },
* { foo: 123, bar: check(v => v instanceof Date) }
* );
*/
export function compare(actual: any, expected: any): boolean {
if (isCheck(expected)) {
return expected[Compare](actual);
}
// Everything below here is just `fast-deep-equal` (unminified and reformatted)
if (actual === expected) {
return true;
}
if (actual && expected && typeof actual == "object" && typeof expected == "object") {
if (actual.constructor !== expected.constructor) {
return false;
}
if (Array.isArray(actual)) {
let length = actual.length;
if (length != expected.length) {
return false;
}
for (let i = length; i-- !== 0; ) {
if (!compare(actual[i], expected[i])) {
return false;
}
}
return true;
}
if (actual instanceof Map && expected instanceof Map) {
if (actual.size !== expected.size) {
return false;
}
for (let i of actual.entries()) {
if (!expected.has(i[0])) {
return false;
}
}
for (let i of actual.entries()) {
if (!compare(i[1], expected.get(i[0]))) {
return false;
}
}
return true;
}
if (actual instanceof Set && expected instanceof Set) {
if (actual.size !== expected.size) {
return false;
}
for (let i of actual.entries()) {
if (!expected.has(i[0])) {
return false;
}
}
return true;
}
if (ArrayBuffer.isView(actual) && ArrayBuffer.isView(expected)) {
let length = actual.byteLength;
if (length != expected.byteLength) {
return false;
}
for (let i = length; i-- !== 0; ) {
if ((actual as any)[i] !== (expected as any)[i]) {
return false;
}
}
return true;
}
if (actual.constructor === RegExp) {
return actual.source === expected.source && actual.flags === expected.flags;
}
if (actual.valueOf !== Object.prototype.valueOf) {
return actual.valueOf() === expected.valueOf();
}
if (actual.toString !== Object.prototype.toString) {
return actual.toString() === expected.toString();
}
let keys = Object.keys(actual);
let length = keys.length;
if (length !== Object.keys(expected).length) {
return false;
}
for (let i = length; i-- !== 0; ) {
if (!Object.prototype.hasOwnProperty.call(expected, keys[i])) {
return false;
}
}
for (let i = length; i-- !== 0; ) {
const key = keys[i];
if (!compare(actual[key], expected[key])) {
return false;
}
}
return true;
}
// true if both NaN, false otherwise
return actual !== actual && expected !== expected;
}
Unfortunately, as you can see, compare
is a verbatim copy/paste of fast-deep-equal
, which is a bit unfortunate - the function is recursive, so there is no way to override itās own internal references to itself, hence no way to just expand this function with the 3 lines I added at the top. The feature itself is just a few lines of code and some type-declarations.
Note that, in my example above, Iāve replaced Assert.equal
with my own assertions - not that interesting, but just to clarify for any readers:
const equal = (
actual: any,
expected: any,
description = 'should be equivalent'
) => ({
pass: compare(actual, expected),
actual,
expected,
description,
operator: "equal",
});
const notEqual = (
actual: any,
expected: any,
description = 'should not be equivalent'
) => ({
pass: !compare(actual, expected),
actual,
expected,
description,
operator: "notEqual",
});
Assert.equal =
Assert.equals =
Assert.eq =
Assert.deepEqual =
Assert.same = equal;
Assert.notEqual =
Assert.notEquals =
Assert.notEq =
Assert.notDeepEqual =
Assert.notSame = notEqual;
I would not proposition this as a replacement, but probably as a new addition, e.g. Assert.compare
or something.
The major caveat/roadblock for adoption of this feature as something āofficialā is the reporter - if comparison fails for some other property, the diff unfortunately will highlight any differing actual/expected values as errors.
Thatās kind of where this idea is stuck at the moment.
Iām definitely going to use it myself, and for now just live with this issue, since this improves the speed of writing and the readability of my tests by leaps and bounds.
But the idea/implementation would need some refinement before this feature could be made generally available.
The diff output right now is based on JSON, which is problematic in itself - the reporter currently compares JSON serialized object graphs, not the native JS object graphs that fast-deep-equal
actually uses to decide if theyāre equal. Itās essentially a naive line diff with JSON.stringify
made canonical.
Something like node-inspect-extracted would work better here - this formatter can be configured to produce canonical output as well, and since all the JSON diff function in jsdiff
appears to do is a line-diff on (canonical) JSON output, I expect this would work just as well?
The point here is, the assertion API actually lets you return a different actual
and expected
from the ones that were actually compared by the assertion - and so, this would enable me to produce modified object graphs, in which values that were compared by checks could be replaced with e.g. identical Symbols, causing them to show up āanonymizedā as something like [numeric value]
or [Date instance]
in the object graph.
(I suppose I could achieve something similar now by replacing checked values with just strings - but then weāre back to the problem of strings being compared as equal to things that serialize to the same string valueā¦ e.g. wouldnāt work for actually writing a unit-test for the comparison function itself. Might be an opportunity to kill two birds with one stone?)
Issue Analytics
- State:
- Created 2 years ago
- Comments:6 (4 by maintainers)
Top GitHub Comments
Nice. The limitation here is that the expected value (b) must be a pojo with no method at all, not to confuse the deep-equal algo, is not it ? Does not seem a problem to me though
Thank you for that feedback. I use your approach in my production projects, so my
expectDb
handlesupdatedAt, createdAt, _id
fields for instance.I started playing around with async matchers when testing Jest. It takes a different route. Instead of this generic
expectDb
for all savable structures, each structure will have its own custom set of matchers. Which can be part of a base object, of course.I think if you can use Jestās assert lib it would be enough. I havenāt tried yet with Zora. I use Jestās expect over Chai in some pet Mocha projects. It has a bunch of nice to haves but not essential goodies, like a better diff for large objects for instance.
All these fringe beneficial features makes for a better DX, at the cost of bloating the lib. Jest has ~1500 open issues.