From 4318575908ab3fe7351c4f55d192e94d8dd85ac1 Mon Sep 17 00:00:00 2001 From: Tom Mrazauskas Date: Tue, 15 Feb 2022 22:10:31 +0200 Subject: [PATCH] feat(expect): replace `RawMatcherFn` with `MatcherFunction` and `MatcherFunctionWithState` types (#12376) --- CHANGELOG.md | 2 +- examples/expect-extend/toBeWithinRange.ts | 18 ++- packages/expect/__typetests__/expect.test.ts | 136 +++++++++++++++- packages/expect/src/index.ts | 3 +- packages/expect/src/types.ts | 33 ++-- packages/jest-jasmine2/src/jestExpect.ts | 16 +- packages/jest-jasmine2/src/types.ts | 6 +- packages/jest-snapshot/src/index.ts | 156 +++++++++---------- packages/jest-snapshot/src/types.ts | 4 +- yarn.lock | 6 +- 10 files changed, 261 insertions(+), 119 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f64017b68461..d1856c8f98ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)) @@ -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)) diff --git a/examples/expect-extend/toBeWithinRange.ts b/examples/expect-extend/toBeWithinRange.ts index b7d43cddec73..55ce710f440c 100644 --- a/examples/expect-extend/toBeWithinRange.ts +++ b/examples/expect-extend/toBeWithinRange.ts @@ -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 { diff --git a/packages/expect/__typetests__/expect.test.ts b/packages/expect/__typetests__/expect.test.ts index acd40562f0a0..6f5d73bcced0 100644 --- a/packages/expect/__typetests__/expect.test.ts +++ b/packages/expect/__typetests__/expect.test.ts @@ -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; @@ -24,6 +30,7 @@ type MatcherUtils = typeof jestMatcherUtils & { subsetEquality: Tester; }; +// TODO `actual` should be allowed to have only `unknown` type expectType( expect.extend({ toBeWithinRange(actual: number, floor: number, ceiling: number) { @@ -79,3 +86,128 @@ expectType( 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); + +type AllowOmittingExpected = (this: MatcherState, actual: unknown) => any; + +const allowOmittingExpected: MatcherFunction = ( + actual: unknown, + ...expect: Array +) => { + if (expect.length !== 0) { + throw new Error('This matcher does not take any expected argument.'); + } + + return { + message: () => `actual ${actual}`, + pass: true, + }; +}; + +expectAssignable(allowOmittingExpected); + +// MatcherState + +const toHaveContext: MatcherFunction = function ( + actual: unknown, + ...expect: Array +) { + expectType(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 = function ( + actual: unknown, + ...expect: Array +) { + expectType(this); + expectType(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(this); + expectType(this.customMethod()); + + return { + message: () => `count: ${count}`, + pass: actual === count, + }; +}; + +expectAssignable(customStateAndExpected); diff --git a/packages/expect/src/index.ts b/packages/expect/src/index.ts index 1be8f83c9c0c..855339906134 100644 --- a/packages/expect/src/index.ts +++ b/packages/expect/src/index.ts @@ -52,9 +52,10 @@ import type { export type { AsymmetricMatchers, Expect, + MatcherFunction, + MatcherFunctionWithState, MatcherState, Matchers, - RawMatcherFn, } from './types'; export class JestAssertionError extends Error { diff --git a/packages/expect/src/types.ts b/packages/expect/src/types.ts index 9e037c42d9f0..94a03e70f127 100644 --- a/packages/expect/src/types.ts +++ b/packages/expect/src/types.ts @@ -20,16 +20,29 @@ export type AsyncExpectationResult = Promise; export type ExpectationResult = SyncExpectationResult | AsyncExpectationResult; -export type RawMatcherFn = { - (this: T, actual: any, expected: any, options?: any): ExpectationResult; +export type MatcherFunctionWithState< + State extends MatcherState = MatcherState, + Expected extends Array = [] /** TODO should be: extends Array = [] */, +> = (this: State, actual: unknown, ...expected: Expected) => ExpectationResult; + +export type MatcherFunction = []> = + MatcherFunctionWithState; + +// TODO should be replaced with `MatcherFunctionWithContext` +export type RawMatcherFn = { + (this: State, actual: any, ...expected: Array): ExpectationResult; /** @internal */ [INTERNAL_MATCHER_FLAG]?: boolean; }; +export type MatchersObject = { + [name: string]: RawMatcherFn; +}; + export type ThrowingMatcherFn = (actual: any) => void; export type PromiseMatcherFn = (actual: any) => Promise; -export type MatcherState = { +export interface MatcherState { assertionCalls: number; currentTestName?: string; dontThrow?(): void; @@ -48,7 +61,7 @@ export type MatcherState = { iterableEquality: Tester; subsetEquality: Tester; }; -}; +} export interface AsymmetricMatcher { asymmetricMatch(other: unknown): boolean; @@ -56,9 +69,7 @@ export interface AsymmetricMatcher { getExpectedType?(): string; toAsymmetricMatcher?(): string; } -export type MatchersObject = { - [name: string]: RawMatcherFn; -}; + export type ExpectedAssertionsErrors = Array<{ actual: string | number; error: Error; @@ -189,6 +200,10 @@ export interface Matchers, 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. @@ -204,10 +219,6 @@ export interface Matchers, 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. diff --git a/packages/jest-jasmine2/src/jestExpect.ts b/packages/jest-jasmine2/src/jestExpect.ts index 836de548e832..579b6a7de605 100644 --- a/packages/jest-jasmine2/src/jestExpect.ts +++ b/packages/jest-jasmine2/src/jestExpect.ts @@ -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, @@ -41,23 +41,15 @@ export default function jestExpect(config: {expand: boolean}): void { jestMatchersObject[name] = function ( this: MatcherState, ...args: Array - ): 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); }; }); diff --git a/packages/jest-jasmine2/src/types.ts b/packages/jest-jasmine2/src/types.ts index 34ef38ebddec..c444f8e77a98 100644 --- a/packages/jest-jasmine2/src/types.ts +++ b/packages/jest-jasmine2/src/types.ts @@ -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'; @@ -48,8 +48,8 @@ export interface Spy extends Record { type JasmineMatcher = { (matchersUtil: unknown, context: unknown): JasmineMatcher; - compare: () => RawMatcherFn; - negativeCompare: () => RawMatcherFn; + compare(...args: Array): unknown; + negativeCompare(...args: Array): unknown; }; export type JasmineMatchersObject = {[id: string]: JasmineMatcher}; diff --git a/packages/jest-snapshot/src/index.ts b/packages/jest-snapshot/src/index.ts index fd3b7f73fe2f..5af2cbdf69e7 100644 --- a/packages/jest-snapshot/src/index.ts +++ b/packages/jest-snapshot/src/index.ts @@ -7,7 +7,7 @@ import * as fs from 'graceful-fs'; import type {Config} from '@jest/types'; -import type {RawMatcherFn} from 'expect'; +import type {MatcherFunctionWithState} from 'expect'; import type {FS as HasteFS} from 'jest-haste-map'; import { BOLD_WEIGHT, @@ -156,7 +156,7 @@ export const cleanup = ( }; }; -export const toMatchSnapshot: RawMatcherFn = function ( +export const toMatchSnapshot: MatcherFunctionWithState = function ( received: unknown, propertiesOrHint?: object | Config.Path, hint?: Config.Path, @@ -214,69 +214,70 @@ export const toMatchSnapshot: RawMatcherFn = function ( }); }; -export const toMatchInlineSnapshot: RawMatcherFn = function ( - received: unknown, - propertiesOrSnapshot?: object | string, - inlineSnapshot?: string, -) { - const matcherName = 'toMatchInlineSnapshot'; - let properties; +export const toMatchInlineSnapshot: MatcherFunctionWithState = + function ( + received: unknown, + propertiesOrSnapshot?: object | string, + inlineSnapshot?: string, + ) { + const matcherName = 'toMatchInlineSnapshot'; + let properties; + + const length = arguments.length; + if (length === 2 && typeof propertiesOrSnapshot === 'string') { + inlineSnapshot = propertiesOrSnapshot; + } else if (length >= 2) { + const options: MatcherHintOptions = { + isNot: this.isNot, + promise: this.promise, + }; + if (length === 3) { + options.secondArgument = SNAPSHOT_ARG; + options.secondArgumentColor = noColor; + } - const length = arguments.length; - if (length === 2 && typeof propertiesOrSnapshot === 'string') { - inlineSnapshot = propertiesOrSnapshot; - } else if (length >= 2) { - const options: MatcherHintOptions = { - isNot: this.isNot, - promise: this.promise, - }; - if (length === 3) { - options.secondArgument = SNAPSHOT_ARG; - options.secondArgumentColor = noColor; - } + if ( + typeof propertiesOrSnapshot !== 'object' || + propertiesOrSnapshot === null + ) { + throw new Error( + matcherErrorMessage( + matcherHint(matcherName, undefined, PROPERTIES_ARG, options), + `Expected ${EXPECTED_COLOR('properties')} must be an object`, + printWithType( + 'Expected properties', + propertiesOrSnapshot, + printExpected, + ), + ), + ); + } - if ( - typeof propertiesOrSnapshot !== 'object' || - propertiesOrSnapshot === null - ) { - throw new Error( - matcherErrorMessage( - matcherHint(matcherName, undefined, PROPERTIES_ARG, options), - `Expected ${EXPECTED_COLOR('properties')} must be an object`, - printWithType( - 'Expected properties', - propertiesOrSnapshot, - printExpected, + if (length === 3 && typeof inlineSnapshot !== 'string') { + throw new Error( + matcherErrorMessage( + matcherHint(matcherName, undefined, PROPERTIES_ARG, options), + 'Inline snapshot must be a string', + printWithType('Inline snapshot', inlineSnapshot, serialize), ), - ), - ); - } + ); + } - if (length === 3 && typeof inlineSnapshot !== 'string') { - throw new Error( - matcherErrorMessage( - matcherHint(matcherName, undefined, PROPERTIES_ARG, options), - 'Inline snapshot must be a string', - printWithType('Inline snapshot', inlineSnapshot, serialize), - ), - ); + properties = propertiesOrSnapshot; } - properties = propertiesOrSnapshot; - } - - return _toMatchSnapshot({ - context: this, - inlineSnapshot: - inlineSnapshot !== undefined - ? stripAddedIndentation(inlineSnapshot) - : undefined, - isInline: true, - matcherName, - properties, - received, - }); -}; + return _toMatchSnapshot({ + context: this, + inlineSnapshot: + inlineSnapshot !== undefined + ? stripAddedIndentation(inlineSnapshot) + : undefined, + isInline: true, + matcherName, + properties, + received, + }); + }; const _toMatchSnapshot = (config: MatchSnapshotConfig) => { const {context, hint, inlineSnapshot, isInline, matcherName, properties} = @@ -407,29 +408,26 @@ const _toMatchSnapshot = (config: MatchSnapshotConfig) => { }; }; -export const toThrowErrorMatchingSnapshot: RawMatcherFn = function ( - received: unknown, - hint: string | undefined, // because error TS1016 for hint?: string - fromPromise: boolean, -) { - const matcherName = 'toThrowErrorMatchingSnapshot'; +export const toThrowErrorMatchingSnapshot: MatcherFunctionWithState = + function (received: unknown, hint?: string, fromPromise?: boolean) { + const matcherName = 'toThrowErrorMatchingSnapshot'; - // Future breaking change: Snapshot hint must be a string - // if (hint !== undefined && typeof hint !== string) {} + // Future breaking change: Snapshot hint must be a string + // if (hint !== undefined && typeof hint !== string) {} - return _toThrowErrorMatchingSnapshot( - { - context: this, - hint, - isInline: false, - matcherName, - received, - }, - fromPromise, - ); -}; + return _toThrowErrorMatchingSnapshot( + { + context: this, + hint, + isInline: false, + matcherName, + received, + }, + fromPromise, + ); + }; -export const toThrowErrorMatchingInlineSnapshot: RawMatcherFn = +export const toThrowErrorMatchingInlineSnapshot: MatcherFunctionWithState = function (received: unknown, inlineSnapshot?: string, fromPromise?: boolean) { const matcherName = 'toThrowErrorMatchingInlineSnapshot'; diff --git a/packages/jest-snapshot/src/types.ts b/packages/jest-snapshot/src/types.ts index dcc0c8e12de5..0013a08d081f 100644 --- a/packages/jest-snapshot/src/types.ts +++ b/packages/jest-snapshot/src/types.ts @@ -8,9 +8,9 @@ import type {MatcherState} from 'expect'; import type SnapshotState from './State'; -export type Context = MatcherState & { +export interface Context extends MatcherState { snapshotState: SnapshotState; -}; +} export type MatchSnapshotConfig = { context: Context; diff --git a/yarn.lock b/yarn.lock index 12c26d529683..a842ef679930 100644 --- a/yarn.lock +++ b/yarn.lock @@ -21283,11 +21283,11 @@ __metadata: linkType: hard "tsd-lite@npm:^0.5.0, tsd-lite@npm:^0.5.1": - version: 0.5.2 - resolution: "tsd-lite@npm:0.5.2" + version: 0.5.3 + resolution: "tsd-lite@npm:0.5.3" peerDependencies: "@tsd/typescript": ^3.8.3 || ^4.0.7 - checksum: e3e49a4b660149bc2056154db0b1f42d636826e9b7a1c59f4790efd6f5b3d158ab2b9c996bf4362688b41b479d9ae99ec19b09dc35211c07ccb0d0f41f006a0b + checksum: b1a5ffcd584b66e34173f0182d4cc7b2c5a145655346f9014318dd16a7ab3418d462df1cfa13923f79628e7900e5b8992c1210f59492adc9b7ef242b2026ab14 languageName: node linkType: hard