React-testing-library: fireEvent vs userEvent
The black sheep
When we develop our React applications there is a part which is as important as the development itself, and which sometimes is left behind: the testing.
With an appropriate testing we can adjust and change our code knowing that our application (or our components) will continue working as expected since, otherwise, our tests will warn us about it.
We can discuss wether we should follow a TDD or a BDD approach, or if we should develop first and test later, or maybe a little bit of this and that because we are non-conformist people and we like to do it our own way breaking with everything pre-established; but there is something that everyone agrees with: having tools that make your life easier is always better.
React-testing-library: testing from user experience
Here is where we find react-testing-library (or @testing-library/react
, as its package name).
This library will help us to approach our testing from a user experience perspective, as it will give us tools not to test the implementation details of the component, but to test the user behaviour. It will help us to make our testing thinking in the user and what they will see and will experience in the end as the result of any interaction.
If we want to interact with our components and elements in our tests we should be capable to simulate those interactions, and that's the reason why this library includes the fireEvent
tool, which will allow us to trigger events in our DOM elements and simulate the events that the user would trigger in their interactions.
import { fireEvent } from "@testing-library/react";
Inside this fireEvent
object, we can find some methods with which we'll be able to trigger events programatically, such as click()
, mouseOver()
, focus()
, change()
...
But if we want to take full advantage of this kind of tests and we really want to simulate what the user would do and how our application will react to those user interactions, we need something else.
fireEvent vs userEvent
Here is where we can use user-event (or @testing-library/user-event
, as its package name).
import userEvent from "@testing-library/user-event";
This library will allow us, same as fireEvent
, to trigger some events in our DOM elements, so we can simulate those interactions and check how our application reacts to them.
So, what's the difference between them? Well, the difference is that userEvent
simulates complete interactions, and not only single events. We can find inside the userEvent
object events such as click()
, type()
, hover()
, clear()
...
But what does it mean to say that it simulates complete interactions? We can have a better understanding if we take a look at its source code:
function click(...) {
if (!skipPointerEventsCheck && !hasPointerEvents(element)) {
throw new Error(...)
}
// We just checked for `pointerEvents`. We can always skip this one in `hover`.
if (!skipHover) hover(element, init, {skipPointerEventsCheck: true})
if (isElementType(element, 'label')) {
clickLabel(element, init, {clickCount})
} else if (isElementType(element, 'input')) {
if (element.type === 'checkbox' || element.type === 'radio') {
clickBooleanElement(element, init, {clickCount})
} else {
clickElement(element, init, {clickCount})
}
} else {
clickElement(element, init, {clickCount})
}
}
In this piece of code, we can see how the userEvent
library uses fireEvent
methods to trigger some events, but it calls them in sequence and in the order in which they would occur if a real user was the one behind the screen.
In this case, the click()
method won't only click the element, but it will also simulate the previous hover that a real user would trigger moving the pointer over the element to click it.
If we follow the traces, we'll see how the clickElement()
method which is being called is nothing else than a set of fireEvent
methods triggered one after the other, simulating a pointerDown
and a pointerUp
later, among the rest.
We can see a very clear example explained in the userEvent
official documentation.
Testing the theory
Maybe there are some doubts on how this affects us, but we can test it with a little React project and some very simple tests so we can see the differences between these two libraries.
To create the React project, we can follow these simple steps, which include also Prettier and Eslint installation so we keep good practices in our code, even if it's just a test project!
Create our project with CRA
> npx create-react-app testing-library-project
Install eslint y prettier
> yarn add -D eslint-config-airbnb eslint-config-prettier eslint-plugin-jsx-a11y eslint-plugin-prettier prettier
Create .eslintrc file in our root
After creating our file with this content, we'll also delete the "eslintConfig"
key in our package.json to avoid conflicts:
{
"extends": [
"react-app",
"react-app/jest",
"airbnb",
"plugin:jsx-a11y/recommended",
"prettier"
],
"plugins": ["jsx-a11y", "prettier"],
"rules": {
"jsx-a11y/mouse-events-have-key-events": 0
}
}
Optional: create .eslintignore file to avoid errors
We can see some eslint errors in our console due to some packages installed in our folder node_modules. To avoid them, simply create an .eslintignore file and add this content:
node_modules
Add user-event and dom libraries
> yarn add -D @testing-library/user-event @testing-library/dom
Preparing the battlefield
Due to our newly added eslint rules, we'll have to do some changes in our files to fit the requirements and remove the errors and warnings. Once done, we'll modify our App.jsx
file so that its content looks like this:
import React, { useState } from "react";
import "./App.css";
function App() {
const [state, setState] = useState({
onHoverActivated: false,
onClickActivated: false,
onTypeActivated: false,
onFocusActivated: false,
});
const {
onHoverActivated,
onClickActivated,
onTypeActivated,
onFocusActivated,
} = state;
const handleOnHover = () => {
setState((current) => ({ ...current, onHoverActivated: true }));
};
const handleOnClick = () => {
setState((current) => ({ ...current, onClickActivated: true }));
};
const handleOnType = () => {
setState((current) => ({ ...current, onTypeActivated: true }));
};
const handleOnFocus = () => {
setState((current) => ({ ...current, onFocusActivated: true }));
};
return (
<div className="App">
<section>
<div>
<button
type="button"
data-testid="button-target"
onClick={handleOnClick}
onMouseOver={handleOnHover}
>
This will have hover and click events assigned!
</button>
<span data-testid="on-hover-span">{onHoverActivated.toString()}</span>
<span data-testid="on-click-span">{onClickActivated.toString()}</span>
</div>
</section>
<section>
<div>
<input
type="text"
onFocus={handleOnFocus}
onKeyDown={handleOnType}
data-testid="input-target"
/>
<span data-testid="on-type-span">{onTypeActivated.toString()}</span>
<span data-testid="on-focus-span">{onFocusActivated.toString()}</span>
</div>
</section>
</div>
);
}
export default App;
As we can see, we're only creating a button and a text input with some event handlers associated to them, and we've added some span
elements which will contain our variables' values.
Let's begin.
Testing the differences
We'll modify our App.test.jsx
file to add the needed import
statements for our tests:
import React from "react";
import { render, screen, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import App from "./App";
If we want, we can include a little helper that renders our component in each test, and we can create an initial test so we can check if everything is in place, wrapping everything around a describe
block for the sake of organisation:
describe("App container", () => {
const renderApp = () => render(<App />);
it("renders the app", () => {
renderApp();
expect(screen.getByTestId("on-hover-span")).toBeInTheDocument();
});
});
Now we should be able to run yarn test
in our consoles and everything should be in green.
Checking interactions with our button
We will create now the tests for our button, so we'll use again the describe
block:
describe("button control", () => {
let onClickValue;
let onHoverValue;
it("clicks the button using fireEvent", () => {
renderApp();
fireEvent.click(screen.getByTestId("button-target"));
expect(screen.getByTestId("on-click-span")).toHaveTextContent(onClickValue);
expect(screen.getByTestId("on-hover-span")).toHaveTextContent(onHoverValue);
});
it("clicks the button using userEvent", () => {
renderApp();
userEvent.click(screen.getByTestId("button-target"));
expect(screen.getByTestId("on-click-span")).toHaveTextContent(onClickValue);
expect(screen.getByTestId("on-hover-span")).toHaveTextContent(onHoverValue);
});
});
This test is going to fail, of course, because in our document will appear true
or false
as the content of these span
elements, and we've not assigned any value to those variables yet. Having into account what we've already seen, which values do you think our onClickValue
and onHoverValue
variables will have?
As we've explained, fireEvent
will only trigger a specific event, so in the first test, the values will equal true
and false
, respectively. Only the click
event has been triggered, and nothing else.
In the second test, however, using userEvent
we're simulating a complete interaction from the user, so both values will be true
: the library will simulate that the user is moving the mouse until it's over the button, and they press and release the mouse button.
In this case, if we had used event handlers for mouseUp
and mouseMove
events, they would have also their values set to true
.
Checking interactions with our input
We will create our tests for our input interactions:
describe("input control", () => {
let onTypeValue;
let onFocusValue;
it("changes the input value using fireEvent", () => {
renderApp();
const input = screen.getByTestId("input-target");
fireEvent.change(input, { target: { value: "hello" } });
expect(input).toHaveDisplayValue("hello");
expect(screen.getByTestId("on-type-span")).toHaveTextContent(onTypeValue);
expect(screen.getByTestId("on-focus-span")).toHaveTextContent(onFocusValue);
});
it("changes the input value using userEvent", () => {
renderApp();
const input = screen.getByTestId("input-target");
userEvent.type(input, "hello");
expect(input).toHaveDisplayValue("hello");
expect(screen.getByTestId("on-type-span")).toHaveTextContent(onTypeValue);
expect(screen.getByTestId("on-focus-span")).toHaveTextContent(onFocusValue);
});
});
In this case, which value do you think our onTypeValue
and onFocusValue
variables will have? As we've seen previously, fireEvent
will only trigger the onChange
event, so both of them will be false in the first test. Keep in mind that in our application, we are listening for onFocus
and onKeyDown
events, but none of them were triggered.
On the other hand, in the second case, using userEvent
, both values will be true
, because we're simulating a typing interaction, and the library will execute onFocus
and onKeyDown
events, which will also trigger the onChange
event in our input
element.
Which to use and when to use it?
The answer to this question is easy: we should always try to use userEvent
over fireEvent
whenever we are able to, except in very specific situations.
Those exepctions can be scenarios in which some of those events inside the interaction chain make impossible to test correctly the logic we want to test.
For example, if we want to test if our input
doesn't execute the onChange
method if it has not been focused, using userEvent
will trigger the onFocus
event, which will make it impossible for us to test that behaviour. In this case, fireEvent
would be the chosen one so we can trigger only and specifically that event.
But this approach corresponds to a more logical focused testing, and that is not useful for our end users, as they won't be able to see nor experiment these kind of behaviours. If we want to test really how our user will see and use our application, this is not the correct methodology.
Because of this, userEvent
should be the library of choice in those tests that require to simulate interactions with our components and DOM elements.