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.
The failure modes map to those steps:
- Transform decision (Jest). Jest's default
transformIgnorePatternsskips everything undernode_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.tssource, and Jest decides not to transform it, you getUnexpected token 'export'. - Entry selection. A package whose
exportspoints atdist/fails ifdist/was not built before tests run; one that points atsrc/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.tsximportingreactfindspackages/ui/node_modules/reactfirst — 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.
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.
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:
- Test against source. Workspace packages expose source through a
developmentcondition; test runners and dev servers use it; builds and consumers usedist/. Tests need no prior build and always reflect current code. This is the approach in Using Internal Packages Without a Build Step. - Test against built output. Packages build first (task runner
testdepends on^build), and tests importdist/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.dedupeormoduleNameMapper) 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 explains how workspace packages are linked.
- Deduplicating Duplicate React Versions covers the duplicate-instance problem in depth.
- Using Internal Packages Without a Build Step sets up source-based consumption.
- Testing Local Packages with npm link and yalc handles the cross-repository version of this problem.