React.js 13: Testing: RTL


Docs

https://testing-library.com/docs/

DOM Testing Cheatsheet : https://testing-library.com/docs/dom-testing-library/cheatsheet/

  • getBy , findBy , queryBy , getAllBy , findAllBy , queryAllBy

Jest Matchers : https://jestjs.io/docs/expect

  • toBe , toEqual , toBeDefined , toBeUndefined() , toBeTruthy , toBeFalsy , toBeNull , toThrow, expect.not


Common Imports

import { render, screen, cleanup , waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import '@testing-library/jest-dom';

Problem Statement

We want a reliable way to test our UI as if we were a user, from a user perspective.

UI-layer testing framework that helps us ensure that our React components are rendering and behaving properly.

  • built explicitly for testing React components.

  • allows us to test our components in a way that mimics real user interactions.


Best Practice Reminder

AAA: Arrange, Act, Assert


RTL : Install

npm install --save-dev @testing-library/react

p.s. create-react-app by default includes RTL

npm install --save-dev @testing-library/jest-dom

p.s. create-react-app by default includes jest-dom (and jest)

npm install --save-dev @testing-library/user-event

p.s. create-react-app by default includes user-event (and jest)


RTL : Watch Mode

npm test

Launches the test in watch mode, allowing the test to re-run every time the file is saved!

Type q in the terminal to quit out of the watch mode.


RTL : Watch Mode

Customize terminal output by RTL

npm test -- --coverage // --coverage : Indicates that test coverage information should be collected and reported in the output

npm test -- --silent // --silent : Prevents tests from printing messages through the console.

npm test -- --help // --help : Displays help


RTL : render() and screen

render() : function that we can use to virtually render components and make them available in our unit tests

render() : similar to ReactDOM.render(), RTL’s render() function takes in JSX as an argument

screen : special object which can be thought of as a representation of the browser window

screen.debug() : prints out all the DOM contents

After importing the render and screen values from @testing-library/react, you can test using the it() function from the Jest testing framework.


RTL : Example

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

const Greeting = () => {
 return (<h1>Hello World</h1>)
};

it('prints out the contents of the DOM', () => {
   render(<Greeting />);
   screen.debug();
});

RTL : Example

GroceryList.js

const GroceryList = () => {
  return (
  <div>
    <h1>Grocery List</h1>
      <ul>
        <li>
          <label htmlFor="item1">Apples</label>
          <input type="checkbox" id="item1"/>
        </li>
        <li>
          <label htmlFor="item2">Milk</label>
          <input type="checkbox" id="item2"/>
        </li>
        <li>
          <label htmlFor="item3">Cereal</label>
          <input type="checkbox" id="item3"/>
        </li>
      </ul>
    </div>
  )
};
export default GroceryList;

testfile.js

import { render, screen, cleanup } from '@testing-library/react';
import GroceryList from './components/GroceryList';
import userEvent from '@testing-library/user-event';

it('should mark the first checkbox as checked', () => {
  // render the grocery list
  render(<GroceryList />);
  // grab the apple item
  const appleItem = screen.getByLabelText('Apples');
  // simulate a "click" on the apple checkbox
  userEvent.click(appleItem);
  // assert that the apple checkbox was checked
  expect(appleItem).toBeChecked();  // extension provided by : testing-library/jest-dom
});

RTL : it() Example Skeleton

const header = screen.getByText("Passing Thoughts");
expect(header).toHaveTextContent("Passing Thoughts");
const button = screen.getByRole('button');
expect(button).toBeEnabled();

RTL : getByRole() Example Skeleton

const myCheckbox = screen.getByRole('checkbox')
const myInput = screen.getByRole("input");
const myButton = screen.getByRole("submit");
const nameInput = screen.getByRole('textbox' , { name: /name/i });
const emailInput = screen.getByRole('textbox' , { name: /email/i });
const addressInput = screen.getByRole('textbox' , { name: /address/i });
const selectDrowdown = screen.getByRole('combobox' , { name: /payment method/i });   // for dropdown
const checkoutButton = screen.getByRole('button' , { name: /checkout/i });

TLDR on Accessible Name : https://www.tpgi.com/what-is-an-accessible-name/


RTL : getByText() w Regex

Matching a string

getByText(container, 'Hello World')   // full string match

Matching a regex

getByText(container, /World/)   // substring match
getByText(container, /world/i)   // substring match, ignore case

https://testing-library.com/docs/dom-testing-library/cheatsheet/#text-match-options


RTL : getByX vs queryByX methods

.getByX methods: throw an error and immediately cause the test to fail.

.queryByX methods: return null if they don’t find a DOM node

  • useful when asserting that an element is NOT present in the DOM.


RTL : queryByX methods

const emptyThought = screen.queryByText("Oreos are delicious")
expect(emptyThought).toBeNull();

RTL : queryByX methods

something.test.js

import App from './components/App';
import { render, screen } from '@testing-library/react';

it('Header should not show Goodbye yet', () => {
  render(<App />);
  const header = screen.queryByText('Goodbye!');
  expect(header).toBeNull();  // Assert null as we have not clicked the button
});

RTL : getByX VS findByX methods

.getByX methods: throw an error and immediately cause the test to fail.

.findByX methods: query for asynchronous elements, which will eventually appear in the DOM.

  • The .findByX methods work by returning a Promise, which resolves when the queried element renders in the DOM.

  • async/await keywords can be used to enable asynchronous logic.


RTL : getByX VS findByX vs queryByX

.getByX methods: throw an error and immediately cause the test to fail.

  • assert regular elements - synchronous ones

.queryByX methods: return null if they don’t find a DOM node

  • assert an element is NOT present in the DOM.

  • whether sync or asynch

.findByX methods: query for asynchronous elements

  • asset an element will eventually appear in the DOM.


RTL : findByX methods

Thought.test.js

it("Should show new thought to be present", async () => {
  render(<App />);

  // The code below mimics a user posting a thought with text 'Oreos are delicious'
  const addThoughtInput = screen.getByRole("input");
  const addButton = screen.getByRole("submit");
  userEvent.type(addThoughtInput, "Oreos are delicious");
  userEvent.click(addButton);

  const thought = await screen.findByText("Oreos are delicious");
  expect(thought).toBeInTheDocument();
});

RTL : findByX + Await

it("Should display copied text after removing tape", async () => {
  render(<CopyCatContainer />); 
  const input = screen.getByRole("textbox"); 
  // Await typing "Eventually this will appear" into the input before moving on
  await userEvent.type(input, "Eventually this will appear"); 
  // 
  const copyCatImage = screen.getByRole("button", { name: /copycat/i }); 
  userEvent.click(copyCatImage); 
  // Find and wait for the quiet cat image button to be displayed before moving on
  const quietCatImage = await screen.findByRole("button", { name: /quietcat/i }); 
  // 
  userEvent.click(quietCatImage); 
  // Find and wait for "Eventually this will appear" to be displayed
  const copiedText = await screen.findByText("Eventually this will appear");
});

RTL : Mimick User Interactions

Problem Statement

As a JS Dev testing my app, I want to mimic user interactions such as clicking a checkbox and typing text.

Solution

The library @testing-library/user-event in the @testing-library suite is there just for that.


RTL : userEvent library/object

import userEvent from '@testing-library/user-event';

// userEvent object contains many built-in methods that allow us to mimic user interactions

userEvent.interactionType(nodeToInteractWith);

userEvent.type()
userEvent.click()
userEvent.hover()
...

https://github.com/testing-library/user-event

https://testing-library.com/docs/user-event/utility


RTL : userEvent.interaction() vs user.interaction()

You can do

userEvent.click();

Or

const user = userEvent.setup();

user.click();

TLDR: most of the time userEvent.click() is ok

c.f. https://github.com/testing-library/user-event/discussions/1036


RTL : waitFor() method

Problem Statement

I have components that disappear asynchronously. How can I test they disappear as planned?

  • waitFor() method

Basic Syntax

await waitFor(() => {
	...
})

RTL : waitFor() Example

App.js

import { waitFor, render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import userEvent from '@testing-library/user-event';
import { Header } from './header.js'

it('should remove header display', async () => {
  // Render Header
  render(<Header/>)
  // Extract button node 
  const button = screen.getByRole('button');
  // click button
  userEvent.click(button);

  // Wait for the element to be removed asynchronously
  await waitFor(() => {
    const header = screen.queryByText('Hey Everybody');
    expect(header).toBeNull(); 
    // OR: expect(header).not.toBeInTheDocument();
  })
});

RTL : waitFor() deets

waitFor() callback function : confirms this by querying for this element and then waiting for the expect() assertion to pass.

waitFor() optional 2nd arg: options object, can be used to control how long to wait for before aborting and much more


RTL : Testing for Accessibility

Writing tests that adhere to this principle forces you to make your applications more accessible. If a test can find and interact with your elements by their text, it’s more likely that a user who uses assistive technology can as well.

One way we can write tests with accessibility concerns in mind is by sticking to querying with ByRole queries (getByRole, findByRole, queryByRole). The ByRole variant will be able to query any elements within the accessibility tree. If you are unable to query for the component you want to test, you may have just exposed a part of your application that is inaccessible.

React Testing Library Playground for suggestions on accessible queries for more complex needs.

https://testing-playground.com/


RTL : Testing for Accessibility

CheckoutForm.js

import React, { useState } from "react";

const CheckoutForm = () => {
  const [formState, setFormState] = useState({
    name: "",
    email: "",
    address: "",
    payment: "Credit Card",
  });

  const handleChange = (e) => {
    setFormState({ ...formState, [e.target.name]: e.target.value });
  };

  const handleSubmit = (e) => {
    e.preventDefault();
    console.log(formState);
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label htmlFor="name">
          Name:
          <input id="name" name="name" type="text" onChange={handleChange} />
        </label>
      </div>
      <div>
        <label htmlFor="email">
          Email:
          <input id="email" name="email" type="email" onChange={handleChange} />
        </label>
      </div>
      <div>
        <label htmlFor="address">
          Address:
          <input
            id="address"
            name="address"
            type="text"
            onChange={handleChange}
          />
        </label>
      </div>
      <div>
        <label htmlFor="payment">
          Payment Method:
          <select id="payment" name="payment" onChange={handleChange}>
            <option>Credit Card</option>
            <option>Debit Card</option>
            <option>PayPal</option>
          </select>
        </label>
      </div>
      <div>
        <button type="submit">Checkout</button>
      </div>
    </form>
  );
};
export default CheckoutForm;

CheckoutForm.test.js

import { render, screen } from "@testing-library/react";
import CheckoutForm from "./CheckoutForm";

it("finds form fields and checkout button", () => {
  render(<CheckoutForm />);
  screen.getByRole('textbox' , { name: /name/i });
  screen.getByRole('textbox' , { name: /email/i });
  screen.getByRole('textbox' , { name: /address/i });
  screen.getByRole('combobox' , { name: /payment method/i });
  screen.getByRole('button' , { name: /checkout/i });
});

Last updated