Something we can probably all agree on is that a fundamental part of building software is testing. A product that goes to production without tests is risky, but how do you know if the tests you’re writing are actually good tests?
What tests are really about
The reality is that the products we build are very complex. Even small products have hundreds of use cases, and testing each one manually very quickly becomes impossible.
Complexity doesn’t only happen in software. Every industry that builds things faces this, which is why they all need testing.
At the end of the day, tests aren’t about checking that our logic is the best, the fastest, or that we’re following the right steps. Tests are about confidence.
Confidence that we can make changes without breaking things that already existed. Confidence that when new features are released to users, they work as expected. Confidence in being able to ship a product.
So the more certain we are that our tests verify our software works as it should, the better our tests will be.
What characteristics do good tests have?
There are a ton of testing approaches, and each team chooses which ones best fit what they need to test. But there are characteristics that good tests share:
- They run fast: Tests shouldn’t take hours to run. Many tests should only take a few minutes (unless your project is very large). Normally, testing tools will warn us when a test takes longer than it should.
- They don’t break all the time: Tests also need maintenance, so it’s important that they don’t break every time we make a change, or it would become too costly to have them.
- Easy to read and understand: Tests shouldn’t be more complex than the code they’re verifying. That’s why our tests should be simple to read and write.
- Can prevent bugs: This is perhaps the most important characteristic of a test. If we make changes that affect existing functionality, our tests should fail and give us clues about what went wrong.
- Good coverage ratio: The coverage ratio is basically how much of our code we’re covering with tests. It shouldn’t be our goal to hit 100% — each team decides what coverage level works for their project.
- Survives major refactoring: If we wanted to change the project’s internal architecture or change something to improve performance, tests should be our best friend, helping us make sure nothing stops working.
The cost of tests
Another factor to consider when figuring out if we’re writing the right tests is thinking about how much having tests costs the team. Not all the tests we have will be equal. Here’s an image that illustrates this well:
The fastest tests to implement are unit tests, which makes them the lowest cost. But they’re also the ones that might help us find the fewest bugs.
Meanwhile, E2E tests would give us much more confidence, but the implementation and maintenance cost is very high.
Perhaps a good approach is the one proposed by Guillermo Rauch: mostly invest time writing integration tests, since they can represent a good balance between speed and cost.
For the most critical parts of our software, we could invest in having E2E tests.
Some of the most common testing mistakes
I’ve worked on several development teams and tried various methods for testing. Some approaches I’ve used are:
- Testing that a component mounts without error: This test renders a component and checks that no error is thrown. But this doesn’t really test anything, because if the library/framework works, it will render without a doubt.
- Testing components using snapshot tests: This is a common practice. It checks that the markup a component returns is always the same. The problem with them is they generate a ton of files, and many times teams just update the snapshots when they see an error without looking for the reason behind the failure.
- Only testing reducers: Teams using Redux often do this. While it’s a good attempt since you’re testing the application’s logic, if we need to make significant changes that involve changing flows or stop using reducers, the tests won’t help us prevent regressions.
So then, how should we write tests?
After trying to implement the tests I just mentioned, I found this tweet by Kent C. Dodds that made me rethink how I was writing my tests.
The reality is that no matter what framework or architecture we use, users will always interact with our product through an interface, regardless of what logic is behind it.
If our tests try to emulate the way our users use what we build, we’ll have greater confidence in the end, and we’ll be writing good tests that meet most of the characteristics I listed above.
There are several libraries for achieving this. Personally, I like using react-testing-library (also available for vue and angular).
Final words
Creating tests that recreate as closely as possible how the user would use what I build has turned out to be the best way to write tests for me. But each team should decide which approach is most convenient for them based on many factors, like the type of project, resources, team size, etc.
In upcoming posts, we’ll see how to start implementing tests that meet the characteristics we discussed.
Lastly, I’d like to ask you: Do you think having tests in your project is important? Does your current project have a test suite? Do you feel confident with the tests you have?