It is possible to check whether an object has selected fields (expect.objectContaining
) and in a separate assertion whether it does not have selected fields (expect.not.objectContaining
). However, it is not possible, by default, to check these two things in one assertion, at least I have not heard of it yet.
Goal: create a expect.missing
matcher similar to standard expect.any
or expect.anything
which will check if the object does not have the selected field and can be used alongside matchers of existing fields.
My attempts to reach this goal are summarized below, maybe someone will find them useful or be able to improve upon them.
I point out that this is a proof of concept and it is possible that there are many errors and cases that I did not anticipate.
AsymmetricMatchers in their current form lack the ability to check their context, for example, when checking the expect.any
condition for a
in the object { a: expect.any(String), b: [] }
, expect.any
knows nothing about the existence of b
, the object in which a
is a field or even that the expected value is assigned to the key a
. For this reason, it is not enough to create only expect.missing
but also a custom version of expect.objectContaining
, which will be able to provide the context for our expect.missing
matcher.
expect.missing
draft:
import { AsymmetricMatcher, expect } from 'expect'; // npm i expect
class Missing extends AsymmetricMatcher<void> {
asymmetricMatch(actual: unknown): boolean {
// By default, here we have access only to the actual value of the selected field
return !Object.hasOwn(/* TODO get parent object */, /* TODO get property name */);
}
toString(): string {
return 'Missing';
}
toAsymmetricMatcher(): string {
return this.toString(); // how the selected field will be marked in the diff view
}
}
Somehow the matcher above should be given context: object and property name. We will create a custom expect.objectContaining
- let's call it expect.objectContainingOrNot
:
class ObjectContainingOrNot extends AsymmetricMatcher<Record<string, unknown>> {
asymmetricMatch(actual: any): boolean {
const { equals } = this.getMatcherContext();
for (const [ property, expected ] of Object.entries(this.sample)) {
const received = actual[ property ];
if (expected instanceof Missing) {
Object.assign(expected, { property, propertyContext: actual });
} // TODO: this would be sufficient if we didn't care about nested values
if (!equals(received, expected)) {
return false;
}
}
return true;
}
toString(): string {
// borrowed from .objectContaining for sake of nice diff printing
return 'ObjectContaining';
}
override getExpectedType(): string {
return 'object';
}
}
Register new matchers to the expect
:
expect.missing = () => new Missing();
expect.objectContainingOrNot = (sample: Record<string, unknown>) =>
new ObjectContainingOrNot(sample);
declare module 'expect' {
interface AsymmetricMatchers {
missing(): void;
objectContainingOrNot(expected: Record<string, unknown>): void;
}
}
Full complete code:
import { AsymmetricMatcher, expect } from 'expect'; // npm i expect
class Missing extends AsymmetricMatcher<void> {
property?: string;
propertyContext?: object;
asymmetricMatch(_actual: unknown): boolean {
if (!this.property || !this.propertyContext) {
throw new Error(
'.missing() expects to be used only' +
' inside .objectContainingOrNot(...)'
);
}
return !Object.hasOwn(this.propertyContext, this.property);
}
toString(): string {
return 'Missing';
}
toAsymmetricMatcher(): string {
return this.toString();
}
}
class ObjectContainingOrNot extends AsymmetricMatcher<Record<string, unknown>> {
asymmetricMatch(actual: any): boolean {
const { equals } = this.getMatcherContext();
for (const [ property, expected ] of Object.entries(this.sample)) {
const received = actual[ property ];
assignPropertyCtx(actual, property, expected);
if (!equals(received, expected)) {
return false;
}
}
return true;
}
toString(): string {
return 'ObjectContaining';
}
override getExpectedType(): string {
return 'object';
}
}
// Ugly but is able to assign context for nested `expect.missing`s
function assignPropertyCtx(ctx: any, key: PropertyKey, value: unknown): unknown {
if (value instanceof Missing) {
return Object.assign(value, { property: key, propertyContext: ctx });
}
const newCtx = ctx?.[ key ];
if (Array.isArray(value)) {
return value.forEach((e, i) => assignPropertyCtx(newCtx, i, e));
}
if (value && (typeof value === 'object')) {
return Object.entries(value)
.forEach(([ k, v ]) => assignPropertyCtx(newCtx, k, v));
}
}
expect.objectContainingOrNot = (sample: Record<string, unknown>) =>
new ObjectContainingOrNot(sample);
expect.missing = () => new Missing();
declare module 'expect' {
interface AsymmetricMatchers {
objectContainingOrNot(expected: Record<string, unknown>): void;
missing(): void;
}
}
Usage examples:
expect({ baz: 'Baz' }).toEqual(expect.objectContainingOrNot({
baz: expect.stringMatching(/^baz$/i),
foo: expect.missing(),
})); // pass
expect({ baz: 'Baz', foo: undefined }).toEqual(expect.objectContainingOrNot({
baz: 'Baz',
foo: expect.missing(),
})); // fail
// works with nested!
expect({ arr: [ { id: '1' }, { no: '2' } ] }).toEqual(expect.objectContainingOrNot({
arr: [ { id: '1' }, { no: expect.any(String), id: expect.missing() } ],
})); // pass
When we assume that the field is also missing when it equals undefined ({ a: undefined }
=> a
is missing) then the need to pass the context to expect.missing
disappears and the above code can be simplified to:
import { AsymmetricMatcher, expect } from 'expect';
class ObjectContainingOrNot extends AsymmetricMatcher<Record<string, unknown>> {
asymmetricMatch(actual: any): boolean {
const { equals } = this.getMatcherContext();
for (const [ property, expected ] of Object.entries(this.sample)) {
const received = actual[ property ];
if (!equals(received, expected)) {
return false;
}
}
return true;
}
toString(): string {
return `ObjectContaining`;
}
override getExpectedType(): string {
return 'object';
}
}
expect.extend({
missing(actual: unknown) {
// However, it still requires to be used only inside
// expect.objectContainingOrNot.
// expect.objectContaining checks if the objects being compared
// have matching property names which happens before the value
// of those properties reaches this matcher
return {
pass: actual === undefined,
message: () => 'It seems to me that in the' +
' case of this matcher this message is never used',
};
},
});
expect.objectContainingOrNot = (sample: Record<string, unknown>) =>
new ObjectContainingOrNot(sample);
declare module 'expect' {
interface AsymmetricMatchers {
missing(): void;
objectContainingOrNot(expected: Record<string, unknown>): void;
}
}
// With these assumptions, assertion below passes
expect({ baz: 'Baz', foo: undefined }).toEqual(expect.objectContainingOrNot({
baz: 'Baz',
foo: expect.missing(),
}));
It was fun, have a nice day!