Most of our projects use codecov.io to check code coverage and improve review process. But high code coverage value itself is not telling anything about quality of the code and tests, so we always try to improve the way we test our products.
You can read about our mutation testing practices and in this article we will try to find out why we should write tests focused on behavior rather than implementation.
Why do we need tests?
There are some companies and individual developers who are not sure about the importance of software testing for a rapidly growing business. They think about testing as time-consuming, unnecessary and costly part of the development process. Sometimes writing good tests may take more effort than actual implementation of the code being tested, so why do we need them at all?
For us tests are essential and very important part of the development process. We find next features as most important benefits:
- Ensures confidence – we can be sure that code behaves as we want and increases developer’s confidence in refactoring without fear to break something.
- Quick feedback – if something goes wrong with the code and it breaks desired behavior – good tests should provide enough information to quickly find the reason and fix it.
- Code documentation – good tests can provide context and use cases with examples that is enough to get full understanding of tested code.
Test behavior vs implementation
Let’s look at an example component:
import React, { Component } from 'react';
type CounterState = {
count: number;
}
class YetAnotherCounter extends Component<{}, CounterState> {
state = { count: 0 };
increment = () => this.setState({
count: this.state.count + 1
});
decrement = () => this.setState({
count: this.state.count - 1
});
render() {
return (
<div>
<p>{this.state.count}</p>
<button onClick={this.increment}>+</button>
<button onClick={this.decrement}>-</button>
</div>
);
}
}
export default YetAnotherCounter;
We can test it using Enzyme like this:
import React from 'react';
import { shallow } from 'enzyme';
import YetAnotherCounter from './YetAnotherCounter';
it('increments and decrements counter', () => {
const wrapper = shallow(<YetAnotherCounter />);
expect(wrapper.state('count')).toBe(0);
(wrapper.instance() as YetAnotherCounter).increment();
expect(wrapper.state('count')).toBe(1);
(wrapper.instance() as YetAnotherCounter).decrement();
expect(wrapper.state('count')).toBe(0);
});
And using react-testing-library:
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import YetAnotherCounter from './YetAnotherCounter';
it('increments and decrements counter', () => {
const { getByText } = render(<YetAnotherCounter />);
const counter = getByText('0');
const incrementButton = getByText('+');
const decrementButton = getByText('-');
fireEvent.click(incrementButton);
expect(counter.textContent).toEqual('1');
fireEvent.click(decrementButton);
expect(counter.textContent).toEqual('0');
});
Both tests pass and cover the same functionality, but they have important difference.
First one one is testing implementation details and second one tests behavior.
To understand practical difference let’s imagine that someone made a mistake in your code and both of your buttons increment counter
// rest of the code stays the same
<div>
<p>{this.state.count}</p>
<button onClick={this.increment}>+</button>
<button onClick={this.increment}>-</button>
</div>
First test will still pass, even though you have a mistake in your code. It means your test is false positive: the test passes even if the code is broken.
Now let’s say after some time we want to make refactoring and use hooks:
import React, {useState} from 'react';
const YetAnotherCounter = () => {
const [count, setCount] = useState(0);
return (
<div>
<p>{count}</p>
<button onClick={() => setCount(count + 1)}>+</button>
<button onClick={() => setCount(count - 1)}>-</button>
</div>
);
};
export default YetAnotherCounter;
Functionality of the component stays the same as initial one, but first test fails with next error:
ShallowWrapper::state() can only be called on class components
It means that our first test is also false negative: the test is broken even if the code is right.
Summary
Our example illustrates that test focused on implementation is more fragile and can’t ensure confidence in code and provide you quick feedback on problems. You can test for more cases to improve the situation, but in general testing behavior of component is more resilient to code changes.
- changes in the implementation should not break your tests if the behavior stays the same
- tests should be sensitive to changes in the behavior of the code under test. If the behavior changes, the test result should change
Writing tests you should think about your users and how they interact with your product. After all end users don’t care what is your implementation if your product provides them what they expect. So our tests also should focus on user behavior and their expectations rather than our implementation.