解决单元测试中遇到的act问题

我们在做测试的时候,会时不时的遇到一个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的全部问题,实不相瞒,想要您一个赞~

©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容