Skip to content

Commit

Permalink
fix(expect): make types better reflect reality (#11931)
Browse files Browse the repository at this point in the history
  • Loading branch information
SimenB committed Oct 5, 2021
1 parent 02df7d3 commit 508827c
Show file tree
Hide file tree
Showing 6 changed files with 118 additions and 44 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Expand Up @@ -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))
Expand Down
17 changes: 13 additions & 4 deletions packages/expect/src/asymmetricMatchers.ts
Expand Up @@ -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({
Expand All @@ -18,22 +21,28 @@ const utils = Object.freeze({
subsetEquality,
});

export abstract class AsymmetricMatcher<T> {
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<any> {
Expand Down
17 changes: 11 additions & 6 deletions packages/expect/src/index.ts
Expand Up @@ -354,8 +354,9 @@ const makeThrowingMatcher = (
}
};

expect.extend = (matchers: MatchersObject): void =>
setMatchers(matchers, false, expect);
expect.extend = <T extends JestMatcherState = JestMatcherState>(
matchers: MatchersObject<T>,
): void => setMatchers(matchers, false, expect);

expect.anything = anything;
expect.any = any;
Expand Down Expand Up @@ -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<any>) {
const error = new Error();
Expand All @@ -406,8 +409,10 @@ function hasAssertions(...args: Array<any>) {
}

matcherUtils.ensureNoExpected(args[0], '.hasAssertions');
getState().isExpectingAssertions = true;
getState().isExpectingAssertionsError = error;
setState({
isExpectingAssertions: true,
isExpectingAssertionsError: error,
});
}

// add default jest matchers
Expand Down
29 changes: 19 additions & 10 deletions packages/expect/src/jestMatchersObject.ts
Expand Up @@ -37,18 +37,21 @@ if (!global.hasOwnProperty(JEST_MATCHERS_OBJECT)) {
});
}

export const getState = (): MatcherState =>
export const getState = <State extends MatcherState = MatcherState>(): State =>
(global as any)[JEST_MATCHERS_OBJECT].state;

export const setState = (state: Partial<MatcherState>): void => {
export const setState = <State extends MatcherState = MatcherState>(
state: Partial<State>,
): 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<State> => (global as any)[JEST_MATCHERS_OBJECT].matchers;

export const setMatchers = (
matchers: MatchersObject,
export const setMatchers = <State extends MatcherState = MatcherState>(
matchers: MatchersObject<State>,
isInternal: boolean,
expect: Expect,
): void => {
Expand All @@ -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<unknown>],
State
> {
constructor(
inverse: boolean = false,
...sample: [unknown, ...Array<unknown>]
) {
super(sample, inverse);
}

Expand All @@ -89,14 +98,14 @@ export const setMatchers = (
}
}

expect[key] = (...sample: [unknown, unknown]) =>
expect[key] = (...sample: [unknown, ...Array<unknown>]) =>
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<unknown>]) =>
new CustomMatcher(true, ...sample);
}
});
Expand Down
64 changes: 40 additions & 24 deletions packages/expect/src/types.ts
Expand Up @@ -21,13 +21,8 @@ export type AsyncExpectationResult = Promise<SyncExpectationResult>;

export type ExpectationResult = SyncExpectationResult | AsyncExpectationResult;

export type RawMatcherFn = {
(
this: MatcherState,
received: any,
expected: any,
options?: any,
): ExpectationResult;
export type RawMatcherFn<T extends MatcherState = MatcherState> = {
(this: T, received: any, expected: any, options?: any): ExpectationResult;
[INTERNAL_MATCHER_FLAG]?: boolean;
};

Expand Down Expand Up @@ -62,33 +57,54 @@ export type MatcherState = {
};
};

export type AsymmetricMatcher = Record<string, any>;
export type MatchersObject = {[id: string]: RawMatcherFn};
export interface AsymmetricMatcher {
asymmetricMatch(other: unknown): boolean;
toString(): string;
getExpectedType?(): string;
toAsymmetricMatcher?(): string;
}
export type MatchersObject<T extends MatcherState = MatcherState> = {
[id: string]: RawMatcherFn<T>;
};
export type ExpectedAssertionsErrors = Array<{
actual: string | number;
error: Error;
expected: string;
}>;
export type Expect = {
<T = unknown>(actual: T): Matchers<T>;
// 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<MatcherState>): void;

any(expectedObject: any): AsymmetricMatcher;
anything(): AsymmetricMatcher;
interface InverseAsymmetricMatchers {
arrayContaining(sample: Array<unknown>): AsymmetricMatcher;
objectContaining(sample: Record<string, unknown>): 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<unknown>]) => AsymmetricMatcher;
}

export type Expect<State extends MatcherState = MatcherState> = {
<T = unknown>(actual: T): Matchers<void>;
// 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<T extends MatcherState = State>(matchers: MatchersObject<T>): void;
extractExpectedAssertionsErrors: () => ExpectedAssertionsErrors;
getState(): State;
hasAssertions(): void;
setState(state: Partial<State>): void;
} & AsymmetricMatchers &
ExtraAsymmetricMatchers & {
not: InverseAsymmetricMatchers & ExtraAsymmetricMatchers;
};

interface Constructable {
new (...args: Array<unknown>): unknown;
Expand Down
34 changes: 34 additions & 0 deletions test-types/top-level-globals.test.ts
Expand Up @@ -14,6 +14,7 @@ import {
beforeAll,
beforeEach,
describe,
expect,
test,
} from '@jest/globals';
import type {Global} from '@jest/types';
Expand Down Expand Up @@ -108,3 +109,36 @@ expectType<void>(describe.only.each(testTable)(testName, fn));
expectType<void>(describe.only.each(testTable)(testName, fn, timeout));
expectType<void>(describe.skip.each(testTable)(testName, fn));
expectType<void>(describe.skip.each(testTable)(testName, fn, timeout));

/// expect

expectType<void>(expect(2).toBe(2));
expectType<Promise<void>>(expect(2).resolves.toBe(2));

expectType<void>(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<void>(
expect.extend({
toBeDivisibleBy(actual: number, expected: number) {
expectType<boolean>(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)`

0 comments on commit 508827c

Please sign in to comment.