Testing Library 用户交互

Testing Library 用户交互

Flying
2022-04-26 / 0 评论 / 119 阅读 / 正在检测是否收录...

发送事件

注意

大多数项目都有几种使用 fireEvent 的情况,但大部分时间你应该使用@testing-library/user-event

tl.ui.svg

fireEvent

fireEvent(node: HTMLElement, event: Event)

触发 DOM 事件。

// <button>Submit</button>
fireEvent(
  getByText(container, 'Submit'),
  new MouseEvent('click', {
    bubbles: true,
    cancelable: true,
  }),
)

fireEvent[eventName]

fireEvent[eventName](node: HTMLElement, eventProperties: Object)

用于触发 DOM 事件的便捷方法。可以在 src/event-map.js 中查看完整的事件列表以及默认的 eventProperties

target:当在元素上派发事件时,事件在名为 target 的属性上具有该元素。为了方便起见,如果在 eventProperties(第二个参数)中提供了 target 属性,则这些属性将被分配给接收事件的节点。

这对于触发 change 事件特别有用:

fireEvent.change(getByLabelText(/username/i), {target: {value: 'a'}})

// 注意:试图手动设置 HTMLInputElement 的 files 属性会导致错误,因为 files 属性是只读的。
// 这个特性通过使用 Object.defineProperty 来解决这个问题。
fireEvent.change(getByLabelText(/picture/i), {
  target: {
    files: [new File(['(⌐□_□)'], 'chucknorris.png', {type: 'image/png'})],
  },
})

// 注意:当在类型为 "date" 的输入框上触发 change 事件时,'value' 属性必须使用 ISO 8601 格式,否则元素不会反映更改后的值。

// 无效:
fireEvent.change(input, {target: {value: '24/05/2020'}})

// 有效:
fireEvent.change(input, {target: {value: '2020-05-24'}})

dataTransfer:拖放事件具有一个名为 dataTransfer 的属性,其中包含在操作期间传输的数据。为了方便起见,如果在 eventProperties(第二个参数)中提供了 dataTransfer 属性,则这些属性将被添加到事件中。

这主要用于测试拖放交互。

fireEvent.drop(getByLabelText(/drop files here/i), {
  dataTransfer: {
    files: [new File(['(⌐□_□)'], 'chucknorris.png', {type: 'image/png'})],
  },
})

键盘事件:与键盘输入相关的事件类型有三种 - keyPresskeyDownkeyUp。当触发这些事件时,你需要引用 DOM 中的一个元素以及要触发的键。

fireEvent.keyDown(domNode, {key: 'Enter', code: 'Enter', charCode: 13})

fireEvent.keyDown(domNode, {key: 'A', code: 'KeyA'})

你可以在https://www.toptal.com/developers/keycode找到要使用的键码。

createEvent[eventName]

createEvent[eventName](node: HTMLElement, eventProperties: Object)

用于创建可以由 fireEvent 触发的 DOM 事件的便捷方法,允许你引用创建的事件:如果你需要访问无法通过编程方式初始化的事件属性(例如 timeStamp),这可能会很有用。

const myEvent = createEvent.click(node, {button: 2})
fireEvent(node, myEvent)
// 可以像访问其他事件属性一样访问 myEvent.timeStamp
// 注意:通过 `createEvent` 创建的事件访问基于原生事件 API,
// 因此,应该使用 Object.defineProperty 来设置 HTMLEvent 对象的原生属性(例如 `timeStamp`、`cancelable`、`type`)。
// 更多信息请参见:https://developer.mozilla.org/en-US/docs/Web/API/Event

你还可以创建通用事件:

// 模拟文件输入上的 'input' 事件
fireEvent(
  input,
  createEvent('input', input, {
    target: {files: inputFiles},
    ...init,
  }),
)

使用 Jest 函数模拟

Jest 的模拟函数可以用于测试组件是否会在特定事件响应中调用其绑定的回调函数。

import {render, screen, fireEvent} from '@testing-library/react'

const Button = ({onClick, children}) => (
  <button onClick={onClick}>{children}</button>
)

test('点击时调用 onClick 属性', () => {
  const handleClick = jest.fn()
  render(<Button onClick={handleClick}>Click Me</Button>)
  fireEvent.click(screen.getByText(/click me/i))
  expect(handleClick).toHaveBeenCalledTimes(1)
}

异步方法

在测试中,提供了几个处理异步代码的实用工具。这些工具对于等待元素出现或消失以响应事件、用户操作、超时或 Promise 很有用。使用这些工具时,异步方法返回的是 Promise,因此在调用它们时要确保使用 await.then 处理异步行为。

以下是一些常用的异步方法:

findBy 查询

findBy 方法是 getBy 查询waitFor 的结合。它们接受 waitFor 选项作为最后一个参数(例如 await screen.findByText('text', queryOptions, waitForOptions))。

findBy 查询适用于当你期望一个元素出现,但对 DOM 的更改可能不会立即发生的情况。

const button = screen.getByRole('button', {name: 'Click Me'})
fireEvent.click(button)
await screen.findByText('Clicked once')
fireEvent.click(button)
await screen.findByText('Clicked twice')

waitFor

function waitFor<T>(
  callback: () => T | Promise<T>,
  options?: {
    container?: HTMLElement
    timeout?: number
    interval?: number
    onTimeout?: (error: Error) => Error
    mutationObserverOptions?: MutationObserverInit
  },
): Promise<T>

当需要等待一段时间时,你可以使用 waitFor,等待满足你的预期条件。返回一个 falsy 条件不足以触发重试,回调函数必须抛出错误才能重试条件。以下是一个简单的示例:

// ...
// 等待直到回调函数不抛出错误。在此示例中,它将等待模拟函数被调用一次。
await waitFor(() => expect(mockAPI).toHaveBeenCalledTimes(1))
// ...

waitFor 可能会多次运行回调函数,直到达到超时时间为止。请注意,调用次数受限于 timeoutinterval 选项。

如果在 waitFor 回调函数中返回一个 Promise(显式或隐式使用 async 语法),则 waitFor 实用程序在该 Promise 拒绝之前不会再次调用回调函数。这允许你 waitFor 异步检查的内容。

默认的 container 是全局的 document。确保你等待的元素是 container 的后代元素。

默认的 interval50ms。然而,它会在开始间隔之前立即运行回调函数。

默认的 timeout1000ms

默认的 onTimeout 接收错误并将 container 的打印状态附加到错误消息中,这应该能够更容易地找出导致超时的原因。

默认的 mutationObserverOptions{subtree: true, childList: true, attributes: true, characterData: true},它会检测 container 及其后代中的子元素(包括文本节点)的添加和删除。它还检测属性更改。当发生任何这些更改时,它会重新运行回调函数。

waitForElementToBeRemoved

function waitForElementToBeRemoved<T>(
  callback: (() => T) | T,
  options?: {
    container?: HTMLElement
    timeout?: number
    interval?: number
    onTimeout?: (error: Error) => Error
    mutationObserverOptions?: MutationObserverInit
  },
): Promise<void>

要等待从 DOM 中删除元素,你可以使用 waitForElementToBeRemovedwaitForElementToBeRemoved 函数是对 waitFor 实用程序的简单包装。

第一个参数必须是一个元素、元素数组或返回元素、元素数组的回调函数。

以下是一个示例,其中的 Promise 在元素被删除时解析:

const el = document.querySelector('div.getOuttaHere')

waitForElementToBeRemoved(document.querySelector('div.getOuttaHere')).then(() =>
  console.log('Element no longer in DOM'),
)

el.setAttribute('data-neat', true)
// 其他突变被忽略...

el.parentElement.removeChild(el)
// 输出 'Element no longer in DOM'

如果第一个参数为 null 或空数组,waitForElementToBeRemoved 会抛出错误:

waitForElementToBeRemoved(null).catch(err => console.log(err))
waitForElementToBeRemoved(queryByText(/not here/i)).catch(err =>
  console.log(err),
)
waitForElementToBeRemoved(queryAllByText(/not here/i)).catch(err =>
  console.log(err),
)
waitForElementToBeRemoved(() => getByText(/not here/i)).catch(err =>
  console.log(err),
)

// 错误:传递给 waitForElementToBeRemoved 的元素已经被移除。在等待移除之前,waitForElementToBeRemoved 要求元素存在。

选项对象会传递给 waitFor

出现消失

有时候,你需要测试一个元素是否存在,然后消失,或者相反。

等待元素出现

如果你需要等待一个元素出现,异步等待工具允许你在继续之前等待一个断言得到满足。等待工具会重试直到查询通过或超时。异步方法返回一个 Promise,因此在调用它们时必须始终使用 await.then(done)

  1. 使用 findBy 查询
test('电影标题出现', async () => {
  // 元素初始状态下不存在...
  // 等待元素出现并返回该元素
  const movie = await findByText('狮子王')
})
  1. 使用 waitFor
test('电影标题出现', async () => {
  // 元素初始状态下不存在...

  // 在断言中等待元素出现
  await waitFor(() => {
    expect(getByText('狮子王')).toBeInTheDocument()
  })
})

等待元素消失

waitForElementToBeRemoved 异步助手函数 使用回调函数在每次 DOM 变化时查询元素,并在元素被移除时返回 true

test('电影标题在 DOM 中不再存在', async () => {
  // 元素已被移除
  await waitForElementToBeRemoved(() => queryByText('木乃伊'))
})

使用 MutationObserver 比使用 waitFor 在固定时间间隔内轮询 DOM 更高效。

waitFor 异步助手函数 会重试,直到包装函数不再抛出错误。这可用于断言一个元素从页面中消失。

test('电影标题消失了', async () => {
  // 元素初始状态下存在...
  // 注意使用 queryBy 而不是 getBy,这样在查询时返回 null 而不是抛出异常
  await waitFor(() => {
    expect(queryByText('机器人总动员')).not.toBeInTheDocument()
  })
})

断言元素不存在

标准的 getBy 方法在找不到元素时会抛出错误,所以如果你想断言一个元素在 DOM 中 不存在,你可以使用 queryBy API:

const submitButton = screen.queryByText('提交')
expect(submitButton).toBeNull() // 它不存在

queryAll 版本的 API 返回匹配节点的数组。数组的长度在元素被添加或从 DOM 中删除后进行断言时很有用。

const submitButtons = screen.queryAllByText('提交')
expect(submitButtons).toHaveLength(0) // 没有元素

not.toBeInTheDocument

jest-dom 实用工具库提供了 .toBeInTheDocument() 匹配器,它可用于断言一个元素是否在文档的 body 中存在或不存在。。这比断言查询结果为 null 更有意义。

import '@testing-library/jest-dom'
// 使用 `queryBy` 避免使用 `getBy` 时抛出错误
const submitButton = screen.queryByText('提交')
expect(submitButton).not.toBeInTheDocument()

fireEvent 的注意事项

交互 vs.事件

根据指导原则,你的测试应尽可能地模拟用户与你的代码(组件、页面等)的交互方式。在这方面,你应该知道 fireEvent 并不完全等同于用户与应用程序的实际交互方式,但对于大多数场景来说,它已经足够接近了。

fireEvent.click 为例,它会创建一个点击事件并在指定的 DOM 节点上触发该事件。这在大多数情况下适用,当你只是想测试在元素被点击时会发生什么时。但是,当用户实际点击你的元素时,通常会依次触发以下事件:

  • fireEvent.mouseOver(element)
  • fireEvent.mouseMove(element)
  • fireEvent.mouseDown(element)
  • element.focus()(如果该元素可获取焦点)
  • fireEvent.mouseUp(element)
  • fireEvent.click(element)

如果该元素恰好是 label 元素的子元素,则还会将焦点移动到 label 标记的表单控件上。因此,即使你实际上只是想测试点击处理程序,但只使用 fireEvent.click 就会忽略用户在此过程中触发的其他几个潜在重要的事件。

同样,大多数情况下,这对于测试来说并不是关键,使用 fireEvent.click 这种简便方式是值得的。

其他选择

我们将介绍一些简单的调整方法,可以增加你对组件交互行为的信心。对于其他交互,你可能还可以考虑使用 user-event 或在实际环境中测试组件(例如,手动测试、使用 Cypress 自动化测试等)。

Keydown

当前获取焦点的元素、body 元素或文档元素会触发 keydown 事件。根据这个原则,你应该优先使用以下方式:

- fireEvent.keyDown(getByText('click me'));
+ getByText('click me').focus();
+ fireEvent.keyDown(document.activeElement || document.body);

这样还可以测试所涉及的元素是否可以接收键盘事件。

Focus/Blur

如果一个元素获取焦点,会触发 focus 事件,文档中的活动元素会发生变化,并且之前获取焦点的元素会失去焦点。要模拟这种行为,你只需用直接的方式设置焦点,而不使用 fireEvent

- fireEvent.focus(getByText('focus me'));
+ getByText('focus me').focus();

这种方法的一个好处是,如果元素无法获取焦点,则对触发的 focus 事件进行的任何断言都会失败。这在你随后使用 keydown 事件时尤其重要。

使用 Fake Timers

在某些情况下,当你的代码使用定时器(setTimeoutsetIntervalclearTimeoutclearInterval)时,你的测试可能会变得不可预测、缓慢和不稳定。

为了解决这些问题,或者如果你的代码需要依赖特定的时间戳,大多数测试框架都提供了用假定时器替换真实定时器的选项。这应该只偶尔使用,而不是经常使用,因为使用假定时器会带来一些开销。

在测试中使用假定时器时,测试中的所有代码都使用假定时器。

设置假定时器的常见模式通常在 beforeEach 中进行,例如:

// 使用 Jest 设置假定时器
beforeEach(() => {
  jest.useFakeTimers()
})

在使用假定时器时,你需要记得在测试运行结束后恢复真实定时器。

这样做的主要原因是防止第三方库在你的测试完成后(例如清理函数)仍然依赖于你的假定时器并使用真实定时器。

通常你会在 afterEach 中调用 useRealTimers 来实现这一点。

在切换到真实定时器之前,还重要的一点是调用 runOnlyPendingTimers。这样做可以确保在切换到真实定时器之前刷新所有挂起的定时器。如果你不推进定时器的进度,只是切换到真实定时器,那么计划的任务将不会被执行,导致意外行为。这对于在你不知情的情况下调度任务的第三方库来说尤为重要。

以下是使用 Jest 实现上述操作的示例:

// 运行所有挂起的定时器并切换到真实定时器,使用 Jest
afterEach(() => {
  jest.runOnlyPendingTimers()
  jest.useRealTimers()
})

链接

0

评论 (0)

取消