question-mark
Stuck on an issue?

Lightrun Answers was designed to reduce the constant googling that comes with debugging 3rd party libraries. ItĀ collects links to all the places you might be looking at while hunting down a tough bug.

And, if youā€™re still stuck at the end, weā€™re happy to hop on a call to see how we can help out.

Structured comparison

See original GitHub issue

Iā€™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:

image

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:open
  • Created 2 years ago
  • Comments:6 (4 by maintainers)

github_iconTop GitHub Comments

1reaction
lorenzofox3commented, Jan 25, 2022

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

0reactions
icetbrcommented, Jan 26, 2022

Thank you for that feedback. I use your approach in my production projects, so my expectDb handles updatedAt, 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.

Read more comments on GitHub >

github_iconTop Results From Across the Web

Chapter 3: The Method of Structured, Focused Comparison
The method and logic of structured, focused comparison is simple and straightforward. The method is / I structured" in that the researcher writes...
Read more >
Chapter 3 The Method of structured , Focused Comparison
The method and logic of structured, focused comparison is simple and straightforward. The method is /I structured" in that the researcher writes generalĀ ......
Read more >
Case Studies and Theory Development: The ... - Springer Link
Case Studies and Theory Development: The Method of Structured, Focused Comparison. Alexander L. George. Chapter; First Online: 21 July 2018.
Read more >
Comparison of structured storage software - Wikipedia
Structured storage is computer storage for structured data, often in the form of a distributed database. Computer software formally known as structured ......
Read more >
Structural Code Comparison - Documentation
Structural code comparison is an approach that allows you to compare sources via their signature and not only by their location. Code Compare...
Read more >

github_iconTop Related Medium Post

No results found

github_iconTop Related StackOverflow Question

No results found

github_iconTroubleshoot Live Code

Lightrun enables developers to add logs, metrics and snapshots to live code - no restarts or redeploys required.
Start Free

github_iconTop Related Reddit Thread

No results found

github_iconTop Related Hackernoon Post

No results found

github_iconTop Related Tweet

No results found

github_iconTop Related Dev.to Post

No results found

github_iconTop Related Hashnode Post

No results found