Michal Zalecki
Michal Zalecki
software development, testing, JavaScript,
TypeScript, Node.js, React, and other stuff

Fighting legacy JavaScript code

When legacy code is giving you a hard time, it's a good time to learn yourself a few new tricks. You might not think there's a method to this chaos in your codebase, but there are a few. In this post, I share with you four prooven methods of working on improving legacy code.

I've prepared a repository with exercises you can do to put your newly acquired skills to the test after completing the article. I use it when conducting the workshop on working with legacy code.

Before starting my official career as a software developer and a few years in, I don’t think I came across the term legacy code too often. I was more focused on learning new technologies, new languages, new tools, new frameworks, and design patterns (mostly to better understand new frameworks). Some of them were new at the time (AngularJS), and some of them were just new for me (Ruby). I gravitated towards greenfield projects, and greenfield project opportunities gravitated towards me, mostly due to giving talks at the local meetups about new tech I’ve given a try.

My initial intuition was that legacy code is something that sloppy Java developers are producing, and somehow, it doesn’t exist in a non-enterprise environment. The fact that at different stages of my education, I was using Java never found it very appealing, only magnified my lack of interest in the topic. I also didn’t pursue Java positions, but I’m not trying to bash on Java. After all, it was my language of choice for academia and still more fun than alternative C++ or C# (sorry if I hurt your feelings). That said, I wasn’t entirely wrong associating enterprise codebases with legacy code, but I was missing a thorough understanding as to why it’s that way (spoiler: project lifetime). It wasn’t until I started working on projects with many real users, got more interested in software design and architecture that I changed my outlook on legacy code.

Legacy code

Let’s start by trying to define a legacy code, so we have a common understanding of what is legacy code.

  • code maintained by someone other than the original author (since the term legacy)
  • code using outdated APIs or frameworks
  • code that feels easier to rewrite than change

None of these bullet points is a strict definition. I’m sure you could, with ease, come up with an exception to these rules. Does it mean these rules are wrong? Not necessarily, but they are open for interpretation. This is less than ideal when you try to make everyone on your team to be on board. The simpler, the better. Still, this is more or less aligned with the intuition you may have about legacy code. Legacy code is a code that is hard to work with.

Michael Feathers, the author of a timeless book Working Effectively with Legacy Code, came up with a simple definition of legacy code.

  • code without tests

This is a controversial and adamant definition. According to this simple heuristic, not practicing TDD means producing legacy code every time. As someone who enjoys the peace of mind that comes from writing tests first, I find this definition very correct at my day to day work. It is impossible to rely on tests when I can remove a line of code and still have green tests. We need much better coverage to make changes with confidence in an unfamiliar codebase.

The other definition I would like to share with you is the extension of the previous one. Dylan Beattie came up with this definition of legacy code.

  • code too scary to update and too profitable to delete

I already explained that code without extensive tests makes me uneasy about changing it. Why is that? Without tests, the only source of truth about what the code genuinely does is the code itself, and now you're changing it. The tests are the second source of truth that can keep you in check, and it does that in an automated manner. I would argue that tests written before the code was, are better. But then you don't have legacy code! Since we do work with legacy code, we have to settle on the next best thing. Tests we write for already existing code. Tests we write even years after the original code have infinitely more value than not having tests at all. Why am I mumbling so much about tests? Because tests are the foundation of working with legacy code.

Methods for tackling legacy code

Identify Seams to break dependencies

Legacy code not only doesn't have tests but often is written in a way that makes it very difficult to test in isolation. It might be accessing the database, making network requests, depends on global values, etc. To make code easier to test we can identify seams.

A seam is a place where you can alter behavior without editing in that place.

— Michael Feahters, Working Effectively with Legacy Code

The most popular type of seam is an object seam. Each method of the given class is a potential seam.

class MyComponent {
	performAwfulSideEffect() { ... }

	init() {
	  this.performAwfulSideEffect();
    ...
	}
}

What you can do to test the init method is to create a testable instance of MyComponent just for your tests. This way you can override the problematic method and change the init method behavior without changing its code.

class MyTestAbleComponent extends MyComponent {
	performAwfulSideEffect() {
	 // over my dead body
	}
}

This is all nice, but what if you don't practice object-oriented programming? Can you still use seams? You cannot use the object seam per se, but the idea still applies. The caveat is that in a badly written functional code, more often than in case of object-oriented programming, you have to create an opportunity for a seam to exist.

  • Access global values via a value/function passed as an argument (and compose functions)
  • Extract side effects to a separate function passed as an argument (and compose functions)
  • Access global values using wrapper in a separate module you can import (and replace in tests)
  • Extract side effects to a separate module you can import (and replace in tests)

Sprout Method

The Sprout Method can be applied when you don't have time to test the legacy code, but you have to add a new feature. Change that you have to make might be in a code that is difficult to run in an isolated unit test and there's no existing test suite for this module.

You can write tests only for this new feature written as an entirely new code. With tests passing, you can go back to the legacy code and replace or extend old implementation with a call to your new and tested code.

const InternalizationService = {
	translate(key) { ... }

	// bunch more code here
}

This is InternalizationService that relies on the global configuration. You have to add a new feature to format amounts of money. Instead of growing our untested InternalizationService, you decide to write a new code somewhere else and use it within InternalizationService.

import { formatCurrency } from "./formatCurrency";

const InternalizationService = {
	translate(key) { ... }

	formatCurrency(amount) {
		return formatCurrency(globalConfig.locale, this.language, amount);
	}

  // bunch more code here
}

You can do the same with the component framework. Instead of adding even more complexity to the existing component you can extract a portion of the rendered tree to a separate component, implement additional logic there, and don't forget about covering it with unit tests first.

Wrap Method

The Wrap Method fulfills a similar purpose as Sprout Method. When you're under the time pressure, use this method when adding a new feature or modifying the existing code without testing the legacy code first. This is not an ideal situation, but we don't live in an ideal world.

You take the existing function or method and change its name. Then, you implement a new function with the same name as the old function and keep the same signature (input arguments and output). Within the new function, you're going to call the old method.

const ProductsService = {
	getProducts(category) {
		return this.oldGetProducts().filter(isInStock);
	}

	oldGetProducts(category) {
		// ...
	}
}

This gives you the ability to run new code before, after, or instead of the old implementation. You can use it to implement new precondition, wrap a function in an error handler, modify its inputs or outputs, and so on.

While Sprout Method and Wrap Method allow you to practice TDD even in the worst parts of your legacy code, there's a tradeoff. You traded improving existing legacy code for the ease of implementing new code. In other words, you decided to make things not worse instead of making them better.

Snapshot testing

Snapshot is a captured output stored next to the test files or inlined in a test file. Each time your test runs the expected value is compared against the snapshot. You're probably already familiar with snapshot tests. Snapshots are widely used for smoke testing UI on the frontend (components) and UI layer on the backend (controllers and views).

Snapshots don't fit TDD as they assume that code is already written and capture its output. Snapshots also introduce a lot of noise, blow up pull request size, and if they change often, people tend to update them without giving it much thought.

function Component({ name }) {
  return <h1>{`Hello, ${name}!`}</h1>;
}

it("renders correctly", () => {
  expect(render(<Component name="Bob" />).container).toMatchInlineSnapshot(`
    <div>
      <h1>
        Hello, Bob!
      </h1>
    </div>
  `);
});

While this doesn't work for TDD, it's an excellent trait of a tool for the exploration of code we don't fully understand or want to capture unfamiliar behavior. We can then keep using snapshots to make sure that any change we make is intentional and we didn't accidentally break something. We can keep adding more specific checks in tests as long as we don't need snapshots anymore.

Keep in mind

Before we wrap up, I would like to share a few thoughts on the general approach to legacy code.

Sooner or later, the legacy code is going to break. It can be a week from now or ten years from now. Even when the code doesn't change, the surrounding world does.

Good code is easy to unit test. As long it at least runs, even the worst, almost unreachable code can be tested using high-level integration tests. Focus on a happy path first and make sure your edge case bug fix doesn’t break the happy path.

Do not let the perfect to be the enemy of good. Don't aim for perfection from day one. One or two unit tests are infinitely better than no tests. Make sure your setup is right. If you run code coverage with a tight enough budget that is reported on each PR, by adding one test to a previously untested module, you can fail the build by lowering the coverage. Impossible? This is because some testing frameworks account only for code that runs in tests already.

From a business perspective, the rewrite is one of the worst things that can happen to team performance. There's a great chance that we underestimate the complexity of the existing solution and overestimate the ability of our team to deliver a new solution.

Conclusion

Put your knowledge to the test and play with the exercises I prepared! Create an issue if anything is missing and share your feedback by commenting here or on GitHub. If you have more time and want to get your hands dirty try Ugly Trivia Game Kata.

Don't get bogged down by some legacy code in your project and seek a learning experience in it. I based this article on the "Working Effectively with Legacy Code" book, so it makes sense to read it and learn more if you enjoyed this post.

Photo by Thao Le Hoang on Unsplash.