Note
- complete environment setup found in the
README.mdin vite-starter in the course repo.
This file contains a test for the App component to be run in the CLI using: pnpm test.
Breaking down the syntax:
// file: ./src/App.test.jsx
import { render, screen } from "@testing-library/react";
import App from "./App";
test("App contains correct heading", () => {
render(<App />);
const headingElement = screen.getByText(/learn react/i);
expect(headingElement).toBeInTheDocument();
});render: The render method comes from@testing-library/reactand creates the simulated DOM. It allows the test to understand what we are testing against. It creates the simulated DOM for whatever JSX you give it as an argument (i.e.<App />component).screen: Once it has been rendered the simulated DOM can be accessed via the global object calledscreen(Also imported from@testing-library/react).screen.getByText: Is a method ofscreen. It is looking at the simulated DOM and it is trying to find an element that matches the text we give it as an argument (i.e./learn react/i). In this example we passed in a regular expression (regex) denoted by the forward slashes and there is aniflag to say it is case insensitive (The argument can also be an exact string, but regex are more flexible).const headingElement: If there is any element within the rendered simulated DOM whose display text matches the regex it will be stored in theheadingElementvariable.expect(headingElement).toBeInTheDocument(): Is not part of the testing library. It is part of vitest (Syntax is the same in Jest). This is asserting and can cause the test to succeed or fail. We are asserting that theheadingElementreturned by thegetByTextmethod is in the document. If it is, the statement will be true and the test will succeed otherwise the test will fail.
Note
Running Vitest in --watch mode will auto re-run test when a change has been made.
Assertions determine whether a test passes or fails.
expect: All assertions start with anexpectmethod which is a global in Jest or Vitest.expect(argument): The argument is what you are asserting against. It is what Vitest will examine to see if it meets our expectation.toBeInTheDocument: Is a matcher and is what the assertion type is.toBeInTheDocument(argument): Sometimes there is an argument passed into the matcher. To be in the document does not have an argument (It is either in the document or not). Sometimes you are comparing the element to some sort of known quantity which would need an argument.
expect(element.textContent).toBe('hello');: Most likely using a screen method to find the element on the page. Text content is self explanatory and the matcher (toBe) takes a argument for an exact string (i.e.hello).expect(elementsArray).toHaveLength(7);: TheelementsArraywould have been defined in the previous line. It has a matcher oftoHaveLengthwith an argument of7.
Can also be used with Vitest as well as Jest. If it is used it needs to be imported prior to each test in order to be able to use the matchers with jest-DOM.
Both Jest and/or Vitest use a setup file to import the jest-DOM for each test (/src/setupTests.js).
Once it is imported it allows you to use matchers specific to the DOM. The above examples show more general matchers. Below are DOM-specific matchers:
toBeVisible()toBeChecked()toBeInTheDocument()
There is a division of responsibilities between React Testing Library (RTL) and Jest/Vitest.
RTLs job is to create a simulated DOM for your components which you can then use for interactions and assertions.
You cannot use RTL without a test runner. A test runner will find the tests, run the test, and make assertions. This is where Jest/Vitest comes in.
Note
- Vitest is 3x - 5x faster than Jest.
- Jest is harder to configure for Vite.
- However Jest tends to work better with Next.js.
- Less advanced syntax is virtually identical between the two test runners, only the setup differs.
Both Jest/Vitest have a global test method that takes two arguments:
- A string description of the test.
- A test function to run for test pass/fail.
The test fails if there is an error when the second argument function runs.
Assertions throw errors when the expectation fails. If no error thrown then the test passes (Meaning an empty test passes or a test where everything inside the global test method is empty).
// file: ./src/App.test.jsx
// OTHER CODE...
// Empty Test.
test("Empty Test", () => {});
// Throw Error Explicitly.
test("Test throws error explicitly", () => {
throw new Error("Fail this test!");
});With RTL the part of the code that will throw an error is the assertion.
// file: ./src/App.test.jsx
// OTHER CODE...
// RTL Assertion Error.
test("App contains correct heading", () => {
render(<App />);
const headingElement = screen.getByText(/learn react/i);
// Error thrown with .not.
expect(headingElement).not.toBeInTheDocument();
});Writing tests before writing the code then write the code according to specs set by the tests to make the tests pass.
You want the tests to fail (red tests) before the code is written the after the code is written you see passing tests (green tests).
- You write an empty function or empty functional React component.
- You write your tests and expect them to fail because empty functions.
- Write your code and expect the tests to pass.
It is opinionated or drives you towards best practices when React testing.
- Creates a virtual DOM for testing.
- Provides utilities for interacting with said DOM (i.e. Find elements in the DOM or interact with elements).
- Allows for testing without a browser.
- Unit Tests.
- Test one unit of code in isolation.
- Integration Tests.
- Tests how multiple units work together (i.e. Interaction between components).
- Functional Tests.
- Tests a particular function of the software (You are not testing your code but rather its behaviour). RTL encourages this type of test.
- Acceptance/End-to-End Tests (E2E).
- These tests require a browser and server that your app is connected to (Require a special tool such as Cypress or Selenium). RTL is not built for these types of tests.
With unit testing you want your tests to be as isolated as possible. You do this by mocking your components dependencies (Use test version of a function for example that the component relies on). Sometimes you also test internals (i.e. Testing differences it made to the state because you do not have other components to see what it did to the app).
Pro: Since unit testing is isolated it is easier to pinpoint failures.
Con: It is further from how a user would interact with your software. It is also more likely to break with refactoring (Refactoring: Change how code is written, but not its functionality and with unit testing you are testing how the code is written).
With functional testing you include all relevant units for a particular behaviour or user flow.
Pro: Close to how a user would interact with your software. They are also more robust tests so if you refactor it should still pass.
Con: More difficult to debug failing tests because they aren't as tightly coupled to the code.
Note
There is also such thing as Behaviour Driven Development (BDD), but it requires multiple teams and collaboration.
Testing library suggests to find elements using accessibility handles. This is a better way to test as well as ensuring that your app is accessible to users.
getByRole is the top priority recommended. Some roles are: MDN ARIA Roles.
Other accessibility options if getByRole is not available (i.e. for form inputs) you can use getByLabelText or getByPlaceholderText.
Semantic queries such as getByAltText and getByTitle are also good.
Test IDs are a last resort (i.e. getByTestID).
Back in our code for App.test.jsx we are using the frowned upon getByText method. We can switch it to an ARIA: heading role to utilize accessibility handles. When you use getByRole there are several options, but the most common one to use is name which can also take either a regex or string.
For example:
// file: ./src/App.test.jsx
// OTHER CODE...
test("App contains correct heading", () => {
render(<App />);
// const headingElement = screen.getByText(/learn react/i);
// SWITCH TO ROLE
const headingElement = screen.getByRole("heading", { name: /learn react/i });
expect(headingElement).toBeInTheDocument();
});- Remove the
h1element inApp.jsx(<h1>I'm gonna learn React Testing Library</h1>) and the sample test inApp.test.jsx. - In
App.test.jsxwe will now create three tests that all start with the keywordtest. - Test #1 will check to see if the button starts with the correct color. First argument is the test description and the second argument is the function that determines pass/fail.
- Test #2 will check if the button starts with the correct text.
- Test #3 and #4 will duplicate the first two tests but for after the button click.
- Next thing we do is
renderand we willrenderthe app component. - Then we will use
screento find the element we are looking for (i.e.button). We will append.getByRoletoscreenand pass in the role ofbuttontogetByRole. Save thescreeninto a variable (i.e.buttonElement). - To determine color we will use the jest-DOM matcher of
toHaveClass(). First we pass in thebuttonElementconstant intoexpectand append.toHaveClass()toexpect. Pass in a string of"red"totoHaveClassto represent a class of red (Anticipating making a class with the name of red and assigning the button that class initially). - The arguments for getByRole are creating assertions. We can have more than one assertion, but it is not necessary in a front end test so we can just add it as a second argument to
getByRolein the first assertion. The second assertion will be as follows:{name: /blue/i}since the button will start out with a text change to blue.
Currently we will have the first test fail because we have no buttons with the text of blue and two tests will pass which are the last two empty tests (The red part of red-green testing).
For example:
// file: ./src/App.test.jsx
// OTHER CODE...
// BEFORE CLICKING BUTTON.
test("button starts with correct color", () => {
render(<App />);
const buttonElement = screen.getByRole("button", {
name: /blue/i,
});
expect(buttonElement).toHaveClass("red");
});
// ? test("button starts with correct text", () => {});
// AFTER CLICKING BUTTON.
test("button has correct color after click", () => {});
test("button has correct text after click", () => {});Let's say you did not know the role was button we can use logRoles from testing library DOM.
- If we want to see what the roles are in the app we can destructure
containerfrom the output of therender:const { container } = render(<App />);. - Run
logRoleson the abovecontainer:logRoles(container);.
For example:
// file: ./src/App.test.jsx
import { render, screen } from "@testing-library/react";
import { logRoles } from "@testing-library/dom";
import App from "./App";
test("button starts with correct color", () => {
const { container } = render(<App />);
logRoles(container);
const buttonElement = screen.getByRole("button", {
name: /blue/i,
});
expect(buttonElement).toHaveClass("red");
});
test("button has correct color after click", () => {});
test("button has correct text after click", () => {});The logRoles now provides some output. A button and what its name is:
# CLI Output
button:
Name "Change to blue":
<button
class="red"
/>Note
It is not much because of only one component, but useful in large apps when you are not sure of all the roles.
It is common to have longer tests with multiple assertions that test a particular flow.
To click the button we can use fireEvent from testing library react (There is a more involved userEvent which we will look at later).
- Add
fireEventand append.click()method. - Pass in what to click, which is the
buttonElement. - Skip down a step and add the assertion to check button color after click. We will use the same as before click, but
toHaveClassof blue instead of red:expect(buttonElement).toHaveClass("blue");. - For checking the button text we will be using a new matcher from jest-DOM (
toHaveTextContent). We always start an assertion withexpect, then we will pass in thebuttonElement, append.toHaveTextContent(), and pass a regex for text ofredinto the new matcher:expect(buttonElement).toHaveTextContent(/red/i);.
Since we are currently in the red portion of red-green testing the test will fail. When the button is clicked the text content does not include regex of red.
For example:
// file: ./src/App.test.jsx
// OTHER CODE...
test("Button click flow", () => {
// Render the app.
render(<App />);
// Find the button.
const buttonElement = screen.getByRole("button", {
name: /blue/i,
});
// Check initial color.
expect(buttonElement).toHaveClass("red");
// Click the button.
fireEvent.click(buttonElement);
// Check button text.
expect(buttonElement).toHaveTextContent(/red/i);
// Check button color.
expect(buttonElement).toHaveClass("blue");
});We just finished the failing/red code for testing.
- Go to
App.jsxand within theAppcomponent we will use state to track what the button color is. - Assign
useStateto destructuredbuttonColorstate variable andsetButtonColorsetter function. The initial state for the button will be"red":const [buttonColor, setButtonColor] = useState("red");. - Instead of the button
classNamebeing"red"we will assign it to whatever the state variablebuttonColoris:<button className={buttonColor}>Change to blue</button>. - The button text content will display whatever the opposite of the button color is. We can derive that from the current state and call it
nextColor. If the button color is red (buttonColor === "red") then next color is blue (? "blue"), else the next color is red (: "red") using ternary operators:const nextColor = buttonColor === "red" ? "blue" : "red";. - We now have to give the button an on click event. The
onClicktakes a return function that returns the setter function (setButtonColor) with a value passed in ofnextColor:<button className={buttonColor} onClick={() => setButtonColor(nextColor)}>. We change the color button state to whatever thenextColoris on click. - Lastly, we need to make the blue class and set styles in
App.css.
For example:
// file: ./src/App.jsx
// OTHER CODE...
function App() {
// State variables.
const [buttonColor, setButtonColor] = useState("red");
// Constants.
const nextColor = buttonColor === "red" ? "blue" : "red";
return (
<div>
{/* <button className="red">Change to blue</button> */}
{/* <button className={buttonColor}>Change to blue</button> */}
<button className={buttonColor} onClick={() => setButtonColor(nextColor)}>
Change to {nextColor}
</button>
</div>
);
}
// OTHER CODE...Note
You will notice that the tests pass even before we add the blue button color styles. It is quite complicated to test for actual styles versus testing for classes.
To test for styles you need to make sure the css is being interpreted as part of your tests.
- In the
vite.config.jsit is one line that says:css: true,. - With Jest it is quite slow and requires some extra plugins.
- It is not always obvious what the styles come out as. For example if we switch the last assertion in our test to:
expect(buttonElement).toHaveStyle({"background-color": "blue"});then it willfailbecause the blue style will render as an RGB value (rgb(0, 0, 255)) instead of"blue". - It is more straightforward to just test for classes and use visual regression testing to catch any visual style (More advanced).
Now we will add a checkbox that when checked the button is disabled and enabled when unchecked.
We will make a new test to separate the button click flow from the checkbox flow.
- Back in
App.test.jsxwe will add a new test. Start with usingtestglobal from Vitest which takes two arguments (1. The name of the test, 2. function for pass/fail):test("Checkbox flow", () => {});. - Start by rendering something, usually a component:
render(<App />);. - We need to find the button and the checkbox because we want to check initially that the checkbox is checked and the button is enabled.
- Find button by using the
screenobject (That has access to the simulated DOM that therendercreated). We then append.getByRole()and pass in"button", and second argument is the expectant name as regex blue (Because the button starts out red and changes to blue):const buttonElement = screen.getByRole("button", { name: /blue/i });. - Find the checkbox. Follow the same steps as finding the button, use checkbox role passed into
getByRole(), second argument beingname(Even thoughnameis not necessary because there is only one checkbox on the page), and assign to variablecheckboxElement. Thenamewill be the accessible name for the checkbox which will be the label for the input:const checkboxElement = screen.getByRole("checkbox", {name: /disable button/i,});. - Initial conditions are the button enabled and checkbox unchecked. Start by creating an assertion which starts with an
expect. Pass inbuttonElementtoexpectand append a matcher to this (i.e.toBeEnabledfor button andtoBeCheckedfor checkbox). ThetoBeEnabled()has no argument because it is either enabled or disabled:expect(buttonElement).toBeEnabled();. - Another assertion that checks NOT condition for
toBeCheckedsince there is no built intoNotBeChecked:expect(checkboxElement).not.toBeChecked();.
For example:
// file: ./src/App.test.jsx
// OTHER CODE...
test("Checkbox flow", () => {
render(<App />);
// Find elements.
const buttonElement = screen.getByRole("button", { name: /blue/i });
const checkboxElement = screen.getByRole("checkbox", {
name: /disable button/i,
});
// Check initial conditions.
expect(buttonElement).toBeEnabled();
expect(checkboxElement).not.toBeChecked();
});Check that the tests are currently red (It cannot find the checkbox because it has not be created yet).
- Go to
App.jsxand add a checkbox input. Make sure to addidofdisabled-button-checkbox(This will make it accessible to screen readers and our tests). - Set the checkbox to not be checked by default:
<input type="checkbox" id="disabled-button-checkbox" defaultChecked={false} />. - Add label and associate it to the checkbox input id using
htmlFor:<label htmlFor="disable-button-checkbox">Disable Button</label>.
For example:
// file: ./src/App.jsx
// OTHER CODE...
<input
type="checkbox"
id="disable-button-checkbox"
defaultChecked={false}
/>
<label htmlFor="disable-button-checkbox">Disable Button</label>
// OTHER CODE...Now the tests are passing or green.
- Add to the current test.
- We want to use
fireEventtwice. When the checkbox is checked and when the checkbox is unchecked. - Check that the button is enabled when checkbox is unchecked (Done) and that the button is disabled when the checkbox is checked.
- For assertion matchers on the button we will again use
toBeEnabled()andtoBeDisabled().
- Checkbox controls the button via a boolean state.
- State will determine the value of
disabledattribute on the button. - Calling the state variable
disabledand initial value of state to be set tofalse. - The onChange for the checkbox will set that state to whether the target (checkbox) of the event is checked or not:
{(e) => setDisabled(e.target.checked)}.
- Add to the checkbox flow test.
- Use
fireEvent.clickon thecheckboxElementfollowed by expectation that thebuttonElementwill bedisabled. - Use
fireEvent.clickon thecheckboxElementagain followed by an expectation that thebuttonElementwill be re-enabled.
For example:
// file: ./src/App.test.jsx
// OTHER CODE...
test("Checkbox flow", () => {
render(<App />);
// Find elements.
const buttonElement = screen.getByRole("button", { name: /blue/i });
const checkboxElement = screen.getByRole("checkbox", {
name: /disable button/i,
});
// Check initial conditions.
expect(buttonElement).toBeEnabled();
expect(checkboxElement).not.toBeChecked();
// Check the checkbox to disable button.
fireEvent.click(checkboxElement);
expect(buttonElement).toBeDisabled();
// Click checkbox again to re-enable button.
fireEvent.click(checkboxElement);
expect(buttonElement).toBeEnabled();
});- Create new state called
isDisabled:const [isDisabled, setIsDisabled] = useState("false");. - The
defaultCheckedprop (Checked or unchecked) on the checkbox will rely on the stateisDisabled:<input defaultChecked={isDisabled} />. - When the checkbox changes (
onChange) we update the state:<input onChange={(e) => setIsDisabled(e.target.checked)} />. - Also use the
isDisabledstate to determine whether the button is disabled or not:<button disabled={isDisabled}>...</button>.
For example:
// file: ./src/App.jsx
// OTHER CODE...
const [buttonDisabled, setButtonDisabled] = useState(false);
// OTHER CODE...
<button
className={buttonColor}
onClick={() => setButtonColor(nextColor)}
disabled={buttonDisabled}
>
Change to {nextColor}
</button>
<br />
<input
type="checkbox"
id="disable-button-checkbox"
// defaultChecked={disabled}
checked={buttonDisabled}
onChange={(e) => setButtonDisabled(e.target.checked)}
/>
// OTHER CODE...- Add assertion when checkbox is checked to check if button has class of
"gray":expect(buttonElement).toHaveClass("gray");. Add to original checkbox flow. - Add assertion when checkbox is unchecked to check if button has class of
"red":expect(buttonElement).toHaveClass("red");. Add to original checkbox flow. - Create a new checkbox flow for what happens after the button has been clicked (i.e. turns blue). The new checkbox flow will be the same as original except that we add a
fireEvent.click(buttonElement)after we find the elements and remove the checking of initial conditions for checkbox and button.
For example:
// file: ./src/App.test.jsx
// OTHER CODE...
test("Checkbox flow after button clicked", () => {
render(<App />);
// Find elements.
const buttonElement = screen.getByRole("button", { name: /blue/i });
const checkboxElement = screen.getByRole("checkbox", {
name: /disable button/i,
});
// Click the button.
fireEvent.click(buttonElement);
// Check the checkbox to disable button.
fireEvent.click(checkboxElement);
expect(buttonElement).toBeDisabled();
expect(buttonElement).toHaveClass("gray");
// Click checkbox again to re-enable button.
fireEvent.click(checkboxElement);
expect(buttonElement).toBeEnabled();
expect(buttonElement).toHaveClass("red");
});Tip
For test code it is less important for the code to be DRY than being able to debug failing tests quickly (Readable).
- Create
classNameconstant equal to condition ofbuttonDisabledwould be"gray"and if not it will be set to whatever thebuttonColorstate is:const className = buttonDisabled ? "gray" : buttonColor;. - Assign the constant
classNameto theclassNameof the button:<button className={className}>...</button>. - Add styles to
App.cssfor the background color of gray.
Sometimes you will have functions separate from components due to being used by several other components or because it is complex and needs modularity.
If we were to use more complicated colors classes (i.e. .midnight-blue) it will work when we have to set the button color (useState=("midnight-blue")), but it will not look great when we are looking at the button text.
- Create a file called
helpers.js. - Within helpers.js add an export for a function that converts kebab case to title case. Leave the function empty:
export function kebabCaseToTitleCase() {}. - In the
App.test.jsxwe are going to add a new global calleddescribe()(A way to group tests). It takes the same two arguments astest()and it allows us to route multiple tests within it's second argument function. - Within describes second argument we will add a test for if it works with no hyphens, one hyphen, and multiple hyphens.
- In the no hyphen test we write an assertion to
expectkebab case helper function lowercase"red"to be uppercase"Red":expect(kebabCaseToTitleCase("red")).toBe("Red");. - For one hyphen we will look at
midnight-blueclass name, which is what we will be working with in the state. We will use the helper function to translate that to"Midnight Blue". - Lastly, for multiple hyphens we will look at
medium-violet-redclass name.
We will have 6 failing tests at this point due to the buttonColor name changes and the three grouped tests in the describe global.
- The
kebabCaseToTitleCase()function needs to take an argument (i.e.colorName). - We will replace the hyphens with spaces:
const colorWithSpaces = colorName.replaceAll("-", " ");. - Change the beginning letter of each word to a capitalized letter. We will pass in a regex to this with out of word boundary (
/\b), a lowercase letter (([a-z])) in parenthesis so we can catch the lowercase letter, find them all (/g), and replace with whatever thematchwas to uppercase (match.toUpperCase()). - Then we will return the result (
colorWithCaps).
For example:
// file: ./src/helpers.js
export function kebabCaseToTitleCase(colorName) {
const colorWithSpaces = colorName.replaceAll("-", " ");
const colorWithCaps = colorWithSpaces.replace(/\b([a-z])/g, (match) =>
match.toUpperCase()
);
return colorWithCaps;
}Now all of the unit tests will be passing.
- Check that color starts with
mediumvioletredand changes tomidnightblue. - Update existing tests.
- After updating the class names in the
toHaveClassassertions the checkbox disabling tests should still pass (free regression testing / free testing when code changes).
Note
We do not have to change any of the name options because they are not very specific (Had keyword and case insensitive).
- Button Click Flow: Update the
toHaveClassto the new color class names for before and after click (i.e.expect(buttonElement).toHaveClass("medium-violet-red");). - Checkbox Flow: Update the
toHaveClassto the new color class names for checkbox click to re-enable button. - Checkbox Flow After Button Clicked: Update the
toHaveClassto the new color class names for checkbox click to re-enable button.
App.jsx
- Create new constant for next color and title case and assign it to kebab case helper function with
nextColorpassed in as an argument. This applies the helper function to whatever the next color is. - Update the
nextColorconstant name tonextColorClass. - For the button text content change
{nextColor}to{nextColorTitleCase}.
- If the logic is complex and difficult to test via functional tests.
- If there are too many edge cases.
- When determining what caused a functional test to fail.
- Functional tests are high level which makes them resistant to refactored code, which is good, except when you are trying to diagnose when a test fails.
- Test interactivity from
fireEvent, an object imported from RTL that has methods on it likeclick. - Used several new jest-DOM assertions:
toBeEnabled()toBeDisabled()toBeChecked()(Note: We used.not.toBeChecked()for the opposite)
- We used the
nameoption forgetByRoleto identify which checkbox and which button we were referring to. - We used the
describeglobal from Vitest to group tests into logical groups. - Discussed unit testing functions, demoed, and talked about when to use them.