Back to monorepo orchestration Target affected workspaces Configure turbo pipelines Speed up type-checking

Resolving Symlinked Workspace Packages in Jest and Vitest

Workspace packages are linked into node_modules as symlinks, and test runners do not all treat symlinks the same way. Jest, with its own module resolver and a transform pipeline that ignores node_modules by default, is the usual source of trouble: a test importing @acme/ui either fails to find it, loads untransformed TypeScript from the package's source, or loads a second copy of React. Vitest, built on Vite's resolver, handles most cases out of the box but has its own sharp edges around dependency optimisation and duplicate instances. This guide explains how each runner resolves a symlinked workspace package and gives the configuration that makes tests import sibling packages reliably.

Exact symptoms and error messages

Jest loading a workspace package whose entry points at TypeScript source:

 FAIL  apps/web/src/Checkout.test.tsx
  ● Test suite failed to run

    Jest encountered an unexpected token
    ...
    /repo/packages/ui/src/index.ts:1
    export { Button } from './Button';
    ^^^^^^
    SyntaxError: Unexpected token 'export'

Jest failing to find a package that pnpm linked:

Cannot find module '@acme/ui' from 'src/Checkout.test.tsx'

Duplicate React instances when the linked package resolves react from its own node_modules:

Warning: Invalid hook call. Hooks can only be called inside of the body of a function component.

Vitest serving a stale version of a linked package after edits, or complaining about a missing export that exists in source:

SyntaxError: The requested module '@acme/ui' does not provide an export named 'Dialog'

Root cause analysis

A test runner resolves import '@acme/ui' in three steps: find the package (following the symlink in node_modules), decide which file is its entry, and decide whether to transform that file. The workspace layout itself is explained in Workspace Symlinks vs Hard Links.

How a test runner loads a symlinked workspace package The runner follows the node_modules symlink to the real package path, selects an entry file through exports, decides whether to transform it, and resolves its own imports from the real path. import '@acme/ui' in apps/web test follow symlink real path packages/ui pick entry exports / main / conditions transform? is the real path ignored? resolve its imports react from which node_modules?
Each step can go wrong — especially the transform decision and where the package's own imports resolve from.

The failure modes map to those steps:

  • Transform decision (Jest). Jest's default transformIgnorePatterns skips everything under node_modules. Whether a symlinked package counts as "in node_modules" depends on whether Jest uses the symlink path or the real path. When a workspace package points its entry at .ts source, and Jest decides not to transform it, you get Unexpected token 'export'.
  • Entry selection. A package whose exports points at dist/ fails if dist/ was not built before tests run; one that points at src/ needs transforming.
  • Import resolution from the real path. Node.js and most runners resolve a file's imports relative to its real path. packages/ui/src/Button.tsx importing react finds packages/ui/node_modules/react first — a different copy from the app's if versions differ or the package has its own dev install.

Resolution for Jest

Transform workspace packages explicitly and resolve their source entry:

// apps/web/jest.config.js
module.exports = {
  testEnvironment: 'jsdom',
  transform: {
    '^.+\\.(t|j)sx?$': ['@swc/jest'],
  },
  // Transform everything except third-party packages; workspace packages are symlinks
  transformIgnorePatterns: ['/node_modules/(?!(@acme)/)'],
  // Resolve with the "development" condition so exports can point at source
  testEnvironmentOptions: { customExportConditions: ['development', 'node', 'require'] },
  // One React for the whole test run
  moduleNameMapper: {
    '^react$': '<rootDir>/node_modules/react',
    '^react-dom$': '<rootDir>/node_modules/react-dom',
  },
};

With customExportConditions, a workspace package can expose its source to tests and its build output to everyone else:

{
  "name": "@acme/ui",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "development": "./src/index.ts",
      "import": "./dist/index.js",
      "require": "./dist/index.cjs"
    }
  }
}

The transformIgnorePatterns negative lookahead matches both symlink and real paths for scoped packages under node_modules/@acme/; if Jest resolves real paths (packages/ui/src/...), those are outside node_modules and transformed by default.

Resolution for Vitest

Vitest usually needs less configuration, because Vite follows symlinks, transforms workspace packages that resolve outside node_modules, and understands exports conditions. Two settings cover the remaining problems:

// apps/web/vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  resolve: {
    conditions: ['development'],          // use source entries of workspace packages
    dedupe: ['react', 'react-dom'],       // one copy of shared peers
  },
  test: {
    environment: 'jsdom',
    server: {
      deps: {
        inline: [/@acme\//],              // process workspace packages through Vite, not Node
      },
    },
  },
});

resolve.dedupe forces a single copy of shared packages regardless of which node_modules a file would resolve from. server.deps.inline makes Vitest transform the workspace packages rather than handing them to Node.js, which matters when their entries are TypeScript or when they import CSS. For a whole monorepo, a Vitest workspace (or projects in newer releases) runs each package's config from one command.

Jest versus Vitest with symlinked workspace packages Compares Jest and Vitest on following symlinks, transforming workspace packages, exports conditions and deduplicating peers. Jest Vitest Follows workspace symlinks depends on resolver/haste yes (Vite) Transforms workspace TS needs transformIgnorePatterns by default outside node_modules Custom exports conditions customExportConditions resolve.conditions One copy of React moduleNameMapper resolve.dedupe
Vitest needs little configuration; Jest needs explicit transform patterns, conditions and peer mapping.

Diagnosing which copy a test loaded

When a test misbehaves in a way that suggests duplicate instances or the wrong entry file, print what the runner actually resolved rather than guessing. In both runners, a temporary test can log resolved paths:

// debug.test.ts — delete after use
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);

test('resolution', () => {
  console.log('ui entry:', require.resolve('@acme/ui'));
  console.log('react from app:', require.resolve('react'));
  console.log('react from ui:', require.resolve('react', { paths: [require.resolve('@acme/ui')] }));
});

If the two React paths differ, the package and the app would load different copies without deduplication. If the entry path points into dist/ when you expected src/, the development condition is not being applied. In Vitest, vitest --reporter=verbose combined with DEBUG=vite:resolve-details prints Vite's resolution decisions; in Jest, --showConfig prints the effective transformIgnorePatterns, moduleNameMapper and export conditions, which is often enough to spot a pattern that does not match.

Narrowing down a symlinked-package test failure Checks whether the error is a syntax error from an untransformed file, a missing module, a duplicate instance, or a stale export, with the fix for each. Unexpected token 'export' or 'import'? Transform workspace packages transformIgnorePatterns / deps.inline yes Cannot find module '@acme/ui'? Check links and entry installed? exports target exists? yes no Invalid hook call or two instances? Deduplicate peers moduleNameMapper / resolve.dedupe yes no Stale or missing export clear runner caches; check condition no
Each symptom points at one step of resolution; fix that step rather than the test.

Runner caches deserve a mention: Jest caches transformed files keyed partly by path, and Vitest pre-bundles some dependencies. After changing resolution configuration, run once with --no-cache (Jest) or delete node_modules/.vite and node_modules/.vitest (Vitest) so old results do not hide whether the fix worked.

Choosing between source and built entries

There are two consistent strategies, and mixing them causes most of the confusion:

  1. Test against source. Workspace packages expose source through a development condition; test runners and dev servers use it; builds and consumers use dist/. Tests need no prior build and always reflect current code. This is the approach in Using Internal Packages Without a Build Step.
  2. Test against built output. Packages build first (task runner test depends on ^build), and tests import dist/ like consumers do. Slower, but closer to what ships.

Pick one per repository and configure every runner the same way. Mixing them — some packages tested against source, others against dist/ — produces failures that depend on whether a previous build happened to run, which is the hardest kind of flakiness to debug because it disappears on a clean machine.

Worked example: moving from Jest to consistent source testing

A team's Jest suite fails intermittently with Unexpected token 'export' depending on whether dist/ folders exist from a previous build. Some packages point main at dist, others at src. The team adds a development condition to every internal package's exports, sets customExportConditions and a transformIgnorePatterns exception for @acme in the shared Jest preset, and maps React to the app's copy. Tests now run against source with no build step, and the intermittent failures disappear. Later, migrating to Vitest removes most of that configuration.

Prevention and CI/CD guardrails

  • Choose source or built entries for tests, and configure every runner consistently.
  • Deduplicate shared peers (resolve.dedupe or moduleNameMapper) in every test config.
  • Keep the test config in a shared preset so new packages inherit it.
  • Run tests from a clean checkout in CI so stale dist/ folders cannot mask resolution problems.

Frequently Asked Questions

Should I set preserveSymlinks? Rarely. Node's --preserve-symlinks changes resolution for every package and often causes more duplicate-instance problems than it fixes. Prefer runner-level deduplication.

Why does the test pass in the package but fail from the app? Inside the package, imports resolve from its own node_modules; from the app, the runner must transform and resolve the package through the symlink. Configure the app's runner for workspace packages as shown above.

Does this apply to npm and Yarn workspaces? Yes. npm and Yarn with the node-modules linker also symlink workspace packages into node_modules, so the same transform and deduplication rules apply.

Do I need a separate test config per package? Not necessarily. A shared preset in a configuration package, extended by each package's small config file, keeps transforms, conditions and deduplication consistent. Vitest's workspace or projects feature can also run every package's tests from the root with one command.

How do TypeScript path aliases interact with these settings? Test runners do not read tsconfig.json paths unless configured to (Jest's moduleNameMapper, Vitest's vite-tsconfig-paths plugin). Prefer package names resolved through exports over aliases, so tests, builds and editors all use one mechanism.

What about Node's built-in test runner? node --test uses Node's own resolver, so symlinked packages resolve like any installed package and --conditions=development selects source entries. It does not transform TypeScript beyond type stripping, so workspace packages must use erasable syntax or point at built output.

Related

Workspace Symlinks vs Hard Links