我们在做测试的时候,会时不时的遇到一个act问题,有的时候它不影响测试结果和覆盖率,有的时候会因为act的存在而导致一些原本可以拿到的渲染而断言失败。经常表现为如下错误:
console.error
Warning: An update to ForwardRef(ImperativeCounter) inside a test was not wrapped in act(...).
When testing, code that causes React state updates should be wrapped into act(...):
act(() => {
/* fire events that update state */
});
/* assert on the output */
内容主要来自 肯特·多兹,不过可能时间比较久远(2020年)因此他的写法换成tsx和函数风格是跑不通的,因此我重新整理了一份,并对例子做出了调整。以下是几种可能遇到的场景:
地址:https://kentcdodds.com/blog/fix-the-not-wrapped-in-act-warning
场景一
如何解决组件测试中触发的act问题?如何解决setState触发的act问题?
函数风格组件中,当异步程序中使用了setState函数是出现act问题,包括useState中的setState方法,一般发生在网络请求后执行
了一些值的改变,因此执行了setState方法。
解决方法:
将异步方法扔进act中,执行的结果扔到回调函数中,或者给act函数添加async,让函数异步执行,因此下面两种写法都可以
写法一:
await act(() => {
return handleUpdateUsername().then(() => {
expect(handleUpdateUsername).toHaveBeenCalled();
})
})
写法二:
await act(async() => {
expect(handleUpdateUsername).toHaveBeenCalled();
return handleUpdateUsername
})
例子
新建文件 com/username-form.tsx
import React, { useState } from 'react';
// interface
interface Option {
status: string,
error: any
}
export default function UsernameForm(
{updateUsername}:
{
updateUsername: (username: string) => Promise<void>
}
) {
const [options,setOptions] = useState<Option>({
status: 'idle',
error: null
})
const [age,setAge] = useState(12);
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const target = event.target as typeof event.target & {
username: string
}
const newUsername = target.username;
setOptions({
...options,
status: 'pending'
})
try {
await updateUsername(newUsername);
console.log('i am in')
setAge(23);
setOptions({
...options,
status: 'fulfilled'
})
} catch (e) {
setOptions({
...options,
status: 'rejected',
error: e
})
}
}
return (
<form onSubmit={handleSubmit}>
<label htmlFor="username">Username</label>
<input id="username" />
<button type="submit">Submit</button>
<div>{JSON.stringify(options)}</div>
<div>my age is: {age}</div>
<div>
<span>{options.status === 'pending' ? 'Saving...' : null}</span>
<span>{options.status === 'rejected' ? options?.error?.message : null}</span>
<span>{options.status === 'fulfilled' ? '更新成功' : null}</span>
</div>
</form>
)
}
然后新建测试 act/index.test.tsx
import * as React from 'react'
import user from '@testing-library/user-event';
import {render, screen, act, fireEvent} from '@testing-library/react';
import UsernameForm from 'src/page/act/com/username-form';
describe('test act',() => {
test('calls updateUsername with the new username', async () => {
const promise = () => Promise.resolve()
const handleUpdateUsername = jest.fn(promise)
const fakeUsername = 'sonicthehedgehog'
render(<UsernameForm updateUsername={handleUpdateUsername} />)
// 获取输入框
const usernameInput = screen.getByLabelText(/username/i)
// 注意了,user-event方法都是异步,因此最好加上await
// 给输入框赋值
// await user.type(usernameInput, fakeUsername)
fireEvent.change(usernameInput,{
target: {
value: fakeUsername
}
})
// 点击提交
// await user.click(screen.getByText(/submit/i))
// 如果提交成功,那么handleUpdateUsername方法将被执行
// expect(handleUpdateUsername).toHaveBeenCalledWith(fakeUsername)
fireEvent.click(screen.getByText(/submit/i));
await act(() => {
return handleUpdateUsername().then(() => {
expect(handleUpdateUsername).toHaveBeenCalled();
})
})
// await act(async() => {
// expect(handleUpdateUsername).toHaveBeenCalled();
// return handleUpdateUsername
// })
})
})
可以尝试去掉act,然后跑测试进行复现问题
情景二:
为什么使用useReducer会触发act问题?如何解决useReducer中的act问题?
函数风格组件中,使用了useReducer,由于他会返回一个Dispatch,因此也存在一个setState,所以当它处于异步函数之后执行时时,测试会检测到组件有所更新。
解决方法:
找到对应的异步们,正如order-status.tsx中的checkStatus和setInterval,我们需要为他俩构造一个异步执行的环境需要注意的是setInterval的使用,就意味着测试需要开启假计时,而我们也是为这个假计时构造一个异步执行的环境
act(() => jest.advanceTimersByTime(1000) )
await act(async() => {
return expect(checkStatus).toBeCalled();
})
这里有一个注意点:假计时不是异步,因此不需要添加await,但是如果是异步,那么就需要添加await/async
例子:
我们新建文件 com/order-status.tst
import React, { useReducer,useEffect } from "react";
import { checkStatus } from './api';
export default function OrderStatus(
{orderId}:
{
orderId: string
}
) {
const [{status, data, error}, setState] = useReducer(
(s: any, a: any) => ({...s, ...a}) as any,
{status: 'idle', data: null, error: null},
)
useEffect(() => {
let current = true
function tick() {
// 这个位置会提示act问题
setState({status: 'pending'})
checkStatus(orderId).then(
d => {
// 这个位置也会出现act问题
if (current) setState({status: 'fulfilled', data: d})
},
e => {
if (current) setState({status: 'rejected', error: e})
},
)
}
const id = setInterval(tick, 1000)
return () => {
// 清除计时器
current = false
clearInterval(id)
}
}, [orderId]);
return (
// 效果为,一秒钟后,checkStatus成功返回数据,同时status=fulfilled,因此显示data.orderStatus
<div>
Order Status:{' '}
<span>
{status === 'idle' || status === 'pending'
? '...'
: status === 'error'
? error.message
: status === 'fulfilled'
? data.orderStatus
: null}
</span>
</div>
)
}
这个组件的意思是:一秒钟后,checkStatus成功返回数据,同时status=fulfilled,因此显示data.orderStatus
然后新建测试 act/order-status.test.tsx
import * as React from 'react'
import {render, act} from '@testing-library/react'
import * as api from '../../page/act/com/api';
import OrderStatus from 'src/page/act/com/order-status';
jest.mock('../../page/act/com/api');
const mockCheckStatus = jest.spyOn(api,'checkStatus') as jest.MockedFunction< typeof api.checkStatus>
mockCheckStatus.mockImplementation(
jest.fn((id: string) => {
return Promise.resolve({
orderStatus: 'i am status123 => '+ id
})
})
)
beforeAll(() => {
// we're using fake timers because we don't want to
// wait a full second for this test to run.
jest.useFakeTimers()
})
afterAll(() => {
jest.useRealTimers()
})
test('polling backend on an interval', async () => {
const orderId = 'abc123'
const checkStatus = mockCheckStatus;
const container = render(<OrderStatus orderId={orderId} />)
//expect(screen.getByText(/\.\.\./i)).toBeInTheDocument()
// advance the timers by a second to kick off the first request
act(() => jest.advanceTimersByTime(1000) )
await act(async() => {
return expect(checkStatus).toBeCalled();
})
// expect(await screen.findByText(orderStatus)).toBeInTheDocument()
// expect(checkStatus).toHaveBeenCalledWith(orderId)
expect(checkStatus).toHaveBeenCalledTimes(1);
expect(container).toMatchSnapshot();
})
注意这里的mock写法,和假计时的用法
情景三:
为什么使用hook会触发act问题?如何解决hook中触发的act问题?
当我们使用自定义hook的时候,使用到了setState,那么我们在测试这个hook的方法时会调用这个方法来做测试;此时会由于setState是异步情况,导致修改值不及时,从而expect断言不准确;为此,我们需要给触发的方法创建异步执行环境。
解决方案:
act(() => result.current.increment())
expect(result.current.count).toBe(1)
act(() => result.current.decrement())
expect(result.current.count).toBe(0)
举例
我们新建文件 com/hook/utils.tsx
import React , { useState} from 'react';
export default function useCount() {
const [count,setCount] = useState(0);
const increment = () => setCount(c => c + 1);
const decrement = () => setCount(c => c - 1);
return {
count,
increment,
decrement
}
}
然后新建测试文件 act/hook-counter.test.tsx
import * as React from 'react'
import {renderHook,act} from '@testing-library/react';
import useCount from 'src/page/act/com/hook/utils';
describe('test hook',() => {
test('increment and decrement updates the count', () => {
const {result} = renderHook(() => useCount())
expect(result.current.count).toBe(0)
act(() => result.current.increment())
expect(result.current.count).toBe(1)
act(() => result.current.decrement())
expect(result.current.count).toBe(0)
})
})
情景四:
为什么使用useImperativeHandle会触发act问题?如何解决useImperativeHandle中触发的act问题?
当我们使用了useImperativeHandle的时候,通过forwardRef给父组件传递一个ref,此时父组件的ref拿到的是一个对象并可以触发一些方法,如果这些方法中存在修改setState的情形,那么就会出现act问题,同时因为act会导致页面中本应该有的内容而无法通过jest api拿到,进而影响判断.
举例
我们新建文件 com/imperativeCount.tsx
import { useState,useImperativeHandle,forwardRef, Ref } from "react"
// eslint-disable-next-line react-refresh/only-export-components
function ImperativeCounter(props: any, ref: Ref<any> | undefined) {
const [count, setCount] = useState(0)
useImperativeHandle(ref, () => ({
increment: () => setCount(c => c + 1),
decrement: () => setCount(c => c - 1),
}))
return <div>The count is: {count}</div>
}
const ZlImperativeCounter = forwardRef(ImperativeCounter)
export default ZlImperativeCounter;
然后新建测试文件 act/hook-count.test.tsx
import * as React from 'react'
import {renderHook,act} from '@testing-library/react';
import useCount from 'src/page/act/com/hook/utils';
describe('test hook',() => {
test('increment and decrement updates the count', () => {
const {result} = renderHook(() => useCount())
expect(result.current.count).toBe(0)
act(() => result.current.increment())
expect(result.current.count).toBe(1)
act(() => result.current.decrement())
expect(result.current.count).toBe(0)
})
})
总结
从根源来看,是单元测试没有给setState创建异步执行的环境,虽然不影响测试结果,但由于抛出的是error级别的错误,因此需要处理。当出现act错误的时候,如果对其进行一些断言,那么该断言将无法实现预期效果。另外从以上四种场景来看,后面两种,如果是以组件形式的引入,其实并不一定会触发act问题,主要还是聚焦与前面两种。
以上内容可以到gitee上找到,大家可以复制出来看效果
组件地址:
https://gitee.com/xifeng-canyang/jest-formik/tree/master/src/page/act/com
对应测试:
https://gitee.com/xifeng-canyang/jest-formik/tree/master/src/__tests__/act
以上便是jest关于act的全部问题,实不相瞒,想要您一个赞~