Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(expect): make types better reflect reality #11931

Merged
merged 2 commits into from Oct 5, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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)`