Skip to content

Commit

Permalink
Extract definitions for expect and mocks from generic test setup to s…
Browse files Browse the repository at this point in the history
…tandalone modules

Summary:
Changelog: [internal]

Just a small refactor to have a better code organization for the testing runtime infra.

Differential Revision: D66753269
  • Loading branch information
rubennorte authored and facebook-github-bot committed Dec 4, 2024
1 parent 5475e6f commit 5180221
Show file tree
Hide file tree
Showing 3 changed files with 314 additions and 258 deletions.
228 changes: 228 additions & 0 deletions jest/integration/runtime/expect.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict-local
* @format
* @oncall react_native
*/

import {ensureMockFunction} from './mocks';
import deepEqual from 'deep-equal';

class ErrorWithCustomBlame extends Error {
// Initially 5 to ignore all the frames from Babel helpers to instantiate this
// custom error class.
#ignoredFrameCount: number = 5;
#cachedProcessedStack: ?string;
#customStack: ?string;

blameToPreviousFrame(): this {
this.#cachedProcessedStack = null;
this.#ignoredFrameCount++;
return this;
}

// $FlowExpectedError[unsafe-getters-setters]
get stack(): string {
if (this.#cachedProcessedStack == null) {
const originalStack = this.#customStack ?? super.stack;

const lines = originalStack.split('\n');
const index = lines.findIndex(line =>
/at (.*) \((.*):(\d+):(\d+)\)/.test(line),
);
lines.splice(index > -1 ? index : 1, this.#ignoredFrameCount);
this.#cachedProcessedStack = lines.join('\n');
}

return this.#cachedProcessedStack;
}

// $FlowExpectedError[unsafe-getters-setters]
set stack(value: string) {
this.#cachedProcessedStack = null;
this.#customStack = value;
}

static fromError(error: Error): ErrorWithCustomBlame {
const errorWithCustomBlame = new ErrorWithCustomBlame(error.message);
// In this case we're inheriting the error and we don't know if the stack
// contains helpers that we need to ignore.
errorWithCustomBlame.#ignoredFrameCount = 0;
errorWithCustomBlame.stack = error.stack;
return errorWithCustomBlame;
}
}

class Expect {
#received: mixed;
#isNot: boolean = false;

constructor(received: mixed) {
this.#received = received;
}

// $FlowExpectedError[unsafe-getters-setters]
get not(): this {
this.#isNot = !this.#isNot;
return this;
}

toEqual(expected: mixed): void {
const pass = deepEqual(this.#received, expected, {strict: true});
if (!this.#isExpectedResult(pass)) {
throw new ErrorWithCustomBlame(
`Expected${this.#maybeNotLabel()} to equal ${String(expected)} but received ${String(this.#received)}.`,
).blameToPreviousFrame();
}
}

toBe(expected: mixed): void {
const pass = this.#received === expected;
if (!this.#isExpectedResult(pass)) {
throw new ErrorWithCustomBlame(
`Expected${this.#maybeNotLabel()} ${String(expected)} but received ${String(this.#received)}.`,
).blameToPreviousFrame();
}
}

toBeInstanceOf(expected: Class<mixed>): void {
const pass = this.#received instanceof expected;
if (!this.#isExpectedResult(pass)) {
throw new ErrorWithCustomBlame(
`expected ${String(this.#received)}${this.#maybeNotLabel()} to be an instance of ${String(expected)}`,
).blameToPreviousFrame();
}
}

toBeCloseTo(expected: number, precision: number = 2): void {
const pass =
Math.abs(expected - Number(this.#received)) < Math.pow(10, -precision);
if (!this.#isExpectedResult(pass)) {
throw new ErrorWithCustomBlame(
`Expected ${String(this.#received)}${this.#maybeNotLabel()} to be close to ${expected}`,
).blameToPreviousFrame();
}
}

toBeNull(): void {
const pass = this.#received == null;
if (!this.#isExpectedResult(pass)) {
throw new ErrorWithCustomBlame(
`Expected ${String(this.#received)}${this.#maybeNotLabel()} to be null`,
).blameToPreviousFrame();
}
}

toThrow(expected?: string): void {
if (expected != null && typeof expected !== 'string') {
throw new ErrorWithCustomBlame(
'toThrow() implementation only accepts strings as arguments.',
).blameToPreviousFrame();
}

let pass = false;
try {
// $FlowExpectedError[not-a-function]
this.#received();
} catch (error) {
pass = expected != null ? error.message === expected : true;
}
if (!this.#isExpectedResult(pass)) {
throw new ErrorWithCustomBlame(
`Expected ${String(this.#received)}${this.#maybeNotLabel()} to throw`,
).blameToPreviousFrame();
}
}

toHaveBeenCalled(): void {
const mock = this.#requireMock();
const pass = mock.calls.length > 0;
if (!this.#isExpectedResult(pass)) {
throw new ErrorWithCustomBlame(
`Expected ${String(this.#received)}${this.#maybeNotLabel()} to have been called, but it was${this.#isNot ? '' : "n't"}`,
).blameToPreviousFrame();
}
}

toHaveBeenCalledTimes(times: number): void {
const mock = this.#requireMock();
const pass = mock.calls.length === times;
if (!this.#isExpectedResult(pass)) {
throw new ErrorWithCustomBlame(
`Expected ${String(this.#received)}${this.#maybeNotLabel()} to have been called ${times} times, but it was called ${mock.calls.length} times`,
).blameToPreviousFrame();
}
}

toBeGreaterThanOrEqual(expected: number): void {
if (typeof this.#received !== 'number') {
throw new ErrorWithCustomBlame(
`Expected ${String(this.#received)} to be a number but it was a ${typeof this.#received}`,
).blameToPreviousFrame();
}

if (typeof expected !== 'number') {
throw new ErrorWithCustomBlame(
`Expected ${String(expected)} to be a number but it was a ${typeof expected}`,
).blameToPreviousFrame();
}

const pass = this.#received >= expected;
if (!this.#isExpectedResult(pass)) {
throw new ErrorWithCustomBlame(
`Expected ${String(this.#received)}${this.#maybeNotLabel()} to be greater than or equal to ${expected}`,
).blameToPreviousFrame();
}
}

toBeLessThanOrEqual(expected: number): void {
if (typeof this.#received !== 'number') {
throw new ErrorWithCustomBlame(
`Expected ${String(this.#received)} to be a number but it was a ${typeof this.#received}`,
).blameToPreviousFrame();
}

if (typeof expected !== 'number') {
throw new ErrorWithCustomBlame(
`Expected ${String(expected)} to be a number but it was a ${typeof expected}`,
).blameToPreviousFrame();
}

const pass = this.#received <= expected;
if (!this.#isExpectedResult(pass)) {
throw new ErrorWithCustomBlame(
`Expected ${String(this.#received)}${this.#maybeNotLabel()} to be less than or equal to ${expected}`,
).blameToPreviousFrame();
}
}

#isExpectedResult(pass: boolean): boolean {
return this.#isNot ? !pass : pass;
}

#maybeNotLabel(): string {
return this.#isNot ? ' not' : '';
}

#requireMock(): JestMockFn<Array<mixed>, mixed>['mock'] {
try {
return ensureMockFunction(this.#received).mock;
} catch (error) {
const errorWithCustomBlame = ErrorWithCustomBlame.fromError(error);
errorWithCustomBlame.message = `Expected ${String(this.#received)} to be a mock function, but it wasn't`;
errorWithCustomBlame
.blameToPreviousFrame() // ignore `ensureMockFunction`
.blameToPreviousFrame() // ignore `requireMock`
.blameToPreviousFrame(); // ignore `expect().[method]`
throw errorWithCustomBlame;
}
}
}

const expect: mixed => Expect = (received: mixed) => new Expect(received);

export default expect;
83 changes: 83 additions & 0 deletions jest/integration/runtime/mocks.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict-local
* @format
* @oncall react_native
*/

export const MOCK_FN_TAG: symbol = Symbol('mock function');

// The type is defined this way because if we get a mixed value, we return
// a generic mock function, and if we get a typed function, we get a typed mock.
export const ensureMockFunction: (<TArgs: Array<mixed>, TReturn>(
fn: (...TArgs) => TReturn,
) => JestMockFn<TArgs, TReturn>) &
((fn: mixed) => JestMockFn<Array<mixed>, mixed>) = fn => {
// $FlowExpectedError[invalid-computed-prop]
// $FlowExpectedError[incompatible-use]
if (typeof fn !== 'function' || !fn[MOCK_FN_TAG]) {
throw new Error(
`Expected ${String(fn)} to be a mock function, but it wasn't`,
);
}

// $FlowExpectedError[incompatible-type]
// $FlowExpectedError[prop-missing]
return fn;
};

export function createMockFunction<TArgs: Array<mixed>, TReturn>(
initialImplementation?: (...TArgs) => TReturn,
): JestMockFn<TArgs, TReturn> {
let implementation: ?(...TArgs) => TReturn = initialImplementation;

const mock: JestMockFn<TArgs, TReturn>['mock'] = {
calls: [],
// $FlowExpectedError[incompatible-type]
lastCall: undefined,
instances: [],
contexts: [],
results: [],
};

const mockFunction = function (this: mixed, ...args: TArgs): TReturn {
let result: JestMockFn<TArgs, TReturn>['mock']['results'][number] = {
isThrow: false,
// $FlowExpectedError[incompatible-type]
value: undefined,
};

if (implementation != null) {
try {
result.value = implementation.apply(this, args);
} catch (error) {
result.isThrow = true;
result.value = error;
}
}

mock.calls.push(args);
mock.lastCall = args;
// $FlowExpectedError[incompatible-call]
mock.instances.push(new.target ? this : undefined);
mock.contexts.push(this);
mock.results.push(result);

if (result.isThrow) {
throw result.value;
}

return result.value;
};

mockFunction.mock = mock;
// $FlowExpectedError[invalid-computed-prop]
mockFunction[MOCK_FN_TAG] = true;

// $FlowExpectedError[prop-missing]
return mockFunction;
}
Loading

0 comments on commit 5180221

Please sign in to comment.