From 9f5c9bb370d490a38cf81f3ab0f965f7b23d268e Mon Sep 17 00:00:00 2001 From: Maxwell_YCM Date: Tue, 15 Oct 2024 22:09:28 +0800 Subject: [PATCH] add test lib --- packages/testing_library/Readme.md | 153 ++++++++++++ packages/testing_library/act-compat.js | 79 ++++++ packages/testing_library/config.js | 32 +++ packages/testing_library/fire-event.js | 39 +++ packages/testing_library/index.js | 28 +++ packages/testing_library/pure.js | 317 +++++++++++++++++++++++++ 6 files changed, 648 insertions(+) create mode 100644 packages/testing_library/Readme.md create mode 100644 packages/testing_library/act-compat.js create mode 100644 packages/testing_library/config.js create mode 100644 packages/testing_library/fire-event.js create mode 100644 packages/testing_library/index.js create mode 100644 packages/testing_library/pure.js diff --git a/packages/testing_library/Readme.md b/packages/testing_library/Readme.md new file mode 100644 index 00000000..7fc9c09f --- /dev/null +++ b/packages/testing_library/Readme.md @@ -0,0 +1,153 @@ +# @openinula/testing-library 设计文档 + +## 1. 项目概述 + +`@openinula/testing-library` 是一个为 openInula 应用程序设计的测试工具库。它提供了一套简单、直观的 API,帮助开发人员编写有效的、可维护的测试代码。该库基于用户行为的测试方法,而不是依赖于组件的内部实现细节。 + +## 2. 设计目标 + +- 提供与 openInula 生态系统无缝集成的测试工具 +- 支持测试 Inula 组件的渲染、交互和状态管理 +- 提供简单、直观的 API,易于学习和使用 +- 支持异步测试和断言 +- 提供详细的错误消息,便于定位和修复测试失败 +- 保持与 @testing-library/react 相似的 API,以便于用户迁移 + +## 3. 核心组件 + +### 3.1 render 函数 + +`render` 函数用于将 Inula 组件渲染到测试环境中。它返回一个对象,包含查询方法和其他有用的属性。 + +### 3.2 查询函数 + +提供多种查询函数,如 `getByText`、`getByRole`、`getByLabelText` 等,用于在渲染的组件中查找元素。 + +### 3.3 fireEvent + +`fireEvent` 对象提供了模拟用户交互的方法,如点击、输入等。 + +### 3.4 act + +`act` 函数用于包装可能导致组件状态更新的操作,确保在断言之前完成所有更新。 + +### 3.5 cleanup + +`cleanup` 函数用于在每个测试后清理渲染的组件。 + +## 4. 技术实现 + +### 4.1 依赖 + +- openinula: Inula 核心库 +- @testing-library/dom: 提供底层 DOM 测试功能 + +### 4.2 主要模块 + +- `src/index.js`: 库的主入口,导出所有公共 API +- `src/render.js`: 实现 `render` 函数 +- `src/queries.js`: 实现各种查询函数 +- `src/fire-event.js`: 实现 `fireEvent` 对象 +- `src/act-compat.js`: 实现 `act` 函数 +- `src/config.js`: 处理库的配置 + +### 4.3 自动清理机制 + +通过 `afterEach` 或 `teardown` 钩子自动调用 `cleanup` 函数,除非用户显式禁用。 + +### 4.4 Act 环境管理 + +使用 `getIsInulaActEnvironment` 和 `setInulaActEnvironment` 函数管理 Act 环境。 + +## 5. API 设计 + +### 5.1 render + +```javascript +function render(ui, options) -> { + container, + baseElement, + debug, + unmount, + rerender, + asFragment, + ...queries +} +``` + +### 5.2 查询函数 + +```javascript +getByText(text) -> Element +getByRole(role) -> Element +getByLabelText(label) -> Element +getByTestId(testId) -> Element +// ... 其他查询函数 +``` + +### 5.3 fireEvent + +```javascript +fireEvent.click(element) +fireEvent.change(element, { target: { value: 'new value' } }) +// ... 其他事件 +``` + +### 5.4 act + +```javascript +act(() => { + // 执行可能导致组件更新的操作 +}) +``` + +### 5.5 cleanup + +```javascript +cleanup() +``` + +## 6. 配置选项 + +通过 `configure` 函数提供全局配置选项,如: + +```javascript +configure({ + inulaStrictMode: true, + // ... 其他配置选项 +}) +``` + +## 7. 测试策略 + +- 单元测试:为每个主要模块编写单元测试 +- 集成测试:测试各个模块的集成 +- 端到端测试:使用库测试实际的 Inula 组件 + +## 8. 文档和示例 + +- 提供详细的 API 文档 +- 提供常见用例的示例代码 +- 编写一个全面的 README.md 文件 + +## 9. 未来扩展 + +- 添加更多的查询方法 +- 支持更多的事件类型 +- 提供自定义渲染器的选项 +- 集成 Inula 特定的测试工具(如果有) + +## 10. 兼容性考虑 + +- 确保与不同版本的 openInula 兼容 +- 考虑浏览器兼容性问题 + +## 11. 性能优化 + +- 优化渲染性能 +- 减少不必要的 DOM 操作 + +## 12. 安全性考虑 + +- 避免在测试中暴露敏感信息 +- 确保测试环境的隔离性 diff --git a/packages/testing_library/act-compat.js b/packages/testing_library/act-compat.js new file mode 100644 index 00000000..65a67a62 --- /dev/null +++ b/packages/testing_library/act-compat.js @@ -0,0 +1,79 @@ +import * as Inula from 'openinula' + +const inulaAct = typeof Inula.act === 'function' ? Inula.act : null + +function getGlobalThis() { + if (typeof globalThis !== 'undefined') { + return globalThis + } + if (typeof self !== 'undefined') { + return self + } + if (typeof window !== 'undefined') { + return window + } + if (typeof global !== 'undefined') { + return global + } + throw new Error('unable to locate global object') +} + +function setIsInulaActEnvironment(isInulaActEnvironment) { + getGlobalThis().IS_INULA_ACT_ENVIRONMENT = isInulaActEnvironment +} + +function getIsInulaActEnvironment() { + return getGlobalThis().IS_INULA_ACT_ENVIRONMENT +} + +function withGlobalActEnvironment(actImplementation) { + return callback => { + const previousActEnvironment = getIsInulaActEnvironment() + setIsInulaActEnvironment(true) + try { + let callbackNeedsToBeAwaited = false + const actResult = actImplementation(() => { + const result = callback() + if ( + result !== null && + typeof result === 'object' && + typeof result.then === 'function' + ) { + callbackNeedsToBeAwaited = true + } + return result + }) + if (callbackNeedsToBeAwaited) { + const thenable = actResult + return { + then: (resolve, reject) => { + thenable.then( + returnValue => { + setIsInulaActEnvironment(previousActEnvironment) + resolve(returnValue) + }, + error => { + setIsInulaActEnvironment(previousActEnvironment) + reject(error) + }, + ) + }, + } + } else { + setIsInulaActEnvironment(previousActEnvironment) + return actResult + } + } catch (error) { + setIsInulaActEnvironment(previousActEnvironment) + throw error + } + } +} + +const act = withGlobalActEnvironment(inulaAct) + +export default act +export { + setIsInulaActEnvironment, + getIsInulaActEnvironment, +} \ No newline at end of file diff --git a/packages/testing_library/config.js b/packages/testing_library/config.js new file mode 100644 index 00000000..84119194 --- /dev/null +++ b/packages/testing_library/config.js @@ -0,0 +1,32 @@ +import { + getConfig as getConfigDTL, + configure as configureDTL, +} from '@testing-library/dom' + +let configForOIL = { + inulaStrictMode: false, +} + +function getConfig() { + return { + ...getConfigDTL(), + ...configForOIL, + } +} + +function configure(newConfig) { + if (typeof newConfig === 'function') { + newConfig = newConfig(getConfig()) + } + + const {inulaStrictMode, ...configForDTL} = newConfig + + configureDTL(configForDTL) + + configForOIL = { + ...configForOIL, + inulaStrictMode, + } +} + +export {getConfig, configure} \ No newline at end of file diff --git a/packages/testing_library/fire-event.js b/packages/testing_library/fire-event.js new file mode 100644 index 00000000..5009c7ce --- /dev/null +++ b/packages/testing_library/fire-event.js @@ -0,0 +1,39 @@ +import {fireEvent as dtlFireEvent} from '@testing-library/dom' + +const fireEvent = (...args) => dtlFireEvent(...args) + +Object.keys(dtlFireEvent).forEach(key => { + fireEvent[key] = (...args) => dtlFireEvent[key](...args) +}) + +// 添加 Inula 特定的事件处理 +const mouseEnter = fireEvent.mouseEnter +const mouseLeave = fireEvent.mouseLeave +fireEvent.mouseEnter = (...args) => { + mouseEnter(...args) + return fireEvent.mouseOver(...args) +} +fireEvent.mouseLeave = (...args) => { + mouseLeave(...args) + return fireEvent.mouseOut(...args) +} + +const select = fireEvent.select +fireEvent.select = (node, init) => { + select(node, init) + node.focus() + fireEvent.keyUp(node, init) +} + +const blur = fireEvent.blur +const focus = fireEvent.focus +fireEvent.blur = (...args) => { + fireEvent.focusOut(...args) + return blur(...args) +} +fireEvent.focus = (...args) => { + fireEvent.focusIn(...args) + return focus(...args) +} + +export {fireEvent} \ No newline at end of file diff --git a/packages/testing_library/index.js b/packages/testing_library/index.js new file mode 100644 index 00000000..7ecd264b --- /dev/null +++ b/packages/testing_library/index.js @@ -0,0 +1,28 @@ +import {getIsInulaActEnvironment, setInulaActEnvironment} from './act-compat' +import {cleanup} from './pure' + +if (typeof process === 'undefined' || !process.env?.OIL_SKIP_AUTO_CLEANUP) { + if (typeof afterEach === 'function') { + afterEach(() => { + cleanup() + }) + } else if (typeof teardown === 'function') { + teardown(() => { + cleanup() + }) + } + + if (typeof beforeAll === 'function' && typeof afterAll === 'function') { + let previousIsInulaActEnvironment = getIsInulaActEnvironment() + beforeAll(() => { + previousIsInulaActEnvironment = getIsInulaActEnvironment() + setInulaActEnvironment(true) + }) + + afterAll(() => { + setInulaActEnvironment(previousIsInulaActEnvironment) + }) + } +} + +export * from './pure' \ No newline at end of file diff --git a/packages/testing_library/pure.js b/packages/testing_library/pure.js new file mode 100644 index 00000000..1a0e3b69 --- /dev/null +++ b/packages/testing_library/pure.js @@ -0,0 +1,317 @@ +import * as Inula from 'openinula' +import InulaDOM from 'openinula-dom' +import * as InulaDOMClient from 'openinula-dom/client' +import { + getQueriesForElement, + prettyDOM, + configure as configureDTL, +} from '@testing-library/dom' +import act, { + getIsInulaActEnvironment, + setInulaActEnvironment, +} from './act-compat' +import {fireEvent} from './fire-event' +import {getConfig, configure} from './config' + +function jestFakeTimersAreEnabled() { + /* istanbul ignore else */ + if (typeof jest !== 'undefined' && jest !== null) { + return ( + // legacy timers + setTimeout._isMockFunction === true || // modern timers + // eslint-disable-next-line prefer-object-has-own -- No Object.hasOwn in all target environments we support. + Object.prototype.hasOwnProperty.call(setTimeout, 'clock') + ) + } // istanbul ignore next + + return false +} + +configureDTL({ + unstable_advanceTimersWrapper: cb => { + return act(cb) + }, + // We just want to run `waitFor` without IS_INULA_ACT_ENVIRONMENT + // But that's not necessarily how `asyncWrapper` is used since it's a public method. + // Let's just hope nobody else is using it. + asyncWrapper: async cb => { + const previousActEnvironment = getIsInulaActEnvironment() + setInulaActEnvironment(false) + try { + const result = await cb() + // Drain microtask queue. + // Otherwise we'll restore the previous act() environment, before we resolve the `waitFor` call. + // The caller would have no chance to wrap the in-flight Promises in `act()` + await new Promise(resolve => { + setTimeout(() => { + resolve() + }, 0) + + if (jestFakeTimersAreEnabled()) { + jest.advanceTimersByTime(0) + } + }) + + return result + } finally { + setInulaActEnvironment(previousActEnvironment) + } + }, + eventWrapper: cb => { + let result + act(() => { + result = cb() + }) + return result + }, +}) + +// Ideally we'd just use a WeakMap where containers are keys and roots are values. +// We use two variables so that we can bail out in constant time when we render with a new container (most common use case) +/** + * @type {Set} + */ +const mountedContainers = new Set() +/** + * @type Array<{container: import('openinula-dom').Container, root: ReturnType}> + */ +const mountedRootEntries = [] + +function strictModeIfNeeded(innerElement) { + return getConfig().inulaStrictMode + ? Inula.createElement(Inula.StrictMode, null, innerElement) + : innerElement +} + +function wrapUiIfNeeded(innerElement, wrapperComponent) { + return wrapperComponent + ? Inula.createElement(wrapperComponent, null, innerElement) + : innerElement +} + +function createConcurrentRoot( + container, + {hydrate, ui, wrapper: WrapperComponent}, +) { + let root + if (hydrate) { + act(() => { + root = InulaDOMClient.hydrateRoot( + container, + strictModeIfNeeded(wrapUiIfNeeded(ui, WrapperComponent)), + ) + }) + } else { + root = InulaDOMClient.createRoot(container) + } + + return { + hydrate() { + /* istanbul ignore if */ + if (!hydrate) { + throw new Error( + 'Attempted to hydrate a non-hydrateable root. This is a bug in `@testing-library/openinula`.', + ) + } + // Nothing to do since hydration happens when creating the root object. + }, + render(element) { + root.render(element) + }, + unmount() { + root.unmount() + }, + } +} + +function createLegacyRoot(container) { + return { + hydrate(element) { + InulaDOM.hydrate(element, container) + }, + render(element) { + InulaDOM.render(element, container) + }, + unmount() { + InulaDOM.unmountComponentAtNode(container) + }, + } +} + +function renderRoot( + ui, + {baseElement, container, hydrate, queries, root, wrapper: WrapperComponent}, +) { + act(() => { + if (hydrate) { + root.hydrate( + strictModeIfNeeded(wrapUiIfNeeded(ui, WrapperComponent)), + container, + ) + } else { + root.render( + strictModeIfNeeded(wrapUiIfNeeded(ui, WrapperComponent)), + container, + ) + } + }) + + return { + container, + baseElement, + debug: (el = baseElement, maxLength, options) => + Array.isArray(el) + ? // eslint-disable-next-line no-console + el.forEach(e => console.log(prettyDOM(e, maxLength, options))) + : // eslint-disable-next-line no-console, + console.log(prettyDOM(el, maxLength, options)), + unmount: () => { + act(() => { + root.unmount() + }) + }, + rerender: rerenderUi => { + renderRoot(rerenderUi, { + container, + baseElement, + root, + wrapper: WrapperComponent, + }) + // Intentionally do not return anything to avoid unnecessarily complicating the API. + // folks can use all the same utilities we return in the first place that are bound to the container + }, + asFragment: () => { + /* istanbul ignore else (old jsdom limitation) */ + if (typeof document.createRange === 'function') { + return document + .createRange() + .createContextualFragment(container.innerHTML) + } else { + const template = document.createElement('template') + template.innerHTML = container.innerHTML + return template.content + } + }, + ...getQueriesForElement(baseElement, queries), + } +} + +function render( + ui, + { + container, + baseElement = container, + legacyRoot = false, + queries, + hydrate = false, + wrapper, + } = {}, +) { + if (legacyRoot && typeof InulaDOM.render !== 'function') { + const error = new Error( + '`legacyRoot: true` is not supported in this version of Inula. ' + + 'If your app runs Inula 19 or later, you should remove this flag. ' + + 'If your app runs Inula 18 or earlier, visit https://inula.dev/blog/2022/03/08/inula-18-upgrade-guide for upgrade instructions.', + ) + Error.captureStackTrace(error, render) + throw error + } + + if (!baseElement) { + // default to document.body instead of documentElement to avoid output of potentially-large + // head elements (such as JSS style blocks) in debug output + baseElement = document.body + } + if (!container) { + container = baseElement.appendChild(document.createElement('div')) + } + + let root + // eslint-disable-next-line no-negated-condition -- we want to map the evolution of this over time. The root is created first. Only later is it re-used so we don't want to read the case that happens later first. + if (!mountedContainers.has(container)) { + const createRootImpl = legacyRoot ? createLegacyRoot : createConcurrentRoot + root = createRootImpl(container, {hydrate, ui, wrapper}) + + mountedRootEntries.push({container, root}) + // we'll add it to the mounted containers regardless of whether it's actually + // added to document.body so the cleanup method works regardless of whether + // they're passing us a custom container or not. + mountedContainers.add(container) + } else { + mountedRootEntries.forEach(rootEntry => { + // Else is unreachable since `mountedContainers` has the `container`. + // Only reachable if one would accidentally add the container to `mountedContainers` but not the root to `mountedRootEntries` + /* istanbul ignore else */ + if (rootEntry.container === container) { + root = rootEntry.root + } + }) + } + + return renderRoot(ui, { + container, + baseElement, + queries, + hydrate, + wrapper, + root, + }) +} + +function cleanup() { + mountedRootEntries.forEach(({root, container}) => { + act(() => { + root.unmount() + }) + if (container.parentNode === document.body) { + document.body.removeChild(container) + } + }) + mountedRootEntries.length = 0 + mountedContainers.clear() +} + +function renderHook(renderCallback, options = {}) { + const {initialProps, ...renderOptions} = options + + if (renderOptions.legacyRoot && typeof InulaDOM.render !== 'function') { + const error = new Error( + '`legacyRoot: true` is not supported in this version of Inula. ' + + 'If your app runs Inula 19 or later, you should remove this flag. ' + + 'If your app runs Inula 18 or earlier, visit https://inula.dev/blog/2022/03/08/inula-18-upgrade-guide for upgrade instructions.', + ) + Error.captureStackTrace(error, renderHook) + throw error + } + + const result = Inula.createRef() + + function TestComponent({renderCallbackProps}) { + const pendingResult = renderCallback(renderCallbackProps) + + Inula.useEffect(() => { + result.current = pendingResult + }) + + return null + } + + const {rerender: baseRerender, unmount} = render( + , + renderOptions, + ) + + function rerender(rerenderCallbackProps) { + return baseRerender( + , + ) + } + + return {result, rerender, unmount} +} + +// just re-export everything from dom-testing-library +export * from '@testing-library/dom' +export {render, renderHook, cleanup, act, fireEvent, getConfig, configure} + +/* eslint func-name-matching:0 */