From 508827c9ded625b38cd74a012539aa6740e33118 Mon Sep 17 00:00:00 2001 From: Simen Bekkhus Date: Tue, 5 Oct 2021 17:30:40 +0200 Subject: [PATCH] fix(expect): make types better reflect reality (#11931) --- CHANGELOG.md | 1 + packages/expect/src/asymmetricMatchers.ts | 17 ++++-- packages/expect/src/index.ts | 17 +++--- packages/expect/src/jestMatchersObject.ts | 29 ++++++---- packages/expect/src/types.ts | 64 ++++++++++++++--------- test-types/top-level-globals.test.ts | 34 ++++++++++++ 6 files changed, 118 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 56daf793e58e..e6fc46417d28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixes - `[expect]` Pass matcher context to asymmetric matchers ([#11926](https://github.com/facebook/jest/pull/11926) & [#11930](https://github.com/facebook/jest/pull/11930)) +- `[expect]` Improve TypeScript types ([#11931](https://github.com/facebook/jest/pull/11931)) - `[@jest/types]` Mark deprecated configuration options as `@deprecated` ([#11913](https://github.com/facebook/jest/pull/11913)) - `[jest-cli]` Improve `--help` printout by removing defunct `--browser` option ([#11914](https://github.com/facebook/jest/pull/11914)) - `[jest-haste-map]` Use distinct cache paths for different values of `computeDependencies` ([#11916](https://github.com/facebook/jest/pull/11916)) diff --git a/packages/expect/src/asymmetricMatchers.ts b/packages/expect/src/asymmetricMatchers.ts index 6533d2d3fb47..c8d2dae00625 100644 --- a/packages/expect/src/asymmetricMatchers.ts +++ b/packages/expect/src/asymmetricMatchers.ts @@ -9,7 +9,10 @@ import * as matcherUtils from 'jest-matcher-utils'; import {equals, fnNameFor, hasProperty, isA, isUndefined} from './jasmineUtils'; import {getState} from './jestMatchersObject'; -import type {MatcherState} from './types'; +import type { + AsymmetricMatcher as AsymmetricMatcherInterface, + MatcherState, +} from './types'; import {iterableEquality, subsetEquality} from './utils'; const utils = Object.freeze({ @@ -18,22 +21,28 @@ const utils = Object.freeze({ subsetEquality, }); -export abstract class AsymmetricMatcher { +export abstract class AsymmetricMatcher< + T, + State extends MatcherState = MatcherState, +> implements AsymmetricMatcherInterface +{ $$typeof = Symbol.for('jest.asymmetricMatcher'); constructor(protected sample: T, protected inverse = false) {} - protected getMatcherContext(): MatcherState { + protected getMatcherContext(): State { return { ...getState(), equals, isNot: this.inverse, utils, - }; + } as State; } abstract asymmetricMatch(other: unknown): boolean; abstract toString(): string; + getExpectedType?(): string; + toAsymmetricMatcher?(): string; } class Any extends AsymmetricMatcher { diff --git a/packages/expect/src/index.ts b/packages/expect/src/index.ts index 9b86118551d2..9b779ddfea92 100644 --- a/packages/expect/src/index.ts +++ b/packages/expect/src/index.ts @@ -354,8 +354,9 @@ const makeThrowingMatcher = ( } }; -expect.extend = (matchers: MatchersObject): void => - setMatchers(matchers, false, expect); +expect.extend = ( + matchers: MatchersObject, +): void => setMatchers(matchers, false, expect); expect.anything = anything; expect.any = any; @@ -396,8 +397,10 @@ function assertions(expected: number) { Error.captureStackTrace(error, assertions); } - getState().expectedAssertionsNumber = expected; - getState().expectedAssertionsNumberError = error; + setState({ + expectedAssertionsNumber: expected, + expectedAssertionsNumberError: error, + }); } function hasAssertions(...args: Array) { const error = new Error(); @@ -406,8 +409,10 @@ function hasAssertions(...args: Array) { } matcherUtils.ensureNoExpected(args[0], '.hasAssertions'); - getState().isExpectingAssertions = true; - getState().isExpectingAssertionsError = error; + setState({ + isExpectingAssertions: true, + isExpectingAssertionsError: error, + }); } // add default jest matchers diff --git a/packages/expect/src/jestMatchersObject.ts b/packages/expect/src/jestMatchersObject.ts index c9fade8ae6ed..370802661c30 100644 --- a/packages/expect/src/jestMatchersObject.ts +++ b/packages/expect/src/jestMatchersObject.ts @@ -37,18 +37,21 @@ if (!global.hasOwnProperty(JEST_MATCHERS_OBJECT)) { }); } -export const getState = (): MatcherState => +export const getState = (): State => (global as any)[JEST_MATCHERS_OBJECT].state; -export const setState = (state: Partial): void => { +export const setState = ( + state: Partial, +): void => { Object.assign((global as any)[JEST_MATCHERS_OBJECT].state, state); }; -export const getMatchers = (): MatchersObject => - (global as any)[JEST_MATCHERS_OBJECT].matchers; +export const getMatchers = < + State extends MatcherState = MatcherState, +>(): MatchersObject => (global as any)[JEST_MATCHERS_OBJECT].matchers; -export const setMatchers = ( - matchers: MatchersObject, +export const setMatchers = ( + matchers: MatchersObject, isInternal: boolean, expect: Expect, ): void => { @@ -61,8 +64,14 @@ export const setMatchers = ( if (!isInternal) { // expect is defined - class CustomMatcher extends AsymmetricMatcher<[unknown, unknown]> { - constructor(inverse: boolean = false, ...sample: [unknown, unknown]) { + class CustomMatcher extends AsymmetricMatcher< + [unknown, ...Array], + State + > { + constructor( + inverse: boolean = false, + ...sample: [unknown, ...Array] + ) { super(sample, inverse); } @@ -89,14 +98,14 @@ export const setMatchers = ( } } - expect[key] = (...sample: [unknown, unknown]) => + expect[key] = (...sample: [unknown, ...Array]) => new CustomMatcher(false, ...sample); if (!expect.not) { throw new Error( '`expect.not` is not defined - please report this bug to https://github.com/facebook/jest', ); } - expect.not[key] = (...sample: [unknown, unknown]) => + expect.not[key] = (...sample: [unknown, ...Array]) => new CustomMatcher(true, ...sample); } }); diff --git a/packages/expect/src/types.ts b/packages/expect/src/types.ts index 0828eeca49a3..7642ebdccd62 100644 --- a/packages/expect/src/types.ts +++ b/packages/expect/src/types.ts @@ -21,13 +21,8 @@ export type AsyncExpectationResult = Promise; export type ExpectationResult = SyncExpectationResult | AsyncExpectationResult; -export type RawMatcherFn = { - ( - this: MatcherState, - received: any, - expected: any, - options?: any, - ): ExpectationResult; +export type RawMatcherFn = { + (this: T, received: any, expected: any, options?: any): ExpectationResult; [INTERNAL_MATCHER_FLAG]?: boolean; }; @@ -62,33 +57,54 @@ export type MatcherState = { }; }; -export type AsymmetricMatcher = Record; -export type MatchersObject = {[id: string]: RawMatcherFn}; +export interface AsymmetricMatcher { + asymmetricMatch(other: unknown): boolean; + toString(): string; + getExpectedType?(): string; + toAsymmetricMatcher?(): string; +} +export type MatchersObject = { + [id: string]: RawMatcherFn; +}; export type ExpectedAssertionsErrors = Array<{ actual: string | number; error: Error; expected: string; }>; -export type Expect = { - (actual: T): Matchers; - // TODO: this is added by test runners, not `expect` itself - addSnapshotSerializer(arg0: any): void; - assertions(arg0: number): void; - extend(arg0: any): void; - extractExpectedAssertionsErrors: () => ExpectedAssertionsErrors; - getState(): MatcherState; - hasAssertions(): void; - setState(state: Partial): void; - any(expectedObject: any): AsymmetricMatcher; - anything(): AsymmetricMatcher; +interface InverseAsymmetricMatchers { arrayContaining(sample: Array): AsymmetricMatcher; objectContaining(sample: Record): AsymmetricMatcher; stringContaining(expected: string): AsymmetricMatcher; stringMatching(expected: string | RegExp): AsymmetricMatcher; - [id: string]: AsymmetricMatcher; - not: {[id: string]: AsymmetricMatcher}; -}; +} + +interface AsymmetricMatchers extends InverseAsymmetricMatchers { + any(expectedObject: unknown): AsymmetricMatcher; + anything(): AsymmetricMatcher; +} + +// Should use interface merging somehow +interface ExtraAsymmetricMatchers { + // at least one argument is needed - that's probably wrong. Should allow `expect.toBeDivisibleBy2()` like `expect.anything()` + [id: string]: (...sample: [unknown, ...Array]) => AsymmetricMatcher; +} + +export type Expect = { + (actual: T): Matchers; + // TODO: this is added by test runners, not `expect` itself + addSnapshotSerializer(serializer: unknown): void; + assertions(numberOfAssertions: number): void; + // TODO: remove this `T extends` - should get from some interface merging + extend(matchers: MatchersObject): void; + extractExpectedAssertionsErrors: () => ExpectedAssertionsErrors; + getState(): State; + hasAssertions(): void; + setState(state: Partial): void; +} & AsymmetricMatchers & + ExtraAsymmetricMatchers & { + not: InverseAsymmetricMatchers & ExtraAsymmetricMatchers; + }; interface Constructable { new (...args: Array): unknown; diff --git a/test-types/top-level-globals.test.ts b/test-types/top-level-globals.test.ts index d37807961542..320aa875cfbb 100644 --- a/test-types/top-level-globals.test.ts +++ b/test-types/top-level-globals.test.ts @@ -14,6 +14,7 @@ import { beforeAll, beforeEach, describe, + expect, test, } from '@jest/globals'; import type {Global} from '@jest/types'; @@ -108,3 +109,36 @@ expectType(describe.only.each(testTable)(testName, fn)); expectType(describe.only.each(testTable)(testName, fn, timeout)); expectType(describe.skip.each(testTable)(testName, fn)); expectType(describe.skip.each(testTable)(testName, fn, timeout)); + +/// expect + +expectType(expect(2).toBe(2)); +expectType>(expect(2).resolves.toBe(2)); + +expectType(expect('Hello').toEqual(expect.any(String))); + +// this currently does not error due to `[id: string]` in ExtraAsymmetricMatchers - we should have nothing there and force people to use interface merging +// expectError(expect('Hello').toEqual(expect.not.any(Number))); + +expectType( + expect.extend({ + toBeDivisibleBy(actual: number, expected: number) { + 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}; + }, + }), +); + +// TODO: some way of calling `expect(4).toBeDivisbleBy(2)` and `expect.toBeDivisbleBy(2)`