Skip to content

Migrating away from Enzyme

What will you learn from this article?

  • Applying an incremental (piecemeal) approach to migrate away from Enzyme;
  • How to create a custom ESLint plugin with rules and formatter;
  • How to use the new plugin in order to prevent pushing new code using Enzyme’s APIs as well as tracking the progress of the migration.

What is wrong with Enzyme?

Enzyme is a popular testing library for React applications, but it has limitations. One of the main criticisms of Enzyme is that it encourages a testing style focused on implementation details rather than behavior. This can lead to brittle tests that break easily when the implementation of a component changes. Another limitation of Enzyme is that it is tightly coupled to the React library. This makes it challenging to use with other libraries or frameworks and can make it harder to write tests that are truly isolated from the implementation of the component.

If your project is using React+Enzyme and you dream about upgrading React version to 18 (and enjoying cool features like server-side and concurrent rendering) then I have bad news for you – Enzyme is dead and there would be no official adapters released compatible with future versions of React (you can still find some unofficial libraries but it would not trust them).

The MISSION of this article is not only to explain the approach you may take to shift your test code to a new library but also to propose an idea of how you can automate the monitoring of the migration progress.

Time to migrate!

Migrating from Enzyme to a different testing library can be a daunting task, especially if you’re working on a large codebase. However, by taking an incremental approach, you can make the process much more manageable.

An incremental migration involves gradually transitioning your tests from Enzyme to the new library, rather than trying to make the switch all at once. This approach has several benefits:

  • It allows you to test the new library in a smaller, more controlled environment before committing to a full-scale migration.
  • It minimizes the risk of introducing regressions or breaking existing tests.
  • It allows you to learn and become familiar with the new library as you go, rather than trying to absorb everything at once.

Here’s an example of how you might approach an incremental migration from Enzyme to a new library like React Testing Library (RTL):

  1. Start by identifying the tests in your codebase that are using Enzyme. These are typically the tests that import Enzyme or Enzyme-specific methods (such as shallow or mount).
  2. Begin by migrating a small subset of these tests to RTL. This might be a component or set of components that you’re already familiar with, or a section of the codebase that doesn’t have a lot of dependencies.
  3. As you migrate each test, pay attention to how the test is structured and how it interacts with the component being tested. Take note of any differences in how the new library handles things like querying for elements or simulating events.
  4. As you migrate more tests, you’ll start to get a better sense of how RTL differs from Enzyme. Take advantage of this learning opportunity to refactor your tests and improve their overall structure and readability.
  5. Repeat steps 2-4 until all of your tests are using the new library.
  6. Finally, After you’ve completed your migration, make sure to run your test suite to make sure everything is working as expected.

Overall, an incremental migration is a great way to move away from Enzyme and transition to a new testing library. By taking a step-by-step approach, you can minimize the risk of breaking things, learn the new library as you go, and make the overall migration process much more manageable. However, it is hard to know whether this approach is successful or not without knowing the metrics that describe the success. As tech lead of the team, how can you know that team follows the strategy you came up with and how far is the end (step #6)?

TLDR; Link to the code

GitHub repo: https://github.com/sr-shifu/eslint-plugin-enzyme-deprecation

Write a plugin to track the progress!

What tool do you usually use to enforce the coding style and find potential errors in your project? You probably got it right – ESLint! (If you answered TSLint, you’re a little behind the times!). ESLint is highly customizable, you can set your own rules, use your own formatter or combine both inside one plugin! It is also can be easily integrated into a development workflow, such as a continuous integration pipeline, to automatically report any issues before they committed.

Writing a custom ESLint plugin is not as hard as you may think but you might need to spend more time learning deeper about Abstract Syntax Tree (tree representation of the abstract syntactic structure of source code written in a programming language) and ESLint selectors to traverse this tree. Without further ado, let me introduce my solution:

const noShallowRule = require("./rules/no-shallow");
const noMountRule = require("./rules/no-mount");

const rules = {
  "no-shallow": noShallowRule,
  "no-mount": noMountRule,
};
module.exports = {
  rules,
  configs: {
    recommended: {
      rules,
    },
  },
};

THE END!

.

.

.

.

Just kidding! Now, will come the most interesting part!

Rules are rules

Let’s go deeper into the rule’s code. Both no-shallow and no-mount rules use the same logic (the idea to break them apart is simply for giving users more flexibility on what they want to deprecate), so let’s dive deep into one of those (I picked shallow):

const schema = require("./schema");
const astUtils = require("ast-utils");

const resolveEnzymeIdentifierInScope = (scope, name) => {
  if (!scope) {
    return false;
  }
  const node = scope.set.get(name);
  if (node != null) {
    const nodeDef = node.defs[0];
    if (
      nodeDef.type === "ImportBinding" &&
      nodeDef.parent.source.value === "enzyme"
    ) {
      return true;
    }

    if (
      astUtils.isStaticRequire(nodeDef.node.init) &&
      astUtils.getRequireSource(nodeDef.node.init) === "enzyme"
    ) {
      return true;
    }
  }

  return false;
};

module.exports = {
  meta: {
    messages: {
      noShallowCall: "Enzyme is deprecated: do not use shallow API.",
    },
    docs: {
      description: "Disallow Enzyme shallow rendering",
      category: "Tests",
      recommended: true,
    },
    schema,
    fixable: null,
  },

  create(context) {
    const [options = {}] = context.options || [];
    return {
      "CallExpression"(node) {
        if (
          node.callee.name !== "shallow" &&
          node.callee.property?.name !== "shallow"
        ) {
          return;
        }
        let targetDeclarationName = "shallow";
        if (node.callee.property?.name === "shallow") {
          targetDeclarationName = node.callee.object.name;
        }
        const resolved = context
          .getScope()
          .references.find(
            ({ identifier }) => identifier.name === targetDeclarationName
          ).resolved;
        const isEnzyme = resolveEnzymeIdentifierInScope(
          resolved?.scope,
          targetDeclarationName
        );
        if (isEnzyme || options.implicitlyGlobal) {
          context.report({ node, messageId: "noShallowCall" });
        }
      },
    };
  },
};
  • CallExpression is an Enzyme selector that tells Enzyme that we are interested only in function calls. These selectors are pretty similar to CSS ones, you can learn more about them here.
  • node.callee.name refers to the name of the called function (in our case, shallow), while node.callee.property?.name checks whether this function was called as a property of a higher-order object (for instance, const enzymeApi = require('enzyme'); enzymeApi.shallow(<Component />).
  • context.getScope() gives a reference to the scope where the target function (shallow) was called and has a reference to the object that owns this shallow method. Basically, what we need to check here is that shallow method belongs to the enzyme source – usually enzyme imported into the test module, or required if you are using CommonJS (if you are curious, about how you can write the library that produces build for both EcmaScript Modules and CommonJS targets, go to this article).
  • options.implicitlyGlobal is an option that can be provided by the consumer of the rule, for instance in .eslintrc.js config file. In this specific example, it allows users to tell the rule that they are not interested in the source that shallow comes from (maybe, you have assigned it to the global scope somewhere in your test setup flow – bad idea IMHO).

Report your progress

For brave souls who made it to this part, thank you, and let’s continue! Now we have rules that we can use to prevent pushing deprecated APIs as part of new and changed code in your PRs (hopefully, you are using Jest’s findRelatedTests API as part of your Git pre-commit flow).

But we still don’t know how things look from a bird-eye view – maybe the team is mostly working on new parts of the project, completely forgetting about some legacy systems (or bypassing pre-commit hook – yikes!). For this case, we need to write a custom formatter to output the statistics.

I don’t want to throw huge blocks of the code here – you may find it in my GitHub repo but I will try shortly to explain how it works. After running the rules and collecting errors for each test file, ESLint plugin passes this metadata to the formatter:

{ 
   filePath: string;
   messages: Array<{ruleId: string;}>
}

In the formatter, we group this data by file path and by violated rule id (e.g., enzyme-deprecation/no-shallow), and pass this processed data to the visualizer that might output this data in different formats. Just a few ideas about what these formats can be:

  • ASCII printed charts in the terminal (for running in local/dev environment)
  • Markdown-based file written to the filesystem and pushed to the Git repo (for reviewing progress after each individual PR)
  • HTML page using beautiful chart libraries (like D3.js) written to coverage folder (assuming you might already have some integrations with this folder in your code review tool)
  • Plain string message passed to some webhook URL (e.g. Slack notification to code review channel)

How to use it in your project?

Option 1: Define separate ESLint config for migration

.eslintrc.migration.js:

module.exports = {
  parser: '<your-parser>',
  extends: ['plugin:enzyme-deprecation/recommended'],
  env: {
    browser: true,
  },
  rules: {
    'enzyme-deprecation/no-shallow': 2
  }
};

And in your package.json file define command:

 "track:migration": "NODE_ENV=development eslint --no-eslintrc  --config .eslintrc.migration.js -f node_modules/eslint-plugin-enzyme-deprecation/lib/formatter --ext .test.jsx src/"

Option 2: Using Node.js API

You can find an example here (run npm run demo command in the root directory)

Final words

In summary, an incremental migration approach, combined with automation of monitoring, can help you to migrate your codebase to a new testing library in a controlled and efficient manner. This will help you to write more consistent, error-free code and catch issues early in the development process.

Published inTesting

One Comment

Leave a Reply

Your email address will not be published. Required fields are marked *