React18
has been a while since I entered your field of vision. Have you tried the concurrency feature ?
When concurrency is enabled, React
will change from synchronous updates to asynchronous, prioritized, interruptible updates .
This also brings some difficulty to writing unit tests.
This article will talk about React
how the team tests concurrency features.
Welcome to join the human high-quality front-end framework group , with flying
Contents
Difficulties encountered
There are two main problems to face.
1. How to express the rendering result?
React
Renderers that can be connected to different host environments. The most familiar renderer must be ReactDOM
, which is used to connect the browser and Node environment (SSR).
For some scenarios, you can test with the output of ReactDOM
.
For example, the following is to use the output of ReactDOM
to test whether the rendering result of the stateless component is as expected (the test framework is jest
):
it('should render stateless component', () => { const el = document.createElement('div'); ReactDOM.render(<FunctionComponent name="A" />, el); expect(el.textContent).toBe('A'); });
There is an inconvenience here – this use case depends on 浏览器环境
and DOM API
(for example, using document.createElement
).
For scenarios such as testing the internal operating mechanism of React , incorporating information about the host environment will obviously make the test case more cumbersome to write.
2. How to test a concurrent environment?
If the above use case is changed from ReactDOM.render
to ReactDOM.createRoot
, then the use case will fail:
// 之前 ReactDOM.render(<FunctionComponent name="A" />, el); expect(el.textContent).toBe('A'); // 之后 ReactDOM.createRoot(el).render(<FunctionComponent name="A" />); expect(el.textContent).toBe('A');
This is because under the new architecture, many synchronous updates have become concurrent updates . When render
executed, the page has not been rendered.
The simplest modification to make the above use case successful is:
ReactDOM.createRoot(el).render(<FunctionComponent name="A" />); setTimeout(() => { // 异步获取结果 expect(el.textContent).toBe('A'); })
How to gracefully cope with this change?
React’s coping strategy
Next, let’s look React
the team responded.
Let’s first look at the first question – how to express the rendering result?
Since ReactDOM
renderer corresponds to the browser, Node
environment, ReactNative
renderer corresponds to the Native
environment.
Can a renderer be developed specifically for the internal running process of the test?
The answer is yes.
This renderer is called React-Noop-Renderer
.
Simply put, this renderer will render pure JS
objects.
implement a renderer
React
There is a package called Reconciler
, and he will refer to some operating host environment API
.
For example, the following method is used to insert a node into the container :
function appendChildToContainer(child, container) { // 具体实现 }
For the browser environment ( ReactDOM
), use the appendChild
method to implement:
function appendChildToContainer(child, container) { // 使用appendChild方法 container.appendChild(child); }
The packaging tool ( rollup
) will package the Reconciler
package with the above API for the browser environment , which is the ReactDOM
package.
In React-Noop-Renderer
ReactDOM
the DOM
node in —6c9d7ec6eac4e764d68aea41b6b2ad0d— is aligned with the following data structure:
const instance = { id: instanceCounter++, type: type, children: [], parent: -1, props };
Note the children
field, which is used to save child nodes.
So the appendChildToContainer
method can be implemented very simply in React-Noop-Renderer
:
function appendChildToContainer(child, container) { const index = container.children.indexOf(child); if (index !== -1) { container.children.splice(index, 1); } container.children.push(child); };
The packaging tool will package the Reconciler
package with the above API for React-Noop , which is the React-Noop-Renderer
package.
Based on React-Noop-Renderer
, it can be completely separated from the normal host environment and test the internal logic of Reconciler
.
Next, let’s look at the second question.
How to test concurrent environments?
No matter how complicated the concurrency feature is, it is only a variety of asynchronous code execution strategies . The final execution strategy API
is nothing more than setTimeout
, setInterval
, Promise
etc.
In jest
, these asynchronous API
can be simulated to control their execution timing.
For example, the above asynchronous code, the test case in React
will be written like this:
// 测试用例修改后: await act(() => { ReactDOM.createRoot(el).render(<FunctionComponent name="A" />); }) expect(el.textContent).toBe('A');
act
method comes from the jest-react
package, which will execute the jest.runOnlyPendingTimers
method internally to make all waiting timers trigger callbacks.
For example the following code:
setTimeout(() => { console.log('执行') }, 9999999)
After executing jest.runOnlyPendingTimers
, it will print and execute immediately.
In this way, human control React
the speed of concurrent updates, and at the same time intrusion into the framework code 0.
In addition, the Scheduler
(scheduler) module for driving concurrent updates has a version for testing itself.
In this version, developers can manually control the input and output of Scheduler
.
For example, I want to test the order in which the callbacks are executed when the component is uninstalled useEffect
.
As shown in the following code, where Parent
is the mounted component under test :
function Parent() { useEffect(() => { return () => Scheduler.unstable_yieldValue('Unmount parent'); }); return <Child />; } function Child() { useEffect(() => { return () => Scheduler.unstable_yieldValue('Unmount child'); }); return 'Child'; } await act(async () => { root.render(<Parent />); });
According to whether the insertion order of yieldValue
is as expected, it can be determined whether the logic of useEffect
is as expected:
expect(Scheduler).toHaveYielded(['Unmount parent', 'Unmount child']);
Summarize
The strategy for writing test cases in React
is:
- You can use the test case of
ReactDOM
a2adb68405c9f2a638a24201b84e9358—, which is generally combined withReactDOM
andReactTestUtils
(a helper method in the browser environment) to complete - For use cases that need to control the intermediate process, use the test package of —7c5b90198e0460164c010306ce24eaa4
Scheduler
, and useScheduler.unstable_yieldValue
to record the process information - Separate from the host environment and test separately
React
for the internal running process, useReact-Noop-Renderer
- To test scenarios under concurrency, you need to combine the above tools with
jest-react
If you want to study the test-related skills in React
, you can read the work of Mr. Situ Zhengmei anu .
This is a class React
framework, but it can run through the 800+ React
use case. It implements a simplified version of ReactTestUtils
and React-Noop-Renderer
.