This quick introduction to TDD is meant to show the implementation of this practice with a real-life example. For a more comprehensive guide please refer to the wonderful book “Test-Driven Development By Example“ by Kent Beck. I won’t go too deep into all the philosophical, psychological, and practical benefits of it, but still try to show you the general approach and the state of mind it puts you into.
Test-Driven Development is really self-explanatory. Well, almost.
The development process is driven by automated tests. They represent the business requirements for the program, analogous to what a client might give to the development team. But those ones are written in the programming language instead of English (or any other human language).
Just as we, the developers, don’t write the program before we have the requirements for it, we also don’t write it before we have the automated tests. This whole practice is based on that paradox:
You write the test, it fails (the Red)
You write the code to make that test pass (the Green)
You clean the mess you’ve just created (the Refactor)
The development cycle looks like this: Red/Green/Refactor ad infinitum. We decide the size of the cycle, how granular our tests are, and leaps in implementation.
Another core assumption is that unit test represents a unit of client facing behavior, not a separate class or a function. It gives us the freedom to refactor the code as long as its external API is not changed.
TDD tackles breaks the vicious cycle: we don’t have enough time to write the tests → we inevitably introduce more bugs → these bugs steal the precious time to write tests for the next feature, and back to the beginning.
It also allows us to focus on one thing at a time. It lowers the stress of having to think about everything at the same time. A short feedback loop between failing and passing tests keeps us motivated and maintains momentum.
This approach introduces some nice practices, like decoupling. Then you need to spend half an hour mocking and setting up the next test, then you’re doing something wrong. It also increases the likelihood that the next person to come and expand the feature is going to have an easy time writing additional tests.
As with anything, it is not universal and cannot be used in all cases. You probably won’t find it useful when designing the database or testing communication between microservices (even though API of every single microservice is a perfect match for TDD). Both frontend and backend code can be written fully in TDD.
We’re going to go through an implementation of a Sign In page following the TDD approach. We’ll use React v18.2
+ Typescript v4.9
for development and Jest v28.0
for testing and user-event v14
for the interactive part. To make our life easier when handling HTML forms, we'll use Formik v2.2
to manage the form state + yup v1.0
for validation. I'm using create-react-app to make it as straightforward as possible. All that’s going to run on Node v16
and npm v8
. You can follow along on with other/newer versions, it doesn't really matter for our example.
If you are unfamiliar with Formik or yup, that's not a problem. I won't go into much details about them here. But if you feel curious, they both have wonderful documentation to get you up to speed in mere minutes.
First of all, let’s compile a list of requirements for our state-of-the-art Sign In page. In English for now.
The requirements written in human language are a basis for our requirements (tests) in a programing language. Since we have all requirements in place, let’s go on with actual implementation.
Let’s start with just rendering a Sign In compo…
No!
We’ll start with a test, which should fail, before we implement anything. Let's add a first one.
// src/Login.test.tsx
import React from 'react';
import { render, screen } from '@testing-library/react';
import { Login } from './Login';
test('renders the form with "Sign In" title', () => {
render(<Login />);
const form = screen.getByText('Sign In');
expect(form).toBeInTheDocument();
});
…and run it. It fails, nice. Red part is done.
src/Login.test.tsx
✕ renders the form with "Sign In" title (42 ms)
● renders the form with "Sign In" title
Element type is invalid: expected a string (for built-in components) or a class/function (for composite components) but got: undefined. You likely forgot to export your component from the file it's defined in, or you might have mixed up default and named imports.
4 |
5 | test('renders the form with "Sign In" title', () => {
> 6 | render(<Login />);
| ^
7 | const form = screen.getByText('Sign In');
8 | expect(form).toBeInTheDocument();
9 | });
Now to the implementation in its simplest, dirtiest form. Our priority now is making the test pass to see that amazing green line.
We’ll use a simple form for our component. No styling needed for now.
// src/Login.tsx
import React from 'react';
export const Login: React.FC = () => {
return (
<form>
<h1>Sign In</h1>
</form>
)
}
Now the test passes and the coverage is 100%. That last part is the whole point: we don’t write unnecessary code.
Green part is done.
PASS src/Login.test.tsx
✓ renders the form with "Sign In" title (21 ms)
-----------|---------|----------|---------|---------|-------------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
-----------|---------|----------|---------|---------|-------------------
All files | 100 | 100 | 100 | 100 |
Login.tsx | 100 | 100 | 100 | 100 |
-----------|---------|----------|---------|---------|-------------------
Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: 0.79 s, estimated 1 s
There is nothing to refactor at the moment, so we’ll skip that part.
The cycle is done! If we include only the coding part, it took us seconds to go from “no tests, no code at all”, to “code which passed the test and is clean”. That first example is extremely simple, but it sets the pace for the whole process.
The email field is a bot more interesting than the plain text component. As usual, we start with a test and don’t write any more tests until all of them are passing.
We’ll need to get an email component multiple times so we move it into a helper.
// src/Login.test.tsx
...
function getEmailInput(): HTMLInputElement {
return screen.getByRole<HTMLInputElement>('textbox', { name: 'Email' });
}
test('renders an Email field', () => {
render(<Login />);
const email = getEmailInput();
expect(email).toBeInTheDocument();
});
And the field itself. Bear in mind that we do not implement any useState
or onChange
handlers yet. That’ll come next. We need a test to fix first!
// src/Login.tsx
import React from 'react';
export const Login: React.FC = () => {
return (
<form>
<h1>Sign In</h1>
<input name="email" aria-label="Email" />
</form>
);
};
Another test passed! No refactoring needed. Let’s go on to a more interesting part.
// src/Login.test.tsx
...
test('handles the input into Email field', async () => {
const user = userEvent.setup();
render(<Login />);
const email = getEmailInput();
await user.type(email, 'user@email.com');
expect(email).toHaveValue('[email protected]');
});
This test passes by itself because input
component stores its internal state. Thus we just continue with the next one. To test the validation we need to add a Submit button first. And to do that we need another test.
// src/Login.test.tsx
...
function getSubmitButton(): HTMLButtonElement {
return screen.getByRole<HTMLButtonElement>('button', { name: "Submit" });
}
test('renders a Submit button', () => {
render(<Login />);
const button = getSubmitButton();
expect(button).toHaveTextContent("Submit");
});
// src/Login.tsx
export const Login: React.FC = () => {
return (
<form>
<h1>Sign In</h1>
<input name="email" aria-label="Email" />
<button type="submit" aria-label="Submit">Submit</button>
</form>
);
};
And the email validation itself. Let's start with the “empty value“ validation first.
// src/Login.test.tsx
...
function getEmailErrorMessage(): HTMLDivElement {
return screen.getByRole('alert', { name: "Email Error" });
}
test('shows validation error when submitting an empty email', async () => {
const user = userEvent.setup();
render(<Login />);
const button = screen.getByRole('button', { name: "Submit" });
await user.click(button);
const error = screen.getByRole('alert', { name: "Error message" });
expect(error).toHaveTextContent('Please enter an email');
});
The test fails, as it should. Without wasting any time we slap the simplest implementation possible to make it pass.
// src/Login.tsx
import React, { useState } from 'react';
export const Login: React.FC = () => {
const [submitted, setSubmitted] = useState(false);
return (
<form onSubmit={() => setSubmitted(true)}>
<h1>Sign In</h1>
<div>
<input name="email" aria-label="Email" />
{submitted && (
<div role="alert" aria-label="Email error">Please enter an email</div>
)}
</div>
<button type="submit" aria-label="Submit">Submit</button>
</form>
);
};
Yes, it’s a heresy, but it solves the main purpose: the test passed, and coverage is 100%.
We made it work, now we make it right. Formik is a great tool for offloading our job at managing the form state. It allows us to focus on the important stuff — the logic itself. Yup works perfectly with Formik and allows us to set up a declarative validation schema.
import { useFormik } from 'formik';
import React from 'react';
import * as Yup from 'yup';
type FormValues = {
email: string;
}
const getSchema = () => Yup.object({
email: Yup.string().required('Please enter an email'),
});
export const Login: React.FC = () => {
const formik = useFormik<FormValues>({
validateOnChange: false,
validateOnMount: false,
validateOnBlur: false,
initialValues: { email: '' },
validationSchema: getSchema(),
onSubmit: () => {},
});
return (
<form noValidate onSubmit={formik.handleSubmit}>
<h1>Sign In</h1>
<div>
<input
name="email"
aria-label="Email"
value={formik.values.email}
onChange={formik.handleChange}
/>
{!!formik.errors.email && (
<div role="alert" aria-label="Email error">{formik.errors.email}</div>
)}
</div>
<button type="submit" aria-label="Submit">Submit</button>
</form>
);
};
All tests still pass and we have our 100% coverage in place. The last refactoring part took a few minutes, depending on familiar you are with formik and yup. But this part is the fun one since you already have all the tests in place to ensure we did not break anything and the actual implementation is up to us. We use the libraries here, but it could be done manually as well!
Next is the line is the email validation itself.
// src/Login.test.tsx
...
test('shows validation error when submitting an invalid email', async () => {
const user = userEvent.setup();
render(<Login />);
const email = getEmailInput();
await user.type(email, 'invalid');
const button = getSubmitButton();
await user.click(button);
const error = getEmailErrorMessage();
expect(error).toHaveTextContent('Please enter a valid email');
});
In our case implemention is a simple one-liner.
// src/Login.tsx
...
const getSchema = () => Yup.object({
email: Yup
.string()
.required('Please enter an email')
.email('Please enter a valid email'),
});
...
The same applies to striping the email of all whitespace.
// src/Login.test.tsx
...
function getEmailErrorMessageOrNull(): HTMLDivElement | null {
return screen.queryByRole('alert', { name: "Email error" });
}
test('ignores whitespace around an email value', async () => {
const user = userEvent.setup();
render(<Login />);
const email = getEmailInput();
await user.type(email, ' [email protected] ');
const button = getSubmitButton();
await user.click(button);
const error = getEmailErrorMessageOrNull();
expect(error).toBeNull();
});
Implementation is another one-liner.
// src/Login.tsx
...
const getSchema = () => Yup.object({
email: Yup
.string()
.trim()
.required('Please enter an email')
.email('Please enter a valid email'),
});
...
The email field is done! As you can see, it’s a constant back and forth between writing tests and implementation. It could be slow in the beginning, but the pace increases rather fast.
On to the text part.
We took our time with implementing the email field, proceeding in small, easy steps.
Password should go faster for us since we already have examples for the previous field and the component structure is pretty much set up. The nice thing about TDD is that it allows you to pick the size and duration of the Red/Green/Refactor cycle as you go on with your tasks. We’ll make the leaps a bit larger here.
It’s tempting to jump straight into adding the password field, but let’s keep ourselves disciplined and start with the test. The basic render one goes first.
// src/Login.test.tsx
...
function getPasswordInput(): HTMLInputElement {
return screen.getByLabelText<HTMLInputElement>('Password');
}
test('renders a Password field', () => {
render(<Login />);
const password = getPasswordInput();
expect(password).toBeInTheDocument();
});
// src/Login.tsx
...
type FormValues = {
email: string;
password: string;
}
...
export const Login: React.FC = () => {
const formik = useFormik<FormValues>({
validateOnChange: false,
validateOnMount: false,
validateOnBlur: false,
initialValues: { email: '', password: '' },
validationSchema: getSchema(),
onSubmit: () => {},
});
return (
<form onSubmit={formik.handleSubmit}>
<h1>Sign In</h1>
<div>
<input
name="email"
aria-label="Email"
value={formik.values.email}
onChange={formik.handleChange}
/>
{!!formik.errors.email && (
<div role="alert" aria-label="Email error">{formik.errors.email}</div>
)}
<input
name="password"
type="password"
aria-label="Password"
value={formik.values.password}
onChange={formik.handleChange}
/>
</div>
<button type="submit" aria-label="Submit">Submit</button>
</form>
);
};
Now the input handling and validation. At this point the cycles become trivial.
// src/Login.test.tsx
...
function getPasswordErrorMessage(): HTMLDivElement {
return screen.getByRole('alert', { name: "Password error" });
}
test('handles the input into Password field', async () => {
const user = userEvent.setup();
render(<Login />);
const password = getPasswordInput();
await user.type(password, 'secret');
expect(password.value).toBe('secret');
});
test('shows validation error when submitting an empty password', async () => {
const user = userEvent.setup();
render(<Login />);
const button = getSubmitButton();
await user.click(button);
const error = getEmailErrorMessage();
expect(error).toHaveTextContent('Please enter a password');
});
// src/Login.tsx
...
const getSchema = () => Yup.object({
email: Yup
.string()
.trim()
.required('Please enter an email')
.email('Please enter a valid email'),
password: Yup
.string()
.required('Please enter a password'),
});
...
<input
name="password"
type="password"
aria-label="Password"
value={formik.values.password}
onChange={formik.handleChange}
/>
{!!formik.errors.password && (
<div role="alert" aria-label="Password error">{formik.errors.password}</div>
)}
...
The password field is completed!
When clicking on the Submit button, a request should be sent to a server that validates the credentials and returns a response. The response is either 200 OK or 401 Unauthorized. We won’t bother to implement the actual service and just make a stub. After all, the Login component should not really care how exactly the login request/response is carried out.
First (as we already taught ourselves) we add a test first before any implementation whatsoever.
// src/Login.test.tsx
...
test('shows error when credentials are invalid', async () => {
const user = userEvent.setup();
render(<Login />);
const email = getEmailInput();
await user.type(email, '[email protected]');
const password = getPasswordInput();
await user.type(password, 'secret');
const formError = screen.getByRole('alert', { name: 'Form error' });
expect(formError).toHaveTextContent('Email or password is invalid');
});
It successfully fails (heh). Before we go on with an actual code, let's make the test pass.
// src/Login.tsx
...
export const Login: React.FC = () => {
const [submitted, setSubmitted] = useState(false);
const formik = useFormik<FormValues>({
validateOnChange: false,
validateOnMount: false,
validateOnBlur: false,
initialValues: { email: '', password: '' },
validationSchema: getSchema(),
onSubmit: () => setSubmitted(true),
});
return (
<form onSubmit={formik.handleSubmit}>
<h1>Sign In</h1>
{submitted && (
<div role="alert" aria-label="Form error">Email or password is invalid</div>
)}
...
Test passes! Now to the real deal.
To make our example even closer to a real-life login, we create a login
function in a separate file auth-service.ts
// src/auth-service.ts
export const login = async (email: string, password: string): Promise<200 | 401> => {
return Promise.resolve(200);
}
And use it on the onSubmit
callback which was previously a loop function. Formik is not very useful when it comes to errors not related to form fields. We’ll store the error is useState
ourselves.
// src/Login.tsx
...
export const Login: React.FC = () => {
const [formError, setFormError] = useState('');
const formik = useFormik<FormValues>({
validateOnChange: false,
validateOnMount: false,
validateOnBlur: false,
initialValues: { email: '', password: '' },
validationSchema: getSchema(),
onSubmit: async (values) => {
const response = await login(values.email, values.password);
if (response !== 200) {
setFormError('Email or password is invalid');
}
},
});
return (
<form noValidate onSubmit={formik.handleSubmit}>
<h1>Sign In</h1>
{!formik.isSubmitting && !!formError && (
<div role="alert" aria-label="Form error">{formError}</div>
)}
...
And that’s it! We’ve implemented the Sign In page, covered all cases, and have 100% test coverage.
As you’ve seen from the example, it feels like we barely do anything, yet we’ve been making constant progress. It may feel slow but really isn’t. It may feel easy, but why should we make our job any harder than it already is? TDD is fun, but it takes time to get accustomed to the Red/Green/Refactor loop. But any new practice takes time to get used to after all.
TDD does not apply to every situation. But for those it does (and there are plenty of them), it works wonderfully.
Here’s the complete example with some styling to make it pretty. The complete code can also be found there.