Learn TDD in Next.js
Test-Driven Development (TDD) is an approach to automated software testing that involves writing a failing test before writing the production code to make it pass. TDD helps you develop a robust test suite to catch bugs, as well as guiding you to more modular, flexible code.
To see how TDD works in Next.js, let's walk through a simple real-world example of building a feature. We'll be creating a Next.js 12.1 project with create-next-app
. We'll implement end-to-end tests with Cypress and component tests with Jest and React Testing Library.
This tutorial assumes you have some familiarity with Next.js and with automated testing concepts.
If you like, you can follow along in the Git repo that shows the process step-by-step.
The feature we'll build is a simple list of messages.
Setup
First, create a new React app:
$ yarn create next-app learn-tdd-in-next
Now, run your app and leave it open for the duration of the process:
$ cd learn-tdd-in-next
$ yarn dev
Next, we need to add Jest and React Testing Library.
$ yarn add --dev jest@28.1.1 \
jest-environment-jsdom@28.1.1 \
@testing-library/react@13.3.0 \
@testing-library/user-event@14.2.0
Create a file jest.config.js
at the root of your project and add the following contents:
const nextJest = require('next/jest');
const createJestConfig = nextJest({dir: './'});
const customJestConfig = {
moduleDirectories: ['node_modules', '<rootDir>/'],
testEnvironment: 'jest-environment-jsdom',
};
module.exports = createJestConfig(customJestConfig);
Add an NPM script for running the Jest tests into your package.json
:
{
...
"scripts": {
...
"start": "next start",
+ "test": "jest --watch",
"lint": "next lint"
},
...
}
Next, we need to add Cypress:
$ yarn add --dev cypress@10.1.0
Add an NPM script for opening Cypress into your package.json
:
{
...
"scripts": {
...
"start": "next start",
"test": "jest --watch",
+ "cypress": "cypress open",
"lint": "next lint"
},
...
}
Now run that command:
$ yarn cypress
A Cypress window will open. Choose "E2E Testing". You'll see a message that says "We added the following files to your project"--scroll down and click "Continue". Next, you'll see "Choose a Browser". For this tutorial we'll stick with the default, Chrome. If Chrome isn't already selected, click it. Then click "Start E2E Testing in Chrome". The Cypress test runner will open.
As our last setup step, let's clear out some of the default code to get a clean starting point. Replace the contents of pages/index.js
with the following empty component:
export default function Home() {
return null;
}
The Feature Test
When practicing outside-in TDD, our first step is to create an end-to-end test describing the feature we want users to be able to do. For our simple messaging app, the first feature we want is to be able to enter a message, send it, and see it in the list.
In the cypress
folder, create an e2e
folder, then inside it create a file creating_a_message.cy.js
and enter the following contents:
describe('Creating a message', () => {
it('Displays the message in the list', () => {
cy.visit('http://localhost:3000');
cy.get('[data-testid="messageText"]')
.type('New message');
cy.get('[data-testid="sendButton"]')
.click();
cy.get('[data-testid="messageText"]')
.should('have.value', '');
cy.contains('New message');
});
});
The code describes the steps a user would take interacting with our app:
- Visiting the web site
- Entering the text "New message" into a message text field
- Clicking a send button
- Confirming that the message text field is cleared out
- Confirming that the "New message" we entered appears somewhere on screen
After we've created our test, the next step in TDD is to run the test and watch it fail. This test will fail (be "red") at first because we haven't yet implemented the functionality.
If you've closed Cypress, reopen it with:
$ yarn cypress
Run the Cypress test by clicking creating_a_message
in the Cypress window. You should see the test run, then in the left-hand test step column you should see the following error:
Timed out retrying after 4000ms: Expected to find element: [data-testid="messageText"], but never found it.
Write The Code You Wish You Had
The next step of TDD is to write only enough production code to fix the current error or test failure. In our case, all we need to do is add a message text field.
A common principle in TDD is to write the code you wish you had. We could just add an <input type="text">
element to the Home
component directly. But say we want to keep Home
simple and wrap everything related to the input in a custom component. We might call that component NewMessageForm
. We wish we had it, so let's go ahead and add it to pages/index.js
:
+import NewMessageForm from '../components/NewMessageForm';
export default function Home() {
- return null;
+ return <NewMessageForm />;
}
Next, let's create a components
folder at the root of the app and add a NewMessageForm.js
file inside it. It's tempting to fully build out this component. But we want to wait until the test guides us in what to build. Let's just make it an empty but functioning component:
export default function NewMessageForm() {
return null;
}
Now rerun the tests in Cypress. We're still getting the same error, because we haven't actually added a text input. But we're a step closer because we've written the code we wish we had: a component to wrap it. Now we can add the input tag directly. We give it a data-testid
attribute of "messageText": that's the attribute that our test uses to find the component.
export default function NewMessageForm() {
- return null;
+ return (
+ <input
+ type="text"
+ data-testid="messageText"
+ />
+ );
}
Rerun the tests. The error has changed! The tests are now able to find the "messageText" element. The new error is:
Timed out retrying after 4000ms: Expected to find element: [data-testid="sendButton"], but never found it.
Now there's a different element we can't find: the element with attribute data-testid="sendButton"
.
We want the send button to be part of our NewMessageForm
, so fixing this error is easy. We just add a <button>
to our component. Since we're now returning two JSX elements instead of one, we wrap them in a React fragment:
return (
+ <>
<input
type="text"
data-testid="messageText"
/>
+ <button
+ data-testid="sendButton"
+ >
+ Send
+ </button>
+ </>
);
Implementing Component Behavior
Rerun the Cypress test. Now we get a new kind of test failure:
Timed out retrying after 4000ms: expected '<input>' to have value '', but the value was 'New message'
We've made it to our first assertion, which is that the message text box should be empty -- but it isn't. We haven't yet added the behavior to our app to clear out the message text box.
Instead of adding the behavior directly, let's step down from the "outside" level of end-to-end tests to an "inside" component test. This allows us to more precisely specify the behavior of each piece. Also, since end-to-end tests are slow, component tests prevent us from having to write an end-to-end test for every rare edge case.
Create a file components/NewMessageForm.spec.js
and add the following:
import {render, screen} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import NewMessageForm from './NewMessageForm';
describe('<NewMessageForm />', () => {
describe('clicking the send button', () => {
async function sendMessage() {
const user = userEvent.setup();
render(<NewMessageForm />);
await user.type(
screen.getByTestId('messageText'),
'New message',
);
await user.click(screen.getByTestId('sendButton'));
}
it('clears the text field', async () => {
await sendMessage();
expect(screen.getByTestId('messageText').value).toEqual('');
});
});
});
React Testing Library has a different API than Cypress, but a lot of the test seems the same as the end-to-end test: we still enter a new message and click the send button. But this is testing something very different. Instead of testing the whole app running together, we're testing just the NewMessageForm
by itself.
Run yarn test
to run the component test. We get the same error as we did with the end-to-end test:
expect(received).toEqual(expected) // deep equality
Expected: ""
Received: "New message"
Leave yarn test
running for the duration of this tutorial; it will automatically rerun each time you save changes to a test or production code file.
Now, we can add the behavior to the component to get this test to pass. To accomplish this, we'll need to make the input a controlled component, so its text is available in the parent component's state:
+import {useState} from 'react';
+
export default function NewMessageForm() {
+ const [inputText, setInputText] = useState('');
+
+ function handleTextChange(event) {
+ setInputText(event.target.value);
+ }
+
return (
<>
<input
type="text"
data-testid="messageText"
+ value={inputText}
+ onChange={handleTextChange}
/>
<button
data-testid="sendButton"
>
Send
</button>
</>
);
};
Next, we want to clear out inputText
when the send button is clicked:
function handleTextChange(event) {
setInputText(event.target.value);
}
+ function handleSend() {
+ setInputText('');
+ }
+
render() {
...
<button
data-testid="sendButton"
+ onClick={handleSend}
>
Send
</button>
When you save the file, the component test reruns and passes. Once a component test passes, step back up to the outer end-to-end test to see what the next error is. Rerun creating_a_message.cy.js
. Now our final assertion fails:
Timed out retrying after 4000ms: Expected to find content: 'New message' but never did.
Now, finally, the test will drive us to implement the real meat of our feature: storing the message entered and displaying it.
The NewMessageForm
won't be responsible for displaying this message, though: we'll create a separate MessageList
component that also exists in the parent Home
component. The way we can send data to the parent component is by taking in an event handler and calling it.
To add this event handler behavior to NewMessageForm
, we want to step back down to the component test. In this case, the component test won't be asserting exactly the same thing as the end-to-end test. The end-to-end test is looking for the 'New message' content on the screen, but the component test will only be asserting the behavior that the NewMessageForm
component is responsible for: that it calls the event handler.
Add another test case to NewMessageForm.spec.js
:
describe('clicking the send button', () => {
+ let sendHandler;
+
async function sendMessage() {
const user = userEvent.setup();
+ sendHandler = jest.fn().mockName('sendHandler');
+
+ render(<NewMessageForm onSend={sendHandler} />);
- render(<NewMessageForm />));
await userEvent.type(
...
it('clears the text field', async () => {
await sendMessage();
expect(screen.getByTestId('messageText').value).toEqual('');
});
+
+ it('calls the send handler', async () => {
+ await sendMessage();
+ expect(sendHandler).toHaveBeenCalledWith('New message');
+ });
});
});
Notice that we make one assertion per test in component tests. Having separate test cases for each behavior of the component makes it easy to understand what it does, and easy to see what went wrong if one of the assertions fails. The beforeEach
block will run through the same steps for each of the two test cases below.
You may recall that this isn't what we did in the end-to-end test, though. Generally you make multiple assertions per test in end-to-end tests. Why? End-to-end tests are slower, so the overhead of the repeating the steps would significantly slow down our suite as it grows. In fact, larger end-to-end tests tend to turn into "feature tours:" you perform some actions, do some assertions, perform some more actions, do more assertions, etc.
Run the component test again. You'll see the "clears the text field" test pass, and the new 'emits the "send" event' test fail with the error:
expect(sendHandler).toHaveBeenCalledWith(...expected)
Expected: "New message"
Number of calls: 0
So the sendHandler
isn't being called. Let's fix that:
-export default function NewMessageForm() {
+export default function NewMessageForm({onSend}) {
const [inputText, setInputText] = useState('');
...
function handleSend() {
+ onSend(inputText);
setInputText('');
}
Now the component test passes. That's great! Now we step back up again to run our feature test and we get:
(uncaught exception) TypeError: onSend is not a function
We changed NewMessageForm
to use an onSend
event handler, but we haven't passed one to our NewMessageForm
in our production code. Let's add an empty one to get past this error:
export default function Home() {
+ function handleSend() {}
+
+ return <NewMessageForm onSend={handleSend} />;
- return <NewMessageForm />;
}
Rerun the e2e test and we get:
Timed out retrying after 4000ms: Expected to find content: 'New message' but never did.
We no longer get the onSend
error--now we're back to the same assertion failure, because we're still not displaying the message. But we're a step closer!
A List
Next, we need to save the message in state in the Home
component. Let's add it to an array:
+import {useState} from 'react';
import NewMessageForm from '../components/NewMessageForm';
export default function Home() {
+ const [messages, setMessages] = useState([]);
+ function handleSend(newMessage) {
+ setMessages([newMessage, ...messages]);
+ }
- function handleSend() {}
Next, to display the messages, let's create another custom component to keep our Home
component nice and simple. We'll call it MessageList
. We'll write the code we wish we had in Home.js
:
import {useState} from 'react';
import NewMessageForm from '../components/NewMessageForm';
+import MessageList from '../components/MessageList';
export default function Home() {
const [messages, setMessages] = useState([]);
function handleSend(newMessage) {
setMessages([newMessage, ...messages]);
}
- return <NewMessageForm onSend={handleSend} />;
+ return (
+ <>
+ <NewMessageForm onSend={handleSend} />
+ <MessageList data={messages} />
+ </>
+ );
}
Next, we'll create MessageList.js
and add an empty implementation:
export default function MessageList() {
return null;
}
Rerun the tests, and, as we expect, we still aren't displaying the message. But now that we have a MessageList
component, we're ready to finally implement that and make the test pass:
-export default function MessageList() {
- return null;
+export default function MessageList({data}) {
+ return (
+ <ul>
+ {data.map(message => <li key={message}>{message}</li>)}
+ </ul>
+ );
}
Rerun the tests and they pass. We've let the tests drive our first feature!
Let's load up the app in a regular browser: go to http://localhost:3000
. Well, it works, but it's not the prettiest thing in the world. But now we can add styling.
Why TDD?
What have we gained by using outside-in Test-Driven Development?
- Confidence it works. Unit or component tests are great to specify the functionality of functions or classes, but the app can still crash or do the wrong thing when they’re connected together. An end-to-end test confirms that all the pieces connect in the right way.
- Input on our design. Our component test confirms that the way we interact with NewMessageForm is simple. If it was complex, our component test would have been harder to write.
- 100% test coverage. By only writing the minimal code necessary to pass each error, this ensures we don’t have any code that isn’t covered by a test. This avoids the situation where a change we make breaks untested code.
- Minimal code. We’ve built the minimal features that pass our test. This has helped us avoid to speculate on features the code might need in the future, that increase our maintenance cost without adding any benefit.
- Ability to refactor. Because we have 100% test coverage, we can make changes to our code to improve its design to handle future requirements. Our code doesn't develop cruft that makes it complex to work within.
- Ability to ship quickly. We aren't spending time building code our users don't need. When some old code is slowing us down, we can refactor it to make it quicker to work with. And our tests reduce the amount of manual testing we need to do before a release.
End-to-end testing has had major payoffs for server-rendered apps, and with Cypress you can see the same benefits in client-side frameworks like React.
More Resources
To learn more about TDD, I recommend:
- Outside-In React Development: A TDD Primer - a book walking through this style of TDD in much more detail.
- Growing Object-Oriented Software, Guided by Tests - The original work on the style of TDD we describe here, mockist TDD. It has a lot of great detail, not just about testing, but also how it influences design and project methodology.