Testing Shared Packages Without Publishing
There is a category of bug that only appears after a package is published: an export map missing a subpath, a type declaration that never got built, a dependency listed as devDependencies that consumers therefore do not get. Tests that resolve workspace packages to source — the right default for everyday work — cannot see any of them, because source resolution bypasses exactly the machinery that breaks. The answer is not to stop resolving to source; it is to add one job that resolves the way a consumer would, before anything ships. This guide covers both halves: fast source-resolved tests for the inner loop, and a packaging verification pass that catches what they miss. It assumes a pnpm workspace with Vitest, and follows on from wiring Vitest workspace projects in a pnpm monorepo.
Root Cause Analysis
A published package is a different artifact from the directory it was built from. It contains only the files listed in files or not excluded by .npmignore, its entry points are whatever the exports map says rather than whatever exists on disk, and its dependency tree includes only production dependencies. Every one of those transformations can drop something the tests relied on, and none of them happen when a test imports the source directly.
The failures are characteristic and, once seen, immediately recognisable. A component that imports a stylesheet works in tests and fails for consumers because .css was never listed in files. A subpath such as @acme/ui/server works in the workspace because the file exists, and fails after publish because the export map only declares .. A type import works because TypeScript found the source, and fails for consumers because the declaration build was silently skipped.
The reason this persists is that the verification feels redundant — the tests pass, after all. It becomes obviously necessary the first time a release is rolled back for a missing file, which is why it is worth setting up before that release rather than after it.
Reproducible Setup
A shared package with a main entry, a subpath and a stylesheet — enough surface for all three classic failures.
// packages/ui/package.json
{
"name": "@acme/ui",
"version": "1.4.0",
"type": "module",
"files": ["dist"],
"exports": {
".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
"./server": { "types": "./dist/server.d.ts", "default": "./dist/server.js" },
"./styles.css": "./dist/styles.css"
},
"scripts": {
"build": "tsup src/index.ts src/server.ts --format esm --dts && cp src/styles.css dist/",
"test": "vitest run",
"test:packaged": "vitest run --config vitest.packaged.config.ts"
}
}
// packages/ui/src/server.ts — a subpath consumers are expected to import
export function renderToHtml(markup: string) {
return `<!doctype html><body>${markup}</body>`;
}
Implementation
Step 1 — Keep everyday tests on source. This is the fast path, and it should stay the default: a change in the package is visible to its consumers’ tests with no build step in between.
// packages/ui/vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: { name: 'ui', environment: 'jsdom', include: ['src/**/*.test.tsx'] },
});
Step 2 — Add a second configuration that resolves to the built package. Same tests where possible, different resolution. The dist directory must exist, so this configuration is only ever run after a build.
// packages/ui/vitest.packaged.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
name: 'ui-packaged',
environment: 'jsdom',
include: ['test/packaged/**/*.test.ts'],
// No alias: imports resolve through package.json exports, as a consumer's would.
},
});
Step 3 — Write packaging assertions, not duplicate behaviour tests. The packaged suite is small and checks reachability: every documented entry point imports, every expected export exists, every shipped asset is present.
// packages/ui/test/packaged/entrypoints.test.ts
import { test, expect } from 'vitest';
import { existsSync } from 'node:fs';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
test('the main entry exposes the public API', async () => {
const mod = await import('@acme/ui');
expect(Object.keys(mod)).toEqual(expect.arrayContaining(['Button', 'ThemeProvider']));
});
test('the server subpath is reachable', async () => {
const mod = await import('@acme/ui/server');
expect(mod.renderToHtml('<p>hi</p>')).toContain('<!doctype html>');
});
test('the stylesheet ships', () => {
expect(existsSync(require.resolve('@acme/ui/styles.css'))).toBe(true);
});
Step 4 — Verify the package contents directly. npm pack --dry-run lists exactly what would be uploaded, which catches a missing files entry without needing a consumer at all.
// packages/ui/test/packaged/contents.test.ts
import { test, expect } from 'vitest';
import { execSync } from 'node:child_process';
test('the tarball contains the built output and the stylesheet', () => {
const out = execSync('npm pack --dry-run --json', { encoding: 'utf8' });
const files: string[] = JSON.parse(out)[0].files.map((f: any) => f.path);
expect(files).toEqual(expect.arrayContaining([
'dist/index.js',
'dist/index.d.ts',
'dist/server.js',
'dist/server.d.ts',
'dist/styles.css',
]));
expect(files.some((f) => f.startsWith('src/'))).toBe(false);
});
Step 5 — Check types the way a consumer’s compiler will. A declaration file that exists is not the same as one that resolves; a small type test compiled against the built package catches both.
// packages/ui/test/packaged/types.test-d.ts
import { expectTypeOf } from 'vitest';
import { Button } from '@acme/ui';
import { renderToHtml } from '@acme/ui/server';
expectTypeOf(Button).toBeFunction();
expectTypeOf(renderToHtml).parameter(0).toEqualTypeOf<string>();
Step 6 — Wire it into the release path, not the commit path. Running the packaged suite on every commit means building every package on every commit, which is exactly the cost source resolution was avoiding.
# .github/workflows/release.yml
- run: pnpm -r build
- run: pnpm -r test:packaged
- run: pnpm changeset publish
Verification
Prove the packaged suite can fail. Remove the subpath from the export map, run it, and confirm the failure is specific and immediate rather than a confusing module-not-found deep inside a consumer.
pnpm --filter @acme/ui build
pnpm --filter @acme/ui test:packaged
# FAIL test/packaged/entrypoints.test.ts > the server subpath is reachable
# Error: Package subpath './server' is not defined by "exports"
Then confirm the contents assertion catches a dropped asset, which is the failure that is otherwise found by a user.
rm packages/ui/dist/styles.css && pnpm --filter @acme/ui test:packaged
# FAIL test/packaged/contents.test.ts > the tarball contains the built output
# Expected array to contain: "dist/styles.css"
Finally, verify the two suites have not drifted apart in what they consider the public API. A quick comparison of the exported names from source and from the build catches the case where a new export was added to source and never wired into the entry point.
// packages/ui/test/packaged/parity.test.ts — the two views of the public API agree
import { test, expect } from 'vitest';
import * as built from '@acme/ui';
import * as source from '../../src/index';
test('the built entry exports exactly what source does', () => {
expect(Object.keys(built).sort()).toEqual(Object.keys(source).sort());
});
That parity check is the one assertion worth adding even to a package nobody publishes, because the failure it catches — a new export written in source and never added to the entry file — is silent everywhere else and surfaces as a confusing undefined at a consumer’s call site.
Troubleshooting
Symptom: the packaged suite cannot resolve the package at all. Diagnosis: the workspace link points at the package directory, and without a build there is no dist, so the export map resolves to files that do not exist. Fix: always build before running it, and make the script dependency explicit in the task runner so nobody can run them out of order.
Symptom: the packaged tests pass locally and fail in CI. Diagnosis: a stale dist on the developer’s machine still contains a file that the current build no longer produces. Fix: clean before building in both places — a packaging suite that runs against yesterday’s output is worse than none, because it reports confidence it has not earned.
Symptom: the type test passes but consumers still get type errors. Diagnosis: the consumer uses a different module resolution mode, typically bundler versus node16, and the export map’s types condition is only correct for one of them. Fix: check the package against both resolution modes; a mismatch here is one of the most common real-world packaging defects and is invisible to any runtime test.
FAQ
Why not just test everything against the built output?
Because the feedback loop becomes a build per change, and in a workspace that means a build of every dependency too. Source resolution is what makes cross-package development tolerable; the packaged suite exists to cover what it cannot see, which is a small, stable set of checks rather than your whole test suite twice.
Should the packaged suite duplicate behaviour tests?
No. Its job is reachability and contents, not correctness — the behaviour was already verified against the same code in the source suite. Duplicating tests doubles maintenance for almost no additional signal, and the duplicates drift apart, at which point nobody knows which one is authoritative.
What about packages that are never published?
They still have consumers — the other packages in the workspace — so the export map and build still matter if anything resolves them through their entry points rather than through an alias. If nothing ever does, the packaged suite adds little and can be skipped; that is a deliberate decision worth writing down rather than an oversight.
Can this replace a canary release?
Partly. It catches structural problems — missing files, unreachable subpaths, absent types — which is the majority of publish-time breakage. It cannot catch problems that depend on a consumer’s own build configuration, such as a bundler that cannot handle a particular syntax. For widely used packages, keep a small consumer application in the workspace that imports the built package, as a standing integration check.
Related
- Back to Monorepo & Workspace Testing
- Wiring Vitest workspace projects in a pnpm monorepo — the source-resolved half of this setup.
- Versioning test utilities as an internal package — a shared package that also deserves these checks.
- Caching test results with Turborepo remote cache — making the build-then-test path affordable.