diff --git a/.changeset/tasty-points-nail.md b/.changeset/tasty-points-nail.md new file mode 100644 index 00000000..3aff9ce7 --- /dev/null +++ b/.changeset/tasty-points-nail.md @@ -0,0 +1,5 @@ +--- +'jest-extended': minor +--- + +improve error message for `.toIncludeSameMembers` and add optional `keyOrFn` argument for matching changedItems diff --git a/src/matchers/toIncludeSameMembers.js b/src/matchers/toIncludeSameMembers.js index 264c647a..0a42ecc5 100644 --- a/src/matchers/toIncludeSameMembers.js +++ b/src/matchers/toIncludeSameMembers.js @@ -1,24 +1,52 @@ -export function toIncludeSameMembers(actual, expected) { - const { printReceived, printExpected, matcherHint } = this.utils; +const kEmpty = Symbol('kEmpty'); + +export function toIncludeSameMembers(actual, expected, keyOrFn) { + const { printReceived, printExpected, matcherHint, printDiffOrStringify } = this.utils; + + if (keyOrFn !== undefined && typeof keyOrFn !== 'string' && typeof keyOrFn !== 'function') { + throw new Error('toIncludeSameMembers: keyOrFn must be a undefined or string or a function'); + } const pass = predicate(this.equals, actual, expected); return { pass, - message: () => - pass - ? matcherHint('.not.toIncludeSameMembers') + + message: () => { + if (pass) { + return ( + matcherHint('.not.toIncludeSameMembers') + '\n\n' + 'Expected list to not exactly match the members of:\n' + ` ${printExpected(expected)}\n` + 'Received:\n' + ` ${printReceived(actual)}` - : matcherHint('.toIncludeSameMembers') + + ); + } + + let { pass: newPass, newActual, useDiffOutput } = getBetterDiff(this.equals, actual, expected, keyOrFn); + + if (newPass !== pass) { + useDiffOutput = false; + } + + if (useDiffOutput) { + return ( + matcherHint('.toIncludeSameMembers') + '\n\n' + - 'Expected list to have the following members and no more:\n' + - ` ${printExpected(expected)}\n` + - 'Received:\n' + - ` ${printReceived(actual)}`, + printDiffOrStringify(expected, newActual, 'Expected', 'Received', this.expand !== false) + ); + } + + // Fallback to the original hard-to-read for large data output + return ( + matcherHint('.toIncludeSameMembers') + + '\n\n' + + 'Expected list to have the following members and no more:\n' + + ` ${printExpected(expected)}\n` + + 'Received:\n' + + ` ${printReceived(actual)}` + ); + }, }; } @@ -41,3 +69,177 @@ const predicate = (equals, actual, expected) => { return !!remaining && remaining.length === 0; }; + +function getBetterDiff(equals, actual, expected, fnOrKey) { + let { invalid, added, missing, partialNewActual: newActual } = getChanged(equals, actual, expected); + + const pass = !invalid && added.length === 0 && missing.length === 0; + + const containComplexDiffData = + !invalid && actual.concat(expected).some(item => typeof item === 'object' && item !== null); + + // If we have gaps the output would be confusing and element will be displayed as removed and added for the wrong place when having partial match + if (invalid || (containComplexDiffData && !canFillTheGapsIfHave(newActual, added))) { + return { + pass, + newActual: actual, + useDiffOutput: false, + }; + } + + const key = fnOrKey; + fnOrKey = typeof fnOrKey === 'string' ? (itemA, itemB) => itemA?.[key] === itemB?.[key] : fnOrKey; + + // Fill the gaps with matching items + if (added.length && fnOrKey) { + fillWithMatchingItems({ added, missing, newActual, fn: fnOrKey }); + } + + let checkIfArrayHaveGaps = true; + let firstEmptyIndex = added.length ? newActual.findIndex(item => item === kEmpty) : -1; + + // Fill with the rest that don't match or user didn't provide a matching function + for (const item of added) { + while (firstEmptyIndex < expected.length && newActual[firstEmptyIndex] !== kEmpty) { + firstEmptyIndex++; + } + + if (firstEmptyIndex >= expected.length) { + newActual.push(item); + checkIfArrayHaveGaps = false; + } else { + newActual[firstEmptyIndex] = item; + firstEmptyIndex++; + } + } + + let useDiffOutput; + + // If Still have gaps fallback to the original array (the output would be confusing) + if (checkIfArrayHaveGaps && containComplexDiffData && doesArrayHaveGaps(newActual)) { + newActual = actual; + useDiffOutput = false; + } else { + // Compact the array + newActual = newActual.filter(item => item !== kEmpty); + useDiffOutput = true; + } + + return { + pass, + newActual, + useDiffOutput, + }; +} + +function getChanged(equals, actual, expected) { + if (!Array.isArray(actual) || !Array.isArray(expected)) { + return { invalid: true }; + } + + const missing = []; + const newActual = Array(expected.length).fill(kEmpty); + + const added = expected.reduce((actualItemsRemaining, expectedItem, expectedIndex) => { + const index = actualItemsRemaining.findIndex(actualItem => equals(expectedItem, actualItem)); + + if (index === -1) { + missing.push({ index: expectedIndex, value: expectedItem }); + return actualItemsRemaining; + } + + newActual[expectedIndex] = actualItemsRemaining[index]; + return actualItemsRemaining.slice(0, index).concat(actualItemsRemaining.slice(index + 1)); + }, actual); + + return { + added, + missing, + partialNewActual: newActual, + }; +} + +function fillWithMatchingItems({ added, missing, newActual, fn }) { + let addedIndex = 0; + while (added.length > addedIndex) { + const item = added[addedIndex]; + let matched = false; + + for (let i = 0; i < missing.length; i++) { + const { index, value: removedItem } = missing[i]; + if (fn(removedItem, item)) { + newActual[index] = item; + + missing.splice(i, 1); + matched = true; + break; + } + } + + if (matched) { + added.splice(addedIndex, 1); + } else { + addedIndex++; + } + } +} + +function doesArrayHaveGaps(array) { + const lastEmptyIndex = array.lastIndexOf(kEmpty); + + if (lastEmptyIndex === -1) { + return false; + } + + if (lastEmptyIndex !== array.length - 1) { + return true; + } + + const firstEmptyIndex = array.indexOf(kEmpty); + + for (let i = firstEmptyIndex; i <= lastEmptyIndex; i++) { + if (array[i] !== kEmpty) { + return true; + } + } + + return false; +} + +function canFillTheGapsIfHave(arrayWithPossibleGaps, itemsToAdd) { + const lastEmptyIndex = arrayWithPossibleGaps.lastIndexOf(kEmpty); + + if (lastEmptyIndex === -1) { + return true; + } + + if (lastEmptyIndex !== arrayWithPossibleGaps.length - 1) { + // Have gaps + return arrayWithPossibleGaps.filter(item => item === kEmpty).length <= itemsToAdd.length; + } + + let startPaddingIndex; + + // The array ends with empty items, so we need to find the first non-empty item from the end + // so we would only be left with gaps + for (let i = lastEmptyIndex; i >= 0; i--) { + if (arrayWithPossibleGaps[i] !== kEmpty) { + startPaddingIndex = i; + } + } + + // Array full of empty items + if (startPaddingIndex === undefined) { + return arrayWithPossibleGaps.length <= itemsToAdd.length; + } + + let accumulatedGapSize = 0; + + for (let i = startPaddingIndex; i >= 0; i--) { + if (arrayWithPossibleGaps[i] === kEmpty) { + accumulatedGapSize++; + } + } + + return accumulatedGapSize <= itemsToAdd.length; +} diff --git a/test/matchers/__snapshots__/toIncludeSameMembers.test.js.snap b/test/matchers/__snapshots__/toIncludeSameMembers.test.js.snap index db442d73..7692e887 100644 --- a/test/matchers/__snapshots__/toIncludeSameMembers.test.js.snap +++ b/test/matchers/__snapshots__/toIncludeSameMembers.test.js.snap @@ -9,11 +9,221 @@ Received: [1]" `; -exports[`.toIncludeSameMembers fails when the arrays are not equal in length 1`] = ` +exports[`.toIncludeSameMembers fail with fallback output when result of the matcher changed 1`] = ` +"expect(received).toIncludeSameMembers(expected) + +Expected list to have the following members and no more: + [{"id": 1}] +Received: + [{"id": 5}]" +`; + +exports[`.toIncludeSameMembers fails when actual has less items than expected (when the ones exists match) objects 1`] = ` +"expect(received).toIncludeSameMembers(expected) + +- Expected - 3 ++ Received + 0 + +@@ -6,9 +6,6 @@ + "id": 2, + }, + Object { + "id": 3, + }, +- Object { +- "id": 4, +- }, + ]" +`; + +exports[`.toIncludeSameMembers fails when actual has less items than expected (when the ones exists match) simple items 1`] = ` +"expect(received).toIncludeSameMembers(expected) + +- Expected - 1 ++ Received + 0 + + Array [ + 1, + 2, + 3, +- 4, + ]" +`; + +exports[`.toIncludeSameMembers fails when actual has more items than expected (when the ones exists match) objects 1`] = ` +"expect(received).toIncludeSameMembers(expected) + +- Expected - 0 ++ Received + 3 + +@@ -6,6 +6,9 @@ + "id": 2, + }, + Object { + "id": 3, + }, ++ Object { ++ "id": 4, ++ }, + ]" +`; + +exports[`.toIncludeSameMembers fails when actual has more items than expected (when the ones exists match) simple items 1`] = ` +"expect(received).toIncludeSameMembers(expected) + +- Expected - 0 ++ Received + 1 + + Array [ + 1, + 2, + 3, ++ 4, + ]" +`; + +exports[`.toIncludeSameMembers fails when actual has more items than expected (when the ones exists not all match) objects 1`] = ` +"expect(received).toIncludeSameMembers(expected) + +- Expected - 1 ++ Received + 7 + + Array [ + Object { + "id": 1, + }, + Object { +- "id": 2, ++ "id": 8, + }, + Object { + "id": 3, ++ }, ++ Object { ++ "id": 5, ++ }, ++ Object { ++ "id": 6, + }, + ]" +`; + +exports[`.toIncludeSameMembers fails when actual has more items than expected (when the ones exists not all match) simple items 1`] = ` +"expect(received).toIncludeSameMembers(expected) + +- Expected - 1 ++ Received + 3 + + Array [ + 1, +- 2, ++ 8, + 3, ++ 5, ++ 6, + ]" +`; + +exports[`.toIncludeSameMembers fails when not passed array 1`] = ` "expect(received).toIncludeSameMembers(expected) Expected list to have the following members and no more: [1] Received: - [1, 2]" + 2" +`; + +exports[`.toIncludeSameMembers fails when the arrays are not equal in length 1`] = ` +"expect(received).toIncludeSameMembers(expected) + +- Expected - 0 ++ Received + 1 + + Array [ + 1, ++ 2, + ]" +`; + +exports[`.toIncludeSameMembers have gaps objects 1`] = ` +"expect(received).toIncludeSameMembers(expected) + +Expected list to have the following members and no more: + [{"a": 1, "value": "hello"}, {"b": 2, "value": "world"}, {"c": 3, "value": "how are you"}, {"d": 4, "value": "im good"}, {"e": 5, "value": "thanks, you"}] +Received: + [{"e": 5, "value": "thanks, you"}, {"f": 6, "value": "?"}, {"a": 1, "value": "no"}]" +`; + +exports[`.toIncludeSameMembers have gaps simple items 1`] = ` +"expect(received).toIncludeSameMembers(expected) + +- Expected - 3 ++ Received + 1 + + Array [ + 1, +- 2, +- 3, +- 4, ++ 6, + 5, + ]" +`; + +exports[`.toIncludeSameMembers keyOrFn passed function 1`] = ` +"expect(received).toIncludeSameMembers(expected) + +- Expected - 3 ++ Received + 7 + + Array [ + Object { + "id": 1, +- "name": "Tony", ++ "name": "Bruce", + }, + Object { + "id": 2, +- "name": "Bruce", ++ "name": "Steve", + }, + Object { + "id": 3, +- "name": "Steve", ++ "name": "Tony", ++ }, ++ Object { ++ "id": 4, ++ "name": "Bucky", + }, + ]" +`; + +exports[`.toIncludeSameMembers keyOrFn passed property of the items as key 1`] = ` +"expect(received).toIncludeSameMembers(expected) + +- Expected - 3 ++ Received + 7 + + Array [ + Object { + "id": 1, +- "name": "Tony", ++ "name": "Bruce", + }, + Object { + "id": 2, +- "name": "Bruce", ++ "name": "Steve", + }, + Object { + "id": 3, +- "name": "Steve", ++ "name": "Tony", ++ }, ++ Object { ++ "id": 4, ++ "name": "Bucky", + }, + ]" `; diff --git a/test/matchers/toIncludeSameMembers.test.js b/test/matchers/toIncludeSameMembers.test.js index 020ab2ac..c5e43adf 100644 --- a/test/matchers/toIncludeSameMembers.test.js +++ b/test/matchers/toIncludeSameMembers.test.js @@ -17,9 +17,130 @@ describe('.toIncludeSameMembers', () => { expect([{ foo: 'bar' }, { baz: 'qux' }]).toIncludeSameMembers([{ baz: 'qux' }, { foo: 'bar' }]); }); + test('fail with fallback output when result of the matcher changed', () => { + expect(() => + expect([ + { + get id() { + const stack = new Error().stack; + if (!stack.includes('getBetterDiff')) { + // Fail + return 5; + } + return 1; + }, + }, + ]).toIncludeSameMembers([{ id: 1 }]), + ).toThrowErrorMatchingSnapshot(); + }); + test('fails when the arrays are not equal in length', () => { expect(() => expect([1, 2]).toIncludeSameMembers([1])).toThrowErrorMatchingSnapshot(); }); + + test('fails when not passed array', () => { + expect(() => expect(2).toIncludeSameMembers([1])).toThrowErrorMatchingSnapshot(); + }); + + describe('fails when actual has more items than expected (when the ones exists match)', () => { + test('simple items', () => { + expect(() => expect([2, 4, 3, 1]).toIncludeSameMembers([1, 2, 3])).toThrowErrorMatchingSnapshot(); + }); + + test('objects', () => { + expect(() => + expect([{ id: 2 }, { id: 4 }, { id: 3 }, { id: 1 }]).toIncludeSameMembers([{ id: 1 }, { id: 2 }, { id: 3 }]), + ).toThrowErrorMatchingSnapshot(); + }); + }); + + describe('fails when actual has less items than expected (when the ones exists match)', () => { + test('simple items', () => { + expect(() => expect([2, 3, 1]).toIncludeSameMembers([1, 2, 3, 4])).toThrowErrorMatchingSnapshot(); + }); + + test('objects', () => { + expect(() => + expect([{ id: 2 }, { id: 3 }, { id: 1 }]).toIncludeSameMembers([{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]), + ).toThrowErrorMatchingSnapshot(); + }); + }); + + describe('fails when actual has more items than expected (when the ones exists not all match)', () => { + test('simple items', () => { + expect(() => expect([3, 1, 8, 5, 6]).toIncludeSameMembers([1, 2, 3])).toThrowErrorMatchingSnapshot(); + }); + + test('objects', () => { + expect(() => + expect([{ id: 3 }, { id: 1 }, { id: 8 }, { id: 5 }, { id: 6 }]).toIncludeSameMembers([ + { id: 1 }, + { id: 2 }, + { id: 3 }, + ]), + ).toThrowErrorMatchingSnapshot(); + }); + }); + + describe('have gaps', () => { + test('simple items', () => { + expect(() => expect([5, 6, 1]).toIncludeSameMembers([1, 2, 3, 4, 5])).toThrowErrorMatchingSnapshot(); + }); + + test('objects', () => { + expect(() => + expect([ + { e: 5, value: 'thanks, you' }, + { f: 6, value: '?' }, + { a: 1, value: 'no' }, + ]).toIncludeSameMembers([ + { a: 1, value: 'hello' }, + { b: 2, value: 'world' }, + { c: 3, value: 'how are you' }, + { d: 4, value: 'im good' }, + { e: 5, value: 'thanks, you' }, + ]), + ).toThrowErrorMatchingSnapshot(); + }); + }); + + describe('keyOrFn', () => { + test('passed property of the items as key', () => { + expect(() => + expect([ + { id: 2, name: 'Steve' }, + { id: 4, name: 'Bucky' }, + { id: 3, name: 'Tony' }, + { id: 1, name: 'Bruce' }, + ]).toIncludeSameMembers( + [ + { id: 1, name: 'Tony' }, + { id: 2, name: 'Bruce' }, + { id: 3, name: 'Steve' }, + ], + 'id', + ), + ).toThrowErrorMatchingSnapshot(); + }); + + test('passed function', () => { + expect(() => + expect([ + { id: 2, name: 'Steve' }, + { id: 4, name: 'Bucky' }, + { id: 3, name: 'Tony' }, + { id: 1, name: 'Bruce' }, + ]).toIncludeSameMembers( + [ + { id: 1, name: 'Tony' }, + { id: 2, name: 'Bruce' }, + { id: 3, name: 'Steve' }, + ], + (itemA, itemB) => itemA.id === itemB.id, + ), + ).toThrowErrorMatchingSnapshot(); + }); + }); }); describe('.not.toIncludeSameMembers', () => { diff --git a/types/index.d.ts b/types/index.d.ts index 9436c25c..14758ea4 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -76,9 +76,11 @@ interface CustomMatchers extends Record { /** * Use `.toIncludeSameMembers` when checking if two arrays contain equal values, in any order. + * for better error message use the optional `fnOrKey` argument to specify how to determine two items similarity (e.g. the id property) * @param {Array.<*>} members + * @param fnOrKey */ - toIncludeSameMembers(members: readonly E[]): R; + toIncludeSameMembers(members: readonly E[], fnOrKey?: string | ((itemA: E, itemB: E) => boolean)): R; /** * Use `.toPartiallyContain` when checking if any array value matches the partial member. @@ -510,9 +512,11 @@ declare namespace jest { /** * Use `.toIncludeSameMembers` when checking if two arrays contain equal values, in any order. + * for better error message use the optional `fnOrKey` argument to specify how to determine two items similarity (e.g. the id property) * @param {Array.<*>} members + * @param fnOrKey */ - toIncludeSameMembers(members: readonly E[]): R; + toIncludeSameMembers(members: readonly E[], fnOrKey?: string | ((itemA: E, itemB: E) => boolean)): R; /** * Use `.toPartiallyContain` when checking if any array value matches the partial member. diff --git a/website/docs/matchers/Array.mdx b/website/docs/matchers/Array.mdx index 7903a5e6..1980f3ee 100644 --- a/website/docs/matchers/Array.mdx +++ b/website/docs/matchers/Array.mdx @@ -59,9 +59,10 @@ Use `.toIncludeAnyMembers` when checking if an `Array` contains any of the membe });`} -### .toIncludeSameMembers([members]) +### .toIncludeSameMembers([members], fnOrKey) Use `.toIncludeSameMembers` when checking if two arrays contain equal values, in any order. +for better error message use the optional `fnOrKey` argument to specify how to determine two items similarity (e.g. the id property) {`test('passes when arrays match in a different order', () => {