解决单元测试中遇到的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的全部问题,实不相瞒,想要您一个赞~

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 217,542评论 6 504
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,822评论 3 394
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 163,912评论 0 354
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,449评论 1 293
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,500评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,370评论 1 302
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,193评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,074评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,505评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,722评论 3 335
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,841评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,569评论 5 345
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,168评论 3 328
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,783评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,918评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,962评论 2 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,781评论 2 354

推荐阅读更多精彩内容