Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(expect): allow extending matcher interfaces by moving expect types to @jest/types package #12059

Closed
wants to merge 13 commits into from
20 changes: 20 additions & 0 deletions examples/expect-extend/__tests__/ranges.test.ts
@@ -0,0 +1,20 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

import {expect, test} from '@jest/globals';
import '../toBeWithinRange';

test('is within range', () => expect(100).toBeWithinRange(90, 110));

test('is NOT within range', () => expect(101).not.toBeWithinRange(0, 100));

test('asymmetric ranges', () => {
expect({apples: 6, bananas: 3}).toEqual({
apples: expect.toBeWithinRange(1, 10),
bananas: expect.not.toBeWithinRange(11, 20),
});
});
29 changes: 29 additions & 0 deletions examples/expect-extend/package.json
@@ -0,0 +1,29 @@
{
"private": true,
"version": "0.0.0",
"name": "example-expect-extend",
"devDependencies": {
"@babel/core": "*",
"@babel/preset-env": "*",
"@babel/preset-typescript": "*",
"@jest/globals": "*",
"babel-jest": "*",
"jest": "*"
},
"scripts": {
"test": "jest"
},
"babel": {
"presets": [
[
"@babel/preset-env",
{
"targets": {
"node": "current"
}
}
],
"@babel/preset-typescript"
]
}
}
38 changes: 38 additions & 0 deletions examples/expect-extend/toBeWithinRange.ts
@@ -0,0 +1,38 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

import {expect} from '@jest/globals';

expect.extend({
toBeWithinRange(actual: number, floor: number, ceiling: number) {
const pass = actual >= floor && actual <= ceiling;
if (pass) {
return {
message: () =>
`expected ${actual} not to be within range ${floor} - ${ceiling}`,
pass: true,
};
} else {
return {
message: () =>
`expected ${actual} to be within range ${floor} - ${ceiling}`,
pass: false,
};
}
},
});

declare module '@jest/types' {
namespace Expect {
interface AsymmetricMatchers {
toBeWithinRange(a: number, b: number): AsymmetricMatcher;
}
interface Matchers<R> {
toBeWithinRange(a: number, b: number): R;
}
}
}
93 changes: 92 additions & 1 deletion packages/expect/__typetests__/expect.test.ts
Expand Up @@ -5,12 +5,103 @@
* LICENSE file in the root directory of this source tree.
*/

import {expectError} from 'tsd-lite';
import {expectError, expectType} from 'tsd-lite';
import type * as expect from 'expect';
import type * as jestMatcherUtils from 'jest-matcher-utils';

type M = expect.Matchers<void, unknown>;
type N = expect.Matchers<void>;

expectError(() => {
type E = expect.Matchers;
});

// extend

type Tester = (a: any, b: any) => boolean | undefined;

type MatcherUtils = typeof jestMatcherUtils & {
iterableEquality: Tester;
subsetEquality: Tester;
};

expectType<void>(
expect.extend({
toBeWithinRange(actual: number, floor: number, ceiling: number) {
expectType<number>(this.assertionCalls);
expectType<string | undefined>(this.currentTestName);
expectType<(() => void) | undefined>(this.dontThrow);
expectType<Error | undefined>(this.error);
expectType<
(
a: unknown,
b: unknown,
customTesters?: Array<Tester>,
strictCheck?: boolean,
) => boolean
>(this.equals);
expectType<boolean | undefined>(this.expand);
expectType<number | null | undefined>(this.expectedAssertionsNumber);
expectType<Error | undefined>(this.expectedAssertionsNumberError);
expectType<boolean | undefined>(this.isExpectingAssertions);
expectType<Error | undefined>(this.isExpectingAssertionsError);
expectType<boolean>(this.isNot);
expectType<string>(this.promise);
expectType<Array<Error>>(this.suppressedErrors);
expectType<string | undefined>(this.testPath);
expectType<MatcherUtils>(this.utils);

// `snapshotState` type should not leak from `@jest/types`

expectError(this.snapshotState);

const pass = actual >= floor && actual <= ceiling;
if (pass) {
return {
message: () =>
`expected ${actual} not to be within range ${floor} - ${ceiling}`,
pass: true,
};
} else {
return {
message: () =>
`expected ${actual} to be within range ${floor} - ${ceiling}`,
pass: false,
};
}
},
}),
);

declare module '@jest/types' {
namespace Expect {
interface AsymmetricMatchers {
toBeWithinRange(floor: number, ceiling: number): AsymmetricMatcher;
}
interface Matchers<R> {
toBeWithinRange(floor: number, ceiling: number): R;
}
}
}

expectType<void>(expect(100).toBeWithinRange(90, 110));
expectType<void>(expect(101).not.toBeWithinRange(0, 100));

expectType<void>(
expect({apples: 6, bananas: 3}).toEqual({
apples: expect.toBeWithinRange(1, 10),
bananas: expect.not.toBeWithinRange(11, 20),
}),
);

// `addSnapshotSerializer` type should not leak from `@jest/types`

expectError(expect.addSnapshotSerializer());

// snapshot matchers types should not leak from `@jest/types`

expectError(expect({a: 1}).toMatchSnapshot());
expectError(expect('abc').toMatchInlineSnapshot());

expectError(expect(jest.fn()).toThrowErrorMatchingSnapshot());
expectError(expect(jest.fn()).toThrowErrorMatchingInlineSnapshot());
15 changes: 4 additions & 11 deletions packages/expect/src/asymmetricMatchers.ts
Expand Up @@ -12,12 +12,9 @@ import {
iterableEquality,
subsetEquality,
} from '@jest/expect-utils';
import type {Expect} from '@jest/types';
import * as matcherUtils from 'jest-matcher-utils';
import {getState} from './jestMatchersObject';
import type {
AsymmetricMatcher as AsymmetricMatcherInterface,
MatcherState,
} from './types';

const functionToString = Function.prototype.toString;

Expand Down Expand Up @@ -64,22 +61,18 @@ export function hasProperty(obj: object | null, property: string): boolean {
return hasProperty(getPrototype(obj), property);
}

export abstract class AsymmetricMatcher<
T,
State extends MatcherState = MatcherState,
> implements AsymmetricMatcherInterface
{
export abstract class AsymmetricMatcher<T> implements Expect.AsymmetricMatcher {
$$typeof = Symbol.for('jest.asymmetricMatcher');

constructor(protected sample: T, protected inverse = false) {}

protected getMatcherContext(): State {
protected getMatcherContext(): Expect.MatcherState {
return {
...getState(),
equals,
isNot: this.inverse,
utils,
} as State;
};
}

abstract asymmetricMatch(other: unknown): boolean;
Expand Down
6 changes: 3 additions & 3 deletions packages/expect/src/extractExpectedAssertionsErrors.ts
Expand Up @@ -6,14 +6,14 @@
*
*/

import type {Expect} from '@jest/types';
import {
EXPECTED_COLOR,
RECEIVED_COLOR,
matcherHint,
pluralize,
} from 'jest-matcher-utils';
import {getState, setState} from './jestMatchersObject';
import type {Expect, ExpectedAssertionsErrors} from './types';

const resetAssertionsLocalState = () => {
setState({
Expand All @@ -25,9 +25,9 @@ const resetAssertionsLocalState = () => {

// Create and format all errors related to the mismatched number of `expect`
// calls and reset the matcher's state.
const extractExpectedAssertionsErrors: Expect['extractExpectedAssertionsErrors'] =
const extractExpectedAssertionsErrors: Expect.Expect['extractExpectedAssertionsErrors'] =
() => {
const result: ExpectedAssertionsErrors = [];
const result: Expect.ExpectedAssertionsErrors = [];
const {
assertionCalls,
expectedAssertionsNumber,
Expand Down