Skip to content

Commit

Permalink
feat(expect): replace RawMatcherFn with MatcherFunction and `Matc…
Browse files Browse the repository at this point in the history
…herFunctionWithState` types (#12376)
  • Loading branch information
mrazauskas committed Feb 15, 2022
1 parent fefbd1a commit 4318575
Show file tree
Hide file tree
Showing 10 changed files with 261 additions and 119 deletions.
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))
- `[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 = (
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 = (
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`
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>;
};

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 {
) {
// 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;
};

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

0 comments on commit 4318575

Please sign in to comment.