如何测试 React 并发模式安全

quote

"Multitasking? I can't even do two things at once. I can't even do one thing at once."

Helena Bonham Carter

本文聊聊作为库作者如何测试你的库在 React 并发模式下安全。

自宣布一年多过去 React 并发模式(Concurrent Mode)依然在实验阶段,但早期生态已悄然在形成。Concurrent Mode 这个词越来越频繁出现各种 React 库的介绍和讨论中。作为库开发者或者正打算开发 React 库的朋友,现在开始测试并发模式安全能避免日后可能出现的许多隐性问题,同时这也是一个很好的招牌。

注意:本文内容比较前沿,请留意文章的时限,以下的内容随时均可能发生改变。

使用 React 副本测试

目前只有 @experimental 版本的 React 才支持开启并发模式,考虑到稳定性,我们更希望尽量用稳定版 React 测试其它功能,只用实验版 React 测试并发模式下的功能。

yarn add --dev experimental_react@npm:react@experimental experimental_react-dom@npm:react-dom@experimental experimental_react-test-renderer@npm:react-test-renderer@experimental

如此我们安装实验版本并加上了 experimental_ 前缀的别名。选择前缀而不是后缀是为了方便日后统一去除。

设置 Jest Mocks

React 通过 scheduler 这个模块来进行调度,并提供了 jest-mock-scheduler 来在测试时 mock 掉。目前 jest-mock-scheduler 仅仅是导出了 scheduler/unstable_mock.js,所以不装也可以,React 内部也是直接引用 scheduler/unstable_mock.js,但考虑到未来兼容,还是建议安装 jest-mock-scheduler

yarn add --dev jest-mock-scheduler

测试文件中:

let Scheduler
let React
let ReactTestRenderer
let act
let MyLib

describe('Concurrent Mode', () => {
  beforeEach(() => {
    jest.resetModules()
    jest.mock('scheduler', () => require('jest-mock-scheduler'))
    jest.mock('react', () => require('experimental_react'))
    jest.mock('react-dom', () => require('experimental_react-dom'))
    jest.mock('react-test-renderer', () => require('experimental_react-test-renderer'))

    MyLib = require('../src')
    React = require('react')
    ReactTestRenderer = require('react-test-renderer')
    Scheduler = require('scheduler')

    act = ReactTestRenderer.act
  })
})

如果用 TypeScript 写测试,那么

let Scheduler: import('./utils').Scheduler
let React: typeof import('react')
let ReactTestRenderer: typeof import('react-test-renderer')
let act: typeof import('react-test-renderer').act
let MyLib: typeof import('../src')

其中 scheduler mock 的类型目前先手动补上,见这里

自定义断言

React 内部使用了许多自定义断言,为了减少使用难度,这里我们参考同样的方式扩展 Jest expect

jest.config.js 中添加 setupFilesAfterEnv 指定配置文件,如 setupFilesAfterEnv: [require.resolve('./scripts/jest-setup.js')],自定义断言参考这里

如果用 TypeScript 写测试,那么还需要添加 expect-extend.d.ts,参考这里

测试调度

Scheduler mock 掉之后多了许多控制调度的方法。基本逻辑是默认所有调度都只会累积而不处理,通过手动 flush 或者 act 清理。

通过 yeildValue 记录锚值,然后 flush 的时候可以选择只清理到特定锚值的地方,相当于打断点。在断点处我们可以做各种额外的处理以测试我们的库是否会出现异常。

测试断裂

并发模式下的一个常见问题是状态出现断裂(tearing)。这通常出现在依赖外部模块或者 ref 管理状态。当组件渲染暂停时,如果外部状态发生了变化,该组件恢复渲染后将使用新的值进行渲染,但其它组件却可能在之前已经用了旧的值渲染,故出现了断裂。

要测试我们的库会不会产生断裂现象,我们可以在组件渲染结束前打一个点,到断点后触发外部状态变化,然后检查组件状态是否准确。

如一个捏造的监听任意 input 元素值的 hook,

const useInputValue = input => {
  const [value, setValue] = React.useState('A')

  React.useEffect(() => {
    const callback = event => {
      setValue(event.currentTarget.value)
    }
    input.addEventListener('change', callback)
    return () => input.removeEventListener('change', callback)
  }, [input])

  return value
}

为了测试这个 hook 会不会产生断裂,我们设置两个组件监听同个数据源,中断一个组件的渲染,同时数据源产生新值,再恢复组件渲染并对比两个组件结果是否相同。

it('should should not tear', () => {
  const input = document.createElement('input')

  const emit = value => {
    input.value = value
    input.dispatchEvent(new Event('change'))
  }

  const Test = ({ id }) => {
    const value = useInputValue(input)
    // 打点
    Scheduler.unstable_yieldValue(`render:${id}:${value}`)
    return value
  }

  act(() => {
    ReactTestRenderer.create(
      <React.Fragment>
        <Test id="first" />
        <Test id="second" />
      </React.Fragment>,
      // 启用并发模式
      { unstable_isConcurrent: true }
    )

    // 初次渲染
    expect(Scheduler).toFlushAndYield(['render:first:A', 'render:second:A'])

    // 检查正常修改渲染
    emit('B')
    expect(Scheduler).toFlushAndYield(['render:first:B', 'render:second:B'])

    // 这次渲染到第一个组件后停止
    emit('C')
    expect(Scheduler).toFlushAndYieldThrough(['render:first:C'])

    // 同时产生新值
    emit('D')
    expect(Scheduler).toFlushAndYield([
      'render:second:C',
      'render:first:D',
      'render:second:D'
    ])
  })
})

最后两者均渲染 D,故使用该 hook 没有断裂问题。

评论没有加载,检查你的局域网

Cannot load comments. Check you network.

eat();

sleep();

code();

repeat();