Introduction
As a developer, one of the things at the top of your list ought to be shipping bug-free code. Nothing could be worse than finding out on Thursday night that the changes you made on Monday broke the live application. The only way to ensure that your app works according to both system and user requirements is to test it!
Testing is a crucial component of any software development lifecycle and ensures that a piece of software operates properly and according to plan. Web development, mobile app development, and, more significantly in our context, React applications all follow the same principles.
React components can be tested in a few different ways, broadly divided into two groups:
- Rendering component trees in a simple test environment and making assertions about their performance
- Running "end-to-end tests", which involves testing an entire application in a realistic browser environment
While testing React apps may be done in a number of ways, in this guide, we will create a React app and cover a complete guide to how we can perform unit tests on the React application using Jest and React Testing Library so that you can hone your testing skills and learn how to create a tame React application.
Note: You can get access to the repository for this guide and play around with all that's therein, using this link on GitHub.
What is Testing?
First of all, let's put things into a perspective. Testing is a very wide term, and can refer to manual testing, unit testing, regression testing, integration testing, load testing, etc.
In the context of unit testing which we'll focus on today - we test the function of distinctive units, typically on a method-level. This can test the numerical values of the outputs, the length of output values, their shapes, how the method reacts to invalid input, etc.
Since most good software practices advocate for short, actionable methods/functions that are self-contained with a clear purpose, many methods will call other methods. Typically, you'll want to test both the internal methods and external methods, to ensure that any changes you make while refactoring, fixing bugs or improving a feature don't break any other functionality.
In Test-Driven Development (TDD), you're encouraged to write a test and expected value before writing the logic for a method. Naturally, it'll fail at first. After that, you just make it work however, and when it passes the test, you start refactoring it to make it shorter, cleaner, faster, etc. As long as the output stays the same, you know that you haven't broken anything while refactoring!
Writing your own unit tests puts you in the mindset of someone using your methods, rather than someone writing those methods, which often helps to take a fresh look at a feature, incorporate additional checks and validation and hunt for bugs. Sometimes, it leads to design changes to make the code more testable, such as decoupling functionality to enable numerical testing for each individual component.
Once a baseline is established, and your code passes the tests, you can make changes and validate that the individual units (typically methods) work individually. Testing is especially useful when there are updates to a codebase.
Approaches to Testing
Testing can be done in two different ways: manually and automatically. By interacting directly with an application, manual testing verifies that it functions properly. Automated testing is the practice of writing programs to perform the checks for you.
Manual Testing
Most developers manually review their code, as this is the fastest, most natural and simplest way to quickly test a functionality.
Manual testing is the next logical step that follows after writing functionality, much like tasting a dish comes after seasoning it (adding a feature) to check whether it worked as intended.
Assume that, as an employed developer, you are building a sign-up form. You don't simply close your text editor and inform your boss that the form is complete after coding. You'll open the browser, go through the sign-up form process, and make sure everything goes as planned. In other words, you'll manually test the code.
Manual testing is ideal for small projects, and you don't need automated tests if you have a to-do list application that you can check manually every two minutes. However, depending on manual testing becomes difficult as your app grows - it could become all too easy to lose concentration and forget to check something, perhaps. With a growing list of interacting components, manual testing becomes even harder, especially if you've tested something, and progressed to a new item and broken the last feature, so you don't test it again for a while not knowing it's now broken.
Simply put, manual testing is okay for starting out - but doesn't scale well and doesn't guarantee code quality for larger projects. The good news is that computers are awesome at tasks like these, we have automated testing to thank!
Automated Testing
In automated testing, you write additional code to test your application code. After you've written the test code, you can test your app as many times as you want with minimal effort.
There are numerous techniques for writing automated tests:
- Writing programs to automate a browser,
- Calling functions directly from your source code,
- Comparing screenshots of your rendered application...
Each technique has its own set of advantages, but they all have one thing in common - they save you time and ensure higher code quality over manual testing!
Automated tests are excellent for ensuring that your application is performing as planned. They also make it easier to go over code changes within an application.
Types of Testing
So far, we have looked at tests at a high level. It is about time to discuss the various types of tests that can be written.
There are three types of front-end application tests:
-
Unit tests: In unit tests, individual units or components of the software are tested. An individual unit is a single function, method, procedure, module, component, or object. A unit test isolates and verifies a section of code in order to validate that each unit of the software's code performs as expected.
Individual modules or functions are tested in unit testing to ensure that they are working properly as they ought to, and all components are tested individually too. Unit testing would include, for example, determining whether a function, a statement, or a loop in a program is working properly. -
Snapshot tests: This type of test ensures that a web application's user interface (UI) does not change unexpectedly. It captures the code of a component at a specific point in time, allowing us to compare the component in one state to any other possible state it could take.
A typical snapshot test scenario involves rendering a UI component, taking a snapshot, and comparing the snapshot to a reference snapshot file kept with the test. If the two snapshots differ, the test will fail because the change was either unexpected or the reference snapshot needed to be updated to reflect the new UI component. -
End-to-end tests: End-to-end tests are the easiest type of test to understand. End-to-end tests in front-end applications automate a browser to ensure that an application works correctly from the user's perspective.
End-to-end tests save a lot of time. You can run an end-to-end test as many times as you want after you've written it. Consider how much time a suite of hundreds of these tests could potentially save compared to writing tests for each individual unit.
With all the benefits that they bring, end-to-end tests have a few issues. For starters, end-to-end tests are time-consuming. Another issue with end-to-end tests is that they can be difficult to debug.
Note: To avoid reproducibility issues, end-to-end tests can be run in a reproducible environment, such as a Docker container. Docker containers and end-to-end tests are beyond the scope of this guide, but you should look into them if you want to run end-to-end tests to avoid the problem of failures on different machines.
If you'd like to grasp the basics of end-to-end testing with Cypress - read our "End-to-End Testing in JavaScript with Cypress"!
Advantages and Disadvantages of Testing
While testing is important and ought to be done, as usual, it has both benefits and drawbacks.
Advantages
- It guards against unexpected regression
- Testing properly significantly increases the quality of code
- It allows the developer to concentrate on the current task rather than the past
- It enables the modular construction of otherwise hard-to-build applications
- It eliminates the need for manual verification
Disadvantages
- You have to write more code in addition to debugging and maintaining it, and many feel like it's unnecessary overhead in smaller projects, regardless of the benefits
- Non-critical/benign test failures may result in the app being rejected during continuous integration
Overview of Unit Testing
So far, we have taken a look at testing in general. Now is the time to dive into all that pertains to unit testing and how to write unit tests in React applications!
Before defining unit testing, it is necessary for us to come to the knowledge that a good testing approach aims to speed up development time, reduce bugs in an application, and improve the quality of code, while a poor testing approach would cripple an application. As a result, as software developers, we ought to learn effective unit testing approaches, and one of them is unit testing.
A simple definition of testing is that it is the process of checking that an application behaves correctly. Unit testing is the process of running tests against the components or functions of an application. Unit tests are functions that call isolated versions of the functions in your source code to verify that they behave as they should, deterministically.
Pros of Unit Tests
Unit tests are quick and can be run in a few seconds (either individually for a new feature or globally running all tests), giving developers immediate feedback on whether or not a feature is broken. They also help to provide documentation, because if a new developer joins a project, they would need to know how various units of the codebase behave; this can be known by looking at the results of unit tests.
Cons of Unit Tests
While unit tests have their good sides, they also have their own problems. A problem is that refactoring code when it comes to design changes, as these tend to be more difficult with unit tests. Say, for example, you have a complicated function with its unit tests and want to split that function into multiple modular functions. A unit test will probably fail for that function, and you'll need to deprecate it and write two unit tests for the split functions. This is why unit testing implicitly encourages splitting them upfront and testing them individually, leading to more testable, modular code components. Nevertheless, in some cases, you can't foresee the possible changes down the line, and the amount of time it would take to update the unit tests makes serious refactoring processes less appealing.
Another problem with unit testing is that it only checks individual parts of an app, even if that part is a logical combination of multiple smaller parts - there is no unit test for the entire application. Individual parts of an app may work properly, but if how they behave when combined is not tested, the tests may be rendered useless. That's why unit tests should be supplemented with end-to-end tests or integration tests or ideally - both.
Unit Testing a React Application - Demo Project
Let's take a look at a real-world example of unit testing a React application!
In this demo, we will test a Counter app with a lot of different parts to it. Despite sounding like a pretty simple app, it would serve as a good example to learn how unit testing works. The essence of testing this app is that there are different aspects of the component that depend on how the user is interacting with it.
Project Setup
The create-react-app
command, built by the React team, is the best way to get started in creating a real-world and large-scale React application because it is ready to use and works easily with the Jest testing library. If you open the package.json
file, you will find that we have default support for Jest and the React testing library in the setupTests.js
file. This eliminates the need for us to manually install Jest into our project if we need to!
If you haven't already used it - run it with npx
, which will install it for later use:
$ npx create-react-app react-unit-tests
If you do already have the tool installed, create a React app and name it react-unit-tests
:
$ create-react-app react-unit-tests
Note: npx
uses the latest version of create-react-app
, while a globally installed version might not. It's generally advised to run the tool through npx
to ensure the latest versions, unless you purposefully want to use another version.
Next, we enter the project directory and start the development server:
$ cd react-unit-tests && npm start
// OR
$ cd react-unit-tests && yarn start
This will output our newly created app in the browser at localhost:3000
.
Note: A handy feature here is that hot reloading is supported by default, so there is no need to keep reloading the browser just to see new changes, or manually install nodemon
or similar libraries.
Building the Counter Component
In the src
directory of our project, create a new file called Counter.js
. In Counter.js
, we will define all the parts of the component. It'll contain various functions of the Counter, including increment()
, decrement()
, restart()
, and switchSign()
, which inverts the count value from negative to positive when clicked. These functions are created for manipulating the initial count value (which is passed in as a prop):
// Counter.js
import React, { useState } from "react";
function Counter({ initialCount }) {
const [count, setCount] = useState(initialCount);
const increment = () => {
setCount((prev) => prev + 1);
};
const decrement = () => {
setCount((prev) => prev - 1);
};
const restart = () => {
setCount(0);
};
const switchSign = () => {
setCount((prev) => prev * -1);
};
return (
<div>
<h1>
Count: <h3>{count}</h3>
</h1>
<div>
<button onClick={increment}>Increment</button>
<button onClick={decrement}>Decrement</button>
<button onClick={restart}>Restart</button>
<button onClick={switchSign}>Switch sign</button>
</div>
</div>
);
}
export default Counter;
Then, update App.js
:
// App.js
import "./App.css";
import Counter from "./Counter";
function App() {
return (
<div className="App">
<Counter />
</div>
);
}
export default App;
Now, we can view the counter app on the browser:
Creating Tests for Components
Let’s create a test file called Counter.test.js
to represent the test for the Counter component. Make sure to also delete App.test.js
so that it doesn’t create unwanted results while we run tests.
Note: A common practice is to name your test files with a suffix of .test.js
, mirroring the name of the file/component you're testing. This ensures a continuity between test files, changes only being made to the files relevant to the code you're updating when pushing changes (lower number of merge conflicts) and is readable.
Additionally, test files are typically located in a /test
directory parallel to your source code's root directory, though, this is also team-dependent.
In Counter.test.js
, we first import the Counter
component, then start the test with the describe()
function to describe all the different functionalities that could happen within the component.
The
describe()
function is used to group together specific sets of tests that can occur on a component using variousit()
andtest()
methods. It's a sort of logical wrapper, in which you, well, describe what a series of tests do, with eachit()
being a functional test for of a unit.
Testing your React components can be done in a manner such that we may use a test renderer to quickly create a serializable value for your React tree rather than generating the graphical user interface, which would involve creating the complete app.
Testing the Initial Counter Value
When testing, it helps to create a systematic list of features and aspects of a given feature - the states that components can be in, what could conceivably affect them, etc.
The first thing we're going to test is the initial count value and how the component is handling the prop that sets it. With the it()
method, we check if the counter app is actually displaying the exact initial count value that has been passed as a prop, which is 0
in this case, and pass a callback function that describes all the actions that will occur inside the test:
// Counter.test.js
import { render, screen } from "@testing-library/react";
import Counter from "./Counter";
describe(Counter, () => {
it("counter displays correct initial count", () => {
render(<Counter initialCount={0} />);
expect(screen.getByTestId("count").textContent).toEqual(0);
});
});
Here, we used the screen
instance from the React Testing library to render the component for testing purposes. It is useful to render a mock version of a component to be tested. And since the <h3>
element that holds the count
value is bound to dynamically change, we use the screen.getByTestId()
function to listen to it and fetch its value with the textContent
property.
Check out our hands-on, practical guide to learning Git, with best-practices, industry-accepted standards, and included cheat sheet. Stop Googling Git commands and actually learn it!
Note: The screen
function returns a matching DOM node for any query or throws an error if no element is found.
Then, in the Counter.js
component, we will listen to the <h3>
element while testing by setting a data-testid
attribute to the element with a value count
:
// Counter.js
import React, { useState } from "react";
function Counter({ initialCount }) {
const [count, setCount] = useState(initialCount);
const increment = () => {
setCount((prev) => prev + 1);
};
const decrement = () => {
setCount((prev) => prev - 1);
};
const restart = () => {
setCount(0);
};
const switchSign = () => {
setCount((prev) => prev * -1);
};
return (
<div>
<h1>
<!-- Change here! -->
Count: <h3 data-testid="count">{count}</h3>
</h1>
<div>
<button onClick={increment}>Increment</button>
<button onClick={decrement}>Decrement</button>
<button onClick={restart}>Restart</button>
<button onClick={switchSign}>Switch sign</button>
</div>
</div>
);
}
export default Counter;
To test if the initial count
value is equal to 0
, we use the expect()
method to describe what is expected of the test we have set! In our case, we expect the initial count value to be 0
so we use the toEqual()
method, which is used to determine whether the values of two objects match. Instead of determining the object's identity, the toEqual()
matcher recursively checks all fields for equality.
Note: The test is specifically focused on the information you render; in our example, that is, the Counter
component that has received an initialCount
prop. This suggests that even if another file—say, let's App.js
—has missing props in the Counter
component, the test will still pass because it is solely focused on Counter.js
and doesn't know how to use the Counter
component. Additionally, because the tests are independent of one another, rendering the same component with different props in other tests won't affect either.
Now, we can run the set test:
$ yarn test
The test should fail:
FAIL src/Counter.test.js
Counter
× counter displays correct initial count (75 ms)
● Counter › counter displays correct initial count
expect(received).toEqual(expected) // deep equality
Expected: 0
Received: "0"
5 | it("counter displays correct initial count", () => {
6 | render(<Counter initialCount={0} />);
> 7 | expect(screen.getByTestId("count").textContent).toEqual(0);
| ^
8 | });
9 | });
10 |
at Object.<anonymous> (src/Counter.test.js:7:53)
Test Suites: 1 failed, 1 total
Tests: 1 failed, 1 total
Snapshots: 0 total
Time: 1.929 s, estimated 2 s
Ran all test suites related to changed files.
This test failed because we had tested a number against a string, which resulted in a deep equality error. To fix that, cast the textContent
, i.e. the initial value, in our callback function as a number:
// Counter.test.js
import { render, screen } from "@testing-library/react";
import Counter from "./Counter";
describe(Counter, () => {
it("counter displays correct initial count", () => {
render(<Counter initialCount={0} />);
// Change here!
expect(Number(screen.getByTestId("count").textContent)).toEqual(0);
});
});
Now, our code will pass the first test:
$ yarn test
PASS src/Counter.test.js
Counter
√ counter displays correct initial count (81 ms)
Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: 2.271 s
Ran all test suites related to changed files.
This is a simple example of how testing while writing logic helps you avoid issues down the line, before tech debt accumulates further. Testing prematurely can also lock you in, since refactoring and changing logic is more expensive time-wise if you also have to re-write tests.
Finding a good balance can help upgrade your software's quality, with a minimal negative effect on your productivity and speed.
Testing the Increment Button
To test that the increment
button works as it ought to, that is, to increment the count
value by one each time it is clicked, we need to first access the increment
button, then we define a new it()
method for the same.
Since the value of the button is not dynamic, that is, it will always have the value Increment
inside of it, we use the getByRole()
method instead of the getByTestId()
to query the DOM.
When using the
getByRole()
method, a role describes an HTML element.
We must also pass in an object to define which button, in particular, we desire to test, since there might be lots of buttons when the DOM is rendered. In the object, we set a name
with a value that must be the same as the text on the increment button.
The next thing to do is to simulate a click event using the fireEvent()
method, which makes it possible to fire events that simulate user actions while testing.
First, we write a test to see if the count value increases by 1 from its initial value of 0:
// Counter.test.js
import { fireEvent, render, screen } from "@testing-library/react";
import Counter from "./Counter";
describe(Counter, () => {
it("counter displays correct initial count", () => {
render(<Counter initialCount={0} />);
expect(Number(screen.getByTestId("count").textContent)).toEqual(0);
});
it("count should increment by 1 if increment button is clicked", () => {
render(<Counter initialCount={0} />);
fireEvent.click(screen.getByRole("button", { name: "Increment" }));
let countValue = Number(screen.getByTestId("count").textContent);
expect(countValue).toEqual(1);
});
});
This results in:
$ yarn test
PASS src/Counter.test.js
Counter
√ counter displays correct initial count (79 ms)
√ count should increment by 1 if increment button is clicked (66 ms)
Test Suites: 1 passed, 1 total
Tests: 2 passed, 2 total
Snapshots: 0 total
Time: 2.405 s
Ran all test suites related to changed files.
Then, we can also write a test to check if the count
value was 0 before the button was clicked by defining two expect()
methods - one before the click event is fired and another after the click event is fired:
// Counter.test.js
it("count should increment by 1 if increment button is clicked", () => {
render(<Counter initialCount={0} />);
let countValue1 = Number(screen.getByTestId("count").textContent);
expect(countValue1).toEqual(0);
fireEvent.click(screen.getByRole("button", { name: "Increment" }));
let countValue2 = Number(screen.getByTestId("count").textContent);
expect(countValue2).toEqual(1);
});
The tests still passed:
$ yarn test
PASS src/Counter.test.js
Counter
√ counter displays correct initial count (82 ms)
√ count should increment by 1 if increment button is clicked (60 ms)
Test Suites: 1 passed, 1 total
Tests: 2 passed, 2 total
Snapshots: 0 total
Time: 2.388 s
Ran all test suites related to changed files.
Testing the Decrement Button
In the same way we wrote the test for the Increment
button, we define the test for the Decrement
button like so:
// Counter.test.js
it("count should decrement by 1 if decrement button is clicked", () => {
render(<Counter initialCount={0} />);
fireEvent.click(screen.getByRole("button", { name: "Decrement" }));
let countValue = Number(screen.getByTestId("count").textContent);
expect(countValue).toEqual(-1);
});
This results in:
$ yarn test
PASS src/Counter.test.js
Counter
√ counter displays correct initial count (79 ms)
√ count should increment by 1 if increment button is clicked (73 ms)
√ count should decrement by 1 if decrement button is clicked (21 ms)
Test Suites: 1 passed, 1 total
Tests: 3 passed, 3 total
Snapshots: 0 total
Time: 2.346 s
Ran all test suites related to changed files.
Testing the Restart Button
Similar to the Increment
and Decrement
buttons, we define the test for the Restart
button like so:
// Counter.test.js
it("count should reset to 0 if restart button is clicked", () => {
render(<Counter initialCount={50} />);
fireEvent.click(screen.getByRole("button", { name: "Restart" }));
let countValue = Number(screen.getByTestId("count").textContent);
expect(countValue).toEqual(0);
});
For the purpose of testing, the initial value was set to 50 (arbitrary value), and when the test is run, all four tests pass successfully:
$ yarn test
PASS src/Counter.test.js
Counter
√ counter displays correct initial count (81 ms)
√ count should increment by 1 if increment button is clicked (57 ms)
√ count should decrement by 1 if decrement button is clicked (21 ms)
√ count should reset to 0 if restart button is clicked (16 ms)
Test Suites: 1 passed, 1 total
Tests: 4 passed, 4 total
Snapshots: 0 total
Time: 2.583 s
Ran all test suites related to changed files.
Testing the Switch Sign Button
We also write the test for inverting the sign on the count
value by setting the value of count
to 50 in the test file. Then look out for what sign is rendered before and after a click event is fired by the button:
// Counter.test.js
it("count invert signs if switch signs button is clicked", () => {
render(<Counter initialCount={50} />);
let countValue1 = Number(screen.getByTestId("count").textContent);
expect(countValue1).toEqual(50);
fireEvent.click(screen.getByRole("button", { name: "Switch signs" }));
let countValue2 = Number(screen.getByTestId("count").textContent);
expect(countValue2).toEqual(-50);
});
This results in:
$ yarn test
PASS src/Counter.test.js
Counter
√ counter displays correct initial count (91 ms)
√ count should increment by 1 if increment button is clicked (72 ms)
√ count should decrement by 1 if increment button is clicked (21 ms)
√ count should reset to 0 if restart button is clicked (19 ms)
√ count invert signs if switch signs button is clicked (14 ms)
Test Suites: 1 passed, 1 total
Tests: 5 passed, 5 total
Snapshots: 0 total
Time: 3.104 s
Ran all test suites related to changed files.
Wooshh! All tests have passed successfully for our counter app.
Writing tests isn't hard - we effectively simulate the use-cases of a feature to make sure it doesn't break when used as intended, and as unintended. Did someone provide a value out of bounds? A wrong format? The application should resolve the issue instead of fail.
In general, a good starting point for testing is:
- Test for intended behavior (whatever your features are)
- Test for all facets of unintended behavior (wrong inputs, such as unsupported formats, bounds, etc.)
- Test numerically (if your feature produces numerical values that can be verified, compute the result by hand and check whether it returns the right output)
Best Practices for Unit Testing
-
Tests should be deterministic: Running the same tests on the same component multiple times should yield the same results each time. You must ensure that your generated snapshots do not contain platform-specific or other non-deterministic data.
-
Avoid unnecessary tests: Good tests do not come with unnecessary expectations or test cases.
We can find a better understanding by taking a look at the tests below:
test('the success modal is visible', () => {});
test('the success modal has a success message', () => {});
If we know that the success message inside the success modal is visible, then that means the success modal itself is visible too. So in this case, we can safely remove the first test, and only perform the second one, or combine them together. Having lots of tests can give a false sense of security if they're superflous.
-
Avoid exposing internal logic: If your test performs an action that your user does not (such as testing an internal method that isn't exposed to the user), you are most likely testing implementation details. You could end up exposing a private function solely to test your component. This is a code smell that should be avoided. Instead, restructure your codebase so that the private function is testable without exposing it publically.
-
Avoid testing implementation details: If our application increments
x
andy
- whetherx
is incremented first or not likely bares no significance, as long as the result is the same. You should be able to always refactor, change and otherwise update implementation details without breaking the tests, otherwise, tests would catalyze tech debt accumulation by increasing the cost of refactoring and optimization. -
Place business logic into pure functions rather than UI components.
Conclusion
This guide is primarily about unit testing. However, it was important that we first understood and appreciated all that encompasses testing, including what it means, approaches to testing, types of testing, and its advantages and disadvantages.
It's critical to remember why you're writing tests while you're writing them. Typically, the goal of writing tests is to save time. Tests pay dividends if the project you're working on is stable and will be developed for a long time. With this, it is safe to say that testing an application might be seen as not worth it, if it doesn’t save you development time. Above all, good tests are simple to maintain and provide confidence when changing your code.
We also learned how to query the DOM while testing React applications using the getByTestId()
method. It comes off ass useful for defining containers and querying elements with dynamic text, but it should not be your default query. Instead of using the getByTestId()
method right away, try one of these first:
getByRole()
- it queries an element while also ensuring that it is accessible with the correct role and textgetByLabelText()
- it is an excellent query for interacting with form elements, it also checks that our labels are properly linked to our inputs via the for and id attributesgetByText()
- When neither of the previous two queries is available, thegetByText()
method will be useful in accessing elements based on text that is visible to the usergetByPlaceholderText()
: This query is preferable to a test ID when all you have to query for an element is a placeholder.
Testing Playground is an excellent tool for quickly determining how a query works.
We hope that this guide is helpful to you! You can get access to the repository for this guide and play around with all that’s therein, using this link on GitHub.