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): replace RawMatcherFn with MatcherFunction and MatcherFunctionWithState types #12376

Merged
merged 13 commits into from Feb 15, 2022
2 changes: 1 addition & 1 deletion CHANGELOG.md
Expand Up @@ -2,6 +2,7 @@

### Features

- `[expect]` Expose `AsymmetricMatchers`, `MatcherFunction` and `MatcherFunctionWithState` interfaces ([#12363](https://github.com/facebook/jest/pull/12363), [#12376](https://github.com/facebook/jest/pull/12376))
mrazauskas marked this conversation as resolved.
Show resolved Hide resolved
- `[jest-config]` [**BREAKING**] Stop shipping `jest-environment-jsdom` by default ([#12354](https://github.com/facebook/jest/pull/12354))
- `[jest-config]` [**BREAKING**] Stop shipping `jest-jasmine2` by default ([#12355](https://github.com/facebook/jest/pull/12355))
- `[jest-config, @jest/types]` Add `ci` to `GlobalConfig` ([#12378](https://github.com/facebook/jest/pull/12378))
Expand All @@ -16,7 +17,6 @@
### Fixes

- `[expect]` Move typings of `.not`, `.rejects` and `.resolves` modifiers outside of `Matchers` interface ([#12346](https://github.com/facebook/jest/pull/12346))
- `[expect]` Expose `AsymmetricMatchers` and `RawMatcherFn` interfaces ([#12363](https://github.com/facebook/jest/pull/12363))
- `[jest-config]` Correctly detect CI environment and update snapshots accordingly ([#12378](https://github.com/facebook/jest/pull/12378))
- `[jest-config]` Pass `moduleTypes` to `ts-node` to enforce CJS when transpiling ([#12397](https://github.com/facebook/jest/pull/12397))
- `[jest-environment-jsdom]` Make `jsdom` accessible to extending environments again ([#12232](https://github.com/facebook/jest/pull/12232))
Expand Down
18 changes: 13 additions & 5 deletions examples/expect-extend/toBeWithinRange.ts
Expand Up @@ -6,13 +6,21 @@
*/

import {expect} from '@jest/globals';
import type {RawMatcherFn} from 'expect';
import type {MatcherFunction} from 'expect';

const toBeWithinRange: RawMatcherFn = (
SimenB marked this conversation as resolved.
Show resolved Hide resolved
actual: number,
floor: number,
ceiling: number,
const toBeWithinRange: MatcherFunction<[floor: number, ceiling: number]> = (
actual: unknown,
floor: unknown,
ceiling: unknown,
) => {
if (
typeof actual !== 'number' ||
typeof floor !== 'number' ||
typeof ceiling !== 'number'
) {
throw new Error('These must be of type number!');
}

const pass = actual >= floor && actual <= ceiling;
if (pass) {
return {
Expand Down
136 changes: 134 additions & 2 deletions packages/expect/__typetests__/expect.test.ts
Expand Up @@ -5,9 +5,15 @@
* LICENSE file in the root directory of this source tree.
*/

import {expectError, expectType} from 'tsd-lite';
import {expectAssignable, expectError, expectType} from 'tsd-lite';
import type {EqualsFunction, Tester} from '@jest/expect-utils';
import {type Matchers, expect} from 'expect';
import {
type MatcherFunction,
type MatcherFunctionWithState,
type MatcherState,
type Matchers,
expect,
} from 'expect';
import type * as jestMatcherUtils from 'jest-matcher-utils';

type M = Matchers<void, unknown>;
Expand All @@ -24,6 +30,7 @@ type MatcherUtils = typeof jestMatcherUtils & {
subsetEquality: Tester;
};

// TODO `actual` should be allowed to have only `unknown` type
expectType<void>(
expect.extend({
toBeWithinRange(actual: number, floor: number, ceiling: number) {
Expand Down Expand Up @@ -79,3 +86,128 @@ expectType<void>(
bananas: expect.not.toBeWithinRange(11, 20),
}),
);

// MatcherFunction

expectError(() => {
const actualMustBeUnknown: MatcherFunction = (actual: string) => {
return {
message: () => `result: ${actual}`,
pass: actual === 'result',
};
};
});

expectError(() => {
const lacksMessage: MatcherFunction = (actual: unknown) => {
return {
pass: actual === 'result',
};
};
});

expectError(() => {
const lacksPass: MatcherFunction = (actual: unknown) => {
return {
message: () => `result: ${actual}`,
};
};
});

type ToBeWithinRange = (
this: MatcherState,
actual: unknown,
floor: number,
ceiling: number,
) => any;

const toBeWithinRange: MatcherFunction<[floor: number, ceiling: number]> = (
actual: unknown,
floor: unknown,
ceiling: unknown,
) => {
return {
message: () => `actual ${actual}; range ${floor}-${ceiling}`,
pass: true,
};
};

expectAssignable<ToBeWithinRange>(toBeWithinRange);

type AllowOmittingExpected = (this: MatcherState, actual: unknown) => any;

const allowOmittingExpected: MatcherFunction<[]> = (
mrazauskas marked this conversation as resolved.
Show resolved Hide resolved
actual: unknown,
...expect: Array<unknown>
) => {
if (expect.length !== 0) {
throw new Error('This matcher does not take any expected argument.');
}

return {
message: () => `actual ${actual}`,
pass: true,
};
};

expectAssignable<AllowOmittingExpected>(allowOmittingExpected);

// MatcherState

const toHaveContext: MatcherFunction = function (
actual: unknown,
...expect: Array<unknown>
) {
expectType<MatcherState>(this);

if (expect.length !== 0) {
throw new Error('This matcher does not take any expected argument.');
}

return {
message: () => `result: ${actual}`,
pass: actual === 'result',
};
};

interface CustomState extends MatcherState {
customMethod(): void;
}

const customContext: MatcherFunctionWithState<CustomState> = function (
actual: unknown,
...expect: Array<unknown>
) {
expectType<CustomState>(this);
expectType<void>(this.customMethod());

if (expect.length !== 0) {
throw new Error('This matcher does not take any expected argument.');
}

return {
message: () => `result: ${actual}`,
pass: actual === 'result',
};
};

type CustomStateAndExpected = (
this: CustomState,
actual: unknown,
count: number,
) => any;

const customStateAndExpected: MatcherFunctionWithState<
CustomState,
[count: number]
> = function (actual: unknown, count: unknown) {
expectType<CustomState>(this);
expectType<void>(this.customMethod());

return {
message: () => `count: ${count}`,
pass: actual === count,
};
};

expectAssignable<CustomStateAndExpected>(customStateAndExpected);
3 changes: 2 additions & 1 deletion packages/expect/src/index.ts
Expand Up @@ -52,9 +52,10 @@ import type {
export type {
AsymmetricMatchers,
Expect,
MatcherFunction,
MatcherFunctionWithState,
MatcherState,
Matchers,
RawMatcherFn,
} from './types';

export class JestAssertionError extends Error {
Expand Down
33 changes: 22 additions & 11 deletions packages/expect/src/types.ts
Expand Up @@ -20,16 +20,29 @@ export type AsyncExpectationResult = Promise<SyncExpectationResult>;

export type ExpectationResult = SyncExpectationResult | AsyncExpectationResult;

export type RawMatcherFn<T extends MatcherState = MatcherState> = {
(this: T, actual: any, expected: any, options?: any): ExpectationResult;
export type MatcherFunctionWithState<
State extends MatcherState = MatcherState,
Expected extends Array<any> = [] /** TODO should be: extends Array<unknown> = [] */,
> = (this: State, actual: unknown, ...expected: Expected) => ExpectationResult;

export type MatcherFunction<Expected extends Array<unknown> = []> =
MatcherFunctionWithState<MatcherState, Expected>;

// TODO should be replaced with `MatcherFunctionWithContext`
mrazauskas marked this conversation as resolved.
Show resolved Hide resolved
export type RawMatcherFn<State extends MatcherState = MatcherState> = {
(this: State, actual: any, ...expected: Array<any>): ExpectationResult;
/** @internal */
[INTERNAL_MATCHER_FLAG]?: boolean;
};

export type MatchersObject<T extends MatcherState = MatcherState> = {
[name: string]: RawMatcherFn<T>;
};

export type ThrowingMatcherFn = (actual: any) => void;
export type PromiseMatcherFn = (actual: any) => Promise<void>;

export type MatcherState = {
export interface MatcherState {
assertionCalls: number;
currentTestName?: string;
dontThrow?(): void;
Expand All @@ -48,17 +61,15 @@ export type MatcherState = {
iterableEquality: Tester;
subsetEquality: Tester;
};
};
}

export interface AsymmetricMatcher {
asymmetricMatch(other: unknown): boolean;
toString(): string;
getExpectedType?(): string;
toAsymmetricMatcher?(): string;
}
export type MatchersObject<T extends MatcherState = MatcherState> = {
[name: string]: RawMatcherFn<T>;
};
SimenB marked this conversation as resolved.
Show resolved Hide resolved

export type ExpectedAssertionsErrors = Array<{
actual: string | number;
error: Error;
Expand Down Expand Up @@ -189,6 +200,10 @@ export interface Matchers<R extends void | Promise<void>, T = unknown> {
* For comparing floating point numbers.
*/
toBeLessThanOrEqual(expected: number | bigint): R;
/**
* Used to check that a variable is NaN.
*/
toBeNaN(): R;
/**
* This is the same as `.toBe(null)` but the error messages are a bit nicer.
* So use `.toBeNull()` when you want to check that something is null.
Expand All @@ -204,10 +219,6 @@ export interface Matchers<R extends void | Promise<void>, T = unknown> {
* Used to check that a variable is undefined.
*/
toBeUndefined(): R;
/**
* Used to check that a variable is NaN.
*/
toBeNaN(): R;
/**
* Used when you want to check that an item is in a list.
* For testing the items in the list, this uses `===`, a strict equality check.
Expand Down
16 changes: 4 additions & 12 deletions packages/jest-jasmine2/src/jestExpect.ts
Expand Up @@ -7,7 +7,7 @@

/* eslint-disable local/prefer-spread-eventually */

import {type MatcherState, type RawMatcherFn, expect} from 'expect';
import {type MatcherState, expect} from 'expect';
import {
addSerializer,
toMatchInlineSnapshot,
Expand Down Expand Up @@ -41,23 +41,15 @@ export default function jestExpect(config: {expand: boolean}): void {
jestMatchersObject[name] = function (
this: MatcherState,
...args: Array<unknown>
): RawMatcherFn {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function has inferred return type of ExpectationResult without this extra type.

) {
// use "expect.extend" if you need to use equality testers (via this.equal)
const result = jasmineMatchersObject[name](null, null);
// if there is no 'negativeCompare', both should be handled by `compare`
const negativeCompare = result.negativeCompare || result.compare;

return this.isNot
? negativeCompare.apply(
null,
// @ts-expect-error
args,
)
: result.compare.apply(
null,
// @ts-expect-error
args,
);
? negativeCompare.apply(null, args)
: result.compare.apply(null, args);
};
});

Expand Down
6 changes: 3 additions & 3 deletions packages/jest-jasmine2/src/types.ts
Expand Up @@ -7,7 +7,7 @@

import type {AssertionError} from 'assert';
import type {Config} from '@jest/types';
import type {Expect, RawMatcherFn} from 'expect';
import type {Expect} from 'expect';
import type CallTracker from './jasmine/CallTracker';
import type Env from './jasmine/Env';
import type JsApiReporter from './jasmine/JsApiReporter';
Expand Down Expand Up @@ -48,8 +48,8 @@ export interface Spy extends Record<string, any> {

type JasmineMatcher = {
(matchersUtil: unknown, context: unknown): JasmineMatcher;
compare: () => RawMatcherFn;
negativeCompare: () => RawMatcherFn;
compare(...args: Array<unknown>): unknown;
negativeCompare(...args: Array<unknown>): unknown;
mrazauskas marked this conversation as resolved.
Show resolved Hide resolved
};

export type JasmineMatchersObject = {[id: string]: JasmineMatcher};
Expand Down