Skip to content

Instantly share code, notes, and snippets.

@milichev
Created November 14, 2024 15:31
Show Gist options
  • Save milichev/c65182bad051735a8e6939d5ceacaff4 to your computer and use it in GitHub Desktop.
Save milichev/c65182bad051735a8e6939d5ceacaff4 to your computer and use it in GitHub Desktop.
Jest Matchers

Jets Matchers

Examples

Expect Matchers

expect(response).toEqualDeeplyContaining({status: 'OK', body: { orders: [{ id: 1}]}});

Asymetric Matchers

Alas, they don't work. Jest matchers like toHaveBeenCalledTimes expect strictly number expectation argument, and don't tolerate AsymetricMatcherinstances. Hence, it is just for fun for now:

expect(response).toHaveBeenCalledTimes(expect.deepContaining({status: 'OK'}));
expect(resizersAddSpy).toHaveBeenCalledTimes(expect.greaterThan(1));
expect(items).toHaveLength(expect.lessThan(3));

Dependencies

  • @jest/expect-utils: 29.7.0
  • jest-matcher-utils: 29.7.0
  • expect: 29.7.0
import { DeepPartial } from 'mock-typed';
import { isValidElement } from 'react';
import { equals, Tester } from '@jest/expect-utils';
import React from 'react';
import { prettyDOM, PrettyDOMOptions } from '@testing-library/dom';
import invariant from 'tiny-invariant';
/**
* Utility function to deeply apply `expect.objectContaining` and `expect.arrayContaining`
*/
export const deepContaining = <T>(input: DeepPartial<T>): any => {
if (input instanceof RegExp) {
return expect.stringMatching(input);
}
if (Array.isArray(input)) {
return expect.arrayContaining(input.map((item) => deepContaining(item)));
}
if (typeof input === 'object' && input !== null) {
if ((input as any)['$$typeof']?.toString() === 'Symbol(jest.asymmetricMatcher)') {
// AsymmetricMatcher
return input;
}
// If input is an object, map its values with `objectContainingDeep` and wrap in `expect.objectContaining`
return expect.objectContaining(
Object.entries(input).reduce((acc, [key, value]) => {
acc[key as keyof T] = deepContaining(value);
return acc;
}, {} as any)
);
}
// For primitive values, return them directly (no transformation needed)
return input;
};
type Replacer = (input: any) => any | undefined;
export const replaceProps = (obj: any, replacers: Replacer[]): any => {
const process = (input: any): { output: any; isChanged: boolean } => {
for (const replacer of replacers) {
const output = replacer(input);
if (output !== undefined) return { output, isChanged: true };
}
// Handle arrays
if (Array.isArray(input)) {
const mapped = input.map(process);
return mapped.some((item) => item.isChanged)
? { isChanged: true, output: mapped.map((item) => item.output) }
: { isChanged: false, output: input };
}
// Handle objects
if (input && typeof input === 'object') {
const mapped = Object.entries(input).map(([key, val]) => {
const { isChanged, output } = process(val);
return { isChanged, key, output };
});
return !mapped.some((item) => item.isChanged)
? { isChanged: false, output: input }
: {
isChanged: true,
output: mapped.reduce((acc, { key, output }) => {
acc[key] = output;
return acc;
}, {} as any),
};
}
return { isChanged: false, output: input };
};
const { isChanged, output } = process(obj);
return isChanged ? output : obj;
};
export const reactElementMatcher: Tester = (a, b) =>
isValidElement(a) && isValidElement(b)
? a.type === b.type && equals(a.props, b.props, [reactElementMatcher])
: undefined;
const serializeReactElements = (input: any) =>
React.isValidElement(input)
? {
type: (input.type as React.FC)?.displayName ?? (input.type as React.FC)?.name ?? input.type ?? 'Unknown',
props: ensureReactElementsForStringifying(input.props),
}
: undefined;
export const ensureReactElementsForStringifying = (input: any) => replaceProps(input, [serializeReactElements]);
export const printElementHtml = (
element: Element | HTMLElement,
{ maxLength = 500_000, ...options }: { maxLength?: number } & PrettyDOMOptions = {},
...rest: any[]
) => {
invariant(
process.env.npm_lifecycle_event === 'test:watch',
'Sorry, printing DOM to console is only available in test:watch'
);
// eslint-disable-next-line no-console
console.log(...rest.map((item) => `${item}\n`), prettyDOM(element, maxLength, options));
};
import { isA, equals } from '@jest/expect-utils';
import { printDiffOrStringify } from 'jest-matcher-utils';
import { deepContaining, ensureReactElementsForStringifying, reactElementMatcher } from './fns';
import { AsymmetricMatcher, AsymmetricMatchers } from 'expect';
expect.extend({
deepContaining(received, expected) {
const pass = this.equals(received, expect.objectContaining(expected));
return pass
? { message: () => `expected ${received} not to contain ${expected}`, pass: true }
: { message: () => `expected ${received} to contain ${expected}`, pass: false };
},
});
declare global {
namespace jest {
interface Matchers<R> {
toEqualDeeplyContaining(expected: unknown): R;
}
interface Expect {
deepContaining(value: any): any;
greaterThan(expected: number): any;
lessThan(expected: number): any;
}
}
}
const toEqualDeeplyContaining = (received: any, expected: any) => {
const pass = equals(received, deepContaining(expected), [reactElementMatcher]);
return {
pass,
message: () =>
`Expected ${received} to ${pass ? 'not ' : ''}deeply contain ${expected}\n${printDiffOrStringify(
ensureReactElementsForStringifying(expected),
ensureReactElementsForStringifying(received),
'expected',
'received',
true
)}`,
};
};
abstract class NumericCompare extends AsymmetricMatcher<number> {
constructor(sample: number, inverse = false) {
if (!isA('Number', sample)) {
throw new Error('Expected is not a number');
}
super(sample, inverse);
}
toString() {
return `Number${this.inverse ? 'Not' : ''}${this.constructor.name}`;
}
getExpectedType() {
return 'number';
}
toAsymmetricMatcher() {
return `${this.toString()}<${this.sample}>`;
}
}
class GreaterThan extends NumericCompare {
asymmetricMatch(other: number) {
const result = isA('Number', other) && other > this.sample;
return this.inverse ? !result : result;
}
}
class LessThan extends NumericCompare {
asymmetricMatch(other: number) {
const result = isA('Number', other) && other < this.sample;
return this.inverse ? !result : result;
}
}
expect.extend({
toEqualDeeplyContaining,
});
expect.deepContaining = deepContaining;
expect.greaterThan = (expected) => new GreaterThan(expected);
expect.lessThan = (expected) => new LessThan(expected);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment