8

How do we assert for equality of ES6 Maps and Sets?

For example:

// ES6 Map
var m1 = new Map();
m1.set('one', 1);
var m2 = new Map();
m2.set('two', 2);
assert.deepEqual(m1,m2);     // outputs: passed.

// ES6 Set
var s1 = new Set();
s1.add(1);
var s2 = new Set();
s2.add(2);
assert.deepEqual(s1,s2);     // outputs: passed.

The intention is to assert that the elements of the Sets/Maps are equal. Both the both assertions should fail.

Is there an equivalent of deepEqual for Sets/Maps? In other words, short of manually iterating the elements, how do we test for Set/Map equality deeply?

If there is no way in QUnit, is there a unit testing tool that works for ES6 Sets and Maps?

Edit

In Firefox, which supports Array.from(), I have been comparing sets and maps by:

assert.deepEqual(Array.from(m1), Array.from(m2));

But this does not work with other browsers, which do not support Array.from(). Even with an Array.from polyfill, Chrome / IE does not work - Array.from(set) always produces an empty array regardless of the set contents. This is possibly due to these browsers' lack of support for generic iterables.

Secondly, reducing it into a comparison of Arrays may not be always appropriate. We would end up with what I consider to be false positives:

var s = new Set();
s.add([1,2]);
var m = new Map();
m.set(1,2);
assert.deepEqual(Array.from(s), Array.from(m));  // outputs: passed.

Update:

A patch is currently in the works at QUnit to extend deepEqual() to handle ES6 Sets and Maps. When that pull request gets merged in, we should be able to use deepEqual() to compare Sets and Maps. (-:

light
  • 4,157
  • 3
  • 25
  • 38
  • Since as you already know QUint won't do that for you, your question is reduced to "Is there a unit testing tool that understands ES6 Sets & Maps" (Which should probably be the title) which is "not a good question" by SO rules. Alternatively you could look for a code based answer to your requirements, but you actively reject such an answer. So what kind of answer are you looking for here? – Amit Jul 06 '15 at 10:15
  • I don't already know that QUnit won't work. There may be some way to do it, maybe not through `deepEqual`. I'm not arrogant enough to make such a claim like "QUnit won't work", hence the question. And I'm not actively rejecting code-based answers. I just wanted to highlight what I'm doing in QUnit as an approximation, and what problems it entails. The answer I'm looking for: is there a way to compare Sets/Maps it in QUnit? If not, what can I do? – light Jul 06 '15 at 10:30

2 Answers2

3

Exhaustive Map comparison using higher-order functions

I'm going to approach this the same way I approached array comparison in this similar answer: How to compare arrays in JavaScript?

I'm going to go thru the code bit-by-bit, but I'll have a complete runnable example at the end


Shallow comparison

First off, we're going to start with a generic Map comparison function. This way we can do all sorts of comparisons on Map objects, not just testing for equality

This mapCompare function agrees with our intuition about how Maps should be compared - we compare each key from m1 against each key from m2. Note, this specific comparator is doing shallow comparison. We'll handle deep comparison in just a moment

const mapCompare = f => (m1, m2) => {
  const aux = (it, m2) => {
    let {value, done} = it.next()
    if (done) return true
    let [k, v] = value
    return f (v, m2.get(k), $=> aux(it, m2))
  }
  return aux(m1.entries(), m2) && aux(m2.entries(), m1)
}

The only thing that might not be immediately clear is the $=> aux(it, m2) thunk. Maps have a built-in generator, .entries(), and we can take advantage of the lazy evaluation by returning an early false answer as soon as non-matching key/value pair is found. That means we have to write our comparators in a slightly special way.

const shortCircuitEqualComparator = (a, b, k) =>
  a === b ? true && k() : false

a and b are values of m1.get(somekey) and m2.get(somekey) respectively. iff the two values are strictly equal (===), only then do we want to continue the comparison – in this case we return true && k() where k() is the remainder of the key/value pair comparison. On the other hand, if a and b do not match, we can return an early false and skip comparing the rest of the values – ie, we already know that m1 and m2 do not match if any a/b pair do not match.

Finally, we can define mapEqual - it's simple too. It's just mapCompare using our special shortCircuitEqualComparator

const mapEqual = mapCompare (shortCircuitEqualComparator)

Let's take a quick look at how this works

// define two maps that are equal but have keys in different order
const a = new Map([['b', 2], ['a', 1]])
const b = new Map([['a', 1], ['b', 2]])

// define a third map that is not equal
const c = new Map([['a', 3], ['b', 2]])

// verify results
// a === b should be true
// b === a should be true
console.log('true', mapEqual(a, b)) // true true
console.log('true', mapEqual(b, a)) // true true

// a === c should be false
// c === a should be false too
console.log('false', mapEqual(a, c)) // false false
console.log('false', mapEqual(c, a)) // false false

Heck yes. Things are looking good ...


Deep comparisons with Rick & Morty

Now that we have a fricken sweet mapCompare generic, deep comparison is a breeze. Take notice that we're actually implementing mapDeepCompare using mapCompare itself.

We use a custom comparator that simply checks if a and b are both Map objects – if so, we recurse using mapDeepCompare on the nested Maps; also being mindful to call ... && k() to ensure the remaining key/value pairs are compared. If, a or b is a non-Map object, normal comparison is doing using f and we pass the continuation k along directly

const mapDeepCompare = f => mapCompare ((a, b, k) => {
  console.log(a, b)
  if (a instanceof Map && b instanceof Map)
    return mapDeepCompare (f) (a,b) ? true && k() : false
  else
    return f(a,b,k)
})

Now with mapDeepCompare, we can perform any type of deep comparison on nested Maps. Remember, equality is just one of the things we can be checking.

Without further ado, mapDeepEqual. Of importance, we get to reuse our shortCircuitEqualComparator that we defined before. This very clearly demonstrates that our comparators can be (re)used for shallow or deep Map comparisons.

const mapDeepEqual = mapDeepCompare (shortCircuitEqualComparator)  

Let's see it work

// define two nested maps that are equal but have different key order
const e = new Map([
  ['a', 1],
  ['b', new Map([['c', 2]])]
])

const f = new Map([
  ['b', new Map([['c', 2]])],
  ['a', 1]
])

// define a third nested map that is not equal
const g = new Map([
  ['b', new Map([
    ['c', 3]  
  ])],
  ['a', 1]
])

// e === f should be true
// f === e should also be true
console.log('true', mapDeepEqual(e, f)) // true
console.log('true', mapDeepEqual(f, e)) // true

// e === g should be false
// g === e should also be false
console.log('false', mapDeepEqual(e, g)) // false
console.log('false', mapDeepEqual(g, e)) // false

OK, and just to make sure. What happens if we call mapEqual on our nested Maps e and f? Since mapEqual does shallow comparison, we expect that the result should be false

console.log('false', mapEqual(e, f)) // false
console.log('false', mapEqual(f, e)) // false

And there you have it. Shallow and deep comparison of ES6 Map objects. A nearly identical set of functions could be written to support ES6 Set. I'll leave this as an exercise for the readers.


Runnable code demo

This is all of the code above compiled into a single runnable demo. Each console.log call outputs <expected>, <actual>. So true, true or false, false would be a passing test – whereas true, false would be a failed test.

// mapCompare :: ((a, a, (* -> Bool)) -> Bool) -> (Map(k:a), Map(k:a)) -> Bool
const mapCompare = f => (m1, m2) => {
  const aux = (it, m2) => {
    let {value, done} = it.next()
    if (done) return true
    let [k, v] = value
    return f (v, m2.get(k), $=> aux(it, m2))
  }
  return aux(m1.entries(), m2) && aux(m2.entries(), m1)
}

// mapDeepCompare :: ((a, a, (* -> Bool)) -> Bool) -> (Map(k:a), Map(k:a)) -> Bool
const mapDeepCompare = f => mapCompare ((a, b, k) => {
  if (a instanceof Map && b instanceof Map)
    return mapDeepCompare (f) (a,b) ? true && k() : false
  else
    return f(a,b,k)
})

// shortCircuitEqualComparator :: (a, a, (* -> Bool)) -> Bool
const shortCircuitEqualComparator = (a, b, k) =>
  a === b ? true && k() : false

// mapEqual :: (Map(k:a), Map(k:a)) -> Bool
const mapEqual = mapCompare (shortCircuitEqualComparator)

// mapDeepEqual :: (Map(k:a), Map(k:a)) -> Bool
const mapDeepEqual = mapDeepCompare (shortCircuitEqualComparator)
  
// fixtures
const a = new Map([['b', 2], ['a', 1]])
const b = new Map([['a', 1], ['b', 2]])
const c = new Map([['a', 3], ['b', 2]])
const d = new Map([['a', 1], ['c', 2]])
const e = new Map([['a', 1], ['b', new Map([['c', 2]])]])
const f = new Map([['b', new Map([['c', 2]])], ['a', 1]])
const g = new Map([['b', new Map([['c', 3]])], ['a', 1]])

// shallow comparison of two equal maps
console.log('true', mapEqual(a, b))
console.log('true', mapEqual(b, a))
// shallow comparison of two non-equal maps (differing values)
console.log('false', mapEqual(a, c))
console.log('false', mapEqual(c, a))
// shallow comparison of two other non-equal maps (differing keys)
console.log('false', mapEqual(a, d))
console.log('false', mapEqual(d, a))
// deep comparison of two equal nested maps
console.log('true', mapDeepEqual(e, f))
console.log('true', mapDeepEqual(f, e))
// deep comparison of two non-equal nested maps
console.log('false', mapDeepEqual(e, g))
console.log('false', mapDeepEqual(g, e))
// shallow comparison of two equal nested maps
console.log('false', mapEqual(e, f))
console.log('false', mapEqual(f, e))
Community
  • 1
  • 1
Mulan
  • 129,518
  • 31
  • 228
  • 259
  • Thanks. This is actually what I am currently doing, but I just can't help but feel that this is not the best approach, since `Array.from()` will result in an array comparison instead. This means a Map with an element {1: 2} will successfully match a Set with an element [1, 2]. – light Jul 04 '15 at 14:17
  • No it won't. `[['1', 2]] !== [1,2]` – Mulan Jul 06 '15 at 05:50
  • Hi, please see the Edit part of the question. I have tested it on Firefox. – light Jul 06 '15 at 06:09
  • I don't understand what you're getting at tho. Correct, IE does not support `Array.from` but it doesn't support `Map` or `Set` either... so you have a bigger problem there. I updated my answer. – Mulan Jul 06 '15 at 07:18
  • Actually, IE 11 does support Set and Map... Just not the iterable part of it. Likewise for Chrome 43. So if you want to compare sets/maps, it works if you're willing to manually iterate through the contents. So the point and the question is: is there a way to deeply check equivalence without manual iteration? – light Jul 06 '15 at 07:58
  • 1
    @light I think this might help you https://github.com/Benvie/continuum and this one also http://stackoverflow.com/questions/13355486/when-will-i-be-able-to-use-es6-in-a-browser – POQDavid Jul 06 '15 at 08:01
  • @poqdavid thanks. One of those ES6 shims may be just what I need. Right now the shim I'm using does not seem to be filling the "iterable" part of Sets/Maps, probably due to IE and Chrome reporting that they support Sets/Maps and hence bypassing the polyfill altogether – light Jul 06 '15 at 10:37
  • Just an update to the issue: A patch is in the works at jQuery to allow QUnit's `deepEqual()` to handle ES6 Sets and Maps. Hope the next release includes this patch! – light Jul 15 '15 at 04:03
  • This case fails: `assert.deepEqual(Array.from(new Set([1, 2])), Array.from(new Set([2, 1])))` – David Braun Mar 09 '17 at 01:18
  • @DavidBraun thanks for catching this. This answer was really old and hungry for a much-needed update. The revised solution is checked against a set of unit tests to properly ensure the behaviour we require. If there are any other questions, I'm happy to help. – Mulan Mar 09 '17 at 07:02
-1

I just created a library (@nodeguy/assert) to solve this:

const assert = require('@nodeguy/assert')

// ES6 Map
var m1 = new Map();
m1.set('one', 1);
var m2 = new Map();
m2.set('two', 2);
assert.deepEqual(m1,m2);     // outputs: failed.

// ES6 Set
var s1 = new Set();
s1.add(1);
var s2 = new Set();
s2.add(2);
assert.deepEqual(s1,s2);     // outputs: failed.
David Braun
  • 5,573
  • 3
  • 36
  • 42
  • Are you [joking](https://github.com/NodeGuy/assert/blob/master/lib/index.js#L10)? You mean you just created a lib that clobbers [`assert.deepEqual`](https://nodejs.org/api/assert.html#assert_assert_deepequal_actual_expected_message) with [`lodash.isEqual`](https://lodash.com/docs/4.17.4#isEqual)? If you're going to use Lodash, it would be much better to explicitly use it in your tests directly – ie `assert.ok(_.isEqual(m1, m2))` rather than replacing node's `assert.deepEqual` entirely. – Mulan Mar 09 '17 at 06:50
  • To top it off, your tests don't even assert the behaviour your promising. Not to mention the futility of writing tests against other libraries... – Mulan Mar 09 '17 at 06:51
  • No, Lodash is an implementation detail and `assert.ok(_.isEqual(m1, m2))` doesn't show the values in the error message the way `assert.deepEqual` does. My tests handle the first case I care about, hence the `0.1.1` release. I'm open to pull requests if you'd like to add more. – David Braun Mar 09 '17 at 19:15