diff --git a/CHANGELOG.md b/CHANGELOG.md index 4366e726c58a..38b949dba350 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ ### 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-environment-jsdom]` Make `jsdom` accessible to extending environments again ([#12232](https://github.com/facebook/jest/pull/12232)) - `[jest-phabricator]` [**BREAKING**] Convert to ESM ([#12341](https://github.com/facebook/jest/pull/12341)) diff --git a/examples/expect-extend/__tests__/ranges.test.ts b/examples/expect-extend/__tests__/ranges.test.ts new file mode 100644 index 000000000000..77f80e8a5ef8 --- /dev/null +++ b/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), + }); +}); diff --git a/examples/expect-extend/package.json b/examples/expect-extend/package.json new file mode 100644 index 000000000000..7ffa1f92b773 --- /dev/null +++ b/examples/expect-extend/package.json @@ -0,0 +1,30 @@ +{ + "private": true, + "version": "0.0.0", + "name": "example-expect-extend", + "devDependencies": { + "@babel/core": "*", + "@babel/preset-env": "*", + "@babel/preset-typescript": "*", + "@jest/globals": "workspace:*", + "babel-jest": "workspace:*", + "expect": "workspace:*", + "jest": "workspace:*" + }, + "scripts": { + "test": "jest" + }, + "babel": { + "presets": [ + [ + "@babel/preset-env", + { + "targets": { + "node": "current" + } + } + ], + "@babel/preset-typescript" + ] + } +} diff --git a/examples/expect-extend/toBeWithinRange.ts b/examples/expect-extend/toBeWithinRange.ts new file mode 100644 index 000000000000..b7d43cddec73 --- /dev/null +++ b/examples/expect-extend/toBeWithinRange.ts @@ -0,0 +1,43 @@ +/** + * 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'; +import type {RawMatcherFn} from 'expect'; + +const toBeWithinRange: RawMatcherFn = ( + 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, + }; + } +}; + +expect.extend({ + toBeWithinRange, +}); + +declare module 'expect' { + interface AsymmetricMatchers { + toBeWithinRange(a: number, b: number): void; + } + interface Matchers { + toBeWithinRange(a: number, b: number): R; + } +} diff --git a/packages/expect/__typetests__/expect.test.ts b/packages/expect/__typetests__/expect.test.ts index af5d2ad1fe21..acd40562f0a0 100644 --- a/packages/expect/__typetests__/expect.test.ts +++ b/packages/expect/__typetests__/expect.test.ts @@ -5,12 +5,77 @@ * LICENSE file in the root directory of this source tree. */ -import {expectError} from 'tsd-lite'; -import type * as expect from 'expect'; +import {expectError, expectType} from 'tsd-lite'; +import type {EqualsFunction, Tester} from '@jest/expect-utils'; +import {type Matchers, expect} from 'expect'; +import type * as jestMatcherUtils from 'jest-matcher-utils'; -type M = expect.Matchers; -type N = expect.Matchers; +type M = Matchers; +type N = Matchers; expectError(() => { - type E = expect.Matchers; + type E = Matchers; }); + +// extend + +type MatcherUtils = typeof jestMatcherUtils & { + iterableEquality: Tester; + subsetEquality: Tester; +}; + +expectType( + expect.extend({ + toBeWithinRange(actual: number, floor: number, ceiling: number) { + expectType(this.assertionCalls); + expectType(this.currentTestName); + expectType<(() => void) | undefined>(this.dontThrow); + expectType(this.error); + expectType(this.equals); + expectType(this.expand); + expectType(this.expectedAssertionsNumber); + expectType(this.expectedAssertionsNumberError); + expectType(this.isExpectingAssertions); + expectType(this.isExpectingAssertionsError); + expectType(this.isNot); + expectType(this.promise); + expectType>(this.suppressedErrors); + expectType(this.testPath); + expectType(this.utils); + + 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 'expect' { + interface AsymmetricMatchers { + toBeWithinRange(floor: number, ceiling: number): void; + } + interface Matchers { + toBeWithinRange(floor: number, ceiling: number): void; + } +} + +expectType(expect(100).toBeWithinRange(90, 110)); +expectType(expect(101).not.toBeWithinRange(0, 100)); + +expectType( + expect({apples: 6, bananas: 3}).toEqual({ + apples: expect.toBeWithinRange(1, 10), + bananas: expect.not.toBeWithinRange(11, 20), + }), +); diff --git a/packages/expect/src/index.ts b/packages/expect/src/index.ts index 7edb89b4d54d..1be8f83c9c0c 100644 --- a/packages/expect/src/index.ts +++ b/packages/expect/src/index.ts @@ -49,7 +49,13 @@ import type { ThrowingMatcherFn, } from './types'; -export type {Expect, MatcherState, Matchers} from './types'; +export type { + AsymmetricMatchers, + Expect, + MatcherState, + Matchers, + RawMatcherFn, +} from './types'; export class JestAssertionError extends Error { matcherResult?: Omit & {message: string}; @@ -358,7 +364,7 @@ const makeThrowingMatcher = ( expect.extend = ( matchers: MatchersObject, -): void => setMatchers(matchers, false, expect); +) => setMatchers(matchers, false, expect); expect.anything = anything; expect.any = any; diff --git a/packages/expect/src/types.ts b/packages/expect/src/types.ts index d9b7f31307fd..9e037c42d9f0 100644 --- a/packages/expect/src/types.ts +++ b/packages/expect/src/types.ts @@ -13,7 +13,7 @@ import {INTERNAL_MATCHER_FLAG} from './jestMatchersObject'; export type SyncExpectationResult = { pass: boolean; - message: () => string; + message(): string; }; export type AsyncExpectationResult = Promise; @@ -21,7 +21,8 @@ export type AsyncExpectationResult = Promise; export type ExpectationResult = SyncExpectationResult | AsyncExpectationResult; export type RawMatcherFn = { - (this: T, received: any, expected: any, options?: any): ExpectationResult; + (this: T, actual: any, expected: any, options?: any): ExpectationResult; + /** @internal */ [INTERNAL_MATCHER_FLAG]?: boolean; }; @@ -31,7 +32,7 @@ export type PromiseMatcherFn = (actual: any) => Promise; export type MatcherState = { assertionCalls: number; currentTestName?: string; - dontThrow?: () => void; + dontThrow?(): void; error?: Error; equals: EqualsFunction; expand?: boolean; @@ -56,7 +57,7 @@ export interface AsymmetricMatcher { toAsymmetricMatcher?(): string; } export type MatchersObject = { - [id: string]: RawMatcherFn; + [name: string]: RawMatcherFn; }; export type ExpectedAssertionsErrors = Array<{ actual: string | number; @@ -73,7 +74,7 @@ export type Expect = { assertions(numberOfAssertions: number): void; // TODO: remove this `T extends` - should get from some interface merging extend(matchers: MatchersObject): void; - extractExpectedAssertionsErrors: () => ExpectedAssertionsErrors; + extractExpectedAssertionsErrors(): ExpectedAssertionsErrors; getState(): State; hasAssertions(): void; setState(state: Partial): void; diff --git a/packages/jest-jasmine2/src/jestExpect.ts b/packages/jest-jasmine2/src/jestExpect.ts index 6269cdfb40b0..836de548e832 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 {MatcherState, expect} from 'expect'; +import {type MatcherState, type RawMatcherFn, expect} from 'expect'; import { addSerializer, toMatchInlineSnapshot, @@ -15,7 +15,7 @@ import { toThrowErrorMatchingInlineSnapshot, toThrowErrorMatchingSnapshot, } from 'jest-snapshot'; -import type {JasmineMatchersObject, RawMatcherFn} from './types'; +import type {JasmineMatchersObject} from './types'; export default function jestExpect(config: {expand: boolean}): void { global.expect = expect; diff --git a/packages/jest-jasmine2/src/types.ts b/packages/jest-jasmine2/src/types.ts index 2d8282a4be6b..34ef38ebddec 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} from 'expect'; +import type {Expect, RawMatcherFn} from 'expect'; import type CallTracker from './jasmine/CallTracker'; import type Env from './jasmine/Env'; import type JsApiReporter from './jasmine/JsApiReporter'; @@ -25,25 +25,6 @@ export interface AssertionErrorWithStack extends AssertionError { stack: string; } -// TODO Add expect types to @jest/types or leave it here -// Borrowed from "expect" -// -------START------- -export type SyncExpectationResult = { - pass: boolean; - message: () => string; -}; - -export type AsyncExpectationResult = Promise; - -export type ExpectationResult = SyncExpectationResult | AsyncExpectationResult; - -export type RawMatcherFn = ( - expected: unknown, - actual: unknown, - options?: unknown, -) => ExpectationResult; -// -------END------- - export type RunDetails = { totalSpecsDefined?: number; failedExpectations?: SuiteResult['failedExpectations']; diff --git a/packages/jest-snapshot/src/index.ts b/packages/jest-snapshot/src/index.ts index 322f4f549453..fd3b7f73fe2f 100644 --- a/packages/jest-snapshot/src/index.ts +++ b/packages/jest-snapshot/src/index.ts @@ -7,6 +7,7 @@ import * as fs from 'graceful-fs'; import type {Config} from '@jest/types'; +import type {RawMatcherFn} from 'expect'; import type {FS as HasteFS} from 'jest-haste-map'; import { BOLD_WEIGHT, @@ -30,7 +31,7 @@ import { printReceived, printSnapshotAndReceived, } from './printSnapshot'; -import type {Context, ExpectationResult, MatchSnapshotConfig} from './types'; +import type {Context, MatchSnapshotConfig} from './types'; import {deepMerge, escapeBacktickString, serialize} from './utils'; export {addSerializer, getSerializers} from './plugins'; @@ -155,12 +156,11 @@ export const cleanup = ( }; }; -export const toMatchSnapshot = function ( - this: Context, +export const toMatchSnapshot: RawMatcherFn = function ( received: unknown, propertiesOrHint?: object | Config.Path, hint?: Config.Path, -): ExpectationResult { +) { const matcherName = 'toMatchSnapshot'; let properties; @@ -214,12 +214,11 @@ export const toMatchSnapshot = function ( }); }; -export const toMatchInlineSnapshot = function ( - this: Context, +export const toMatchInlineSnapshot: RawMatcherFn = function ( received: unknown, propertiesOrSnapshot?: object | string, inlineSnapshot?: string, -): ExpectationResult { +) { const matcherName = 'toMatchInlineSnapshot'; let properties; @@ -408,12 +407,11 @@ const _toMatchSnapshot = (config: MatchSnapshotConfig) => { }; }; -export const toThrowErrorMatchingSnapshot = function ( - this: Context, +export const toThrowErrorMatchingSnapshot: RawMatcherFn = function ( received: unknown, hint: string | undefined, // because error TS1016 for hint?: string fromPromise: boolean, -): ExpectationResult { +) { const matcherName = 'toThrowErrorMatchingSnapshot'; // Future breaking change: Snapshot hint must be a string @@ -431,44 +429,40 @@ export const toThrowErrorMatchingSnapshot = function ( ); }; -export const toThrowErrorMatchingInlineSnapshot = function ( - this: Context, - received: unknown, - inlineSnapshot?: string, - fromPromise?: boolean, -): ExpectationResult { - const matcherName = 'toThrowErrorMatchingInlineSnapshot'; +export const toThrowErrorMatchingInlineSnapshot: RawMatcherFn = + function (received: unknown, inlineSnapshot?: string, fromPromise?: boolean) { + const matcherName = 'toThrowErrorMatchingInlineSnapshot'; - if (inlineSnapshot !== undefined && typeof inlineSnapshot !== 'string') { - const options: MatcherHintOptions = { - expectedColor: noColor, - isNot: this.isNot, - promise: this.promise, - }; + if (inlineSnapshot !== undefined && typeof inlineSnapshot !== 'string') { + const options: MatcherHintOptions = { + expectedColor: noColor, + isNot: this.isNot, + promise: this.promise, + }; - throw new Error( - matcherErrorMessage( - matcherHint(matcherName, undefined, SNAPSHOT_ARG, options), - 'Inline snapshot must be a string', - printWithType('Inline snapshot', inlineSnapshot, serialize), - ), - ); - } + throw new Error( + matcherErrorMessage( + matcherHint(matcherName, undefined, SNAPSHOT_ARG, options), + 'Inline snapshot must be a string', + printWithType('Inline snapshot', inlineSnapshot, serialize), + ), + ); + } - return _toThrowErrorMatchingSnapshot( - { - context: this, - inlineSnapshot: - inlineSnapshot !== undefined - ? stripAddedIndentation(inlineSnapshot) - : undefined, - isInline: true, - matcherName, - received, - }, - fromPromise, - ); -}; + return _toThrowErrorMatchingSnapshot( + { + context: this, + inlineSnapshot: + inlineSnapshot !== undefined + ? stripAddedIndentation(inlineSnapshot) + : undefined, + isInline: true, + matcherName, + received, + }, + fromPromise, + ); + }; const _toThrowErrorMatchingSnapshot = ( config: MatchSnapshotConfig, diff --git a/packages/jest-snapshot/src/types.ts b/packages/jest-snapshot/src/types.ts index 40ddd1cabfaa..dcc0c8e12de5 100644 --- a/packages/jest-snapshot/src/types.ts +++ b/packages/jest-snapshot/src/types.ts @@ -23,9 +23,3 @@ export type MatchSnapshotConfig = { }; export type SnapshotData = Record; - -// copied from `expect` - should be shared -export type ExpectationResult = { - pass: boolean; - message: () => string; -}; diff --git a/packages/jest-types/__typetests__/expect.test.ts b/packages/jest-types/__typetests__/expect.test.ts index 2c380a0c40c8..ece8c523f242 100644 --- a/packages/jest-types/__typetests__/expect.test.ts +++ b/packages/jest-types/__typetests__/expect.test.ts @@ -6,7 +6,9 @@ */ import {expectError, expectType} from 'tsd-lite'; +import type {EqualsFunction, Tester} from '@jest/expect-utils'; import {expect} from '@jest/globals'; +import type * as jestMatcherUtils from 'jest-matcher-utils'; // asymmetric matchers @@ -349,27 +351,63 @@ expectError(expect(jest.fn()).toThrowErrorMatchingInlineSnapshot(true)); // extend +type MatcherUtils = typeof jestMatcherUtils & { + iterableEquality: Tester; + subsetEquality: Tester; +}; + expectType( expect.extend({ - toBeDivisibleBy(actual: number, expected: number) { + toBeWithinRange(actual: number, floor: number, ceiling: number) { + expectType(this.assertionCalls); + expectType(this.currentTestName); + expectType<(() => void) | undefined>(this.dontThrow); + expectType(this.error); + expectType(this.equals); + expectType(this.expand); + expectType(this.expectedAssertionsNumber); + expectType(this.expectedAssertionsNumberError); + expectType(this.isExpectingAssertions); + expectType(this.isExpectingAssertionsError); expectType(this.isNot); - - const pass = actual % expected === 0; - const message = pass - ? () => - `expected ${this.utils.printReceived( - actual, - )} not to be divisible by ${expected}` - : () => - `expected ${this.utils.printReceived( - actual, - )} to be divisible by ${expected}`; - - return {message, pass}; + expectType(this.promise); + expectType>(this.suppressedErrors); + expectType(this.testPath); + expectType(this.utils); + + 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, + }; + } }, }), ); -// TODO -// expect(4).toBeDivisibleBy(2); -// expect.toBeDivisibleBy(2); +declare module 'expect' { + interface AsymmetricMatchers { + toBeWithinRange(floor: number, ceiling: number): void; + } + interface Matchers { + toBeWithinRange(floor: number, ceiling: number): R; + } +} + +expectType(expect(100).toBeWithinRange(90, 110)); +expectType(expect(101).not.toBeWithinRange(0, 100)); + +expectType( + expect({apples: 6, bananas: 3}).toEqual({ + apples: expect.toBeWithinRange(1, 10), + bananas: expect.not.toBeWithinRange(11, 20), + }), +); diff --git a/tsconfig.json b/tsconfig.json index 90c8a266d483..cff2131d1fb2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,11 +1,11 @@ { "extends": "@tsconfig/node12/tsconfig.json", "compilerOptions": { - "declaration": true, "composite": true, + "declaration": true, "emitDeclarationOnly": true, - "isolatedModules": true, "importsNotUsedAsValues": "error", + "stripInternal": true, "strict": true, @@ -19,6 +19,7 @@ "moduleResolution": "node", /* This needs to be false so our types are possible to consume without setting this */ "esModuleInterop": false, + "isolatedModules": true, "skipLibCheck": false, "resolveJsonModule": true } diff --git a/yarn.lock b/yarn.lock index ea0db676e7c1..7f6815a0fea3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9896,6 +9896,20 @@ __metadata: languageName: unknown linkType: soft +"example-expect-extend@workspace:examples/expect-extend": + version: 0.0.0-use.local + resolution: "example-expect-extend@workspace:examples/expect-extend" + dependencies: + "@babel/core": "*" + "@babel/preset-env": "*" + "@babel/preset-typescript": "*" + "@jest/globals": "workspace:*" + babel-jest: "workspace:*" + expect: "workspace:*" + jest: "workspace:*" + languageName: unknown + linkType: soft + "example-getting-started@workspace:examples/getting-started": version: 0.0.0-use.local resolution: "example-getting-started@workspace:examples/getting-started" @@ -10105,7 +10119,7 @@ __metadata: languageName: node linkType: hard -"expect@^28.0.0-alpha.0, expect@workspace:packages/expect": +"expect@^28.0.0-alpha.0, expect@workspace:*, expect@workspace:packages/expect": version: 0.0.0-use.local resolution: "expect@workspace:packages/expect" dependencies: