diff --git a/testing/README.md b/testing/README.md index e442ea014c09..f970471cbc97 100644 --- a/testing/README.md +++ b/testing/README.md @@ -25,6 +25,8 @@ pretty-printed diff of failing assertion. `expected`. - `assertArrayContains()` - Make an assertion that `actual` array contains the `expected` values. +- `assertObjectMatch()` - Make an assertion that `actual` object match + `expected` subset object - `assertThrows()` - Expects the passed `fn` to throw. If `fn` does not throw, this function does. Also compares any errors thrown to an optional expected `Error` class and checks that the error `.message` includes an optional diff --git a/testing/asserts.ts b/testing/asserts.ts index f93e043dc668..1d58f0a001e4 100644 --- a/testing/asserts.ts +++ b/testing/asserts.ts @@ -429,6 +429,48 @@ export function assertNotMatch( } } +/** + * Make an assertion that `actual` object is a subset of `expected` object, deeply. + * If not, then throw. + */ +export function assertObjectMatch( + actual: Record, + expected: Record, +): void { + type loose = Record; + const seen = new WeakMap(); + return assertEquals( + (function filter(a: loose, b: loose): loose { + // Prevent infinite loop with circular references with same filter + if ((seen.has(a)) && (seen.get(a) === b)) { + return a; + } + seen.set(a, b); + // Filter keys and symbols which are present in both actual and expected + const filtered = {} as loose; + const entries = [ + ...Object.getOwnPropertyNames(a), + ...Object.getOwnPropertySymbols(a), + ] + .filter((key) => key in b) + .map((key) => [key, a[key as string]]) as Array<[string, unknown]>; + // Build filtered object and filter recursively on nested objects references + for (const [key, value] of entries) { + if (typeof value === "object") { + const subset = (b as loose)[key]; + if ((typeof subset === "object") && (subset)) { + filtered[key] = filter(value as loose, subset as loose); + continue; + } + } + filtered[key] = value; + } + return filtered; + })(actual, expected), + expected, + ); +} + /** * Forcefully throws a failed assertion */ diff --git a/testing/asserts_test.ts b/testing/asserts_test.ts index 7b57fbfa0c3b..a0db48e7bacb 100644 --- a/testing/asserts_test.ts +++ b/testing/asserts_test.ts @@ -9,6 +9,7 @@ import { assertNotEquals, assertNotMatch, assertNotStrictEquals, + assertObjectMatch, assertStrictEquals, assertStringContains, assertThrows, @@ -259,6 +260,180 @@ Deno.test("testingAssertStringNotMatchingThrows", function (): void { assert(didThrow); }); +Deno.test("testingAssertObjectMatching", function (): void { + const sym = Symbol("foo"); + const a = { foo: true, bar: false }; + const b = { ...a, baz: a }; + const c = { ...b, qux: b }; + const d = { corge: c, grault: c }; + const e = { foo: true } as { [key: string]: unknown }; + e.bar = e; + const f = { [sym]: true, bar: false }; + // Simple subset + assertObjectMatch(a, { + foo: true, + }); + // Subset with another subset + assertObjectMatch(b, { + foo: true, + baz: { bar: false }, + }); + // Subset with multiple subsets + assertObjectMatch(c, { + foo: true, + baz: { bar: false }, + qux: { + baz: { foo: true }, + }, + }); + // Subset with same object reference as subset + assertObjectMatch(d, { + corge: { + foo: true, + qux: { bar: false }, + }, + grault: { + bar: false, + qux: { foo: true }, + }, + }); + // Subset with circular reference + assertObjectMatch(e, { + foo: true, + bar: { + bar: { + bar: { + foo: true, + }, + }, + }, + }); + // Subset with same symbol + assertObjectMatch(f, { + [sym]: true, + }); + // Missing key + { + let didThrow; + try { + assertObjectMatch({ + foo: true, + }, { + foo: true, + bar: false, + }); + didThrow = false; + } catch (e) { + assert(e instanceof AssertionError); + didThrow = true; + } + assertEquals(didThrow, true); + } + // Simple subset + { + let didThrow; + try { + assertObjectMatch(a, { + foo: false, + }); + didThrow = false; + } catch (e) { + assert(e instanceof AssertionError); + didThrow = true; + } + assertEquals(didThrow, true); + } + // Subset with another subset + { + let didThrow; + try { + assertObjectMatch(b, { + foo: true, + baz: { bar: true }, + }); + didThrow = false; + } catch (e) { + assert(e instanceof AssertionError); + didThrow = true; + } + assertEquals(didThrow, true); + } + // Subset with multiple subsets + { + let didThrow; + try { + assertObjectMatch(c, { + foo: true, + baz: { bar: false }, + qux: { + baz: { foo: false }, + }, + }); + didThrow = false; + } catch (e) { + assert(e instanceof AssertionError); + didThrow = true; + } + assertEquals(didThrow, true); + } + // Subset with same object reference as subset + { + let didThrow; + try { + assertObjectMatch(d, { + corge: { + foo: true, + qux: { bar: true }, + }, + grault: { + bar: false, + qux: { foo: false }, + }, + }); + didThrow = false; + } catch (e) { + assert(e instanceof AssertionError); + didThrow = true; + } + assertEquals(didThrow, true); + } + // Subset with circular reference + { + let didThrow; + try { + assertObjectMatch(e, { + foo: true, + bar: { + bar: { + bar: { + foo: false, + }, + }, + }, + }); + didThrow = false; + } catch (e) { + assert(e instanceof AssertionError); + didThrow = true; + } + assertEquals(didThrow, true); + } + // Subset with symbol key but with string key subset + { + let didThrow; + try { + assertObjectMatch(f, { + foo: true, + }); + didThrow = false; + } catch (e) { + assert(e instanceof AssertionError); + didThrow = true; + } + assertEquals(didThrow, true); + } +}); + Deno.test("testingAssertsUnimplemented", function (): void { let didThrow = false; try {