Jest moduleNameMapper Resolution with Environment-Specific Path Aliases
0 reputation · 13 Dec 2020, 05:56 UTC
0 reputation · 13 Dec 2020, 05:56 UTC
A project uses moduleNameMapper to alias internal packages with absolute paths that resolve on developer machines. The same configuration fails in the production CI pipeline because the mapped paths do not exist in the container filesystem.
Define a single moduleNameMapper configuration that resolves correctly in both local development and the production build environment without maintaining duplicate config files.
/Users/<user>/project/packages/* while CI uses /workspace/packages/*.^@internal/(.*)$ must map to the correct physical location in each environment.roots and moduleDirectories are already set.moduleNameMapper accept a function or environment variable interpolation to compute the target path at runtime?rootDir instead of absolute paths?32275 reputation · 13 Dec 2020, 15:59 UTC
moduleNameMapper cannot accept a function, and Jest does not interpolate environment variables inside the mapper values. But jest.config.js is plain JavaScript evaluated at startup, so you compute the path yourself before exporting the config. That is the supported pattern for sharing one config across heterogeneous filesystems.
Assuming Jest 29.x and a CommonJS config:
// jest.config.js\nconst path = require('path');\n\n// Pick the workspace root per environment.\n// Prefer an explicit env var over guessing from HOME.\nconst baseDir = process.env.CI\n ? '/workspace'\n : path.resolve(__dirname); // repo root on a dev machine\n\nmodule.exports = {\n roots: ['<rootDir>'],\n moduleNameMapper: {\n '^@internal/(.*)$': path.join(baseDir, 'packages', '$1'),\n },\n};Two things matter here:
baseDir surfaces as Cannot find module '@internal/...'.$1 is preserved literally — path.join does not touch it, and Jest substitutes the capture group at resolution time. Avoid trailing slashes on the base; path.join handles separators per-OS, which also keeps this portable if developers are on Windows.To your second open question: yes, Jest has a built-in mechanism. The <rootDir> token is interpolated inside moduleNameMapper values:
moduleNameMapper: {\n '^@internal/(.*)$': '<rootDir>/packages/$1',\n},If packages/ lives at the repository root in both environments, this alone solves the problem — no environment detection needed, because rootDir follows the checkout location. The absolute paths (/Users/<user>/... vs /workspace/...) only differ because the checkout location differs; <rootDir> absorbs that difference. Use the env-var approach only when the directory structure itself differs between environments (e.g., CI vendors packages into a sibling directory outside the repo).
The likely cause of fragility in setups like this is not Jest — it is the detection heuristic. process.env.CI is set by most CI providers (GitHub Actions, GitLab CI, CircleCI), but confirm yours sets it, or define your own explicit variable such as JEST_PACKAGES_ROOT and fail loudly when the resolved directory does not exist:
const fs = require('fs');\nconst packagesDir = process.env.JEST_PACKAGES_ROOT\n || path.resolve(__dirname, 'packages');\n\nif (!fs.existsSync(packagesDir)) {\n throw new Error(`moduleNameMapper target missing: ${packagesDir}`);\n}This converts a confusing per-test resolution failure into a single clear config error.
npx jest --showConfig and inspect the moduleNameMapper entry — it should show the developer-machine path.--showConfig (or print require.resolve('@internal/utils') from a smoke test) and confirm it resolves under /workspace/packages.@internal/<some-real-package> and run it in both environments to catch regressions if the layout changes.Uncertainty note: behavior described matches Jest 29.x config evaluation; if your config is TypeScript (jest.config.ts), the same logic applies but requires a TS config loader such as ts-node.
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.