Why Testing Improves Coding
Last Thursday, I attended “Good enough testing”, an online workshop by Lucian Ghinda, curator of the Short Ruby Newsletter. I’m glad that I saw the registration soon after it was posted, because the workshop was sold out in less than a day!
I took some time to combine the knowledge from the workshop with what I’ve learned over the years, in blogs and books.
Confession time
First, I’ve a confession to make: I’m a self-taught programmer. My first computer was an Atari running Cubase, and I was happy writing music and storing my MIDI tracks on floppies. There was no Internet at the time, and I’m not very fond of videogames, so computers were pretty boring anyway!
Some years later, I setup a blog and learned to code to change its design and functionalities. Thus I started by learning HTML and CSS, then PHP and SQL, then JavaScript. To this day, I still believe that it’s the best way to learn web development: start from the basic building blocks of the Internet. I’ve met too many devs who over-engineer things or reinvent the wheel, just because they never learnt HTML and CSS…
On the other hand, I’ve always felt insecure because I’ve never had formal training: I never wrote sorting algorithms, used matrices, built a parser, or had formal lectures about SOLID principles. Still, I’m constantly striving to deliver high-quality code, both in terms of readability, reusability, and performance, and I have read lots and lots about design patterns and SOLID principles. I even tried to do some basic testing using Codeception during my PHP days.
Testing as a habit
But what really made it click was Ruby on Rails, which generates tests along with the code instead of as an afterthought. This was the push I needed to test all aspects of my code, earlier in the process, and my coding style started to adjust to simplify testing. But enough of personal chit-chat, here’s all I’ve learned about testing!
Testing is (not only) doubting, it’s about making sure that you hold the promise you’ve made when taking on an engagement. And it makes your code better by forcing you to take situations into account that could have been overlooked otherwise. Just like teaching improves your mastery of the subject, testing forces you to look at your code from a different perspective, which can reveal bugs, performance issues, and architectural defects.
Testing is making sure that code does what it’s supposed to. But sometimes you don’t really know what you’re supposed to do! It’s best to remove uncertainties before writing code, as further clarifications could add complexity, require refactoring, or open the possibility of simpler shortcuts.
Writing tests also helps you stay focused on writing the code you need, no more and no less. That’s the whole point of TDD (Test-Driven Development) actually! If you haven’t already, I recommend going through Thoughtbot’s Upcase Fundamental TDD trail.
Code Coverage
Code coverage is a measure of how much code is exercised by tests, usually expressed in percentage.
Testing can be time-consuming, and doesn’t provide direct value apart from avoiding bugs which will never happen. Therefore, new developers will quickly need to decide on the right amount of code coverage.
While researching for this article, I came upon The Way of Testivus and was enlightened. Then I found Testivus’ answer to: How Much Unit Test Coverage Do You Need? and my enlightenment was enlightened. There is therefore no right answer, except the classic “It depends”.
- If the codebase has just started development, write tests for crucial functions first. Keep in mind that parts of the application will be thrown and rewritten very soon.
- If the codebase is complex and lacks tests, write tests as you fix bugs and add functionality. Aim to improve code coverage incrementally but continuously.
- If the codebase is healthy and well-tested, rejoice! And be sure to always add tests for all new code to keep it healthy.
But beware that one line of code may contain multiple branches.
For instance, there are 4 branches in if a && b
(though only 3 matter, since when a
is truthy, b
’s value is ignored).
Code coverage tools may report that the line is covered even if only one condition is tested.
Inputs and Outputs
Functions take inputs and return outputs. Most input arrives as parameters, but beware that in Object-Oriented Programming, some input comes from the state of the object, the environment, or —gasp!— another object.
After identifying all inputs, list each input’s value range, match it to the expected output, then test all situations. If there are too many inputs, Rubocop will warn you about Cyclomatic complexity. Look for a way to reduce branches, or split the function to simplify testing and improve readability when that is the case.
Also note that most functions may throw exceptions, which may need to be accounted for in testing.
Depending on the type of functions and inputs, you’ll need to use different heuristics to write the right amount of tests. Lucian’s Good Enough Testing workshop focused on that, and I learned a lot in those two hours. You’ll have a chance to attend this workshop next week if you go to Euruko, or wait for Lucian to announce a new date.
Isolating the System Under Test
In my eagerness to test thoroughly, junior me wrote tests for validations, then realised that they were actually exercising the framework. Native Rails behaviour is usually not worth writing tests for, as Rails itself usually has pretty high code coverage. In a 2020 article, FastRuby.io calculated that Rails 6.1 code coverage was around 80%, most components around 90%, though some were as low as 30-40%.
When the codebase is properly covered with unit tests, it’s best to use mocks and doubles instead of external objects. Tests that mock external objects are much faster, use much less memory, avoid hitting the database, and are much less likely to fail because of changes elsewhere in the codebase.
As Sandi Metz points out in Principles of Object-Oriented Design for Ruby, it’s important that mocks and doubles fail if they receive methods that the actual object doesn’t accept. There’s a whole chapter on that subject, and I’ve found Jared Norman’s 2024 RailsConf titled “Undervalued: the Most Useful Design Pattern to be a very good complement, as it shows a clear progression from working code to future-proof code. You can also follow the Test Double trail on Upcase for a more hands-on approach.
Fixtures and Factories
When I started learning Rails, my mentor taught me using Rspec, FactoryBot, Faker, and DatabaseCleaner. I used the same stack on other projects, and found that clear specs and properly organised factories are quite effective.
Fixtures load all at once and get reused by all tests. This avoids recreating common setup from test to test, but increases the distance between setup and actual test usage.
Factories have pros and cons, and the biggest drawback is probably their propensity to cascade out of control, slowing test suites. Evil Martians recently helped one of their clients increase CI run speed by a factor of five (!), mainly by adjusting factories. I’ve also seen unrelated tests that started failing after fixing misconfigured factories, which can be quite annoying.
Most teams use Rspec and FactoryBot, but Rails itself is famous for sticking to Minitest and fixtures.
My first contribution to Rails (Allow passing classes to dom_id
) was therefore an opportunity to try this setup.
I was a little uneasy since the Rails codebase is quite extensive, and fixtures are shared, but the syntax itself was easy to grasp.
Finally, I saw that Kasper Timm Hansen has recently launched Oaken, which aims to bring the best of both worlds. I’ve yet to try it out in a side-project, but it looks promising.
Further reading
There’s a lot to say about unit tests (what to test and what tools to use, etc), so I’ll only give these three pointers, but I’m sure that they’ll yield a lot more. Enjoy!