userEvent.click で要素が disabled の場合に警告を出す

Flaky だったユニットテストを直して、再発防止までやった。

結論から書くと、@testing-library/user-event でクリック対象の要素が disabled の場合、ボタンに紐づくイベントハンドラやフォーム送信が実行されず、アサーションに失敗するテストがあった。

disabled のときにクリック関連の動作が起きないのは期待どおりの振る舞いではある。問題だったのは次の 2 点。

  • テスト対象のボタンが disabled になる時とならない時があった
  • 非同期のテストの場合、特にアサーションを waitFor で囲っていると、タイムアウトでテスト失敗になるため、クリックができていなかったことが原因だと気づけない

実際にどのコンポーネントで問題になっていたかは後述する。対応としては、userEvent.clickuserEvent.dblClickuserEvent.tripleClick に渡した要素が disabled だった場合に警告ログを出すようにした。

// https://testing-library.com/docs/user-event/intro/#writing-tests-with-userevent のイメージ

import { render, screen } from '@testing-library/react'
import UserEvent, { UserEvent as UserEventType } from '@testing-library/user-event'

function setup(jsx) {
	const userEvent = UserEvent.setup()
	const user = Object.assign({}, userEvent, {
		click: warnIfDisabled(user, 'click'),
		dblClick: warnIfDisabled(user, 'dblClick'),
		tripleClick: warnIfDisabled(user, 'tripleClick'),
	})

	return { user, ...render(jsx) }
}

function warnIfDisabled<ClickApi extends 'click' | 'dblClick' | 'tripleClick'>(
  user: UserEventType,
  api: ClickApi,
): UserEventType[ClickApi] {
  return (element) => {
    if ((element as { disabled?: boolean }).disabled) {
      console.warn(
        chalk.yellow(
          `Warning: The element is disabled. The click event handler will not be triggered.`,
        ),
      )
      screen.debug(element)
    }

    return user[api](element)
  }
}

これで、disabled だった場合には次のように警告が出る。

test('ボタンのテスト', async () => {
	const maybeDisabled = Math.random() > 0.5
	const { user } = setup(<button type="button" disabled={maybeDisabled}>click me</button>)

	await user.click(screen.getByRole('button', { name: 'click me' }))
	// maybeDisabled === true
	// Logged > Warning: The element is disabled. The click event handler will not be triggered.

	// assertion here 👇
})

どのコンポーネントで起きていたか

問題が起きていたのは、イベントハンドラや form の送信が紐づいたボタンで、このボタンは読込中に disabled になる。

かなり省略・抽象化しているが、要点は伝わると思う。

function Page() {
	const [state, setState] = useState({ pending: false, count: 0 })
	
	useEffect(() => {
		setState(prev => ({ ...prev, pending: true }))
		
		fetch('https://api.example.test/count')
			.then(response => response.json() as Promise<number>)
			.then((count) => setState({ count, pending: false }))
	}, [])
	
	const handleClick = () => { /* カウント加算リクエスト */ }

	return (
		<div>
			<output data-testid="count">{state.count}</output>
			<button
				type="button"
				disabled={state.pending}
				onClick={handleClick}
			>
				click me
			</button>
		</div>
	)
}

テストコードでは MSW でレスポンスをモックしていて、内部で delay を使っていた。

beforeEach(() => {
	server.use(
		http.get('https://api.example.test/count', async () => {
			await delay()
			return HttpResponse.json(10)
		})
	)
})

test('+ボタンをクリックするとカウンターが増える', async () => {
	const user = userEvent.setup()
	
	render(<Page />)

	// 👇 fetch が終わっていない場合、ここで取得するボタンが disabled のことがあった
	await user.click(screen.getByRole('button', { name: 'click me' }))
	
	await waitFor(() => {
		expect(screen.getByTestid('count')).toHaveTextContent('2')
	})
})

No comments yet