Testing
Introduction
TBD
Linting
ESLint
ESLint has been setup using NextJS’ recommended configuration. See the docs for more information.
Unit Testing with Jest and React Testing Library
You need an excellent suite of automated tests to make absolutely sure that when changes reach your users, nothing gets broken. To get this confidence, your tests need to realistically mimic how users actually use your components. Otherwise, tests could pass when the application is broken in the real world.
Running tests
To execute all unit tests regardless of the application, run the following command from the root folder:
yarn testTo execute unit tests specifically for the application, see the following command as an example:
yarn cats:testIf you need to regenerate the snapshots, run:
yarn cats:test -uIf you wish to run tests in watch mode, which reruns tests related to files changed since the last commit, use:
yarn cats:test-watchBest practices
This section lists some of the most valuable best practices for testing React components. Use them as a checklist or guidance.
Coverage Isn’t Everything ❗️
Aim for meaningful tests that cover user flows and critical paths in your application, rather than striving for specific code coverage threshold.
Use jest-config/utils
// ❌
import { render } from '@testing-library/react'
// ✅
import { render, renderWithProviders, userEvent } from 'jest-config/utils'It’s often useful to define a custom render method that includes things like global context providers, data stores, etc. To make this available globally, one approach is to define a utility file that re-exports everything from React Testing Library.
Use the screen for querying and debugging
// ❌
const { getByRole } = render(<Example />)
const errorMessageNode = getByRole('alert')
// ✅
import { render, screen } from 'jest-config/utils'
render(<Example />)
const errorMessageNode = screen.getByRole('alert')The benefit of using screen is you no longer need to keep the render call destructure up-to-date as you add/remove the queries you need. You only need to type screen and let your editor’s magic autocomplete take care of the rest.
Use @testing-library/jest-dom
const button = screen.getByRole("button", { name: /disabled button/i })
// ❌
expect(button.disabled).toBe(true)
// error message:
// expect(received).toBe(expected) // Object.is equality
//
// Expected: true
// Received: false
// ✅
expect(button).toBeDisabled()
// error message:
// Received element is not disabled:
// <button />That toBeDisabled assertion comes from jest-dom. It’s strongly recommended to use jest-dom because the error messages you get with it are much better.
Learn more about the @testing-library/jest-dom following the link.
Make sure you’re using the correct query
// ❌
// assuming you've got this DOM to work with:
// <label>Username</label><input data-testid="username" />
screen.getByTestId("username")
// ✅
// change the DOM to be accessible by associating the label and setting the type
// <label for="username">Username</label><input id="username" type="text" />
screen.getByRole("textbox", { name: /username/i })We want to query the DOM as closely to the way our end users do so as possible. The queries react-testing-library provides help us to do this, but not all queries are created equally.
Please find the priority of the queries following the queries priority link.
Use userEvent over fireEvent where possible
// ❌
fireEvent.change(input, { target: { value: "hello world" } })
// ✅
await userEvent.type(input, "hello world")@testing-library/user-event is a package that’s built on top of fireEvent, but it provides several methods that resemble the user interactions more closely.
In the example above, fireEvent.change will simply trigger a single change event on the input.
However, the type call will trigger keyDown, keyPress, and keyUp events for each character as well. It’s much closer to the user’s actual interactions. This has the benefit of working well with libraries that you may use which don’t actually listen for the change event.
userEvent methods are async because they aim to more closely mimic real user
interactions, which can include delays. Please ensure you use await for the
calls.
You can get userEvent by importing it from the jest utils:
import { userEvent } from "jest-config/utils"Only use the query* variants for asserting that an element cannot be found
// ❌
expect(screen.queryByRole("alert")).toBeInTheDocument()
// ✅
expect(screen.getByRole("alert")).toBeInTheDocument()
expect(screen.queryByRole("alert")).not.toBeInTheDocument()The only reason the query* variant of the queries is exposed is for you to have a function you can call which does not throw an error if no element is found to match the query (it returns null if no element is found).
The only reason this is useful is to verify that an element is not rendered to the page. The reason this is so important is that the get* and find* variants will throw an extremely helpful error if no element is found–it prints out the whole document so you can see what’s rendered and maybe why your query failed to find what you were looking for.
Whereas query* will only return null and the best**toBeInTheDocument** can do is say: “null isn’t in the document” which is not very helpful.
Use find* any time you want to query for something that may not be available right away
// ❌
const submitButton = await waitFor(() =>
screen.getByRole('button', {name: /submit/i}),
)
// ✅
const submitButton = await screen.findByRole('button', {name: /submit/i})Those two bits of code are basically equivalent (find* queries use waitFor under the hood), but the second is simpler and the error message you get will be better.
Put side-effects outside waitFor callbacks and reserve the callback for assertions only
// ❌
await waitFor(() => {
fireEvent.keyDown(input, { key: "ArrowDown" })
expect(screen.getAllByRole("listitem")).toHaveLength(3)
})
// ✅
fireEvent.keyDown(input, { key: "ArrowDown" })
await waitFor(() => {
expect(screen.getAllByRole("listitem")).toHaveLength(3)
})waitFor is intended for things that have a non-deterministic amount of time between the action you performed and the assertion passing. Because of this, the callback can be called (or checked for errors) a non-deterministic number of times and frequency (it’s called both on an interval as well as when there are DOM mutations). So this means that your side-effect could run multiple times!
This also means that you can’t use snapshot assertions within waitFor. If you do want to use a snapshot assertion, then first wait for a specific assertion, and then after that, you can take your snapshot.
If you want to assert that something exists, make that assertion explicit
// ❌
screen.getByRole("alert", { name: /error/i })
// ✅
expect(screen.getByRole("alert", { name: /error/i })).toBeInTheDocument()Use renderHook to test hooks
// ✅
import { renderHook } from "jest-config/utils"
test("returns logged in user", () => {
const { result } = renderHook(() => useLoggedInUser())
expect(result.current).toEqual({ name: "Old user" })
})Learn more about the renderHook by following this link.
Cleanup is Automatic
RTL automatically cleans up rendered components between tests to avoid cross-test contamination. There’s no need for manual cleanup.
Useful resources
E2E
Cypress is used for end-to-end tests and can opened locally using the yarn cypress:open command.
Targeting components
Cypress’ best practices say to use the data-cy selector to target elements however this does not seem to work in combination with Material UI. Use the data-testid attribute instead. There is a custom command available when using this pattern.
<TextField data-testid="name" />const name = () => cy.getByTestId("name")Test cases
Test cases can be used to iterate through the same test with different personas.
// in a real world scenario use Fishery (https://github.com/thoughtbot/fishery) to generate your cases
const cases = [
{
title: "works for a single cat",
cats: [{ name: "Frida", gender: "female" }]
},
{
title: "works for multiple cats",
cats: [
{ name: "Frida", gender: "female" },
{ name: "Ellie", gender: "male" }
]
}
]
describe("my signup form", () => {
for (const test of cases) {
it(test.title, () => {
// is there an name input for each cat?
cy.getByTestId("name").should("have.length", test.cats.length)
// click each gender radio input for each cat
cy.wrap(test.cats).each((cat, i) =>
cy.getByTestId(`gender-${cat.gender}`).eq(i).click()
)
})
}
})API requests
Cypress is blocked from accessing the API, this is by design as all API requests should be mocked using fixtures.
This adds an extra layer of complexity to the tests as some of our API requests occur server side and can’t be intercepted by Cypress. In order to get around this we mock these API requests and intercept them in our SSR code instead of in Cypress. Cypress has been set up to pass a cookie (cypress: true) which can be used to determine if the request is being made from a cypress test.
async function getConfig() {
const cookieStore = cookies()
const cypress = cookieStore.get("cypress")?.value
if (cypress) {
const mock = await import("../api/mock/config/route")
return await (await mock.GET()).json()
}
const host = headers().get("host")
const protocol = process?.env.NODE_ENV === "development" ? "http" : "https"
const res = await fetch(`${protocol}://${host}/api/config`, {
method: "GET",
headers: {
"Content-Type": "application/json"
}
})
return res.json()
}Local storage
In order to manipulate local storage for Cypress tests the Cypress localStorage commands set up is installed and available for use.