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
- 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
- We would need a page which shows the counter.
- It will have a button to increment the counter as an ability to increment
- It will have a button to decrement the counter as an ability to decrement
- On clicking of increment button it should increment the counter
- 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
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:
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
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.