Benjamin Johnson Software Engineer. Learning every day one mistake at a time. https://www.benjaminjohnson.me

Refactoring legacy code with Jest snapshots

6 min read 1846

Snapshot testing is immensely popular for testing React apps or other component-based UIs. However, it’s not exactly drama-free — many people looooove snapshots for their ease of use and ability to quickly bootstrap a testing portfolio, while others feel that the long-term effects of snapshots might be more harmful than they are helpful.

At the end of the day, snapshot testing is simply another tool in our tool belt. And while many people may be divided on how and when to use snapshot testing, it’s good to know that it exists and that it’s available when we need it.

I’ll be honest about my position on snapshots — I tend to be in the camp that’s less enthusiastic about them. However, I recently came across a situation with some legacy code where it felt like snapshot tests were a perfect match. Using snapshots as a refactoring tool helped me successfully tackle and refactor some tricky code written long before I joined my company.

What are snapshot tests?

If you’re not familiar with snapshot tests, we’ll do a little refresher. In a snapshot test, a “picture” of your code’s output is taken the first time the test runs. This “picture” gets saved to a text file in your codebase and all subsequent test runs use this picture as a reference — if your code’s output produces an identical snapshot, the test passes. However, if the output is different from the saved snapshot the test fails.

Here’s an example of what a snapshot test looks like in Jest:

import renderer from "react-test-renderer";

function Test({ message }) {
  return {message};
}

test("renders", () => {
  const wrapper = renderer.create(<Test message="test" />);

  expect(wrapper.toJSON()).toMatchSnapshot();
});

After this test runs for the first time it will create a snapshot file that future test runs will use as a reference. The snapshot file would look something like this:

// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`renders 1`] = `
<div>
  test
</div>
`;

I hadn’t heard about snapshot testing until I started using Jest — I’m not sure if the Jest team invented snapshot testing but they certainly popularized it! At first glance, snapshots are super convenient — instead of writing your own assertions you can just generate tests to see if your code is broken. Why waste time when the computer can automate our problems away? Jest even makes it super easy to automatically fix your snapshots. This means that even if you’ve got a failing test you’re a single keypress away from fixing all of your tests.

When snapshot testing isn’t all it’s cracked up to be

At first glance, snapshot testing sounds like a dream come true — all I have to do is write one snippet of code to generate snapshots and I’ll have these super-detailed tests “for free”? Take my money already!

However, over the past few years that I’ve been working with snapshot testing, I’ve found that snapshots introduce a number of pain points that make them difficult to maintain. And I’m not the only one! For example, this company decided to ditch snapshots and wrote about it. Or consider this tweet:

That’s not to say snapshot testing is all bad! After all, every tool has trade-offs, and it’s worth acknowledging the weaknesses of a tool when we’re evaluating using it. Here are a few reasons why I’m not the biggest fan of having snapshots in my testing suites.

Snapshots break easily

Snapshots are often used to test component trees or large objects. However, since the snapshot takes a picture of every single detail in the component/object, even the slightest change (like fixing a typo in a CSS class) will fail the snapshot test. As a result, you end up with tests that break even when the code still works. These false negatives create a lot of noise and erode your confidence in your testing suite.

Snapshot tests are super easy to create and failing snapshots are easy to fix

You might be thinking, “Isn’t this a good thing?” After all, being a single keypress away from a passing test suite sounds like a dream come true. However, because the tests are so easy to create/update, what tends to happen is that developers care less about the snapshot tests.

In my experience, developers will often simply press the button to update the snapshots without looking to see what changed or if the code is broken. While it is possible to treat your snapshots with the same importance as your code (and recommended in the Jest docs), it requires a ton of diligence. More often, my experience has been seeing engineers blindly update the snapshots and move on with their day (I’ve done it myself many times in the past 😱).

Snapshots can give you false confidence about the robustness of your testing suite

It’s easy to generate a ton of test coverage using snapshots. If your team has a coverage threshold that all code has to meet, snapshots make hitting your coverage numbers a breeze. However, test coverage alone is not a sufficient metric to use to evaluate the quality of your test suite. While test coverage is a valuable tool for seeing gaps in your testing suite, it doesn’t tell you about things like whether your tests are brittle, whether your code stands up to edge cases, or whether the tests accurately test the business requirements.

Where Jest snapshots shine — refactoring legacy code

While I’m not a fan of having snapshots as “long-term residents” of my testing suites, I’ve actually come across a few use cases where they truly shine. For example, refactoring legacy code.

Rarely do we start a job and get thrown into greenfield projects — we get codebases that have existed for years. And when we do, those projects can quickly go from a blank slate to nightmare codebase if we’re not careful. At some point in your career, you’re going to have to work on “legacy code” that you didn’t write. And many times those codebases don’t have any tests.

When you start adding features to this legacy code, and you’re faced with a dilemma. You might need to refactor the code to fit new business requirements, but you don’t want to run the risk of breaking something. In order to refactor safely, you need some type of tests in place.

The thing is, taking a pause to write tests for legacy code can sometimes feel like a luxury you don’t have. After all, you’ve got deadlines to hit, and you finally figured out where you need to modify this legacy code. If you take too long of a break you might lose that context that you’ve built up!

Snapshots can actually be super useful to us in this scenario. Here’s a snapshot testing workflow I’ve found super helpful when working with legacy code.

Step 1: Write snapshots to cover as many inputs as you can think of

Read through the legacy code and try to get a picture of all of the various inputs that it could possibly have. However, you don’t need to figure out the outputs! For each input variant, create a snapshot test. This helps you figure out what outputs are actually produced by the code you’re working with.

Step 2: Start refactoring

Since you’ve got this massive safety net of snapshot tests to fall back on, start refactoring. Remember that this method of refactoring with snapshots is only good if you do not change the output at all. So if you’re working with a React component and you change the rendered output your snapshots will fail. This isn’t the end of the world, just make sure to check why the snapshots failed and if the change was actually intended.

Step 3: Ditch the snapshots and write some more focused tests

Once you’re done refactoring, you can safely replace these snapshots without fear of forgetting how you wanted to refactor the legacy code. However, for the reasons discussed above, you might not want those snapshots to be long-term residents of your testing suite. Now that the code isn’t changing, you can safely start refactoring your tests. To make your tests more resilient long-term, you might want to consider taking each snapshot test and replacing it with a more focused assertion. For example, we could replace the snapshot test from before with this test using react-testing-library and jest-dom.

import { render } from "react-testing-library";
import "jest-dom/extend-expect";

function Test({ message }) {
  return {message};
}

test("renders", () => {
  const { getByText } = render(<Test message="test" />);

  expect(getByText("test")).toBeInTheDocument();
});

Granted, this isn’t an incredibly complex test — the component doesn’t have any logic to refactor! These more focused assertions will stand the test of time (pun intended 😂) better as the component changes with future requirements.

Conclusion

Throughout my (short) career I’ve seen lots of code written without tests by people that have long left the company. It’s no secret that tricky, dense, difficult-to-read code has a negative effect on team morale and that, over time, code should be lovingly refactored to fit new requirements.

However, mocking or complaining about tricky legacy code shouldn’t be our default response — instead, we should try to always leave the code in better shape than when we found it.

This is easier said than done, especially when we’re trying to meet a tight deadline or if we’re afraid to touch the code lest we break something. This method of using Jest snapshots has been incredibly useful for me and I hope that you will find it useful too!

Thanks for reading! If you enjoyed this post, make sure to follow me on Twitter — I make sure to post links to any new articles as I write them. If you’ve had some snapshot test success stories, don’t hesitate to reach out!

Plug: LogRocket, a DVR for web apps

https://logrocket.com/signup/

LogRocket is a frontend logging tool that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.

In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page apps.

Try it for free.

 

Benjamin Johnson Software Engineer. Learning every day one mistake at a time. https://www.benjaminjohnson.me

Leave a Reply