From 47d03e8fc6e0b3d9f2864280d9a1e1156e8d15dc Mon Sep 17 00:00:00 2001 From: Andrew Datsenko Date: Thu, 23 Jan 2025 07:30:32 -0800 Subject: [PATCH] Add support for (before|after)(Each|All) methods (#48820) Summary: Changelog: [Internal] Add capability to setup / teardown tests Reviewed By: rubennorte Differential Revision: D68454176 --- packages/react-native-fantom/runtime/setup.js | 307 ++++++++++++++---- .../__tests__/setup/focused-hooks-itest.js | 83 +++++ .../setup/order-of-execution-itest.js | 51 +++ .../setup/order-of-hooks-executions-itest.js | 48 +++ .../src/__tests__/setup/scoping-itest.js | 48 +++ 5 files changed, 479 insertions(+), 58 deletions(-) create mode 100644 packages/react-native-fantom/src/__tests__/setup/focused-hooks-itest.js create mode 100644 packages/react-native-fantom/src/__tests__/setup/order-of-execution-itest.js create mode 100644 packages/react-native-fantom/src/__tests__/setup/order-of-hooks-executions-itest.js create mode 100644 packages/react-native-fantom/src/__tests__/setup/scoping-itest.js diff --git a/packages/react-native-fantom/runtime/setup.js b/packages/react-native-fantom/runtime/setup.js index 740f6e20288e65..c19e4ee234da11 100644 --- a/packages/react-native-fantom/runtime/setup.js +++ b/packages/react-native-fantom/runtime/setup.js @@ -14,7 +14,6 @@ import type {SnapshotConfig, TestSnapshotResults} from './snapshotContext'; import expect from './expect'; import {createMockFunction} from './mocks'; import {setupSnapshotConfig, snapshotContext} from './snapshotContext'; -import nullthrows from 'nullthrows'; import NativeFantom from 'react-native/src/private/specs/modules/NativeFantom'; export type TestCaseResult = { @@ -40,16 +39,43 @@ export type TestSuiteResult = }, }; -const tests: Array<{ +type FocusState = { + focused: boolean, + skipped: boolean, +}; + +type Spec = { + ...FocusState, title: string, - ancestorTitles: Array, + parentContext: Context, implementation: () => mixed, - isFocused: boolean, - isSkipped: boolean, - result?: TestCaseResult, -}> = []; +}; + +type Suite = Spec | Context; -const ancestorTitles: Array = []; +type Hook = () => void; + +type Context = { + ...FocusState, + title?: string, + afterAllHooks: Hook[], + afterEachHooks: Hook[], + beforeAllHooks: Hook[], + beforeEachHooks: Hook[], + parentContext?: Context, + children: Array, +}; + +const rootContext: Context = { + beforeAllHooks: [], + beforeEachHooks: [], + afterAllHooks: [], + afterEachHooks: [], + children: [], + focused: false, + skipped: false, +}; +let currentContext: Context = rootContext; const globalModifiers: Array<'focused' | 'skipped'> = []; @@ -57,26 +83,64 @@ const globalDescribe = (global.describe = ( title: string, implementation: () => mixed, ) => { - ancestorTitles.push(title); + const parentContext = currentContext; + const {focused, skipped} = getFocusState(); + const childContext: Context = { + title, + parentContext, + afterAllHooks: [], + afterEachHooks: [], + beforeAllHooks: [], + beforeEachHooks: [], + children: [], + focused: focused, + skipped: skipped, + }; + currentContext.children.push(childContext); + currentContext = childContext; implementation(); - ancestorTitles.pop(); + currentContext = parentContext; }); +global.afterAll = (implementation: () => void) => { + currentContext.afterAllHooks.push(implementation); +}; + +global.afterEach = (implementation: () => void) => { + currentContext.afterEachHooks.push(implementation); +}; + +global.beforeAll = (implementation: () => void) => { + currentContext.beforeAllHooks.push(implementation); +}; + +global.beforeEach = (implementation: () => void) => { + currentContext.beforeEachHooks.push(implementation); +}; + +function getFocusState(): {focused: boolean, skipped: boolean} { + const focused = + globalModifiers.length > 0 && + globalModifiers[globalModifiers.length - 1] === 'focused'; + const skipped = + globalModifiers.length > 0 && + globalModifiers[globalModifiers.length - 1] === 'skipped'; + return {focused, skipped}; +} + const globalIt = (global.it = global.test = - (title: string, implementation: () => mixed) => - tests.push({ + (title: string, implementation: () => mixed) => { + const {focused, skipped} = getFocusState(); + currentContext.children.push({ title, + parentContext: currentContext, implementation, - ancestorTitles: ancestorTitles.slice(), - isFocused: - globalModifiers.length > 0 && - globalModifiers[globalModifiers.length - 1] === 'focused', - isSkipped: - globalModifiers.length > 0 && - globalModifiers[globalModifiers.length - 1] === 'skipped', - })); + focused: focused, + skipped: skipped, + }); + }); // $FlowExpectedError[prop-missing] global.fdescribe = global.describe.only = ( @@ -142,52 +206,177 @@ function runWithGuard(fn: () => void) { } } -function executeTests() { - const hasFocusedTests = tests.some(test => test.isFocused); - - for (const test of tests) { - const result: TestCaseResult = { - title: test.title, - fullName: [...test.ancestorTitles, test.title].join(' '), - ancestorTitles: test.ancestorTitles, - status: 'pending', - duration: 0, - failureMessages: [], - numPassingAsserts: 0, - snapshotResults: {}, - }; +const focusCache = new Map(); + +function isFocusedSuite(suite: Suite): boolean { + const cached = focusCache.get(suite); + if (cached != null) { + return cached; + } + + if (isSkipped(suite)) { + focusCache.set(suite, false); + return false; + } + + if ('children' in suite) { + const hasFocused = suite.children.some(isFocusedSuite); + focusCache.set(suite, hasFocused); + return hasFocused; + } + + focusCache.set(suite, suite.focused); + return suite.focused; +} - test.result = result; - snapshotContext.setTargetTest(result.fullName); +const skippedCache = new Map(); +function isSkipped(suite: Suite): boolean { + const cached = skippedCache.get(suite); + if (cached != null) { + return cached; + } - if (!test.isSkipped && (!hasFocusedTests || test.isFocused)) { - let status; - let error; + if (suite.skipped) { + skippedCache.set(suite, true); + return true; + } - const start = Date.now(); + if (suite.parentContext != null) { + const skipped = isSkipped(suite.parentContext); + skippedCache.set(suite, skipped); + return skipped; + } + + skippedCache.set(suite, false); + return false; +} - try { - test.implementation(); - status = 'passed'; - } catch (e) { - error = e; - status = 'failed'; - } +function getContextTitle(context: Context): string[] { + if (context.parentContext == null) { + return []; + } - result.status = status; - result.duration = Date.now() - start; - result.failureMessages = - status === 'failed' && error - ? [error.stack ?? error.message ?? String(error)] - : []; + const titles = context.title != null ? [context.title] : []; + if (context.parentContext) { + titles.push(...getContextTitle(context.parentContext)); + } + return titles.reverse(); +} - result.snapshotResults = snapshotContext.getSnapshotResults(); +function invokeHooks( + context: Context, + hookType: 'beforeEachHooks' | 'afterEachHooks', +) { + const contextStack = []; + let current: ?Context = context; + while (current != null) { + if (hookType === 'beforeEachHooks') { + contextStack.unshift(current); + } else { + contextStack.push(current); } + current = current.parentContext; } - reportTestSuiteResult({ - testResults: tests.map(test => nullthrows(test.result)), - }); + for (const c of contextStack) { + for (const hook of c[hookType]) { + hook(); + } + } +} + +function shouldRunSuite(suite: Suite): boolean { + if (isSkipped(suite)) { + return false; + } + + if (isFocusedSuite(suite)) { + return true; + } + + // there is a focused suite in the root at some point + // but not in this suite hence we should not run it + if (isFocusedSuite(rootContext)) { + return false; + } + + return true; +} + +function runSpec(spec: Spec): TestCaseResult { + const ancestorTitles = getContextTitle(spec.parentContext); + const result: TestCaseResult = { + title: spec.title, + ancestorTitles, + fullName: [...ancestorTitles, spec.title].join(' '), + status: 'pending', + duration: 0, + failureMessages: [], + numPassingAsserts: 0, + snapshotResults: {}, + }; + + if (!shouldRunSuite(spec)) { + return result; + } + + let status; + let error; + + const start = Date.now(); + snapshotContext.setTargetTest(result.fullName); + + try { + invokeHooks(spec.parentContext, 'beforeEachHooks'); + spec.implementation(); + invokeHooks(spec.parentContext, 'afterEachHooks'); + + status = 'passed'; + } catch (e) { + error = e; + status = 'failed'; + } + + result.status = status; + result.duration = Date.now() - start; + result.failureMessages = + status === 'failed' && error + ? [error.stack ?? error.message ?? String(error)] + : []; + + result.snapshotResults = snapshotContext.getSnapshotResults(); + return result; +} + +function runContext(context: Context): TestCaseResult[] { + const shouldRunHooks = shouldRunSuite(context); + + if (shouldRunHooks) { + for (const beforeAllHook of context.beforeAllHooks) { + beforeAllHook(); + } + } + + const testResults: TestCaseResult[] = []; + for (const child of context.children) { + testResults.push(...runSuite(child)); + } + + if (shouldRunHooks) { + for (const afterAllHook of context.afterAllHooks) { + afterAllHook(); + } + } + + return testResults; +} + +function runSuite(suite: Suite): TestCaseResult[] { + if ('children' in suite) { + return runContext(suite); + } else { + return [runSpec(suite)]; + } } function reportTestSuiteResult(testSuiteResult: TestSuiteResult): void { @@ -200,7 +389,9 @@ function reportTestSuiteResult(testSuiteResult: TestSuiteResult): void { } global.$$RunTests$$ = () => { - executeTests(); + reportTestSuiteResult({ + testResults: runSuite(currentContext), + }); }; export function registerTest( diff --git a/packages/react-native-fantom/src/__tests__/setup/focused-hooks-itest.js b/packages/react-native-fantom/src/__tests__/setup/focused-hooks-itest.js new file mode 100644 index 00000000000000..6e097bdf793c0c --- /dev/null +++ b/packages/react-native-fantom/src/__tests__/setup/focused-hooks-itest.js @@ -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 + */ + +const logs: string[] = []; + +afterAll(() => { + expect(logs).toEqual([ + '1 - beforeAll', + '1.1 - beforeAll', + '1.1 - test 1', + '1.1 - afterAll', + '1 - test 2', + '1 - afterAll', + ]); +}); + +describe('1', () => { + beforeAll(() => { + logs.push('1 - beforeAll'); + }); + + afterAll(() => { + logs.push('1 - afterAll'); + }); + + describe('1.1', () => { + beforeAll(() => { + // this is what we want to test + logs.push('1.1 - beforeAll'); + }); + + afterAll(() => { + // this is what we want to test + logs.push('1.1 - afterAll'); + }); + + // this is part of the test suite + // eslint-disable-next-line jest/no-focused-tests + it.only('1.1 - test 1', () => { + logs.push('1.1 - test 1'); + }); + + it('1.1 - test 2', () => { + logs.push('1.1 - test 2'); + }); + }); + + // this is part of the test suite + // eslint-disable-next-line jest/no-disabled-tests + describe.skip('1.2', () => { + beforeAll(() => { + logs.push('1.2 - beforeAll'); + }); + + // this is part of the test suite + // eslint-disable-next-line jest/no-focused-tests + it.only('1.2 - test 1', () => { + logs.push('1.2 - test 1'); + }); + + it('1.2 - test 2', () => { + logs.push('1.2 - test 2'); + }); + }); + + it('1 - test 1', () => { + logs.push('1 - test 1'); + }); + + // this is part of the test suite + // eslint-disable-next-line jest/no-focused-tests + it.only('1 - test 2', () => { + logs.push('1 - test 2'); + }); +}); diff --git a/packages/react-native-fantom/src/__tests__/setup/order-of-execution-itest.js b/packages/react-native-fantom/src/__tests__/setup/order-of-execution-itest.js new file mode 100644 index 00000000000000..5e097eb85f614c --- /dev/null +++ b/packages/react-native-fantom/src/__tests__/setup/order-of-execution-itest.js @@ -0,0 +1,51 @@ +/** + * 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 + */ + +const logs: string[] = []; +const log = (message: string): void => { + logs.push(message); +}; + +afterAll(() => { + // Source https://jestjs.io/docs/setup-teardown#order-of-execution + expect(logs).toEqual([ + 'describe outer-a', + 'describe inner 1', + 'describe outer-b', + 'describe inner 2', + 'describe outer-c', + 'test 1', + 'test 2', + 'test 3', + ]); +}); + +describe('describe outer', () => { + log('describe outer-a'); + + describe('describe inner 1', () => { + log('describe inner 1'); + + test('test 1', () => log('test 1')); + }); + + log('describe outer-b'); + + test('test 2', () => log('test 2')); + + describe('describe inner 2', () => { + log('describe inner 2'); + + test('test 3', () => log('test 3')); + }); + + log('describe outer-c'); +}); diff --git a/packages/react-native-fantom/src/__tests__/setup/order-of-hooks-executions-itest.js b/packages/react-native-fantom/src/__tests__/setup/order-of-hooks-executions-itest.js new file mode 100644 index 00000000000000..5ad0a9e8eb8b5e --- /dev/null +++ b/packages/react-native-fantom/src/__tests__/setup/order-of-hooks-executions-itest.js @@ -0,0 +1,48 @@ +/** + * 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 + */ + +const logs: string[] = []; +const log = (message: string): void => { + logs.push(message); +}; + +beforeEach(() => log('connection setup')); +beforeEach(() => log('database setup')); + +afterEach(() => log('database teardown')); +afterEach(() => log('connection teardown')); +afterAll(() => { + // Source https://jestjs.io/docs/setup-teardown#order-of-execution + expect(logs).toEqual([ + 'connection setup', + 'database setup', + 'test 1', + 'database teardown', + 'connection teardown', + + 'connection setup', + 'database setup', + 'extra database setup', + 'test 2', + 'extra database teardown', + 'database teardown', + 'connection teardown', + ]); +}); + +test('test 1', () => log('test 1')); + +describe('extra', () => { + beforeEach(() => log('extra database setup')); + afterEach(() => log('extra database teardown')); + + test('test 2', () => log('test 2')); +}); diff --git a/packages/react-native-fantom/src/__tests__/setup/scoping-itest.js b/packages/react-native-fantom/src/__tests__/setup/scoping-itest.js new file mode 100644 index 00000000000000..5b9f723fa98793 --- /dev/null +++ b/packages/react-native-fantom/src/__tests__/setup/scoping-itest.js @@ -0,0 +1,48 @@ +/** + * 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 + */ + +const logs: string[] = []; +const log = (message: string): void => { + logs.push(message); +}; + +beforeAll(() => log('1 - beforeAll')); +afterAll(() => { + log('1 - afterAll'); + // Source https://jestjs.io/docs/setup-teardown#scoping + expect(logs).toEqual([ + '1 - beforeAll', + '1 - beforeEach', + '1 - test', + '1 - afterEach', + '2 - beforeAll', + '1 - beforeEach', + '2 - beforeEach', + '2 - test', + '2 - afterEach', + '1 - afterEach', + '2 - afterAll', + '1 - afterAll', + ]); +}); +beforeEach(() => log('1 - beforeEach')); +afterEach(() => log('1 - afterEach')); + +test('root', () => log('1 - test')); + +describe('Scoped / Nested block', () => { + beforeAll(() => log('2 - beforeAll')); + afterAll(() => log('2 - afterAll')); + beforeEach(() => log('2 - beforeEach')); + afterEach(() => log('2 - afterEach')); + + test('inner', () => log('2 - test')); +});