Testing | Part 3 — React Testing Library

getByfindByqueryBygetAllByfindAllByqueryAllBy
No Matcherrorerrornullerrorerrorarray
1 Matchreturnreturnreturnarrayarrayarray
1+ Matcherrorerrorerrorarrayarrayarray
Awaitnoyesnonoyesno

Testing Setup

Let's use Vitest as our testing framework.

Let's first install React Testing Library to our existing frontend project and then modify our test script in our package.json file to use it.

npm i -D 
vitest
jsdom
@testing-library/react
@testing-library/jest-dom
@testing-library/user-event
{
  "name": "frontend",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
    "preview": "vite preview",

    "test": "vitest --globals"
  },
  "dependencies": {
    "@emotion/react": "^11.11.1",
    "@emotion/styled": "^11.11.0",
    "@mui/icons-material": "^5.14.8",
    "@mui/material": "^5.14.8",
    "notistack": "^3.0.1",
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "react-router-dom": "6.4"
  },
  "devDependencies": {
    "@types/react": "^18.2.15",
    "@types/react-dom": "^18.2.7",
    "@vitejs/plugin-react": "^4.0.3",
    "eslint": "^8.45.0",
    "eslint-plugin-react": "^7.32.2",
    "eslint-plugin-react-hooks": "^4.6.0",
    "eslint-plugin-react-refresh": "^0.4.3",
    "vite": "^4.4.5",

    "vitest": "^0.34.4",
    "jsdom": "^22.1.0",
    "@testing-library/react": "^14.0.0",
    "@testing-library/jest-dom": "^6.1.3",
  }
}

Next, let's modify our vite.config.js file to use jsdom as our testing environment.

/// <reference types="vitest" />
/// <reference types="vite/client" />

import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [react()],
  test: {
    globals: true,
    environment: 'jsdom',
    setupFiles: './src/test/setup.js',
    css: true,
  },
})

Create a folder named /src/test with a new file named setup.js. This will house the global setup for our tests.

mkdir src/test
touch src/test/setup.js

/src/test/setup.js:

import '@testing-library/jest-dom'

Now let's create a test file /src/App.test.jsx.

import { expect } from 'vitest';
// -not needed due to --globals flag
// -helps intellisense though

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

it('should pass sanity check', () => {
  render(<App />);
  screen.debug();
  expect(true).toBe(true);
});

The screen.debug() function in React Testing Library is a utility for logging the current state of the DOM in your tests. When you call screen.debug(), it prints a formatted string that represents the current DOM tree of the mounted components. This can be extremely helpful for debugging your tests and understanding what the component's rendered output looks like at the time the function is called.

Please refer to the API documentation for a full description of what is going on here.

Queries

In React Testing Library, the queries get, find, and query are used to interact with and retrieve elements from the rendered component. Here's a brief explanation of the differences between these queries:

  • get: This query is used to find an element by its text content, label, or test ID.
    • It returns the first matching element that it finds.
    • If no matching element is found, it throws an error.
    • It's a good choice when you expect a specific element to exist, and you want to make assertions about it.
  • find: This query is similar to get.
    • But it returns a promise that resolves to the first matching element it finds.
    • This is useful when you're working with asynchronous code or when you need to wait for an element to appear in the DOM before making assertions.
    • You can use await with find to wait for the element to be available.
  • query: This query query is also like get.
    • But it doesn't throw an error if no matching element is found.
    • Instead, it returns null.
    • This can be handy when you want to check if an element exists without causing test failures if it's not present.
    • It's often used in conditional assertions.
getByfindByqueryBygetAllByfindAllByqueryAllBy
No Matcherrorerrornullerrorerrorarray
1 Matchreturnreturnreturnarrayarrayarray
1+ Matcherrorerrorerrorarrayarrayarray
Awaitnoyesnonoyesno

Examples (/src/App.test.jsx):

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

import { expect } from 'vitest';
// -not needed due to --globals flag
// -helps intellisense though

// ==============================================

it('should pass sanity check', () => {
  render(<App />);
  // screen.debug();
  expect(true).toBe(true);
});

// ==============================================

it('should display heading (Get By)', () => {
  render(<App />);

  // Get By with different attributes:

  const heading_text = screen.getByText(/users/i);
  // screen.debug(heading_text);
  
  const heading_role = screen.getByRole('heading', { name: /users/i });
  // screen.debug(heading_role);

  const heading_title = screen.getByTitle('page-home-title');
  // screen.debug(heading_title);

  const heading_testid = screen.getByTestId('page-home-title');
  // screen.debug(heading_testid);
});

// ==============================================

it('should display heading (Find By)', async () => {
  render(<App />);

  const heading_text = await screen.findByText(/users/i);
  // screen.debug(heading_text);
});

// ==============================================

it('should NOT display JOSH heading (Query By)', () => {
  render(<App />);

  const heading_text = screen.queryByText(/josh/i);
  expect(heading_text).not.toBeInTheDocument();
});

Basic Mock Wrapper

Usage of <NavLink> will fail in our tests if we render() a component below the <App /> component in the DOM tree because <BrowserRouter> is applied at the App.jsx level.

To solve this problem we simply need to create a mock wrapper that applies the <BrowserRouter> to the isolated component we are testing.

Example (/src/navbar.test.jsx):

import { BrowserRouter, Routes, Route } from 'react-router-dom';
import Navbar from './navbar';

function MockWrapper() {
  return (
    <BrowserRouter>
      <Navbar />
    </BrowserRouter>
  );
}

// ==============================================

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

it('should render navbar | w. <NavLink>', () => {
  render(<MockWrapper />);

  const home_link = screen.getByText(/home/i);
  expect(home_link).toBeInTheDocument();

  const about_link = screen.getByText(/about/i);
  expect(about_link).toBeInTheDocument();
});

Testing a Component with Props and Firing User Events

Let's test <CreateUserForm { ...{ createUser } } />, where createUser is a function used to send the data from the HTML form to the REST endpoint corresponding to creating a new user.

In our tests, we can simply pass in an empty anonymous function as the createUser prop. This will allow us to test the component without actually hitting the REST endpoint.

test('the email form input should exist [empty anonymous function]', () => {
  render(<CreateUserForm createUser={() => {}} />);

  const email_input = screen.getByPlaceholderText(/email/i);
  expect(email_input).toBeInTheDocument();
});

However, a more robust solution would be to pass in a mocked function.

test('the email form input should exist [mocked function]', () => {  
  const mockedCreateUser = vi.fn();
  render(<CreateUserForm createUser={mockedCreateUser} />);
  
  const email_input = screen.getByPlaceholderText(/email/i);
  expect(email_input).toBeInTheDocument();
});

We can also test user events such as typing into the input field.

test('typing into the form input should change the input', () => {
  
  const mockedCreateUser = vi.fn();
  render(<CreateUserForm createUser={mockedCreateUser} />);

  const email_input = screen.getByPlaceholderText(/email/i);
  expect(email_input).toBeInTheDocument();
  fireEvent.change(email_input, { target: { value: 'josh@josh.com' }});
  expect(email_input.value).toBe('josh@josh.com');
  expect(email_input.value).not.toBe('xxx@xxx.com');
});

Next, let's test that submitting the form clears the input values.

test('submitting the form should clear the form inputs', () => {
  
  const mockedCreateUser = vi.fn();
  render(<CreateUserForm createUser={mockedCreateUser} />);
  
  const email_input = screen.getByPlaceholderText(/email/i);
  fireEvent.change(email_input, { target: { value: 'josh@josh.com' }});

  const password_input = screen.getByPlaceholderText(/password/i);
  fireEvent.change(password_input, { target: { value: 'test' }});

  const is_admin_checkbox = screen.getByLabelText(/admin/i);
  expect(is_admin_checkbox.checked).toBe(false);
  fireEvent.click(is_admin_checkbox);
  expect(is_admin_checkbox.checked).toBe(true);

  const button = screen.getByText('Create New User');
  fireEvent.click(button);
  expect(email_input.value).toBe('');
  expect(password_input.value).toBe('');
  expect(is_admin_checkbox.checked).toBe(false);
});