unsplash/sum-types

Cannot test for equality in Jest

samhh opened this issue · 6 comments

samhh commented

More of a tracking issue than anything else.

toEqual fails presumably because we're using symbols. toMatchObject falsely passes for that reason.

The current workaround is to test their serialized forms. A hypothetical testing framework that used Eq would workaround this issue.

Since #45 we now have this behaviour (equality works for non-nullary sum types but not for nullary sum types):

import * as Sum from '@unsplash/sum-types';
import * as O from 'fp-ts/Option';

type Weather = Sum.Member<'Sun'> | Sum.Member<'Rain', number>;
const Weather = Sum.create<Weather>();

// Passes ✅
it('works', () => {
  expect(O.some({ value: Weather.mk.Rain(5) })).toEqual(O.some({ value: Weather.mk.Rain(5) }));
});

// Fails ❌
/*
Expected: {"_tag": "Some", "value": {"value": [Function nonNullary]}}
Received: serializes to the same string
*/
it('works', () => {
  expect(O.some({ value: Weather.mk.Sun })).toEqual(O.some({ value: Weather.mk.Sun }));
});

I just ran into the following scenario in which Jest's output is really confusing/misleading:

  • Comparing two equal nullary sum types.
  • One sum type is created manually via the constructor, and the other is created automatically via Sum.enumerateNullary.

Jest's diff output is confusing/misleading because the diff suggests that these two sum types are very different, but they are actually the same.

import * as Sum from 'shared/facades/Sum';

type MyUnion = Sum.Member<'A'>;
const MyUnion = Sum.create<MyUnion>();

it('test', () => {
  const Enum = Sum.enumerateNullary<MyUnion>()(['A']);
  expect(MyUnion.mk.A).toEqual({ prop: Enum[0] });
});

Output:

    Expected: {"prop": {Symbol(@unsplash/sum-types internal tag key): "A", Symbol(@unsplash/sum-types internal value key): null}}
    Received: [Function nonNullary]

Example of sum type inside an object:

import * as Sum from 'shared/facades/Sum';

type MyUnion = Sum.Member<'A'>;
const MyUnion = Sum.create<MyUnion>();

it('test', () => {
  const Enum = Sum.enumerateNullary<MyUnion>()(['A']);
  expect({ prop: MyUnion.mk.A }).toEqual({ prop: Enum[0] });
});

Output:

      Object {
    -   "prop": Object {
    -     Symbol(@unsplash/sum-types internal tag key): "A",
    -     Symbol(@unsplash/sum-types internal value key): null,
    -   },
    +   "prop": [Function nonNullary],
      }

Side note: this isn't related to equality testing with Jest but this issue seems related, i.e. nullary sum types behave differently depending on how they were created.

samhh commented

Specifically with Web's enumerateNullary, I think the discrepancy might be here. If we drop the (null as any) it should match the proper constructor.

I don't know if that will allow Jest to match them, mind, given they're functions at that point.

This adds a custom toEq matcher to Jest's expect:

expect.extend({
  toEq<A>(received: A, expected: A, eq: Eq<A>) {
    return {
      pass: eq.equals(received, expected),
      message: () => 'Expected values to be equal.',
    };
  },
});

declare global {
  namespace jest {
    interface Matchers<R, T> {
      toEq: (value: T, eq: Eq<T>) => R;
    }
  }
}

// Example:
expect(a).toEq(b, MyEq);

However, on its own this isn't much better than doing expect(eq.equals(a, b)).toBe(true). The failure message is too generic and unlike toEqual it doesn't provide any information to aid debugging. We could improve the message using a Show type class, but ideally we would show a diff of expected/received like toEqual does:

it('works', () => {
  expect({ foo: 1, bar: 2 }).toEqual({ foo: 1, bar: 3 });
});
    expect(received).toEqual(expected) // deep equality

    - Expected  - 1
    + Received  + 1

      Object {
    -   "bar": 3,
    +   "bar": 2,
        "foo": 1,
      }
import * as Sum from '@unsplash/sum-types';
import * as O from 'fp-ts/Option';

type Weather = Sum.Member<'Sun'> | Sum.Member<'Rain', number>;
const Weather = Sum.create<Weather>();

it('works', () => {
  expect(O.some({ value: Weather.mk.Rain(5) })).toEqual(O.some({ value: Weather.mk.Rain(6) }));
});
    - Expected  - 1
    + Received  + 1

      Object {
        "_tag": "Some",
        "value": Object {
          "value": Object {
            Symbol(@unsplash/sum-types internal tag key): "Rain",
    -       Symbol(@unsplash/sum-types internal value key): 6,
    +       Symbol(@unsplash/sum-types internal value key): 5,
          },
        },
      }

I'm not sure how we could implement this for toEq. For reference, here's the code for toEqual.

Alternatively, seeing as toEqual gives us diffs for free then we could solve this by converting nullary sum types into objects:

expect(f(a)).toEqual(f(b))
// We could alias this e.g. `expect(a).toEqualWithSumTypes(b)`

… where f deeply traverses the object properties and replaces nullary sum type functions with regular objects and regular properties. This function might also help us address the issue with console.log (#48), i.e. you'd be able to do console.log(f(x)).

samhh commented

NB with the merged solution the following will still fail:

diff --git a/test/unit/index.ts b/test/unit/index.ts
index 142de4b..394c8c0 100644
--- a/test/unit/index.ts
+++ b/test/unit/index.ts
@@ -24,8 +24,9 @@ describe("index", () => {
       it("are value-equal", () => {
         type Weather = Member<"Sun"> | Member<"Rain", number>
         const Weather = create<Weather>()
+        const Weather2 = create<Weather>()
 
-        expect(Weather.mk.Sun).toEqual(Weather.mk.Sun)
+        expect(Weather.mk.Sun).toEqual(Weather2.mk.Sun)
         expect(Weather.mk.Sun).not.toEqual(Weather.mk.Rain(123))
         expect(Weather.mk.Rain(123)).toEqual(Weather.mk.Rain(123))
         expect(Weather.mk.Rain(123)).not.toEqual(Weather.mk.Rain(456))

Essentially, the call to create creates a unique identity/reference for the sum. With the current type-based API it's either this or allowing members of different sum types but the same tag names to come up equal. I think this is a lesser evil.

Of course, we can do away with a lot of this complexity by shifting nullary sums out of mk, or requiring that they take null or something. Maybe we should consider that.

Note also we still have this problem: #52.