howto

How to do big refactors without losing your sanity

Picture of Julian Krispel-Samsel
Julian Krispel-Samsel, Monday July 20, 2015

Big refactors are horrible. They are hard to estimate because they often contain unknown work. They are hard to review because there's a lot of code. They often have unforeseen side effects and cause way too many merge conflicts because they touch so many different parts of your codebase.

TLDR: The short answer is QA. Good test coverage can bring you unscathed through a big refactor. Unit tests are great, and functional tests are key.

(Disclaimer: Although this blog post refers to a big front-end refactor, it applies to refactoring in general. If front-end is not your thing, don't tune out just yet.)

First of all, please don't ever do big refactors unless you absolutely have to. If you've been involved in one, you know what I'm talking about. It almost always ends in tears, and sometimes people get laid off (true story). That's something I tell myself all the time, but sometimes they still just have to be done.

I'd been looking for “the right job” for around six months when I finally joined Rainforest. I was anxious not to fuck this up, but then I took up a task that tremendously increased the risk of fucking it up: refactoring a big JavaScript codebase into CommonJS modules.

Doesn't sound so bad, does it? That's what I thought. But when there are hundreds of files full of global variables and circular references, it can be a grueling experience. Here's how big the pull request on github was:

Volume of refactor

And here is the list of tasks associated with it:

Refactoring Task list

As much as I dread big refactors, these changes had to happen. If there is one thing the entire JavaScript community agrees upon (which almost never happens), it's that modules are good. Also, some of our tooling depended on a module-based system.

When it was all done, I expected the app to blow up. I expected users to report a ton of new bugs and that our front-end error rate would skyrocket. I expected that we'd have to revert to a previous commit after we'd deployed, because that's what I'm used to when making big changes like this: disaster.

To my surprise, the app didn't explode. We actually fixed more bugs than we introduced with the changes. Our users didn't churn and, to my utter amazement, we didn't have to revert our changes. It just worked, the first time! It felt like winning the lottery: very, very unlikely!

lucky I guess

Here's how we did it

One way to check your code for mistakes is to have a colleague review it. This is something that happens internally for every change we make but in this case, it was close to impossible. It would have taken days. GitHub couldn't even display the diff:

Github diff

Right - one of our core QA processes doesn't apply. What do I do now?

Fret not, the answer turns out to be surprisingly simple:

Test-driven refactoring

Quite a while ago, listening to my favourite dev podcast of all time, I heard Brandon Hays talk about how he refuses to refactor anything without tests. That struck a chord with me!

The thing with big refactors is that you're guaranteed to break your app, and it's close to impossible to keep track of what you break if your change is big enough. A good test suite will tell you when breakages occur. That's pretty much the entire secret of doing big refactors well:

  • Knowing what you're breaking so you can fix it.
  • Fix it.

Unit tests are of course at the core of this.

Unit tests

We use Jest and Jasmine for our front-end unit tests. Jest uses jsdom, which is a JavaScript-based implementation of the DOM. This means our tests can be run in any JavaScript environment (so we can actually run our tests in node rather than spinning up a headless browser - HURRAY!) and in parallel. Another very neat feature of Jest is that it mocks modules by default, so that your unit tests are only testing units and aren't affected by other units.

Core questions we answer with unit tests are:

  • Does the component render as expected?
  • Does the component behave as expected when events occur?
  • Does the component fail as expected?

Unit tests can catch a large number of bugs, but you need functional tests to make sure an app is usable.

Functional testing

Unit tests provide a good baseline for knowing when your code doesn't behave as originally intended, but that doesn't mean that when those tests go green that your app works in its entirety. That's where functional tests come in. Whereas unit tests test your code, functional tests test if your app works and is usable in a real-world scenario and on different platforms.

Functional tests are important for catching bugs that can only be caught by a series of user interactions. Here's what a typical functional test looks like:

  • Go to our homepage.
  • Log in with username and password. Are you logged in?
  • Go to the Settings page, change a setting and click “Save”. Is the setting still the same when you reload the page?

Even after all our unit tests passed (we have decent coverage) and some manual QA, our functional test suite helped us identify a bunch of remaining bugs.

Having gone through a big refactor before, this stage usually involves manual QA, since functional tests are often really hard to automate. Typically, we developers would hand-test in X different browsers on X different platforms (responsive design ftw), often spinning up different Windows VMs just to look at the product in different versions of Internet Explorer. It’s a tiresome, time-consuming process and one that often goes awry if you're working by yourself and have no formal QA process.

If you aren't lucky enough to have a QA department at your company, you're faced with two options: manual execution or automation.

There are many tools to automate functional testing. Although functional tests are notoriously difficult to automate well, this step is crucial to ensure your app is working as expected.

Our functional test suite has certainly been the saving grace of this refactor. However difficult it may be, this step is key for a safe deployment. Seriously, if you've tried it once, you'll wonder how you could have ever done without functional tests.

Wrapping up

When I started at Rainforest, I had my doubts about the product (we automate functional testing). I always thought it was a little weird to automate tasks executed by actual people.

But this part of the refactoring process really opened my eyes to the value of the product and what it's aiming to achieve. Writing functional tests in plain English is simply a massive time saver.

Test Example

Since we already have a suite of Rainforest tests in place for our app, I just ran those tests against the refactored codebase.

Test Example

Because the tests are written in plain English and actual testers execute them, I didn't need to refactor them at all. I wouldn't have to even had I rewritten the app in another language.

Whether with Rainforest or not, make sure you integrate functional testing into your QA process. It'll save your precious behind and that of your app!

Filed under: ops