Introduction
Manual Testing
Drawbacks:
- Time consuming
- Complex repetitive tasks carries the risk of human error
- May not be able to test all features
Automated Testing
An automated test is code that throws an error if the output of a piece of code is not what was expected.
Advantages:
- Not time consuming
- Reliable, consistent and not error prone
- Easy to identify and fix features that break tests
- Higher shipping confidence
Jest vs RTL
- Jest is a test runner that finds tests, runs the tests, and reports the results in a human readable format.
- React Testing Library (RTL) is a JavaScript testing utility that provides a virtual DOM for testing React components, which we can use to interact with and verify the behavior of a React component.
- Testing Library is a family of packages which help test UI components.
- The core library is called DOM Testing Library and RTL is simply a wrapper around this core library to make it easier to test React components.
- RTL does not care about the implementation details of a component. It only cares about the behavior of a component. If you refactor your component, your tests should still pass.
Types of Tests
Unit Tests
- Focus is on testing the individual building blocks of an application such as a class or a function or a component.
- Each unit or building block is tested in isolation, independent of other units.
- Dependencies are mocked.
- They run in a short amount of time and make it very easy to pinpoint failures.
- Relatively easy to write and maintain.
Integration Testing
- Focus is on testing a combination of units and ensuring they work together.
- Take longer than unit tests.
End-to-End Testing
Focus is on testing the entire application flow and ensuring it works as designed from start to finish. Involves a real UI, a real backend database, real services, etc. Takes the longest as they cover the most amount of code. May have to incur costs for running tests that interact with third party services such as external APIs.
Test Driven Development (TDD)
Write tests before writing the actual code. Once the tests have been written, write the code to make the tests pass.
- Create tests that verify the functionality of a specific feature
- Write code that will run the tests successfully
- Refactor the code for optimization while ensuring the tests continue to pass
Also called red-green testing as the tests are written before the code is written.
Jest
Anatomy of a Test
test(name, fn, timeout)
name: A string that describes the testfn: A function that contains the expectations to testtimeout: The number of milliseconds to wait before considering the test failed
View code
import React from "react";
import { render, screen } from "@testing-library/react";
import App from "./App";
test("renders learn react link", () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i); // case insensitive regex
expect(linkElement).toBeInTheDocument();
});test and expect are global functions provided by Jest. With
create-react-app, the developer does not need to import them.
Instead of the test function, the it function can also be used.
View code
it("renders learn react link", () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i); // case insensitive regex
expect(linkElement).toBeInTheDocument();
});Watch Mode
Watch mode runs unit tests for files that have been changed since the last commit.
Press w in the terminal to see the list of options available in watch mode.
Filtering tests
- Filter tests in the terminal by typing in a regex pattern to match the test name or the file name
- Use
test.onlyto run only the test that is being worked on - Use
test.skipto skip a test - Tests can also be skipped with
xit
Grouping Tests
Use describe to group tests.
describe(name, fn)
name: A string that describes the group of testsfn: A function that contains the expectations to test
View code
describe("App", () => {
test("renders learn react link", () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});
test("renders learn react link", () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});
});One file can have multiple describe blocks. It is also possible to nest
describe blocks.
A test suite corresponds to a file and not describe blocks.
Filename Conventions
Jest looks for files with the following naming conventions:
- Files with
test.jsortest.tsxsuffix - Files with
spec.jsorspec.tsxsuffix - Files with
.jsor.tsxsuffix in a__tests__directory
Although tests can be located anywhere in the codebase, it is recommended to put your tests next to the code they are testing to that relative imports are shorter.
Code Coverage
Code coverage is a metric that measures the amount of code that is tested.
- Statement coverage: number of statements that have been executed
- Branch coverage: number of branches of the control structure that have been executed
- Function coverage: number of functions that have been called
- Line coverage: number of lines of source code that have been executed
For coverage, we need to watch all files and not just the changed files.
npm test -- --coverage --watchAllAsk Jest to only cover certain files by using the collectCoverageFrom option
in the Jest configuration file.
npm test -- --coverage --watchAll --collectCoverageFrom=src/components/**/*.{ts,tsx}collectCoverageFrom can also be used to ignore certain files if we add a
leading ! to the path.
It is also possible to specify a threshold for coverage. If the coverage falls below the threshold, the test will fail.
// in package.json
"jest": {
"coverageThreshold": {
"global": {
"branches": 80,
"functions": 80,
"lines": 80,
"statements": 80
}
}
}Assertions
Assertions check if the values meet certain conditions, and they decide if the test passes or fails.
Assertions are carried out with the global expect function.
Expect is used with matchers to check if the value meets the expected value.
View code
expect(value).toBe(expectedValue);Out of the box, Jest provides matcher functions that does not involve the UI or the DOM.
For testing React components, we can import additional matchers from a library
called @testing-library/jest-dom.
Jest DOM comes pre-installed with create-react-app.
View code
// setupTests.js
import "@testing-library/jest-dom";React Testing Library
Writing Tests
What to test
- Test component renders
- Test component renders with props
- Test component renders in different states
- Test component reacts to events
What not to test
- Implementation details
- Third party code
- Code that is not important from the user’s perspective
RTL Queries
Queries are the methods that the testing library provides to find elements on the page.
To find a single element on the page, use
getByfindByqueryBy
To find multiple elements on the page, use
getAllByfindAllByqueryAllBy
The suffix can be one of the following:
TextLabelTextPlaceholderTextDisplayValueAltTextTitleRoleTestId
getByRole
getByRole is used to find elements by their
role attribute (ARIA-roles).
Button element has a button role, input element has a textbox role, etc.
If you are working with elements that do not have a default role or if you want
to specify a different role, you can use the role attribute to specify the
role.
View code
const button = screen.getByRole("button");getByRole also accepts an options object as the second argument.
View code
const button = screen.getByRole("button", { name: "Click me" });
// the name option is case sensitiveThe name option is helpful when you have multiple elements with the same role.
The level option is used to specify the level of the element in the
accessibility tree, such as heading level 1, heading level 2, etc. There’s also
options such as pressed, expanded, checked, selected, etc.
getByLabelText
getByLabelText is used to find elements by their label text. It is useful for
finding form elements such as input, select, textarea, etc.
<label for="username">Username</label> <input id="username" />View code
const input = screen.getByLabelText("Username");getByLabelText also accepts for wrapper elements such as label.
<label>
<input type="checkbox" id="terms" /> I agree to the terms and conditions
</label>View code
const checkbox = screen.getByLabelText("I agree to the terms and conditions");getByPlaceholderText
getByPlaceholderText is used to find elements by their placeholder text.
<input type="text" placeholder="Enter your username" />View code
const input = screen.getByPlaceholderText("Enter your username");getByText
getByText is used to find elements by their text content.
<button>Click me</button>View code
const button = screen.getByText("Click me");getByDisplayValue
getByDisplayValue is used to find elements (input, textarea, select,
etc.) by their display value.
<input type="text" value="John Doe" />View code
const input = screen.getByDisplayValue("John Doe");getByAltText
getByAltText is used to find elements (image, area, input, etc.) by
their alt text.
<img src="profile.jpg" alt="Profile picture" />View code
const img = screen.getByAltText("Profile picture");getByTitle
getByTitle is used to find elements by their title attribute.
<button title="Click me">Click me</button>View code
const button = screen.getByTitle("Click me");getByTestId
getByTestId is used to find elements by their data-testid attribute.
<button data-testid="submit">Submit</button>View code
const button = screen.getByTestId("submit");Priority Order for Queries
getByRolegetByLabelTextgetByPlaceholderTextgetByTextgetByDisplayValue
If you still cannot find the element, you can use the following queries:
getByAltTextgetByTitle
However, with these attributes, the user experience varies greatly depending on
the browser. For example, the title attribute is not consistently read by
screen readers, and alt text is not always displayed.
If none of these work, your last resort should be getByTestId.
getByTestId
Use this when the text is dynamic.
Queries for Multiple Elements
Each getBy query has a corresponding getAllBy query.
View code
describe("Skills", () => {
test("renders all skills", () => {
render(<Skills skills={skills} />);
const listItemElements = screen.getAllByRole("listitem");
expect(listItemElements).toHaveLength(3);
});
});TextMatch
<div>Hello World</div>View code
screen.getByText("Hello World"); // full string match
screen.getByText("llo Worl", { exact: false }); // substring match
con;
screen.getByText("hello workd", { exact: false }); // ignore case
screen.getByText(/hello world/i); // regex match
screen.getByText((content, element) => {
return content.startsWith("Hello");
}); // custom functionqueryBy
queryByqueries return the matching node for a query, and returnnullif the element is not found.- Useful for asserting an element that is not present
- Throws an error if more than one match is found
queryAllByreturns an array of all matching nodes for a query, and returns an empty array if no elements are found
View code
const button = screen.queryByRole("button");
expect(button).not.toBeInTheDocument();This is useful for finding elements that are not present on the page.
findBy
findByqueries return a promise that resolves when the element is found- The promise is rejected if no element is found or if more than one element is found after a default timeout of 1000ms
- Useful for asserting elements that are not present initially but are rendered after an asynchronous operation
findAllByreturns a promise that resolves to an array of all matching elements for a query, and the promise is rejected if no elements are found after a default timeout of 1000ms
View code
const button = await screen.findByRole("button");- Pass in a third argument to
findByto change the timeout
View code
const button = await screen.findByRole("button", {}, { timeout: 2000 });Manual Queries
You can use the regular querySelector DOM API to find elements.
View code
const { container } = render(<MyComponent />);
const foo = container.querySelector("[data-foo=bar]");Debugging
View code
import { render, screen, logRoles } from "@testing-library/react";
test("renders learn react link", () => {
const view = render(<App />);
logRoles(view.container);
});User Interactions
user-event
User interactions are tested using a companion library called
@testing-library/user-event that simulates user interactions by dispatching
the events that would happen if the interaction took place in a browser.
user-event simulates full interactions, which may fire multiple events and do
additional checks along the way.
fireEvent
fireEvent is a low-level API that simulates events on a DOM node.
user-event vs fireEvent
For example, when a user types into a text box, the element has to be focused, and then keyboard and input events are fired and the selection and value on the element are manipulated as they type.
We can dispatch the change event on an input field using fireEvent.
user-event allows you to describe a user interaction instead of a concrete
event. It adds visibility and intractibility checks along the way and
manipulates the DOM just like a user interaction in the browser would. It
factors in the fact that the user wouldn’t click a hidden element or type in a
disabled text box.
Pointer Events
Convenience APIs internally calls the pointer API.
clickdblClicktripleClickhoverunhover
We can also use low level Pointer APIs.
pointer({keys: '[MouseLeft]'})similates a left clickpointer({keys: '[MouseLeft][MouseRight]'})simulates a left click followed by a right clickpointer('[MouseLeft][MouseRight]')pass in a string if keys is the only argumentpointer('[MouseLeft>]')hold without releasing itpointer('[/MouseLeft]')release the click
View code
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import App from "./App";
test("clicks the button", () => {
userEvent.setup();
render(<App />);
const button = screen.getByRole("button");
await userEvent.click(button);
});Note: All pointer events are asynchronous.
Keyboard Events
Utility APIs
typeclearselectOptionsdeselctOptionsupload
Convenience APIs
tab
Clipboard APIs
pastecopycut
Low Level Keyboard APIs
keyboard('foo') // type 'foo'keyboard('{Shift>}A{/Shift}') // Shift(down) + A + Shift(up)
View code
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import App from "./App";
test("types in the input", () => {
userEvent.setup();
render(<App />);
const input = screen.getByRole("textbox");
await userEvent.type(input, "Hello World");
});Providers
Providers are used to provide context to the component being tested.
View code
import { render, screen } from "@testing-library/react";
import { ThemeProvider } from "styled-components";
import App from "./App";
test("renders learn react link", () => {
render(<App />, {
wrapper: ThemeProvider,
});
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});Custom Render Function
- Create
test-utils.jsfile in thesrcdirectory - Copy sample code from docs
- Import
{ render, screen }fromtest-utils.jsin the test file
View code
import React from "react";
import { render } from "@testing-library/react";
import { ThemeProvider } from "my-ui-lib";
import { TranslationProvider } from "my-i18n-lib";
import defaultStrings from "i18n/en-x-default";
const AllTheProviders = ({ children }) => {
return (
<ThemeProvider theme="light">
<TranslationProvider messages={defaultStrings}>
{children}
</TranslationProvider>
</ThemeProvider>
);
};
const customRender = (ui, options) =>
render(ui, { wrapper: AllTheProviders, ...options });
// re-export everything
export * from "@testing-library/react";
// override render method
export { customRender as render };Custom React Hooks and act utility function
View code
// useCounter.tsx
import { useState } from "react";
import { UseCounterProps } from "./userCouner.types";
export const useCounter = ({ initialCount = 0 }: UseCounterProps = {}) => {
const [count, setCount] = useState(initialCount);
const increment = () => setCount(count + 1);
const decrement = () => setCount(count - 1);
return { count, increment, decrement };
};
// useCounter.test.tsx
import { renderHook, act } from "@testing-library/react";
import { useCounter } from "./useCounter";
describe("useCounter", () => {
test("should render the initial count", () => {
const { result } = renderHook(useCounter);
expect(result.current.count).toBe(0);
});
test("should accept and render the same initial count", () => {
const { result } = renderHook(useCounter, {
initialProps: { initialCount: 10 },
});
expect(result.current.count).toBe(10);
});
test("should increment the count", () => {
const { result } = renderHook(useCounter);
act(() => result.current.increment());
expect(result.current.count).toBe(1);
});
test("should decrement the count", () => {
const { result } = renderHook(useCounter);
act(() => result.current.decrement());
expect(result.current.count).toBe(-1);
});
});Mocking
Mocking Functions
View code
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import App from "./App";
test("clicks the button", () => {
const handleClick = jest.fn();
render(<App onClick={handleClick} />);
const button = screen.getByRole("button");
userEvent.click(button);
expect(handleClick).toHaveBeenCalledTimes(1);
});Mocking HTTP Requests
MSW Setup
Use MSW (Mock Service Worker) to mock HTTP requests.
MSW intercepts the requests made by the application and returns a mocked response.
npm install msw --save-devView code
// mocks/handlers.ts
import { rest } from "msw";
export const handlers = [
rest.get("https://jsonplaceholder.typicode.com/users", (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json([
{
name: "Bruce Wayne",
},
{
name: "Clark Kent",
},
{
name: "Princess Diana",
},
])
);
}),
];View code
// mocks/server.ts
import { setupServer } from "msw/node";
import { handlers } from "./handlers";
// This configures a request mocking server with the given request handlers.
export const server = setupServer(...handlers);View code
// setupTests.js
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import "@testing-library/jest-dom";
// src/setupTests.js
import { server } from "./mocks/server";
// Establish API mocking before all tests.
beforeAll(() => server.listen());
// Reset any request handlers that we may add during the tests,
// so they don't affect other tests.
afterEach(() => server.resetHandlers());
// Clean up after the tests are finished.
afterAll(() => server.close());Testing and Error Handling With MSW
View code
// Users.tsx
import { useState, useEffect } from 'react'
export const Users = () => {
const [users, setUsers] = useState<string[]>([])
const [error, setError] = useState<string | null>(null)
useEffect(() => {
fetch('https://jsonplaceholder.typicode.com/users')
.then((res) => res.json())
.then((data) => setUsers(data.map((user: { name: string }) => user.name)))
.catch(() => setError('Error fetching users'))
}, [])
return (
<div>
<h1>Users</h1>
{error && <p>{error}</p>}
<ul>
{users.map((user) => (
<li key={user}>{user}</li>
))}
</ul>
</div>
)
}
// Users.test.tsx
import { render, screen } from '@testing-library/react'
import { Users } from './Users'
import { rest } from 'msw'
import { server } from '../../mocks/server'
describe('Users', () => {
test('renders correctly', () => {
render(<Users />)
const textElement = screen.getByText('Users')
expect(textElement).toBeInTheDocument()
})
test('renders a list of users', async () => {
render(<Users />)
const users = await screen.findAllByRole('listitem')
expect(users).toHaveLength(3)
})
test('renders error', async () => {
server.use(
rest.get(
'https://jsonplaceholder.typicode.com/users',
(req, res, ctx) => {
// replace the handler with a new one that returns a 500 status code
return res(ctx.status(500))
}
)
)
render(<Users />)
const error = await screen.findByText('Error fetching users')
expect(error).toBeInTheDocument()
})
})Static Analysis Testing
Process of verifying the code without running it.
- Ensure consistent style and formatting
- Check for common mistakes and possible bugs
- Limit the complexity of code and
- Verify type consistency
All types of tests run the code and then compare the output to the expected value.
Static testing analyses aspects such as readability, consistency, error handling, type checking, and alignment with best practices.
Testing checks if your code works or not, whereas static analysis checks if it is written well or not.
Static analysis testing tools:
- TypeScript
- ESLint
- Prettier
- Husky
- lint-staged
ESLint
ESLint is a static analysis tool that checks JavaScript code for common mistakes
and errors. create-react-app comes with ESLint pre-configured.
"scripts": {
"lint": "eslint --ignore-path .gitignore ."
}npm run lintPrettier
Prettier is a code formatter that ensures that all outputted code conforms to a consistent style.
"scripts": {
"format": "prettier --ignore-path --write \"**/*.{ts,tsx,css,scss}\""
}npm run formatCreate a prettierrc.json file in the root directory to customise the Prettier
rules.
{
"singleQuote": true,
"trailingComma": "all",
"printWidth": 80,
"tabWidth": 2
}When using Prettier with ESLint, it is important to install the
eslint-config-prettier package.
npm install eslint-config-prettier --save-deveslintConfig: {
"extends": ["react-app", "react-app/jest", "plugin:prettier/recommended", "eslint-config-prettier"],
}This disables some ESLint rules that conflict with Prettier or are already covered by Prettier.
Husky
Husky prevents developers from committing code that does not pass the linting and formatting checks.
npm install husky --save-dev#!/usr/bin/env sh
. "$(dirname "$0")/_/husky.sh"
npm run lint && npm run formatlint-staged
Lint-staged runs linters on files that are staged in Git.
npm install lint-staged --save-dev"lint-staged": {
"*.{js,jsx,ts,tsx}": ["eslint"],
"*.{ts,tsx,css,scss}": ["prettier --write"]
}#!/usr/bin/env sh
. "$(dirname "$0")/_/husky.sh"
npm run lint-stagedTo ensure no code is pushed without passing the tests, run this:
npx husky add .husky/pre-push "npm test -- --watchAll=falseIf you try to push code that does not pass the tests, Husky will prevent the code from being pushed.