Jest: moduleNameMapper should work on resolved import paths

Created on 25 Oct 2018  ·  15Comments  ·  Source: facebook/jest

_I'm not sure whether this should be a bug report or a feature proposal..._

Unlike webpack, which correctly processes SASS files that are imported sans suffix:

import Styles from './styles'  // file: styles.sass

Jest does not. When I attempted to set up a moduleNameMapper with a custom proxy object (an enhanced version of identity-obj-proxy), I configured it thusly:

  moduleNameMapper: {
    '\\.sass$': '<rootDir>/test/enhanced-obj-proxy.js',
  },

But my suffixless imports fail:

Details:

    /workplace/website/src/components/ContentStripe/styles.sass:1
    ({"Object.<anonymous>":function(module,exports,require,__dirname,__filename,global,jest){.root
                                                                                             ^

    SyntaxError: Unexpected token .

      4 | import { Grid } from 'mui'
      5 | 
    > 6 | import Styles from './styles'
        | ^

Only when I change my import to use a suffix (import Styles from './styles.sass') does the moduleNameMapper actually work. Aside from violating POLA, this means that our components must be tied to a particular stylesheet implementation, and the imports for stylesheets are thus different from all of our other imports...and the only reason is to to satisfy the behavior of a test framework.

In #4488, @cpojer closed a similar feature request saying that one should set up a custom resolver to alleviate the problem, but provided no further guidance. After spending several hours poking around in Jest, and littering the source with logging statements, this advice appears to not only be unreasonable (given the complexity of the default resolver), but also a red herring. As I see it, this solution only makes sense if the problem were that the default resolver is unable to figure out what to do with ./sass as an import path. But that is clearly not the case:

Error: Resolved './styles' to '/workplace/website/src/components/ContentStripe/styles.sass'
    at defaultResolver (/workplace/website/node_modules/jest-resolve/build/default_resolver.js:64:8)
    at Function.findNodeModule (/workplace/website/node_modules/jest-resolve/build/index.js:111:14)
    at resolveNodeModule (/workplace/website/node_modules/jest-resolve/build/index.js:173:16)
    at Resolver.resolveModuleFromDirIfExists (/workplace/website/node_modules/jest-resolve/build/index.js:184:16)
    at Resolver.resolveModule (/workplace/website/node_modules/jest-resolve/build/index.js:213:25)
    at Resolver._getVirtualMockPath (/workplace/website/node_modules/jest-resolve/build/index.js:344:16)
    at Resolver._getAbsolutePath (/workplace/website/node_modules/jest-resolve/build/index.js:330:14)
    at Resolver.getModuleID (/workplace/website/node_modules/jest-resolve/build/index.js:307:31)
    at Runtime._shouldMock (/workplace/website/node_modules/jest-runtime/build/index.js:771:37)
    at Runtime.requireModuleOrMock (/workplace/website/node_modules/jest-runtime/build/index.js:465:14)

As you can see, the default resolver has no problem whatsoever in turning ./styles into .../styles.sass.

Later, Resolver._resolveStubModuleName(), is invoked with the context file path (from) and the raw string from the import statement:

Resolver: _resolveStubModuleName(/workplace/website/src/components/ContentStripe/index.jsx, ./styles)

And it is in this method where the moduleNameMapper finally comes into play:

Resolver: moduleNameMapper: [ { moduleName:
     '/workplace/website/test/enhanced-obj-proxy.js',
    regex: { /\.sass$/ [lastIndex]: 0 } },
  [length]: 1 ]
Resolver: Error
    at Resolver._resolveStubModuleName (/workplace/website/node_modules/jest-resolve/build/index.js:363:10)
    at Resolver.getMockModule (/workplace/website/node_modules/jest-resolve/build/index.js:274:31)
    at Runtime.requireModule (/workplace/website/node_modules/jest-runtime/build/index.js:334:40)
    at Runtime.requireModuleOrMock (/workplace/website/node_modules/jest-runtime/build/index.js:468:19)
    at Object.<anonymous> (/workplace/website/src/components/ContentStripe/index.jsx:6:1)
    at Runtime._execModule (/workplace/website/node_modules/jest-runtime/build/index.js:699:13)
    at Runtime.requireModule (/workplace/website/node_modules/jest-runtime/build/index.js:381:14)
    at Runtime.requireModuleOrMock (/workplace/website/node_modules/jest-runtime/build/index.js:468:19)
    at Object.<anonymous> (/workplace/website/src/components/ContentStripe/index.test.jsx:7:1)
    at Runtime._execModule (/workplace/website/node_modules/jest-runtime/build/index.js:699:13)

To Reproduce

Test

// file: foo.test.js

// Change this to './foostyles.css' and it'll work
import Styles from './foostyles'

describe('Styles', () => {
  it('works', () => {
    expect(Styles).toBeDefined()
  })
})

Stylesheet

/* file: foostyles.css */

.root {
  color: black;
}

jest.config.js

{
  // ...snip...

  moduleNameMapper: {
    '\\.css$': 'identity-obj-proxy',
  },
}

Expected behavior

moduleNameMapper patterns should be applied to the resolved filepath, not the raw file path found in the source code:

import Styles './style'

Run npx envinfo --preset jest

Paste the results here:

npx: installed 1 in 2.142s

  System:
    OS: macOS 10.14
    CPU: x64 Intel(R) Core(TM) i7-7567U CPU @ 3.50GHz
  Binaries:
    Node: 10.8.0 - ~/.nvm/versions/node/v10.8.0/bin/node
    npm: 6.4.1 - ~/.nvm/versions/node/v10.8.0/bin/npm


Most helpful comment

@fotonmoton I hit this issue with the typeface library today and was able to get it working by specifying the path to the css file.

import 'typeface-montserrat/index.css'

Seems moduleNameMapper will unsurprisingly only catch modules that have been imported with .css in the path

All 15 comments

https://jestjs.io/docs/en/configuration#modulefileextensions-array-string

@thymikee, I'm not sure how that would be relevant as I've already demonstrated that the resolver has no problems resolving .sass files. Besides, that's already part of my config:

$ npm test -- --showConfig | grep -A5 moduleFileExtensions
      "moduleFileExtensions": [
        "js",
        "jsx",
        "sass",
        "svg"
      ],

Here's the full config for reference:

{
  "configs": [
    {
      "automock": false,
      "browser": false,
      "cache": true,
      "cacheDirectory": "/var/folders/75/mk6rfnd52xg_mbfwpw2g9s5cwpk8v1/T/jest_fxgjfl",
      "clearMocks": false,
      "coveragePathIgnorePatterns": [
        "/workplace/website/src/index.jsx$"
      ],
      "detectLeaks": false,
      "detectOpenHandles": false,
      "errorOnDeprecated": false,
      "filter": null,
      "forceCoverageMatch": [],
      "globals": {},
      "haste": {
        "providesModuleNodeModules": []
      },
      "moduleDirectories": [
        "node_modules"
      ],
      "moduleFileExtensions": [
        "js",
        "jsx",
        "sass",
        "svg"
      ],
      "moduleNameMapper": [
        [
          "\\.sass$",
          "/workplace/website/test/enhanced-obj-proxy.js"
        ]
      ],
      "modulePathIgnorePatterns": [],
      "name": "46ec5fecf0fa2b69c57c1e81f71ec1b1",
      "prettierPath": null,
      "resetMocks": false,
      "resetModules": false,
      "resolver": null,
      "restoreMocks": false,
      "rootDir": "/workplace/website",
      "roots": [
        "/workplace/website"
      ],
      "runner": "jest-runner",
      "setupFiles": [
        "/workplace/website/node_modules/regenerator-runtime/runtime.js"
      ],
      "setupTestFrameworkScriptFile": "/workplace/website/test/setup-tests.js",
      "skipFilter": false,
      "snapshotSerializers": [],
      "testEnvironment": "/workplace/website/node_modules/jest-environment-JSDOM/build/index.js",
      "testEnvironmentOptions": {},
      "testLocationInResults": false,
      "testMatch": [
        "**/src/**/*.test.js?(x)"
      ],
      "testPathIgnorePatterns": [
        "/node_modules/"
      ],
      "testRegex": "",
      "testRunner": "/workplace/website/node_modules/jest-jasmine2/build/index.js",
      "testURL": "http://localhost/",
      "timers": "real",
      "transform": [
        [
          "^.+\\.jsx?$",
          "/workplace/website/node_modules/babel-jest/build/index.js"
        ]
      ],
      "transformIgnorePatterns": [
        "/node_modules/"
      ],
      "watchPathIgnorePatterns": []
    }
  ],
  "globalConfig": {
    "bail": false,
    "changedFilesWithAncestor": false,
    "collectCoverage": false,
    "collectCoverageFrom": [
      "src/**/*.js?(x)"
    ],
    "coverageDirectory": "/workplace/website/test/coverage",
    "coverageReporters": [
      "json",
      "text",
      "lcov",
      "clover"
    ],
    "coverageThreshold": null,
    "detectLeaks": false,
    "detectOpenHandles": false,
    "errorOnDeprecated": false,
    "expand": false,
    "filter": null,
    "globalSetup": null,
    "globalTeardown": null,
    "listTests": false,
    "maxWorkers": 3,
    "noStackTrace": false,
    "nonFlagArgs": [],
    "notify": false,
    "notifyMode": "always",
    "passWithNoTests": false,
    "projects": null,
    "rootDir": "/workplace/website",
    "runTestsByPath": false,
    "skipFilter": false,
    "testFailureExitCode": 1,
    "testPathPattern": "",
    "testResultsProcessor": null,
    "updateSnapshot": "new",
    "useStderr": false,
    "verbose": null,
    "watch": false,
    "watchman": true
  },
  "version": "23.6.0"
}

By the way, to add insult to injury, the way that transform works is different than moduleNameMapper--it will match on the resolved filename. This makes me believe more strongly that this is a bug, not a feature request.

any news? Got similar error with this package:

13:54 $ yarn test
yarn run v1.12.3
$ jest --config=./config/jest/jest.config.js
 FAIL  src/components/App.spec.jsx
  ● Test suite failed to run

    /home/foton/workspace/self_study/material_ui/node_modules/typeface-roboto/index.css:2
    @font-face {
    ^

    SyntaxError: Invalid or unexpected token

      1 | import React from 'react';
      2 | import { hot } from 'react-hot-loader';
    > 3 | import 'typeface-roboto';
        | ^
      4 |
      5 |
      6 | const App = () => (

      at ScriptTransformer._transformAndBuildScript (node_modules/jest-runtime/build/script_transformer.js:403:17)
      at Object.<anonymous> (src/components/App.jsx:3:1)
      at Object.<anonymous> (src/components/App.spec.jsx:3:1)

Test Suites: 1 failed, 1 total
Tests:       0 total
Snapshots:   0 total
Time:        1.666s
Ran all test suites.
error Command failed with exit code 1.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.

jest.config.js:

const path = require('path');
const dirs = require('../dirs');

const fileMock = path.resolve(__dirname, 'fileMock.js');
const cssMock = path.resolve(__dirname, 'cssMock.js');

module.exports = {
  rootDir: dirs.ROOT,
  testMatch: [
    '**/*.spec.(js|jsx)',
  ],
  setupTestFrameworkScriptFile: 'jest-enzyme',
  testEnvironment: 'enzyme',
  moduleNameMapper: {
    '.(gif|png|jpe?g|svg|woff|woff2|eot|ttf|otf)$': fileMock,
    '.(css|less)$': cssMock,
  },
  moduleFileExtensions: [
    'js',
    'jsx',
    'css',
  ],
};

package.json:

{
  "scripts": {
    "build": "webpack --config config/webpack.prod.js",
    "start": "webpack-dev-server --config config/webpack.dev.js",
    "test": "jest --config=./config/jest/jest.config.js",
    "lint": "eslint --no-inline-config --ext .jsx,.js .",
    "fix": "eslint --fix --ext .jsx,.js ."
  },
  "dependencies": {
    "@material-ui/core": "^3.5.1",
    "@material-ui/icons": "^3.0.1",
    "dotenv": "^6.1.0",
    "prop-types": "^15.6.2",
    "react": "^16.6.0",
    "react-dom": "^16.6.0",
    "react-hot-loader": "^4.3.12",
    "typeface-roboto": "^0.0.54"
  },
  "devDependencies": {
    "@babel/core": "^7.1.5",
    "@babel/preset-env": "^7.1.0",
    "@babel/preset-react": "^7.0.0",
    "autoprefixer": "^9.3.1",
    "babel-core": "^7.0.0-bridge",
    "babel-eslint": "^10.0.1",
    "babel-jest": "^23.6.0",
    "babel-loader": "^8.0.4",
    "clean-webpack-plugin": "^0.1.19",
    "css-loader": "^1.0.1",
    "cssnano": "^4.1.7",
    "enzyme": "^3.7.0",
    "enzyme-adapter-react-16": "^1.7.0",
    "error-overlay-webpack-plugin": "^0.1.5",
    "eslint": "^5.9.0",
    "eslint-config-airbnb": "^17.1.0",
    "eslint-loader": "^2.1.1",
    "eslint-plugin-import": "^2.14.0",
    "eslint-plugin-jest": "^22.0.0",
    "eslint-plugin-jsx-a11y": "^6.1.2",
    "eslint-plugin-react": "^7.11.1",
    "file-loader": "^2.0.0",
    "friendly-errors-webpack-plugin": "^1.7.0",
    "html-webpack-plugin": "^3.2.0",
    "html-webpack-template": "^6.2.0",
    "image-webpack-loader": "^4.5.0",
    "jest": "^23.6.0",
    "jest-environment-enzyme": "^7.0.1",
    "jest-enzyme": "^7.0.1",
    "mini-css-extract-plugin": "^0.4.4",
    "postcss-loader": "^3.0.0",
    "regenerator-runtime": "^0.12.1",
    "style-loader": "^0.23.1",
    "webpack": "^4.25.0",
    "webpack-bundle-analyzer": "^3.0.3",
    "webpack-cli": "^3.1.2",
    "webpack-dev-server": "^3.1.10",
    "webpack-merge": "^4.1.4",
    "webpack-notifier": "^1.7.0",
    "webpackbar": "^2.6.3"
  },
  "license": "MIT"
}

fileMock.js:

module.exports = 'test-file-stub';

cssMock.js:

module.exports = {};

Sorry, but I think this is not related issue. Jest exclude node_modules from testing and can't use my mocks :/

@fotonmoton I hit this issue with the typeface library today and was able to get it working by specifying the path to the css file.

import 'typeface-montserrat/index.css'

Seems moduleNameMapper will unsurprisingly only catch modules that have been imported with .css in the path

@gi-alec, yeah sounds right, how can module resolver decide what to do with module if it doesn't know what module type it is? :) Thanks for solution.

You saved my day @gi-alec ❤️

Ran into the same issue today, any update on how to deal with this problem? :(

After playing around a little bit more, I figured out that what we need to change a little bit the way we were defining the moduleWrapperMapper config. It should look like this instead.

import Styles from './styles'  // file: styles.sass
  moduleNameMapper: {
    "^./styles": "identity-obj-proxy",
  },

And problem solved. cc @dankreft

@nopito, that suggestion works for some, but not all codebases. I ran into the exact same issue as described above, except one of my node_modules’ dependencies imports a javascript file named styles:

// node_modules/colors/lib/colors.js
var ansiStyles = colors.styles = require('./styles');

Although obviously an edge case, the robust solution is still to let moduleNameMapper match on the resolved file name.

@dankreft

I just has a similar issue with a project that imports html files in typescript. This was my solution:

https://github.com/nickvorie/jest-string-transform

Not sure if this will help you any.

I use identity-obj-proxy package for handling style files, but as mentioned above, the usual setting of '.+\\.(css|styl|less|sass|scss)$': 'identity-obj-proxy' only covers imports that have their file extension explicitly stated.

I think most font packages on NPM start with typeface-. So I use this in my moduleNameMapper: '^typeface-(.*)': 'identity-obj-proxy'.

@RemLampa Thx a lot, that helped me testing my gatsby app using material ui. I import 'typeface-roboto' there in the mui-theme.

I ran into this when adding @material-ui to our project. @material-ui has files named style.js and I was mocking files named style.less - both matching the "^./style$" regex in "moduleNameMapper".

By mapping the style.js files to identity-obj-proxy, material-ui would fail to build.

As a workaround I took advantage of the "transform" option to resolve modules against the full file path; and wrote a transform to stub files with identity-obj-proxy:

+ package.json
  ...
  "jest": {
    "testPathIgnorePatterns": [
      "<rootDir>/tests/transforms/*"
    ],
    "moduleFileExtensions": [
      "js",
      "jsx",
      "less"
    ],
    "transform": {
      "^.+\\.js$": "babel-jest",
      "\\.(css|less)$": "<rootDir>/tests/transforms/identity-obj-proxy.js"
    }
  }
+ tests/transforms/identity-obj-proxy.js

const fs = require('fs');

// Read the identity-obj-proxy as text. NOTE This only works because the entire module is inside one file
const identityObjProxySrc = fs.readFileSync(require.resolve('identity-obj-proxy'), 'utf-8');

/**
 * The jest config option "moduleNameMapper" does not resolve to absolute paths, which
 * can cause conflicts with similar file names. In this case, "style.less" and "style.js" are
 * both mocked with the identity-obj-proxy. This causes compiling of @material-ui to fail.
 * Instead use the "transforms" option to accomplish stubbing CSS/LESS files with identityObjProxy
 */

module.exports = {
  process() {
    return identityObjProxySrc;
  }
};

Not pretty, but it worked for me.

I truly believe that JEST should give us the way to map modules by matching the entire resolved paths and not only the module names specified in the specific import expressions. Otherwise it's pretty hard to use this option reliably. Also, all other tools (like e.g. webpack) does work on full resolved paths. It would get JEST more consistent with the ecosystem (and less surprising) if it would do the same.

Was this page helpful?
0 / 5 - 0 ratings