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
Mar 18, 2024Technology
From boring to exciting: turn learning to code into an adventure

Tired of boring programming courses where you're forced to read thick textbooks and write code that's never used? Need a platform that makes learning fun and exciting? Then you're in the right place!

Jul 13, 2022Technology
Prosemirror: Render node as react component

In this article I’m going to show how to declare custom prosemirror node, how to render it with toDom method and how improve that with custom NodeView using React component.

May 10, 2018Technology
How to Build a Cloud-Based Leads Management System for Universities

Lead management is an important part of the marketing strategy of every company of any size. Besides automating various business processes, privately-held organizations should consider implementing an IT solution that would help them manage their leads. So, how should you make a web-based leads management system for a University in order to significantly increase sales?

Aug 8, 2016TechnologyBusiness
How To Add HTML5 Geolocation To Your Web App?

In this article I will describe how to integrate geolocation HTML5 function to a web app so you can then easily implement it in your apps or websites. As an example we are going to create small web app which will be able to calculate the shortest route between detected user’s location and predefined destination using Google Maps API.

Sep 23, 2010Technology
OR and AND without django.db.models.Q

Learn how to use "OR" and "AND" queries efficiently in Django without using database models Q. Enhance your query-building skills. Dive in now.

Sep 23, 2010Technology
Dynamic class generation, QuerySetManager and use_for_related_fields

It appears that not everyone knows that in python you can create classes dynamically without metaclasses. I'll show an example of how to do it.So we've learned how to use custom QuerySet to chain requests:Article.objects.old().public()Now we need to make it work for related objects:user.articles.old().public()This is done using use_for_related_fields, but it needs a little trick.