I wrote this for my team at the time to ensure that we were all on the same page regarding testing. I watched Kent Dodd's two FEM courses, read all his testing blog posts, and synthesized what I learned into this lightweight guide.
A Distillation of the Scattered Teachings of Kent Dodds
Updated: 10/12/2020
I wrote this for my team at MPR in late 2020 to ensure that we were all on the same page regarding testing. Nobody on my team (myself included) really had strong opinions other than:
- We should be doing more of it
- They should be written in the same way everywhere
In light of that, I watched Kent Dodd's two Frontend Masters courses, read all his testing blog posts and synthesized what I learned into this guide.
The goal of this rulebook was to set out in writing what it meant for one of MPR's NPM component libraries to be well tested.
The Testing Trophy
Terminology & Definitions
Dodds' "testing trophy" diagram lays out the confidence gained in each stage of testing, with cost and difficulty increasing the higher you go. My thought was that we should keep doing what we're doing for static testing, ensure that our unit tests follow all the rules I'll outline later, and try to expand on integration testing where we can, since that is where the most confidence can be gained for the least work.
Here is what we're at for each of these stages:
Static
We use ESLint, Sasslint, PropTypes, and Prettier. Dodds recommends Flow or TypeScript, but Flow is basically dead and TypeScript is more of an intense lift for our apps that we can likely do at the moment.
Unit
This is most of the tests we have written so far. Here is an example of how to unit test a bit of UI, from one of Dodd's blog posts:
import '@testing-library/jest-dom/extend-expect'
import React from 'react'
import { render } from '@testing-library/react'
import ItemList from '../item-list'
// Some people don't call these a unit test because we're rendering to the DOM with React.
// They'd tell you to use shallow rendering instead.
// When they tell you this, send them to https://kcd.im/shallow
test('renders "no items" when the item list is empty', () => {
const { getByText } = render(<ItemList items={[]} />)
expect(getByText(/no items/i)).toBeInTheDocument()
})
test('renders the items in a list', () => {
const { getByText, queryByText } = render(
<ItemList items={['apple', 'orange', 'pear']} />
)
// note: with something so simple I might consider using a snapshot instead, but only if:
// 1. the snapshot is small
// 2. we use toMatchInlineSnapshot()
expect(getByText(/apple/i)).toBeInTheDocument()
expect(getByText(/orange/i)).toBeInTheDocument()
expect(getByText(/pear/i)).toBeInTheDocument()
expect(queryByText(/no items/i)).not.toBeInTheDocument()
})
Here's an example of unit testing a function.
import cases from 'jest-in-case'
import fizzbuzz from '../fizzbuzz'
cases(
'fizzbuzz',
({ input, output }) => expect(fizzbuzz(input)).toBe(output),
[
[1, '1'],
[2, '2'],
[3, 'Fizz'],
[5, 'Buzz'],
[9, 'Fizz'],
[15, 'FizzBuzz'],
[16, '16'],
].map(([input, output]) => ({
title: `${input} => ${output}`,
input,
output,
}))
)
Integration
Integration tests will render a good chunk of your app—all of the providers, etc.—mocking as little as possible to see if it all works together. If your app is small enough, this test may even render the whole thing, but that will not be the case for most of our stuff. The line between this and E2E testing is a bit blurry.
The only things that should consistently be mocked are network requests and components responsible for animation. For more detail, check out the dedicated section on Mocking.
import React from 'react'
// this module is mocked via jest's __mocks__ directory feature
import axiosMock from 'axios'
import { render, generate, fireEvent } from 'til-client-test-utils'
import { init as initAPI } from '../utils/api'
import App from '../app'
beforeEach(() => {
window.localStorage.removeItem('token')
axiosMock.__mock.reset()
initAPI()
})
test('login as an existing user', async () => {
const { getByTestId, container, getByText, getByLabelText, finishLoading } =
render(<App />)
// wait for the app to finish loading the mocked requests
await finishLoading()
fireEvent.click(getByText(/login/i))
expect(window.location.href).toContain('login')
// fill out form
const fakeUser = generate.loginForm()
const usernameNode = getByLabelText(/username/i)
const passwordNode = getByLabelText(/password/i)
usernameNode.value = fakeUser.username
passwordNode.value = fakeUser.password
// submit form
const { post } = axiosMock.__mock.instance
const token = generate.token(fakeUser)
post.mockImplementationOnce(() =>
Promise.resolve({
data: { user: { ...fakeUser, token } },
})
)
fireEvent.click(getByText(/submit/i))
// wait for the mocked requests to finish
await finishLoading()
// assert calls
expect(axiosMock.__mock.instance.post).toHaveBeenCalledTimes(1)
expect(axiosMock.__mock.instance.post).toHaveBeenCalledWith(
'/auth/login',
fakeUser
)
// assert the state of the world
expect(window.localStorage.getItem('token')).toBe(token)
expect(window.location.href).not.toContain('login')
expect(getByTestId('username-display').textContent).toEqual(fakeUser.username)
expect(getByText(/logout/i)).toBeTruthy()
})
E2E
This is where you use a tool like Cypress or Playwright to click through your site just like a user would. It doesn’t even apply to our libraries and would be overkill for most of our sites. We should perhaps try it with a big site like MPR News if we ever get the time (which we won’t).
Mocking
Mocks are, at their core, functions that keep track of how they’re called and what called them. Dodds prefers to avoid mocking wherever possible, but sometimes it is not just feasible or desirable to import and run an actual function as many times as you need to test the features that use it. Mock where it makes sense -- where things are slow, where you have to wait for data from an API, where you have to wait for animations, where JSDOM can’t figure out what an SVG is, and so on.
For the purposes of this illustration, we’re going to pretend that the following component is making a call to some third party machine learning service that has a testing environment we don’t control and is unreliable. Instead of basically reimplementing that so that we can “test” it, we’re going to mock it out and test what we’ve built using it.
import { getWinner } from './utils'
function thumbWar(player1, player2) {
const numberToWin = 2
let player1Wins = 0
let player2Wins = 0
while (player1Wins < numberToWin && player2Wins < numberToWin) {
const winner = getWinner(player1, player2)
if (winner === player1) {
player1Wins++
} else if (winner === player2) {
player2Wins++
}
}
return player1Wins > player2Wins ? player1 : player2
}
export default thumbWar
We don’t want to re-implement the workings of getWinner
in our tests, so it’s fine to keep it simple and just hardcode a winner. There are tons of ways to mock something, from monkey patching to Jest’s own spyOn
, but the best and easiest of these is to just use jest.mock
.
import thumbWar from './thumbWar'
import * as utilsMock from '../utils'
jest.mock('../utils', () => {
return {
getWinner: jest.fn((p1, p2) => p2),
}
})
test('returns winner', () => {
const winner = thumbWar('Ken Wheeler', 'Kent C. Dodds')
expect(winner).toBe('Kent C. Dodds')
expect(utilsMock.getWinner).toHaveBeenCalledTimes(2)
utilsMock.getWinner.mock.calls.forEach((args) => {
expect(args).toEqual(['Ken Wheeler', 'Kent C. Dodds'])
})
})
If you want something to be mocked in a lot of tests, you can even create a __mocks__
directory right next to your __tests__
directory for that area and import it from there. In this case, in __mocks__/utils.js
:
export const getWinner = jest.fn((p1, p2) => p2)
It is then called in the tests instead of the normal getWinner
:
import thumbWar from '../thumbWar'
import * as utilsMock from '../utils'
jest.mock('../utils')
test('returns winner', () => {
const winner = thumbWar('Ken Wheeler', 'Kent C. Dodds')
expect(winner).toBe('Kent C. Dodds')
expect(utilsMock.getWinner).toHaveBeenCalledTimes(2)
utilsMock.getWinner.mock.calls.forEach((args) => {
expect(args).toEqual(['Ken Wheeler', 'Kent C. Dodds'])
})
})
File Structure
Our apps currently have kind of a mix of organization schemes. Some have a __tests__
folder at the root level, while others have a __tests__
folder in each component folder, while some skip that entirely and pair the *.test.js
file right next to what it is testing. I favored the latter for its terseness, but if we organize the way Dodds recommends, more options are open to us.
Most tests should be contained right next to their component, in a __tests__
folder. This makes it a little harder to forget that tests exist when you update a component. If you need to, you can also have a __mocks__
folder in that directory for your nearby tests to access.
If you need to run more of a sitewide test or mock something for all tests, you can put a __tests__
and a __mocks__
directory at the root level of the app, which is wherever you declared in your Jest config. For data that your tests all need to access, keep it all in a __data__
folder within your root __tests__
folder.
To summarize, the most common file structure in our tested applications, assuming we need component-specific mocks at all, will look a lot like this:
Component/
├── index.js
├── Subcomponent.js
├── __data__/
│ └── data.json
├── __mocks__/
│ └── utils.js
└── __tests__/
├── Component.test.js
└── Subcomponent.test.js
Code Coverage
Sometimes, managers make the mistake of instituting a 100% code coverage rule. This is a misguided impulse and that will lead to superficial tests.
What does code coverage tell you? All it says is “this code was run when your tests were run”. It cannot confirm that this code will work according to business requirements, or that this code works with all other code in the application.
There is no one size fits all solution for a good code coverage number to shoot for. The only metric that matters is how confident you are that the important parts of your application are covered.
In that light, I would propose that our average websites aim for 80% code coverage. This was inspired by a heuristic I like outside of coding, where 80% of the effort in any given space generally nets you 99% of the results, and any more effort after that has diminished returns.
More important than whatever I think might be a good number, after our initial refactor of our tests we should set up coverage thresholds in our projects so that our coverage cannot sink any lower when we add new features (see the section on configuration for how to do this).
Snapshots
Snapshots don’t often end up being all that useful. More often than not, any change will break the snapshot, and you’ll just hit the u
button to update, assuming that it’s nothing, almost always correctly.
Some authorities on testing say it should never be used, but Dodds is a bit softer on the issue. He thinks they are great for detecting changes in the UI on very small bits of code, for example, if a component relies on some CSS for functionality and you want to be sure you don’t change it or knock it out without some sort of alarm going off.
We’ll be purging most of our snapshots and likely not use them much in the future.
Simulating Events
When you create a container for testing, it’s isolated. You’re not actually attaching it to the DOM, so you need to use Jest functions like fireEvent()
to interact with your page. You ought to use renderIntoDocument()
instead of the normal render()
function as well. See the Integration Test example for an idea of what this looks like.
Configuration
Here's an example of one of our jest.config.js
files:
module.exports = {
coverageDirectory: './coverage',
collectCoverageFrom: [
'**/src/**/*.js',
'!**/__tests__/**',
'!**/node_modules/**',
],
setupFilesAfterEnv: ['./src/setupTests.js'],
coverageThreshold: {
global: {
statements: 85,
branches: 80,
functions: 100,
lines: 85,
},
},
}
As you can see, we also have a setupTests.js
file that takes care of a few important things our tests will need to run properly.
import '@testing-library/jest-dom'
import 'jest-prop-type-error'
import 'babel-polyfill'
You can add more here as necessary and as you discover new tools that help your tests run better. These are just the tools I use and the things I installed to make the confusing runtime errors go away.
Best Practices
Arrange, Act, Assert
The generic pattern most people follow for setting up tests: render or run the function you want, do something with it (if applicable) and then see if everything turned out like you expected.
Object & Test Factories
An object factory is basically a function that will create the object you wish to test and provide you an opportunity to overwrite it. Here’s an example:
import * as blogPostController from '../blog-post'
jest.mock('../../lib/db')
function setup(overrides = {}) {
const req = {
locale: {
source: 'default',
language: 'en',
region: 'GB',
},
user: {
guid: '0336397b-e29d-4b63-b94d-7e68a6fa3747',
isActive: false,
picture: 'http://placehold.it/32x32',
age: 30,
name: {
first: 'Francine',
last: 'Oconnor',
},
company: 'ACME',
email: 'francine.oconnor@ac.me',
latitude: 51.507351,
longitude: -0.127758,
favoriteFruit: 'banana',
},
body: {},
cookies: {},
query: {},
params: {
bucket: 'photography',
},
header(name) {
return {
Authorization: 'Bearer TEST_TOKN',
}[name]
},
}
const res = {
clearCookie: jest.fn(),
cookie: jest.fn(),
end: jest.fn(),
locals: {
content: {},
},
json: jest.fn(),
send: jest.fn(),
sendStatus: jest.fn(),
set: jest.fn(),
}
const next = jest.fn()
return { req, res, next }
}
test('lists blog posts for the logged in user', async () => {
const { req, res, next } = setup()
await blogPostController.loadBlogPosts(req, res, next)
expect(res.json).toHaveBeenCalledTimes(1)
expect(res.json).toHaveBeenCalledWith({
posts: expect.arrayContaining([
expect.objectContaining({
title: 'Test Post 1',
subtitle: 'This is the subtitle of Test Post 1',
body: 'The is the body of Test Post 1',
}),
]),
})
})
test('returns an empty list when there are no blog posts', async () => {
const { req, res, next } = setup()
req.user.latitude = 31.230416
req.user.longitude = 121.473701
await blogPostController.loadBlogPosts(req, res, next)
expect(res.json).toHaveBeenCalledTimes(1)
expect(res.json).toHaveBeenCalledWith({
posts: [],
})
})
Throwing that object generation into a setup function really helped with the clarity of this code, since it makes it crystal clear that, for example, the final test is looking at latitude and longitude. If you had set up a new object in every single test, that would have been difficult to spot.
Try to follow the AHA (Avoid Hasty Abstraction) principle for testing, which falls somewhere in the middle of ANA (Absolutely No Abstraction) and DRY (Don’t Repeat Yourself). Basically, what Dodds means by this is that you should only split things out into object or test factories if it will make your tests easier to understand. Sometimes doing clever little things to avoid repeating yourself actually makes your tests harder to read because someone has to scroll around to see what you’re referring to.
Then again, if you never abstract anything some of your tests with a lot of setup can become so long that you forget which one you’re looking at. Abstract, but not too much. What that means is kind of up to you and whoever is reviewing your code.
Why would you not just use beforeEach()
? That brings us to our second practice:
Avoid Nesting
The following code works fine and isn’t bad. But according to Dodds, it is harder to read than it ought to be.
import React from 'react'
import { render, fireEvent } from '@testing-library/react'
import Login from '../login'
describe('Login', () => {
let utils,
handleSubmit,
user,
changeUsernameInput,
changePasswordInput,
clickSubmit
beforeEach(() => {
handleSubmit = jest.fn()
user = { username: 'michelle', password: 'smith' }
utils = render(<Login onSubmit={handleSubmit} />)
changeUsernameInput = (value) =>
fireEvent.change(utils.getByLabelText(/username/i), {
target: { value },
})
changePasswordInput = (value) =>
fireEvent.change(utils.getByLabelText(/password/i), {
target: { value },
})
clickSubmit = () => fireEvent.click(utils.getByText(/submit/i))
})
describe('when username and password is provided', () => {
beforeEach(() => {
changeUsernameInput(user.username)
changePasswordInput(user.password)
})
describe('when the submit button is clicked', () => {
beforeEach(() => {
clickSubmit()
})
it('should call onSubmit with the username and password', () => {
expect(handleSubmit).toHaveBeenCalledTimes(1)
expect(handleSubmit).toHaveBeenCalledWith(user)
})
})
})
describe('when the password is not provided', () => {
beforeEach(() => {
changeUsernameInput(user.username)
})
describe('when the submit button is clicked', () => {
let errorMessage
beforeEach(() => {
clickSubmit()
errorMessage = utils.getByRole('alert')
})
it('should show an error message', () => {
expect(errorMessage).toHaveTextContent(/password is required/i)
})
})
})
describe('when the username is not provided', () => {
beforeEach(() => {
changePasswordInput(user.password)
})
describe('when the submit button is clicked', () => {
let errorMessage
beforeEach(() => {
clickSubmit()
errorMessage = utils.getByRole('alert')
})
it('should show an error message', () => {
expect(errorMessage).toHaveTextContent(/username is required/i)
})
})
})
})
For an example of why he thinks this, try to read this test in isolation.
it('should call onSubmit with the username and password', () => {
expect(handleSubmit).toHaveBeenCalledTimes(1)
expect(handleSubmit).toHaveBeenCalledWith(user)
})
What is handleSubmit
? What is its value? Where is user
coming from? What does it do? You can look all this stuff up, but your test isn't as easy to interpret as it seems. For the three tests it was used in, did you really save any space?
As a side node, we prefer to use the test
function instead of the it
function most of the time, but on the rare occasion we group tests together with describe
, we will then use it
. They are functionally interchangeable.
jest-in-case is good
import cases from 'jest-in-case'
import add from '../add'
cases(
'add',
({ first, second, result }) => {
expect(add(first, second)).toBe(result)
},
[
{ first: 1, second: 2, result: 3 },
{ first: 3, second: 4, result: 7 },
{ first: 100, second: 2, result: 102 },
]
)
Documentation
Each component will have a corresponding page in GitHub’s Wiki. You can edit markdown files these using GitHub’s interface or by cloning down the wiki repo with git clone https://github.com/YOUR_USERNAME/YOUR_REPOSITORY.wiki.git
(this is created automatically when you first create a wiki page).
Please try to keep them up to date. At a minimum, the main page should involve installation and basic usage instructions, while each component page should outline props and functionality.
Conclusion
Check out @apmg/mimas v1.0.0 or later for an idea of what this looks like in practice, keeping in mind that Mimas is tiny and there’s not much integration testing to be done.
Blog Selections
- https://kentcdodds.com/blog/aha-testing
- https://kentcdodds.com/blog/testing-implementation-details
- https://kentcdodds.com/blog/avoid-nesting-when-youre-testing
- https://kentcdodds.com/blog/common-testing-mistakes
- https://kentcdodds.com/blog/demystifying-testing
- https://kentcdodds.com/blog/effective-snapshot-testing
- https://kentcdodds.com/blog/unit-vs-integration-vs-e2e-tests
- https://kentcdodds.com/blog/make-your-test-fail
- https://kentcdodds.com/blog/write-fewer-longer-tests
- https://kentcdodds.com/blog/write-tests