Working Effectively with(out) Tests.

How to effectively work with code without test and how to add tests to your project for them to help you the most.

Code without tests #

So you're working in a codebase without tests. It doesn't matter if your project is more than a decade old or if it's barely started. Michael Feathers categorized it as a legacy code in his 2004 foundational book "Working Effectively with Legacy Code".

"To me, legacy code is simply code without tests."
― Michael C. Feathers, Working Effectively with Legacy Code

So from now on, we'll treat the terms "code without tests" and "legacy code" as the same thing. What can we learn from it?

Working Effectively with Legacy Code by Michael C. Feathers book cover

Making changes #

First, let's start with how do you work with the project right now. It's probably some version of:

  1. Write code.
  2. Run the project.
  3. Open a browser/app and click through it to get to what you're changing.
  4. If something is wrong, then go back to step 1.
  5. When done, you may look at some other pages or screens to make sure you didn't break anything by accident.

It may be fine in a simplistic hello-world application. Unfortunately, when you have more features, more code, and logic, it ends up slowing the team significantly until it feels like even a simple change takes a month or more.

Why it's bad #

Although our first joy of programming may have been intense, the misery of dealing with legacy code is often sufficient to extinguish that flame."
― Michael Feathers, Working Effectively with Legacy Code

I find a quote from Clean Architecture by Robert C Martin fitting as well.

Why don't you fix bad code when you see it? Your first reaction upon seeing a messy function is "This is a mess, it needs to be cleaned". Your second reaction is "I'm not touching it!". Because you know that if you touch it you risk breaking it; and if you break it; it becomes yours.
― Robert C. Martin, Clean Architecture

Speed #

The most obvious one will be speed. Even if code without test is an effect of Technical debt, which for me means it's a deliberate decision, it's still going to slow down the project to a halt at some point.

Self-efficacy #

If you read more about Self-efficacy theory, then you will realize that working with legacy code is a substantial factor in lowering it:

Factors affecting self-efficacy #

Bandura identifies four factors affecting self-efficacy.

  1. Experience, or "enactive attainment" – The experience of mastery is the most important factor determining a person's self-efficacy. Success raises self-efficacy, while failure lowers it.

Why does it matter?

Motivation #

High self-efficacy can affect motivation in both positive and negative ways. In general, people with high self-efficacy are more likely to make efforts to complete a task, and to persist longer in those efforts, than those with low self-efficacy.

A negative effect of low self-efficacy is that it can lead to a state of learned helplessness.

Academic contexts[edit] #

Parents' sense of academic efficacy for their child is linked to their children's scholastic achievement. If the parents have higher perceived academic capabilities and aspirations for their child, the child itself will share those same beliefs. This promotes academic self-efficacy for the child, and in turn, leads to scholastic achievement. It also leads to prosocial behavior, and reduces vulnerability to feelings of futility and depression. There is a relationship between low self-efficacy and depression.

Choices regarding behavior #

People generally avoid tasks where self-efficacy is low, but undertake tasks where self-efficacy is high. When self-efficacy is significantly beyond actual ability, it leads to an overestimation of the ability to complete tasks.

Progress principle #

Another critical factor is that legacy code makes it harder for people to make progress every day.

The Progress Principle

Our research inside companies revealed that the best way to motivate people, day in and day out, is by facilitating progress—even small wins.

You'll see how the software engineers needed a massively positive project to lift their inner work lives out of the polluted stream of bad news that had engulfed them. Analyses across all teams' diaries will reveal that progress in meaningful work is the most important of the key three positive influences on inner work life.

What can you do about it? #

The biggest problem with legacy code is that people are afraid to improve or refactor it, fearing that they will break something. Everyone tries to make the smallest possible changes to accomplish the task. What it leads to is code getting worse and worse over time.

If you have to test it manually anyway #

But, you need to test even small changes thoroughly.

Wait a bit. If you need to do many manual tests, even for small changes, then what most people are doing doesn't make sense.

If you have to test it manually anyway, then the most logical thing to do is improve the code at the same time.

But, what should you do? #

"Code without tests is bad code. It doesn't matter how well written it is; it doesn't matter how pretty or object-oriented or well-encapsulated it is. With tests, we can change the behavior of our code quickly and verifiably. Without them, we really don't know if our code is getting better or worse."
― Michael Feathers, Working Effectively with Legacy Code

The best thing to do is to add a test.

  1. Write a test checking how code works before your changes.
  2. A test for other behavior you don't want to break.
  3. And finally, a new test for how it should work after your changes.

But, I can't write a test without refactoring. #

"we can't let "best" be the enemy of "better."
― Michael C. Feathers, Working Effectively with Legacy Code

If you need to refactor your code to add tests, then Refactor it.

  1. You will manually test it anyway.
  2. After adding a test, there will be less manual testing for you in the future.

So the risk of refactoring and adding a test is just the same as most business tasks. Fortunately, adding tests improves code quality over time instead of degrading it.

Where to put a new code? #

Most common situation. You want to add a new feature. You find the right place to add it. It's a pretty long a complicated function with lots of if and else statements. Most people try to squeeze in a new if statement somewhere in between all that. Test it manually and call it a day.
Even if you want to write a test for it, you don't have time to write a test for the whole big function from scratch. It would take way too long. What should you do?

The best advice here is to move new code into a new separate function and write a test only for the new code. You didn't improve the code much, but at least you didn't make it worse.
If you can take some of the surrounding code from the big function with you, you can improve the code quality at the same time.

  1. Put new code into a new function.
  2. Write tests for the new function.

Now is time to look at it from a different perspective.

Software Design X-Rays #

I hope you want to write tests. Let's discuss where you should put them.

Software Design X-Rays by Adam Tornhill book cover

Where do we put tests? #

You may notice that some old programming languages use separate src and test directories. The pattern got some popular that it shows up even in languages and platforms that don't have the same build and deployment problems as C++ or Java had in 1990. For some reason, people are using arguments that made sense on computers much slower than the phone in your pocket to convince new generations of programmers that it's a good idea. For example, answers to "I was wondering why in many typescript tutorials where it was also about testing there was always an extra test folder created?" doesn't make sense for TypeScript build tools.

Where do modern languages and tools put tests? #

In the same file #

The purpose of unit tests is to test each unit of code in isolation from the rest of the code to quickly pinpoint where code is and isn't working as expected. You'll put unit tests in the src directory in each file with the code that they're testing. The convention is to create a module named tests in each file to contain the test functions and to annotate the module with cfg(test).
The Rust Programming Language > Test Organization > Unit Tests

In the same directory #

To write a new test suite, create a file whose name ends _test.go that contains the TestXxx functions as described here. Put the file in the same package as the one being tested. The file will be excluded from regular package builds but will be included when the go test command is run. For more detail, run go help test and go help testflag.
Go-lang testing

Tests are as important as types #

The other reason is a harmful belief that your tests are somehow different from your code. That they have a different reason or purpose in the project. Or even worse, they somehow "pollute" the main code.

What is surprising is that you don't hear the same argument about:

I don't want to spend too much time on details here, and I'll call them as types for simplicity. Let's focus on two roles that types have in your code:

  1. Help programmers reason about the code.
  2. Make sure your code is correct.

Tests fulfill both of those roles.

  1. Test helps you understand the code by seeing how to use it and what you can expect from it.
  2. You can re-run tests every time you make a change to makes sure your code is correct.

Most developers understand now that separating types and implementation has a cost, and you need a good reason for separating them. It would be best if you used the same reasoning for your tests.

The Principle of Proximity #

The principle of proximity focuses on how well organized your code is with respect to readability and change. Proximity implies that functions that are changed together are moved closer together. Proximity is both a design principle and a heuristic for refactoring hotspots toward code that's easier to understand.
― Adam Tornhill, Software Design X-Rays

Most people forget that most changes require changes in the tests at the same time.

I've found that some of the readers react better if it phrased differently.

Common Closure Principle (CCP) #

Classes within a released component should share common closure. That is, if one needs to be changed, they all are likely to need to be changed. What affects one, affects all.
One of the PrinciplesOfObjectOrientedDesign.
Ward Cunningham, https://wiki.c2.com/?CommonClosurePrinciple [1]

Common Closure Principle is the same thing but put in more Object Oriented terms.

The Gestalt law of proximity #

I'm adding the law of proximity to remind you that it's not something that software developers invented. It's something you depend on when using all well-designed software. There is no reason for your code to be an exception.

The Gestalt law of proximity states that "objects or shapes that are close to one another appear to form groups". Even if the shapes, sizes, and objects are radically different, they will appear as a group if they are close.
Wikipedia: Principles of grouping

Gestalt proximity image

Put tests next to the code they test #

It all means for you in practice that you should place your tests as close as possible to the code they test.
I've found that the universal rule of thumb rule is:

Put test file next to the file it tests

You can use that in JavaScript, Go, Rust, and every other modern language with a modern build tool.

Summary #

  1. Put new code into new functions.
  2. Write a test for all new code.
  3. Put code next to files they test.

  1. I'm not sure about origin. I'm pretty sure it's coming from Robert C Martin, but I found his explanation not to be that useful.

    This is the Single-Responsibility Principle (SRP) restated for components. Just as SRP says that a class should not contain multiple reasons to change, CCP says that a component should not have multiple reasons to change. In most applications, maintainability is more important that reusability. If the code in an application must change, you would prefer the changes to occur all in one component rather than being distributed through many components. If changes are focused into a single component, we need redeploy only the one changed component. Other components that don't depend on the changed component do not need to be revalidated or redeployed.
    — Robert C. Martin, Agile Principles, Patterns, and Practices in C#.

    ↩︎


Share on Hacker News
Share on LinkedIn


← Home