jest icon indicating copy to clipboard operation
jest copied to clipboard

Slow start times due to use of barrel files

Open greyscalemotif opened this issue 3 years ago • 36 comments

🐛 Bug Report

It is unclear that large dependency graphs caused by the use of barrel files can slow test initialization dramatically. There are some tangential mentions of this in the documentation, but it is not outlined in a clear and direct manner. This likely leads to a lot of inefficiency in many projects.

Here are the most important points to stress:

  • Jest simulates Node's require cache to allow for isolation and mocking
  • The require cache is built independently for every test suite
  • All dependencies of a file are included, even if unused

It is the last bullet that leads to the largest reduction in efficiency, due mainly to barrel files. Barrel files are index files that re-export the exports of other files in a directory. They make it possible to import multiple related dependencies in a single import statement. The downside of this is that Jest sees all of the re-exported contents of the barrel file as dependencies and crawls through them, used or not.

Reducing the use of barrel files can help quite a bit in reducing the amount of time it takes before a test can start. This is especially true of dependencies pulled from NPM packages. Packages are typically developed with a root index file that acts as a barrel file for the entire package. If the package is rolled up into a single file at build time, there is only one file for the Jest runtime to open and parse. If the package publishes its source files independently, without a rollup stage, the Jest runtime will need to open and parse every file in the package independently.

In an enterprise setting, there are often internally developed tools and libraries. These packages can grow to be fairly large, and given their internal use, it can be tempting to provide the source files as the output of the packages. In fact, this can improve tree shaking when building the applications that depend on them. Jest, however, can suffer greatly in this environment because the raw number of files can grow without bounds. This problem becomes exponentially worse when overly tight internal dependency semvers reduce the ability of package managers to de-duplicate their installs.

Resolving these issues can lead to tremendous decreases in Jest's test suite initialization and this should be highlighted in the documentation. Barrel files can have a huge impact on the number of files that the Jest runtime needs to parse, and that is not clear without a deep dive into the way dependencies are evaluated. Sharing this knowledge more broadly could make an already fantastic test runner that much better and improve the quality of many products that rely upon it in the process.

To Reproduce

As this is a documentation issue and not a code issue, this may not apply. However, in the spirit of completeness:

  • Read the Jest documentation
  • Experience slow test start times in enterprise scale development
  • Be unaware of the impact that barrel files can have on running Jest

Expected behavior

The expectation is that the Jest documentation should more explicitly explain the impact that barrel files can have on test start times.

Link to repl or repo (highly encouraged)

Given that this issue is manifested in large scale applications with many inter-related dependencies, it is not feasible to provide a replication of the issue.

envinfo

N/A

greyscalemotif avatar Mar 23 '21 19:03 greyscalemotif

On our project we have ~100 test suites, ~600 tests. it take about 6 minutes to run all the tests. The tests are very fast (less than 50ms. per test), but the startup time is very slow (And annoying)

I've created a script that changes all the imports from barrel files to explicit import, and the tests run 10x faster (Still not optimal, but shows the problem)

Anyone with a solution/workaround/idea?

gilamran avatar Apr 12 '21 07:04 gilamran

I'm happy to take a PR outlining this issue. 👍


In Jest itself we've made lots of imports lazy to avoid this issue, but doing across the entire codebase requires the use of babel (or some other code transform). Might be an OK workaround in tests, but won't help in node_modules as it's not transformed by default (you can have Jest transform node_modules if you want, but at some point there are diminishing returns since you then have to wait for the file to be transformed).

SimenB avatar Apr 25 '21 06:04 SimenB

@gilamran could you please share that script for changing the barrel files import? I'm in a similar bind where changing one reusable component is running 100s of tests. I've tried using babel transform plugin imports but it was not working for me.

fadi-george avatar Aug 24 '21 21:08 fadi-george

@fadi-george sorry I don't have the script anymore (Maybe I can look it up in the git history), but I've found even faster solution. I'm using esbuild to bundle ALL my tests + code into one huge file, then I run jest on this file only. so instead of 350sec. with jest as it is, it's taking 30sec tops.

I had to do some juggling to make esbuild and jest work together, like importing svgs etc. you can see the script here

gilamran avatar Aug 25 '21 09:08 gilamran

@gilamran ah interesting approach, thank you for sharing! I'll definitely play around with that idea.

fadi-george avatar Aug 25 '21 16:08 fadi-george

@gilamran I assume you are doing something like this? Have you ran into any memory issues?

node build-tests && jest ./src/bundle.spec.ts

bundle.spec.ts:

import testBundle from './tests/bundle';

test('should pass', () => {
  testBundle();
});

fadi-george avatar Sep 08 '21 02:09 fadi-george

I believe I fixed my memory issues but now I ran into this problem with jest mocks and mockImplementations failing.

fadi-george avatar Sep 08 '21 03:09 fadi-george

@gilamran did you use any jest.mock calls? For me, it forces my tests to fail

import { someAction } from 'actions/something';
jest.mock('actions/something');

It might be supported later on with esbuild or jest, I tried top-level-await but I couldn't get it to work

jest.mock('actions/something');
const { someAction } = await import('actions/something');

fadi-george avatar Sep 09 '21 21:09 fadi-george

I don't use jest.mock at all (And I think that it's bad practice in most cases). I also don't think that it's possible to use when doing the esbuild bundle. The bundle already includes all the code, and I think that jest.mock is overriding the import which doesn't exist anymore...

gilamran avatar Sep 10 '21 08:09 gilamran

I have made a simple repo that reproduces this issue using a barrel file provided by a large third-party library, in this case Material UI icons: https://github.com/rsslldnphy/jest-barrel-files

On my machine, the test that imports icons as import * as Icons from "@mui/icons-material"; takes about 7 seconds to run, compared to fractions of a second for the test that doesn't.

I do not have a good understanding of the mechanics of what goes on (with treeshaking etc) when importing files in this way, but I'm not encountering this slowness issue the other tools I'm using - is there a way to make jest aware of code that can be ignored?

rsslldnphy avatar Nov 06 '21 17:11 rsslldnphy

@rsslldnphy for material specifically, which is a quite big library, I isolate the parts of the library my code actually uses in a re-exported barrel file:

// components/index.ts

export { default as AppBar, type AppBarProps } from "@mui/material/AppBar";
export { default as Button, type ButtonProps } from "@mui/material/Button";
// ...

export { default as DateRangeIcon } from "@mui/icons-material/DateRange";
export { default as CheckCircleIcon } from "@mui/icons-material/CheckCircle";
export { default as LocalShippingIcon } from "@mui/icons-material/LocalShipping";
// ... keep in mind that even 50-100 icons is only a small part of the 2000+ icons material exports

To be fair, we did this to mark to other developers in the team what parts of the material ui they could use without having to have a talk with our UI/UX designer beforehand. But it also mitigates this issue as a side effect.

PupoSDC avatar Dec 14 '21 08:12 PupoSDC

On our project we have ~100 test suites, ~600 tests. it take about 6 minutes to run all the tests. The tests are very fast (less than 50ms. per test), but the startup time is very slow (And annoying)

I've created a script that changes all the imports from barrel files to explicit import, and the tests run 10x faster (Still not optimal, but shows the problem)

Anyone with a solution/workaround/idea?

I've had this problem. Problem is barreling, and the way the import mapping works.

Unfortunately, it seems that transpilers (TypeScript), bundlers (WebPack, SnowPack, Vite, Rollup) can't differentiate exactly which is the import you're trying to use, and it will pull all the other files that were specified in the barrel index file. I'm currently refactoring a somewhat big app because I was waiting for 3 minutes until I ran 3 tests, and it was because my entire app was barreled.

superjose avatar Dec 15 '21 02:12 superjose

@rsslldnphy for material specifically, which is a quite big library, I isolate the parts of the library my code actually uses in a re-exported barrel file:

// components/index.ts

export { default as AppBar, type AppBarProps } from "@mui/material/AppBar";
export { default as Button, type ButtonProps } from "@mui/material/Button";
// ...

export { default as DateRangeIcon } from "@mui/icons-material/DateRange";
export { default as CheckCircleIcon } from "@mui/icons-material/CheckCircle";
export { default as LocalShippingIcon } from "@mui/icons-material/LocalShipping";
// ... keep in mind that even 50-100 icons is only a small part of the 2000+ icons material exports

To be fair, we did this to mark to other developers in the team what parts of the material ui they could use without having to have a talk with our UI/UX designer beforehand. But it also mitigates this issue as a side effect.

Nice idea on both counts @PupoSDC! May well implement this. Will be a bit of a faff to set up and maintain with icons especially but not a bad trade-off at all for mitigating this issue. Thanks!

rsslldnphy avatar Dec 16 '21 10:12 rsslldnphy

Not sure if it will help, but here's my 2 cents:

I face problems with barrel files not only in jest, but on some external and internal libs that have optional peer dependencies. Including these libs which do all exports from a barrel causes compile errors due to indirect import of unused components that use optional not installed dependencies

To fix this, I was using the already mentioned babel-plugin-transform-imports, which IMO works great (although I had to fork it and fix import aliases issues)

I even tried to improve the transform imports solution writing babel-plugin-resolve-barrel-files, but its a simple solution for ESM modules only.

But I guess that for jest, people could try implementing a lazy import with mocks:

jest.mock('module-with-barrel', () => {

  const RequiredComponent = jest.requireActual('module-with-barrel/RequiredComponent').default;
  return {
     __esModule: true,
    RequiredComponent
    // or use a more lazy approach...
    //  get RequiredComponent() {
    //     return jest.requireActual('module-with-barrel/RequiredComponent').default;
    // }
   }
})

Unfortunately, it seems that transpilers (TypeScript), bundlers (WebPack, SnowPack, Vite, Rollup) can't differentiate exactly which is the import you're trying to use

They probably can, but since barrel files are normal source code files, they can execute side effects (read the tip) (eg: some dep exporting global stuff).. so it's more safe to just don't optimize unless you explicit tell them to do it (the case for the babel plugins and webpack configs)

Grohden avatar Dec 17 '21 15:12 Grohden

I confirm. Using barrel imports extreamly slows down starting test suites. Test cases run very fast. Please manage this problem.

piotrroda avatar Feb 21 '22 15:02 piotrroda

Is there any solution for this? I have more than 3000 imports to change if i want to reverse the barrels import to full file path import, which would take very long time.

boubou158 avatar Apr 27 '22 17:04 boubou158

No solution, but there's a workaround

gilamran avatar Apr 27 '22 18:04 gilamran

Just a thought on this as recently i was facing a similar issue and the way i fixed it is by creating a custom barrel import transformer. The way it works is in first step we iterate all files to determine all the exports of files in project making a Map of import to lookup later.

Now when the test start to execute, using jest transform configuration, then execute a transform which uses the import lookup map created in first step to rewrite the import statements to specific imports statements and then jest executes on the transformed code.

Was able to significant improvement with this approach when using jest with ts-jest (isolatedModules enabled).

dsmalik avatar May 06 '22 16:05 dsmalik

How did you manage it? Do you have any example somewhere? I am very interested as i can't manage to reduce the time execution of my spec files due to the size of the project and barrel imports everywhere.

boubou158 avatar May 09 '22 16:05 boubou158

@boubou158 added a typescript based sample here with some readme- https://github.com/dsmalik/ts-barrel-import-transformer

dsmalik avatar May 14 '22 09:05 dsmalik

After spending a month trying to make esm working on our project trying to speed up the jest performance, it seems impossible to have 100% working. I am giving up on the esm option. I am now checking your solution (thanks a lot for that by the way !) but i am a little confused on how i could integrate this transformer with the transformer used by jest-preset-angular?

boubou158 avatar Jun 01 '22 13:06 boubou158

@fadi-george sorry I don't have the script anymore (Maybe I can look it up in the git history), but I've found even faster solution. I'm using esbuild to bundle ALL my tests + code into one huge file, then I run jest on this file only. so instead of 350sec. with jest as it is, it's taking 30sec tops.

I had to do some juggling to make esbuild and jest work together, like importing svgs etc. you can see the script here

How would you apply your approach on an angular project? I am running esbuild on a single spec file with --bundle option, it is then throwing errors TypeError: Cannot read properties of null (reading 'ngModule') every where when i run this file with jest. The barrel imports slow performance is definitely a major issue of using jest for big projects :(

boubou158 avatar Jun 02 '22 11:06 boubou158

We decided to introduce jest.mock('your-module') to our jest setup file and we are able to see a decrease in setup time.

Reference: https://jestjs.io/docs/manual-mocks#mocking-user-modules

duydnguyen07 avatar Jun 20 '22 13:06 duydnguyen07

For the one looking for a quick solution, adding the option isolatedModules: true to ts-jest and it divided by 6 the execution time. We are now able to run parralel agent on a pipeline to run our thousands of tests thanks to this little option.

boubou158 avatar Jun 21 '22 07:06 boubou158

TLDR

For those struggling with 3rd party barrle imports and using babel as a jest transformer - babel-plugin-direct-import does seem to improve load times (at least for us)

We test a component which imports another component from a 3rd party lib (let's call it libA to be short). The libA imports an icon from some icon pack which exports icons via a barrel import, kinda similar to the way material-ui does it. So what we have as a result is jest importing and transforming around 8k icons even though libA just uses a single one of them. I had to exclude this icon pack from transformIgnorePatterns because I simply could not figure out how to foce jest into working with esm, --experimental-vm-modules did not work for me, I guess there is some problem with the way that icons pack build commonjs and esm files

On my PC such a test was running for 80-90 sec (the actual test took around 50ms) I don't have a solid grasp on how all these modules magic works so this is what I tried:

  1. babel-jest -> @swc/jest with no .swcrc at all. My test started to take only 35-40 sec to run which already was a huge improvement. I tried to configure swc with @swc/plugin-transform-imports to get rid of barrel imports but it seems like I cannot use look-around regexp in Rust to split an icon component name the way I need it (( Looking into it now

  2. jest -> vitest. I had huge hopes for that one but unfortunately it didn't work with icons pack, I get a /.js seems to be an ES Module but shipped in a CommonJS package. Again - I guess the icon pack is doing smth wrong while building esm

  3. ✔️babel-plugin-direct-import. Adding

  plugins: [
    [
      "babel-plugin-direct-import",
      {
        modules: ["<icon pack name>"],
      },
    ],
  ],

made our test run for 10 sec comparing to initial 80-90 so I see it as a huge improvement

Hope it helps someone! Cheers!

MaksimMedvedev avatar Feb 17 '23 07:02 MaksimMedvedev

We have around 1300 test suites with about 6k tests. Jest is taking a lot of time. We believe Barrel import is one of the main contributors to the overall slowness. It would be super helpful to get some guidance from the Jest maintainers on addressing this problem.

anandtiwary avatar May 30 '23 19:05 anandtiwary

Would #9430 resolve this issue by allowing for tree-shaking in Jest tests?

rdebeasi avatar Aug 25 '23 16:08 rdebeasi

Would #9430 resolve this issue by allowing for tree-shaking in Jest tests?

As far as I know, tree shaking is a bundler feature, not a native ESM feature, so it's dubious it would help.

csvan avatar Aug 26 '23 06:08 csvan

@fadi-george sorry I don't have the script anymore (Maybe I can look it up in the git history), but I've found even faster solution. I'm using esbuild to bundle ALL my tests + code into one huge file, then I run jest on this file only. so instead of 350sec. with jest as it is, it's taking 30sec tops.

I had to do some juggling to make esbuild and jest work together, like importing svgs etc. you can see the script here

I know it's been a long time since you've posted this solution, but I hope you can help me out. I have been trying to use this approach and the bundling seems to work fine. Also, when passing the single bundled file to jest, it doesn't work for tests that are using jest.mock() to mock some of our own modules that are defined within the same project.

For instance, we have this piece of code in one of our tests: jest.mock("../services/ProductService/ProductService");

The corresponding file (both the actual implementation and mocked version) does exist, but when running Jest, it gives the following error: Cannot find module '../services/ProductService/ProductService' from 'bundles/test-files-bundle.js' Have you by any chance ran into this and/or do you know how to fix this?

roelvdwater avatar Dec 08 '23 14:12 roelvdwater

We also face long startup times in our tests. After trying a lot of "solutions" mentioned on the internet we discovered that in our case the problem are the barrel files.

I created two simple unit tests in our code base. Test A which imports from barrel files and test B, in which these imports are replaced by named imports. Here are the results:

test duration
A ~29s
B 91ms

To be able to dig deeper into the problem you have to understand what is going on during the long startup time. For this I modified the class Runtime in node_modules/jest-runtime/build/index.js.

Modify Jest Runtime Class

Instead of a patch I link the code-pointers and the added code, in case you run a different jest version. All modifications are marked with // MARKER to easily find them afterwards.

The file to modify is: node_modules/jest-runtime/build/index.js

Step 1

Add additional class properties to track the number of loaded modules. code pointer Add before the marked line:

// MARKER
this._LOAD_COUNTER = 0;
this._INDENTS = [];
// MARKER

Step 2

Count and log loaded modules. code pointer 1 Add before the marked line:

// MARKER
this._LOAD_COUNTER += 1;
console.log(`${this._INDENTS.join('')}>>_loadModule`, this._LOAD_COUNTER, moduleName)
this._INDENTS.push('\t')
// MARKER

code pointer 2 Add before the marked line:

// MARKER
this._INDENTS.pop()
// MARKER

Outcome

After patching the Runtime class I got the following numbers for my simple unit tests:

test loaded modules
A 17424
B 1503

And you can clearly see by the logged output that jest is importing everything in case of barrel files before running a test.

After adjusting only some of the imports (to use named imports) in our code base as a PoC, I was able to reduce the build time from 15 minutes to 6 minutes.

rendner avatar Jan 11 '24 08:01 rendner