TDD with React

TDD with React

ยท

17 min read

Introduction

TDD is basically test-driven development. Where you write tests first before even implementing the first line of feature.

Why?

You might ask why I would want to write tests first when there is hardly any time or monetary budget for it

So some of the benefits which I have experienced are

  • Requirement clarification: It helps you stress on what exactly expected as part of the feature. To write the tests, you would ask questions like What am I suppose to show on the landing page as default? What should happen when the user is waiting for a response? How do we show a particular state? Sometimes you might end up discovering the missing UX transitions or state representations.

  • Scoped Implementation: Since you would be writing code to satisfy the tests cases, there is hardly any chance to overdo any of the development. As long as tests pass you could consider it done. Though your tests should be through ๐Ÿ˜‰

  • Early verification: You can get your test cases verified by the product team before implementation. This would bring product, UX and development team on the same page in terms of what is expected. Setting correct expectation and avoiding classic software development failure shown below

software requiremet failures.jpg

  • Speed: It might seem like counter-productive to write tests and then code instead of just code in beginning. We all follow agile methodology, where requirements are frequently refined late after looking at the outcome of initial iterations. In such situations refactoring becomes a norm. Having tests in such scenarios builds confidence in refactoring and speeds up further iterations and maintenance in long run.

Prerequisite

Setup: You need to have a setup to write and run your tests. There are a lot of starter kits like Create React App available to start with.

In the following example, I am using the custom toolchain setup. I have explained it in React custom toolchain setup

What?

So what exactly you do as part of TDD?

Let's take a very trivial example that you want to build for the purpose of explanation

Initial Requirement: Create an HTML page to show counter. It should have an ability to increment and decrement the counter

How?

Assuming you have done setup as per prerequisite.

Lets first think about the test cases for the given requirement

  1. We would need a page which shows the counter.
  2. It will have a button to increment the counter as an ability to increment
  3. It will have a button to decrement the counter as an ability to decrement
  4. On clicking of increment button it should increment the counter
  5. On clicking of decrement button it should decrement the counter

Now let's code these

import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event'
import Counter from './Counter';

test('loads and displays counter', () => {
    render(<Counter />)

    expect(screen.getByText('Counter')).toBeDefined();
})
test('renders increment button', () => {
    render(<Counter />)
    expect(screen.getByText('Increment')).toBeDefined();

})
test('renders decrement button', () => {
    render(<Counter />)
    expect(screen.getByText('Decrement')).toBeDefined();
})

test('it increments count', () => {
    render(<Counter />)
    userEvent.click(screen.getByText('Increment'))
    expect(screen.getByText('Count: 1')).toBeDefined();
})
test('it decrement count ', () => {
    render(<Counter />)
    userEvent.click(screen.getByText('Increment'))
    userEvent.click(screen.getByText('Decrement'))
    expect(screen.getByText('Count: 0')).toBeDefined();
})

Let's add a dummy Counter component

import { useState } from "react";
import React from 'react';

const Counter = () => {
    return null;
}

export default Counter;

We could run these tests in watch mode via

yarn test --watch

Obviously, they would fail the output would be something like

render1604147864116.gif

Let's implement just what is required to pass the tests, this helps in focusing on what is required.

const Counter = () => {
    const [count, setCount] = useState();

    return (<>
        <h1>Counter</h1>
        <label>Count: {count}</label>
        <div>
            <button type="button" onClick={() => setCount(count + 1)}>Increment</button>
            <button type="button" onClick={() => setCount(count - 1)} >Decrement</button>
        </div>
    </>
    );
}

We will observe that some tests pass and some fail. This helps in failing fast and looking at what exactly went wrong.

(node:25288) ExperimentalWarning: The fs.promises API is experimental
 FAIL  src/components/Counter/Counter.spec.js
  โˆš loads and displays counter (29 ms)
  โˆš renders increment button (3 ms)
  โˆš renders decrement button (2 ms)
  ร— it increments count (18 ms)
  ร— it decrements count  (7 ms)

  โ— it increments count                                                                                                                                                   

    TestingLibraryElementError: Unable to find an element with the text: Count: 1. This could be because the text is broken up by multiple elements. In this case, you can provide a function for your text matcher to make your matcher more flexible.

    <body>
      <div>
        <h1>
          Counter
        </h1>
        <label>
          Count:
          NaN
        </label>
        <div>
          <button
            type="button"
          >
            Increment
          </button>
          <button
            type="button"
          >
            Decrement
          </button>
        </div>
      </div>
    </body>

      22 |     render(<Counter />)
      23 |     userEvent.click(screen.getByText('Increment'))
    > 24 |     expect(screen.getByText('Count: 1')).toBeDefined();
         |                   ^
      25 | })
      26 | test('it decrement count ', () => {
      27 |     render(<Counter />)

      at Object.getElementError (node_modules/@testing-library/dom/dist/config.js:37:19)
      at args (node_modules/@testing-library/dom/dist/query-helpers.js:92:38)
      at args (node_modules/@testing-library/dom/dist/query-helpers.js:64:17)
      at getByText (node_modules/@testing-library/dom/dist/query-helpers.js:108:19)
      at Object.<anonymous> (src/components/Counter/Counter.spec.js:24:19)

  โ— it decrements count 

    TestingLibraryElementError: Unable to find an element with the text: Count: 0. This could be because the text is broken up by multiple elements. In this case, you can provide a function for your text matcher to make your matcher more flexible.

    <body>
      <div>
        <h1>
          Counter
        </h1>
        <label>
          Count:
          NaN
        </label>
        <div>
          <button
            type="button"
          >
            Increment
          </button>
          <button
            type="button"
          >
            Decrement
          </button>
        </div>
      </div>
    </body>

      28 |     userEvent.click(screen.getByText('Increment'))
      29 |     userEvent.click(screen.getByText('Decrement'))
    > 30 |     expect(screen.getByText('Count: 0')).toBeDefined();
         |                   ^
      31 | })
      32 |
      33 | // xtest('renders with couter label displaying initial count as 0', () => {

      at Object.getElementError (node_modules/@testing-library/dom/dist/config.js:37:19)
      at args (node_modules/@testing-library/dom/dist/query-helpers.js:92:38)
      at args (node_modules/@testing-library/dom/dist/query-helpers.js:64:17)
      at getByText (node_modules/@testing-library/dom/dist/query-helpers.js:108:19)
      at Object.<anonymous> (src/components/Counter/Counter.spec.js:30:19)

Test Suites: 1 failed, 1 total
Tests:       2 failed, 3 passed, 5 total
Snapshots:   0 total
Time:        3.167 s
Ran all test suites.
error Command failed with exit code 1.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.

We could observe that we are expecting the initial count to be 0 in tests. This is by intuition not by requirement. This gives an opportunity to clarify requirements.

We should also include the test for the same so that we could verify the specific change.

test('renders with couter label displaying initial count as 0', () => {
    render(<Counter />)
    expect(screen.getByText('Count: 0')).toBeDefined();
})

Once we fix the bug all tests will pass and we could demo the component to the client as the first iteration.

(node:28108) ExperimentalWarning: The fs.promises API is experimental
 PASS  src/components/Counter/Counter.spec.js
  โˆš loads and displays counter (29 ms)                                                                                                                                    
  โˆš renders increment button (3 ms)                                                                                                                                       
  โˆš renders decrement button (3 ms)
  โˆš it increments count (15 ms)                                                                                                                                           
  โˆš it decrement count  (11 ms)
  โˆš renders with couter label displaying initial count as 0 (2 ms)

Test Suites: 1 passed, 1 total                                                                                                                                            
Tests:       6 passed, 6 total                                                                                                                                            
Snapshots:   0 total
Time:        3.436 s
Ran all test suites.
Done in 5.43s.

Output:

first-iteration.gif.gif

By looking at this iteration client might suggest changes in requirement like counter should not decrement below

In this case, we are supposed to add tests first again and refactor code a bit.

So the added test is

test('it decrement count but not below zero', () => {
    render(<Counter />)
    userEvent.click(screen.getByText('Increment'))
    userEvent.click(screen.getByText('Decrement'))
    expect(screen.getByText('Count: 0')).toBeDefined();
    userEvent.click(screen.getByText('Decrement'))
    expect(screen.getByText('Count: 0')).toBeDefined();
})

Updated counter component code is

const Counter = () => {
    const [count, setCount] = useState(0);

    return (<>
        <h1>Counter</h1>
        <label>Count: {count}</label>
        <div>
            <button type="button" onClick={() => setCount(count + 1)}>Increment</button>
            <button type="button" **disabled={count == 0}** onClick={() => setCount(count - 1)} >Decrement</button>
        </div>
    </>
    );
}

And the demo output is

final-outcome.gif

This way you make sure that none of the previous functionality is altered while changing for new requirements. Making a feature update without adding any new bugs

Conclusion

Hopefully, this would have given you an overview of

Why use TDD in day to day development?

How can we use it in practice?

I would be writing more about the setup I mentioned in prerequisite as it helps in the process to achieve the outcomes we mentioned.

Let me know if you wish to explain on How to achieve certain testing scenarios or Situations to suite the TDD workflow in comments.