触发事件
注意:大多数项目都会为 fireEvent 设置一些用例,但大多数情况下,您可能应该使用@testing-library/user-event。
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。
target:当在元素上分发事件时,事件具有一个名为 target 的属性,该属性包含受事件影响的元素。作为便利之处,如果您在 eventProperties(第二个参数)中提供了target属性,那么这些属性将被分配给接收事件的节点。
这对于更改事件特别有用:
fireEvent.change(getByLabelText(/username/i), {target: {value: 'a'}})
// note: attempting to manually set the files property of an HTMLInputElement
// results in an error as the files property is read-only.
// this feature works around that by using Object.defineProperty.
fireEvent.change(getByLabelText(/picture/i), {
target: {
files: [new File(['(⌐□_□)'], 'chucknorris.png', {type: 'image/png'})],
},
})
// Note: The 'value' attribute must use ISO 8601 format when firing a
// change event on an input of type "date". Otherwise the element will not
// reflect the changed value.
// Invalid:
fireEvent.change(input, {target: {value: '24/05/2020'}})
// Valid:
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'})],
},
})
Keyboard events: 有三种与键盘输入相关的事件类型:keyPress、keyDown 和 keyUp。触发这些事件时,你需要引用 DOM 中的一个元素和你要触发的键。
fireEvent.keyDown(domNode, {key: 'Enter', code: 'Enter', charCode: 13})
fireEvent.keyDown(domNode, {key: 'A', code: 'KeyA'})
您可以在找到要使用的key code https://www.toptal.com/developers/keycode.
createEvent[eventName]
createEvent[eventName](node: HTMLElement, eventProperties: Object)
这是创建DOM事件的便捷方法,然后可以通过 fireEvent 来触发这些事件,使您能够引用所创建的事件:如果您需要访问无法通过编程方式初始化的事件属性(例如timeStamp),那么这可能很有用。
const myEvent = createEvent.click(node, {button: 2})
fireEvent(node, myEvent)
// myEvent.timeStamp can be accessed just like any other properties from myEvent
// note: The access to the events created by `createEvent` is based on the native event API,
// Therefore, native properties of HTMLEvent object (e.g. `timeStamp`, `cancelable`, `type`) should be set using Object.defineProperty
// For more info see: https://developer.mozilla.org/en-US/docs/Web/API/Event
您也可以创建通用事件:
// simulate the 'input' event on a file input
fireEvent(
input,
createEvent('input', input, {
target: {files: inputFiles},
...init,
}),
)
使用 Jest 函数模拟(Using Jest Function Mocks)
Jest的模拟函数可以用来测试,一个组件是否会在某个特定事件发生时调用其绑定的回调函数。
// React
import {render, screen, fireEvent} from '@testing-library/react'
const Button = ({onClick, children}) => (
<button onClick={onClick}>{children}</button>
)
test('calls onClick prop when clicked', () => {
const handleClick = jest.fn()
render(<Button onClick={handleClick}>Click Me</Button>)
fireEvent.click(screen.getByText(/click me/i))
expect(handleClick).toHaveBeenCalledTimes(1)
})
// Angular
import {render, screen, fireEvent} from '@testing-library/angular'
@Component({
template: `<button (click)="handleClick.emit()">Click Me</button>`,
})
class ButtonComponent {
@Output() handleClick = new EventEmitter<void>()
}
test('calls onClick prop when clicked', async () => {
const handleClick = jest.fn()
await render(ButtonComponent, {
componentOutputs: {
handleClick: {emit: handleClick} as any,
},
})
await fireEvent.click(screen.getByText(/click me/i))
expect(handleClick).toHaveBeenCalledTimes(1)
})
异步方法
提供了几个用于处理异步代码的实用工具。 这些在等待元素响应事件、用户操作、超时或 Promise 出现或消失时可能很有用。 (请参阅测试消失的指南。)
异步方法返回 Promise,因此请确保在调用它们时使用 await 或 .then。
findBy
Queries
findBy
方法是 getBy
查询和 waitFor
的组合。它们将 waitFor
选项作为最后一个参数接受(例如 await screen.findByText('text', queryOptions, waitForOptions)
)。
当你期望一个元素出现,但 DOM 的更改可能不会立即发生时,findBy
查询就派上用场了。
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
来等待的期望通过。返回一个假条件不足以触发重试,回调函数必须抛出一个错误才能重试条件。这是一个简单的例子:
// ...
// Wait until the callback does not throw an error. In this case, that means
// it'll wait until the mock function has been called once.
await waitFor(() => expect(mockAPI).toHaveBeenCalledTimes(1))
// ...
waitFor
可能会多次运行回调,直到达到超时时间。请注意,调用的次数受超时和间隔选项的限制。
如果你有一个模拟API调用的单元测试,并且需要等待所有的模拟Promise都解决,那么这将非常有用。
如果在waitFor
回调中返回一个Promise(无论是显式还是隐式地使用异步语法),那么waitFor
工具将不会再次调用你的回调,直到那个Promise被拒绝。这允许你等待必须异步检查的事情。
默认容器是全局文档。请确保你等待的元素是容器的后代。
默认间隔是50毫秒。但是,在开始间隔之前,它会立即运行你的回调。
默认超时是1000毫秒。
默认的onTimeout
接受错误,并将容器的打印状态附加到错误消息中,这应该有助于更容易地追踪导致超时的原因。
默认的mutationObserverOptions
是{subtree: true, childList: true, attributes: true, characterData: true}
,它检测容器及其任何后代中子元素的添加和删除(包括文本节点),还检测属性更改。当发生这些更改中的任何一个时,它将重新运行回调。
waitForElementToBeRemoved
function waitForElementToBeRemoved<T>(
callback: (() => T) | T,
options?: {
container?: HTMLElement
timeout?: number
interval?: number
onTimeout?: (error: Error) => Error
mutationObserverOptions?: MutationObserverInit
},
): Promise<void>
等待 DOM 中元素的移除,你可以使用 waitForElementToBeRemoved
。waitForElementToBeRemoved
函数是 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)
// other mutations are ignored...
el.parentElement.removeChild(el)
// logs '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),
)
// Error: The element(s) given to waitForElementToBeRemoved are already removed. waitForElementToBeRemoved requires that the element(s) exist(s) before waiting for removal.
选项对象被转发给waitFor。
判断元素是否存在
有时你需要测试一个元素是否存在或者消失。
等待出现
如果你需要等待某个元素出现,异步等待工具允许你在继续执行之前等待断言的满足。等待工具会一直重试直到查询通过或超时。异步方法返回的是一个 Promise,所以你在调用它们时必须始终使用 await 或 .then(done)。
1. Using findBy
Queries
test('movie title appears', async () => {
// element is initially not present...
// wait for appearance and return the element
const movie = await findByText('the lion king')
})
2. Using waitFor
test('movie title appears', async () => {
// element is initially not present...
// wait for appearance inside an assertion
await waitFor(() => {
expect(getByText('the lion king')).toBeInTheDocument()
})
})
等待消失
waitForElementToBeRemoved异步辅助函数使用回调来在每次DOM更改时查询元素,并在元素被移除时解析为 true。
test('movie title no longer present in DOM', async () => {
// element is removed
await waitForElementToBeRemoved(() => queryByText('the mummy'))
})
使用 MutationObserver 比使用 waitFor 定期轮询DOM更有效。
waitFor 异步辅助函数会不断重试,直到包装的函数不再抛出错误。这可以用于断言页面上的元素已消失。
test('movie title goes away', async () => {
// element is initially present...
// note use of queryBy instead of getBy to return null
// instead of throwing in the query itself
await waitFor(() => {
expect(queryByText('i, robot')).not.toBeInTheDocument()
})
})
断言元素不存在
标准的 getBy 方法在找不到元素时会抛出错误,因此,如果你想断言 DOM 中不存在某个元素,你可以使用 queryBy API 替代:
const submitButton = screen.queryByText('submit')
expect(submitButton).toBeNull() // it doesn't exist
queryAll API 版本返回一个匹配节点的数组。在元素添加到或删除自 DOM 后,该数组的长度对于断言很有用。
const submitButtons = screen.queryAllByText('submit')
expect(submitButtons).toHaveLength(0) // expect no elements
not.toBeInTheDocument
Jest-dom 实用库提供了 .toBeInTheDocument() 匹配器,它可以用来断言一个元素是否存在于文档的主体中,或者不存在。这比断言查询结果为 null 更有意义。
import '@testing-library/jest-dom'
// use `queryBy` to avoid throwing an error with `getBy`
const submitButton = screen.queryByText('submit')
expect(submitButton).not.toBeInTheDocument()
fireEvent 的考虑因素
交互与事件
基于指导原则,你的测试应尽可能模拟用户与你的代码(组件、页面等)的交互方式。记住这一点,你应该知道fireEvent并不完全是用户与你的应用程序交互的方式,但对于大多数场景来说,它已经足够接近了。
以fireEvent.click为例,它创建了一个点击事件并在给定的DOM节点上派发了该事件。当你想测试元素被点击时会发生什么时,这在大多数情况下都能正常工作,但当用户实际点击你的元素时,通常会按以下顺序触发这些事件:
* fireEvent.mouseOver(element)
* fireEvent.mouseMove(element)
* fireEvent.mouseDown(element)
* element.focus()(如果该元素可聚焦)
* fireEvent.mouseUp(element)
* fireEvent.click(element)
然后,如果那个元素恰好是一个标签的子元素,那么它还会将焦点移动到标签所标记的表单控件上。因此,即使你真正要测试的只是点击处理程序,但仅仅使用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
如果一个元素被聚焦,那么就会派发一个聚焦事件,文档中的活动元素会发生变化,之前被聚焦的元素会变得模糊。为了模拟这种行为,你可以简单地用强制聚焦替换掉fireEvent:
- fireEvent.focus(getByText('focus me'));
+ getByText('focus me').focus();
这种方法的一个很好的副作用是,如果元素不能获得焦点,则任何关于触发焦点事件的断言都将失败。 如果你随后执行一个键盘按下事件,这一点尤其重要。
使用Fake Timers
在某些情况下,当你的代码使用定时器(setTimeout、setInterval、clearTimeout、clearInterval)时,你的测试可能会变得不可预测、缓慢且不稳定。
为了解决这些问题,或者如果你需要在你的代码中依赖特定的时间戳,大多数测试框架都提供了用虚拟定时器替换测试中的实际定时器的选项。由于使用它包含一些开销,因此应该偶尔使用,而不是常规使用。在测试中使用虚拟定时器时,测试中的所有代码都使用虚拟定时器。设置虚拟定时器的常见模式通常是在 beforeEach 中,例如:
// Fake timers using Jest
beforeEach(() => {
jest.useFakeTimers()
})
在使用假定时器时,您需要在测试运行后记得恢复定时器。这样做的主要原因是防止测试结束后运行的第三方库(例如清理函数)与您的假定时器耦合并使用真实定时器。
为此,您通常会在 afterEach 中调用 useRealTimers。
在切换到真实定时器之前,调用 runOnlyPendingTimers 也很重要。这将确保在切换到真实定时器之前刷新所有挂起的定时器。如果您不推进定时器而只是切换到真实定时器,则计划的任务将不会执行,您将遇到意外的行为。这对于您未意识到的第三方计划的任务尤为重要。
以下是一个使用jest进行此操作的示例:
// Running all pending timers and switching to real timers using Jest
afterEach(() => {
jest.runOnlyPendingTimers()
jest.useRealTimers()
})