We all know that we should write tests for our code. We understand that writing tests makes our existing code better. Importantly, it also influences our future code to improve.
Consider the following React component that implements FizzBuzz:
import React, { useState } from 'react';
const FizzBuzz = ({ number }) => {
let message = '';
if (number % 3 === 0 && number % 5 === 0) {
message = 'FizzBuzz';
} else if (number % 3 === 0) {
message = 'Fizz';
} else if (number % 5 === 0) {
message = 'Buzz';
} else {
message = number.toString();
}
return (
<div>
{message}
</div>
);
};
const App = () => {
const [fizzBuzzInput, setNumber] = useState(0);
const handleFizzBuzzInput = (event) => {
setNumber(event.target.value);
};
return (
<div>
<input type="number" value={fizzBuzzInput} onChange={handleFizzBuzzInput} />
<FizzBuzz number={fizzBuzzInput} />
</div>
);
};
export default App;
Here are some tests for it:
import React from 'react';
import FizzBuzz from './FizzBuzz';
import {render} from "@testing-library/react";
describe('FizzBuzz', () => {
it('renders without crashing', () => {
render(<FizzBuzz number={1} />);
});
test.each([
[15, 'FizzBuzz'],
[10, 'Buzz'],
[9, 'Fizz'],
[8, '8'],
[5, 'Buzz'],
[3, 'Fizz'],
[2, '2'],
[1, '1'],
])('returns %s when number is %s', (number, expected) => {
const wrapper = render(<FizzBuzz number={number} />);
expect(wrapper.baseElement.textContent).toBe(expected);
});
});
The FizzBuzz component above—while simple in appearance—will tend towards naive changes in the wrong direction and thus has a subtle design error in it.
It’s not a problem just yet. However, since it has tests, we’ll be given signal about this design error in subsequent changes.
The Bug Fix
Let’s consider a common case: a bug. Specifically, that this FizzBuzz code is wrong. The common definition of the FizzBuzz exercise is below:
Write a program that prints the numbers from 1 to 100. But for multiples of three print "Fizz" instead of the number and for the multiples of five print "Buzz". For numbers which are multiples of both three and five print "FizzBuzz".
The exact bit of 1 to 100 is likely just bounding logic to a reasonable range. However, the important functional bit is that it should be printing out a list. This code doesn’t output a list. It outputs the input value.
Let’s just implement the change, and while we’re at it, refactor the code into a FizzBuzz.js
file:
import React from 'react';
const FizzBuzz = ({ number }) => {
let messages = [];
const messages = Array.from({ length: number }, (_, i) => {
let num = number - i;
let message;
if (num % 3 === 0 && num % 5 === 0) {
message = 'FizzBuzz';
} else if (num % 3 === 0) {
message = 'Fizz';
} else if (num % 5 === 0) {
message = 'Buzz';
} else {
message = num.toString();
}
messages.push(<div key={num}>{message}</div>)
});
return (
<div>
{messages}
</div>
);
};
export default FizzBuzz;
The bug is now fixed. However, you’ll find that as you rewrite the tests so that they pass, they’ll suggest something’s wrong.
describe('FizzBuzz React Component', () => {
test.each([
[15, 'FizzBuzz1413Fizz11BuzzFizz87FizzBuzz4Fizz21'],
[10, 'BuzzFizz87FizzBuzz4Fizz21'],
[9, 'Fizz87FizzBuzz4Fizz21'],
[8, '87FizzBuzz4Fizz21'],
[5, 'Buzz4Fizz21'],
[3, 'Fizz21'],
[2, '21'],
[1, '1'],
])('returns %s when number is %s', (number, expected) => {
const wrapper = render(<FizzBuzz number={number} />);
expect(wrapper.baseElement.textContent).toBe(expected);
});
});
By the DRY principle itself1, something probably should be change. The principle aside, this amount of repetition discourages people from maintaining these tests.
In fact, these tests are giving us a very strong code smell signal that becomes more clear if I change the assertion to be the innerHTML
instead of the textContent
. Let’s also have fewer tests here to make this reasonable to look at.
describe('FizzBuzz React Component', () => {
it('renders without crashing', () => {
render(<FizzBuzz number={1} />);
});
test.each([
[2, '<div><div><div>2</div><div>1</div></div></div>'],
[1, '<div><div><div>1</div></div></div>'],
])('returns %s when number is %s', (number, expected) => {
const wrapper = render(<FizzBuzz number={number} />);
expect(wrapper.baseElement.innerHTML).toBe(expected);
});
});
Oh! We are testing both presentation and the logic at the same time, because both of those are present in the above function. The test has made this obvious now. So let’s fix it.
import React from 'react';
const fizzBuzzLogic = (number ) => {
let message = '';
if (number % 3 === 0 && number % 5 === 0) {
message = 'FizzBuzz';
} else if (number % 3 === 0) {
message = 'Fizz';
} else if (number % 5 === 0) {
message = 'Buzz';
} else {
message = number.toString();
}
return message
};
const FizzBuzzDisplay = ({ number }) => {
const messages = Array.from({ length: number }, (_, i) => {
const message = fizzBuzzLogic(number - i);
return <div key={number - i}>{message}</div>;
});
return <div>{messages}</div>;
};
return (
<div>
{messages}
</div>
);
};
export default FizzBuzzDisplay;
export {fizzBuzzLogic};
And let’s write appropriate tests for both of these.
describe('FizzBuzzDisplay React Component', () => {
test.each([
[2, '<div><div><div>2</div><div>1</div></div></div>'],
[1, '<div><div><div>1</div></div></div>'],
])('returns %s when number is %s', (number, expected) => {
const wrapper = render(<FizzBuzzDisplay number={number} />);
expect(wrapper.baseElement.innerHTML).toBe(expected);
});
});
describe('fizzBuzzLogic function', () => {
test.each([
[15, 'FizzBuzz'],
[10, 'Buzz'],
[9, 'Fizz'],
[8, '8'],
[5, 'Buzz'],
[3, 'Fizz'],
[2, '2'],
[1, '1']
])('returns %s when number is %s', (number, expected) => {
expect(fizzBuzzLogic(number)).toBe(expected);
});
});
You’ll note we’ve kept the original test of the display logic to assert some of the behavior that’s going on, specifically the order of the display and what HTML it expects. The reason is simple: in that test, that’s what’s under test.
And your new test is also the same as the old test—a simple expression of the logic being correct.
We could even go further, but I’ll stop here for now.2
Final Thoughts
Something subtle I want to put forward: when did I make better code?
It wasn’t when I jumped straight to the fix. When I paused, thought about the expected output, then wrote a test that failed—that’s when the code got better. And that is the pattern of TDD.
By writing any tests at all, someone before us has influenced us to reconsider the code and to refactor it, and it has been made more maintainable for the next person after us.3
The value of testing is not always about present gain. In fact it’s more about systemic confidence in the future.
And yes, this applies to tests too. Don’t throw DAMP in my face as if that’s an adequate answer.
How could we go further? The React component is fetching the data itself, when it could be passed in. This small change would make it possible for this component to be truly just display.
We happen to also be practicing separation of concerns now, but we didn’t write the code to practice that principle. We observed that the code was taking us in a nasty direction, and we paused and thought about it.