πŸ• Reading time: 6 minutes

"Do I really need ten developers?"

Anyone who pays the bill for a project, or coordinates its resources, has asked this question at least once. And the answer is: maybe not. Often, though, those ten aren't building anything new – they're keeping alive a legacy monster that nobody dares to touch, because nobody knows what breaks when you touch it. There is a way to need fewer of them, and it's having quality code. And quality code, first of all, is tested code.

With pill #27 I said goodbye to DetectION, and it felt like a good season finale. It's October, so we start again: the third season opens with a sub-series, Common Dictionary.

The idea comes from an internal conference at ION a couple of days ago, where I was one of the speakers on a panel in front of an audience of new colleagues. One of the topics of the day was T-shaped profiles: the vertical bar of the T is the skill you specialise in – in my case the backend – the horizontal one is the ability to move across the neighbouring disciplines and understand the people who practise them. It was already true before, but with the arrival of AI it's truer than ever: going vertical isn't enough, you also need to "cross-pollinate" horizontally. And the first step is learning a common dictionary.

Still technical pills, then, but written also for those who never touch the code and work with developers every day – business and functional analysts, POs, PMs and so on. When words mean the same thing to everyone, people work together better. And argue less about estimates.

Today's two expressions are unit test and integration test. With a note for my fellow developers: this pill is for us too, because we all love talking about tests – writing them, a little less.

πŸ“Œ Code that checks other code

A test is simply code that verifies other code. It calls a function, passes it an input and checks that the output is the expected one. Let's take the most trivial example possible, a division: if I divide 10 by 2, I expect 5.

@Test
public void test_divide_returnsQuotient() {
    final int actual = calculator.divide(10, 2);
    assertEquals(5, actual);
}

That's all. In the backend of a real product the inputs are much bigger and the outputs more complex, but the principle doesn't change: given this, I expect that.

Manual checks don't disappear: the developer calls their services from Postman or clicks through the interface, and testers have their own verifications. But these tests have an extra gear, they are automatic: a good unit test suite runs in a few minutes, and it plugs into the pipeline that brings the code to the environments – first the test and acceptance ones, then production. At every change and every release everything runs again from scratch, and if a check fails the release stops. You pay for a test once, when you write it; then it works for free for the whole life of the project.

πŸ“Œ Let's leave the abstract: a car

Code is an abstract material, so let's try with something tangible. A moving car is the result of many components working together: engine, clutch, steering wheel, power steering, brakes, tyres.

Before the car goes on the road, each component is tried on its own, on the test bench. Does the tyre have enough grip? Does the brake pad withstand a certain stress without wearing out? This is a unit test: it verifies a single piece, isolated from everything else. There is no road: a roller simulates it, and you only care about how that piece reacts. It's fast, it's cheap, and when it fails you know exactly which piece has a problem.

Then comes the test drive. You start the engine, set off, steer, brake: you're verifying that all those components, together, talk to each other and make the car move. And this time the road is real. This is an integration test: it puts the pieces of the application to the test while they work together, and together with what sits outside the application but that it must interface with – a database, an external system. The road, precisely: it's not part of the car, but without a road the car goes nowhere. It's a slower and more expensive test, and it doesn't have the granularity of a unit test: its job isn't to try every combination of inputs that can go through a single piece, but to verify, precisely, the integration: that the pieces, once assembled, work together.

You need both. Four perfect tyres don't guarantee that the car steers; and a successful test drive doesn't tell you how long the brakes will last. That's why in a healthy project unit tests are many and integration tests far fewer: on the main module of DetectION they were about 1,100 against 200. There's also a consequence on the way you write code. A piece can be tried on its own only if it is a piece: a function that does ten different things is as hard to test as a car cast in a single block. Suitably layered code, made of small functions with a single responsibility, isn't a developer's whim – it's what makes it verifiable.

One clarification: I'm speaking as a backend developer. On the frontend the two expressions apply in the same way, but testing gets more complicated – the output isn't a value, it's a screen, and the input is a person who clicks wherever they like. On how to deal with that, I'll leave the floor to those who work on it every day.

πŸ“Œ Not only when everything goes well

The most common mistake is testing only the case where everything works – which is also the one where tests are needed the least. But you don't try a brake only on a dry road at 30 km/h. Back to the division. Dividing by 1 must return the starting number: it's an edge case, and it must be verified. Dividing by zero, instead, has no result: the function must fail, and it must do so in the expected way.

@Test
public void test_divide_byZero_throwsException() {
    assertThrows(ArithmeticException.class, () -> calculator.divide(10, 0));
}

Here the check isn't about a result but about an error: I expect exactly that exception. Wrong inputs, missing data, systems that don't respond: the job is to expect the unexpected. It's normal for an exception to show up – what matters is that it doesn't catch the code by surprise.

Some people take this idea to the extreme and write the test before the code: that's Test Driven Development. It's like defining the acceptance test for the brake before even building it – from what speed it must stop the car, and within how many metres. Only then do you build the piece, and you know it's done when it passes the test: the advantage is that it forces you to decide in advance not only what you want to build, but what the criteria are to say that it works. Those who write requirements will recognise them right away: they're the acceptance criteria, translated into code.

πŸ“Œ So, how many developers do you need?

It depends, of course. But it depends less on how much there is to build and more on how scary it is to touch what already exists. And this is where tests make the difference:

To give an order of magnitude: the DetectION backend modules had about 41,000 lines of application code and 37,000 of tests, almost a one-to-one ratio. It sounds like a lot, and it is a lot. It's also the reason why, in almost three years, we could touch any point of the code without holding our breath. Careful, though: tests verify the cases someone thought of, they don't prove that bugs don't exist. And having many doesn't mean having good ones – I talked about it in pill #23, about the coverage illusion.

So, the next time an estimate includes the line item "tests", it's not time lost before delivery: it's what, a year from now, will let you change that software with three people and not with ten.

For now we've only warmed up the engines: the third season has just started! β˜•