Back
Apr 18, 2023

TDD guide for busy people, with example

The What, the Why, the When

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.

What is even TDD?

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.

Why bother?

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.

When is it applicable?

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.

Example

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.

You can find the full code in the link at the end of the article.

The requirements

First of all, let’s compile a list of requirements for our state-of-the-art Sign In page. In English for now.

  • The title should say “Sign In”
  • There should be two fields, “Email” and “Password”, and a “Submit” button
  • Email field:
    • Is required. When submitting an empty email, show the “Please enter an email” message
    • When entering an email, any whitespace chars before/after an actual email address should be ignored
    • Entered email should be validated on submit
    • If invalid, then show “Please enter a valid email message”
  • Password field
    • Is required. When submitting a password email, show the “Please enter a password” message
    • Should be masked (we won’t bother with implementing the “show password“ toggle)
  • When a validation error comes from a server (we’ll make a stub for it), then show “Email or password is invalid“

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.

The title

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();
  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();
        |         ^
      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 (
    

Sign In

) }

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.

Email field

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('textbox', { name: 'Email' });
}

test('renders an Email field', () => {
  render();
  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 (
    

Sign In

); };

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();
  const email = getEmailInput();
  await user.type(email, 'user@email.com');
  expect(email).toHaveValue('user@email.com');
});

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('button', { name: "Submit" });
}

test('renders a Submit button', () => {
  render();
  const button = getSubmitButton();
  expect(button).toHaveTextContent("Submit");
});
// src/Login.tsx
export const Login: React.FC = () => {
  return (
    

Sign In

); };

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();
  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 (
    
setSubmitted(true)}>

Sign In

{submitted && (
Please enter an email
)}
); };

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({
    validateOnChange: false,
    validateOnMount: false,
    validateOnBlur: false,
    initialValues: { email: '' },
    validationSchema: getSchema(),
    onSubmit: () => {},
  });
  return (
    

Sign In

{!!formik.errors.email && (
{formik.errors.email}
)}
); };

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();
  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();
  const email = getEmailInput();
  await user.type(email, '  user@email.com  ');
  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.

Password field

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('Password');
}

test('renders a Password field', () => {
  render();
  const password = getPasswordInput();
  expect(password).toBeInTheDocument();
});
// src/Login.tsx
...
type FormValues = {
  email: string;
  password: string;
}
...
export const Login: React.FC = () => {
  const formik = useFormik({
    validateOnChange: false,
    validateOnMount: false,
    validateOnBlur: false,
    initialValues: { email: '', password: '' },
    validationSchema: getSchema(),
    onSubmit: () => {},
  });

  return (
    

Sign In

{!!formik.errors.email && (
{formik.errors.email}
)}
); };

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();
  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();
  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'),
});
...
        
        {!!formik.errors.password && (
          
{formik.errors.password}
)} ...

The password field is completed!

The submit

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();
  const email = getEmailInput();
  await user.type(email, 'user@email.com');
  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({
    validateOnChange: false,
    validateOnMount: false,
    validateOnBlur: false,
    initialValues: { email: '', password: '' },
    validationSchema: getSchema(),
    onSubmit: () => setSubmitted(true),
  });

  return (
    

Sign In

{submitted && (
Email or password is invalid
)} ...

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({
    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 (
    
      

Sign In

{!formik.isSubmitting && !!formError && (
{formError}
)} ...

And that’s it! We’ve implemented the Sign In page, covered all cases, and have 100% test coverage.

Summary

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.

Subscribe for the news and updates

More thoughts
Aug 18, 2022Technology
5 Best Practices of RESTful API Design to Keep Your Users Happy

Dive into our guide to RESTful API best practices

Jun 8, 2022Technology
How to Use MongoDB in Python: Gearheart`s Experience

In this article, we have prepared a quick tutorial on how to use MongoDB in Python and listed top ORM.

Apr 27, 2022TechnologyBusiness
How to Choose the Best Javascript Framework: Comparison of the Top Javascript Frameworks

In our article, you will find the best JavaScript framework comparison so that you know for sure how to choose the right one for your project.

Apr 19, 2022Technology
Improve efficiency of your SELECT queries

SQL is a fairly complicated language with a steep learning curve. For a large number of people who make use of SQL, learning to apply it efficiently takes lots of trials and errors. Here are some tips on how you can make your SELECT queries better. The majority of tips should be applicable to any relational database management system, but the terminology and exact namings will be taken from PostgreSQL.

Jun 14, 2017Technology
How to Deploy a Django Application on Heroku?

In this article I'll show you how to deploy Django with Celery and Postgres to Heroku.

Jan 9, 2017Technology
How to Use GraphQL with Django

GraphQL is a very powerful library, which is not difficult to understand. GraphQL will help to write simple and clear REST API to suit every taste and meet any requirements.