Testing and Validating Packages Before Publishing
A package can pass every unit test in its repository and still be broken for the people who install it: the tarball is missing dist/, the exports map sends CommonJS consumers to an ES module, the types resolve under one TypeScript setting and not another, or the CLI has no shebang. Those failures live in the gap between your source tree and the published artefact, and ordinary tests never cross it. This section covers the checks that do — packing, linting the manifest, verifying types across resolvers, installing the tarball into real consumers, and running a runtime matrix — and shows how to combine them into a release gate.
Why unit tests are not enough
Unit tests import your source files through your repository's configuration: your tsconfig.json paths, your test runner's resolver, your node_modules layout. Consumers do none of that. They install a tarball built from a subset of your files, resolve it through the exports map with their bundler or Node.js version, and type-check it with their compiler settings. Every difference between those two paths is a place a bug can hide.
The checks in this section close that gap in layers, from the cheapest static checks to full installs. They are part of the broader package workflow described in Core JavaScript Package Workflows, and they sit between building and publishing in any release pipeline.
The validation stack
Each layer catches a different class of defect, and together they cover almost every "works for me, broken on npm" report:
- Pack inspection answers "what ships?" — see Smoke-Testing a Tarball with npm pack.
- Manifest lint answers "does
package.jsondescribe the files correctly?" — see Validating Package Exports with publint. - Type resolution answers "will TypeScript find correct types for every consumer?" — see Checking Published Types with Are the Types Wrong.
- Fixture installs answer "does it actually work when installed?" — the same smoke-testing guide covers them.
- Runtime matrix answers "does it work on every runtime I claim to support?" — see Running a Node.js Version Matrix for a Library.
Local development against unpublished changes is a related but different need, covered in Testing Local Packages with npm link and yalc.
Core initialisation and configuration
Start by giving the package a small set of scripts that each validation layer can call, so local runs and CI are identical:
pnpm add -D publint @arethetypeswrong/cli
{
"scripts": {
"build": "tsup",
"validate:pack": "node scripts/check-pack.mjs",
"validate:manifest": "publint --strict",
"validate:types": "attw --pack . --profile node16",
"validate:consumers": "node scripts/fixture-install.mjs",
"validate": "pnpm run build && pnpm run validate:pack && pnpm run validate:manifest && pnpm run validate:types && pnpm run validate:consumers"
}
}
The pack check asserts the tarball's contents from the JSON output of npm pack --dry-run:
// scripts/check-pack.mjs
import { execFileSync } from 'node:child_process';
const [info] = JSON.parse(execFileSync('npm', ['pack', '--dry-run', '--json'], { encoding: 'utf8' }));
const files = info.files.map((f) => f.path);
const required = ['dist/index.js', 'dist/index.cjs', 'dist/index.d.ts', 'dist/index.d.cts', 'package.json'];
const forbidden = [/^src\/.*\.test\./, /^coverage\//, /\.env/, /^\.github\//];
const missing = required.filter((f) => !files.includes(f));
const leaked = files.filter((f) => forbidden.some((re) => re.test(f)));
if (missing.length || leaked.length) {
console.error({ missing, leaked });
process.exit(1);
}
console.log(`pack ok: ${files.length} files, ${info.unpackedSize} bytes unpacked`);
The --profile node16 flag on Are the Types Wrong ignores node10 resolution problems for packages that no longer support that resolver; drop it if you do support it.
How the checks model a consumer
The key design idea behind this stack is to test the artefact, not the source. Every layer after pack inspection works from the tarball — either by analysing it (publint and attw both accept a packed tarball) or by installing it. That matters because the tarball is the only thing consumers receive. A bug in your files field, a build step that ran against stale output, or a prepack script that failed silently all change the tarball without changing your source tree.
Fixture consumers are tiny projects kept in the repository — typically under test/consumers/ — each representing one way your package is used: an ESM Node.js script, a CommonJS script, a TypeScript project with moduleResolution: nodenext, a bundled browser app. The fixture script installs the freshly packed tarball into each, then runs its import test:
// scripts/fixture-install.mjs
import { execFileSync } from 'node:child_process';
import { readdirSync } from 'node:fs';
import { resolve } from 'node:path';
const tarball = resolve(execFileSync('npm', ['pack', '--silent'], { encoding: 'utf8' }).trim());
for (const name of readdirSync('test/consumers')) {
const cwd = resolve('test/consumers', name);
execFileSync('npm', ['install', '--no-save', '--no-package-lock', tarball], { cwd, stdio: 'inherit' });
execFileSync('npm', ['test'], { cwd, stdio: 'inherit' });
console.log(`consumer ${name}: ok`);
}
Installing with --no-save keeps fixture manifests stable; --no-package-lock avoids committing tarball paths into lockfiles.
Choosing fixture consumers
Fixtures are only as good as the consumers they represent, so pick them from how your package is actually used rather than from every theoretical combination. Four fixtures cover most libraries:
- Node.js ESM — a
package.jsonwith"type": "module"and a test script that runsnode test.mjs, importing every public entry point with staticimportstatements. This proves theimportconditions and file extensions are right. - Node.js CommonJS — no
typefield, atest.cjsthat callsrequire()on every entry. On runtimes withoutrequire(esm), this is where ESM-only mistakes surface. - TypeScript with
nodenext— a fixture compiled withtsc --noEmitundermoduleandmoduleResolutionset tonodenext, once as ESM and once as CommonJS. It proves your types resolve and describe the right module format. - A bundler — a minimal Vite or esbuild project that bundles an import of your package for the browser, and fails if the build emits warnings about Node.js built-ins or
process. It proves yourbrowserconditions and side-effect claims hold.
Add specialised fixtures only when your package's audience needs them: a React Server Components fixture for component libraries, a Deno or Bun fixture if you advertise support, a fixture using moduleResolution: node10 if you still support legacy consumers. Each fixture should import exactly what your README tells users to import — if the README shows import { createClient } from 'your-lib/client', that line belongs in every fixture.
Keep fixtures deliberately boring. They are not a second test suite; they test that loading works and that one or two representative calls succeed. Behavioural testing stays in your unit tests, where it is fast and debuggable.
Validating special kinds of packages
Some package types need checks beyond the standard stack.
CLI packages need their bin entries exercised: install the tarball globally in a fixture environment, run your-cli --version and one real command, and check the exit code. Run this on Windows as well as Unix, because the shims differ. See Adding a bin Field for CLI Packages.
Packages with native or platform-specific dependencies need the runtime matrix to cover every operating system and CPU architecture you publish binaries for, including arm64 runners, and a fixture that installs with a fresh lockfile to catch missing platform packages.
Component libraries need a bundler fixture that renders a component and a check that the framework itself is not bundled — the "invalid hook call" failure is a packaging bug that only a consumer build reveals.
Packages with peer dependencies should run fixtures against both the lowest and highest peer versions they claim to support. A peer range of ^18.2.0 || ^19.0.0 is a promise; two fixtures keep it honest.
Reading failures quickly
When validation fails, the layer that failed tells you where to look. A pack failure is a files or build problem — the build did not produce the file, or the allowlist excludes it. A publint failure is a manifest problem, and its message names the field. An attw failure is a type-layout problem: check the types conditions and declaration extensions, as in Fixing Types Not Found Under node16 Module Resolution. A fixture failure with static layers green is usually a runtime interop problem — module format, missing dependency, or an environment global — and the error message from the fixture is the real consumer error your users would have reported. A matrix failure on one runtime only points at a version-specific feature, such as require(esm) or import.meta.dirname, that your declared engines range does not guarantee.
Execution strategy: what runs when
Not every layer needs to run on every commit. A practical split:
| Trigger | Layers | Typical time |
|---|---|---|
| Every pull request touching the package | pack inspection, publint, attw | seconds |
Pull requests touching package.json, build config or entry points |
+ fixture installs | 1–2 minutes |
| Release branch and release job | + full runtime matrix across OS and Node.js versions | several minutes, parallel |
In a monorepo, run these checks only for packages that are published and affected by the change. With Turborepo or Nx, define a validate task that depends on build, list the tarball-relevant inputs (package.json, src/**, build config), and let caching skip unchanged packages. With pnpm alone, pnpm --filter "...[origin/main]" run validate restricts the run to changed packages and their dependents, as covered in pnpm Workspace Filtering.
Validating many packages in a monorepo
In a repository that publishes dozens of packages, the validation stack becomes a shared task rather than per-package scripts. Put the check scripts in a private tooling package, parameterise them by package directory, and expose one validate task per publishable package through your task runner. Three practices keep it maintainable at scale.
First, derive expectations from the manifest: the pack check can compute its required file list from exports, main, types and bin, so adding an entry point never requires editing the check. Second, share fixtures across packages where the consumer shape is the same — one ESM fixture template that is copied and pointed at each package's tarball is easier to maintain than a hand-written fixture per package. Third, skip private packages automatically; anything with "private": true is never published, so validating its tarball wastes time.
Release tooling ties it together. With Changesets, the release job can read the list of packages about to be published from changeset status --output, validate exactly those, and only then run changeset publish. That keeps release jobs fast even in large repositories, because unchanged packages are neither validated nor published.
Beyond the tarball: prereleases and size budgets
Two further checks sit at the edge of this stack and are worth adding once the basics are in place.
Prerelease channels as a final gate. For high-traffic packages, publishing a release candidate under a non-default dist-tag (next, rc or canary) before promoting it to latest gives real consumers — often your own applications — a chance to install it through the registry. That exercises everything a local tarball cannot: registry access for scoped packages, provenance attestations, dist-tag behaviour and CDN propagation. Promotion is then a single npm dist-tag add your-lib@2.0.0 latest, with no rebuild. The mechanics are covered in Release Channels and Dist-Tags.
Size budgets. A package can pass every correctness check and still regress badly for consumers by growing. Record the unpacked size from npm pack --dry-run --json and the bundled cost of a representative import (for example, with size-limit or an esbuild metafile) on every release, and fail when either grows beyond a budget. Size jumps are one of the earliest signals that a dependency was bundled by mistake, that tree-shaking broke, or that test fixtures slipped into the tarball.
Both checks share the artefact-first principle. The prerelease is the same tarball you validated, promoted by changing a tag rather than rebuilding; the size budget measures what consumers download rather than what your source tree contains.
Making validation part of the definition of done
The stack works best when nobody has to remember it. Put pnpm run validate in the pull request template's checklist, make the static layers required status checks, and have the release job refuse to publish if any layer was skipped. When a consumer reports a packaging bug that slipped through, add a fixture or a pack-check rule that reproduces it before fixing it, exactly as you would add a regression test for a logic bug. Over time the fixtures become a precise record of every way your package has been consumed — and every way it has broken.
Security and isolation
Validation doubles as a security control. The pack check's forbidden list is the last line of defence against publishing secrets or internal files; the leaked-file failure mode is covered in Choosing Between the files Field and .npmignore. Run fixture installs with --ignore-scripts unless your package needs install scripts, so the fixtures prove the package works without them — increasingly important as consumers adopt script blocking. Keep fixture installs isolated from the workspace: install into directories outside the workspace root or with their own .npmrc, so the workspace's hoisted node_modules cannot satisfy an import that a real consumer would fail. For the release job itself, validate and publish the same tarball in one job with provenance, as described in Setting Up npm Provenance with GitHub Actions, so no unvalidated artefact can be substituted between steps.
CI/CD integration
A complete workflow for a single published package:
name: validate-package
on:
pull_request:
push:
branches: [main]
jobs:
static:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4 # reads packageManager from package.json
- uses: actions/setup-node@v4
with: { node-version: 22, cache: pnpm }
- run: pnpm install --frozen-lockfile
- run: pnpm run build
- run: pnpm run validate:pack # 1. tarball contents
- run: pnpm run validate:manifest # 2. publint --strict
- run: pnpm run validate:types # 3. attw across resolvers
consumers:
needs: static
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node: [18, 20, 22, 24]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with: { node-version: "${{ matrix.node }}", cache: pnpm }
- run: pnpm install --frozen-lockfile
- run: pnpm run build
- run: pnpm run validate:consumers # 4 + 5. install tarball, import on this runtime
Step by step: the static job builds once and runs the three cheap checks; the consumers job fans out across operating systems and Node.js versions, building and installing the tarball on each so platform-specific problems (shebangs, path separators, native bindings) surface. fail-fast: false keeps every cell running, so one failure does not hide others.
Pitfalls
| Mistake | Impact | Remediation |
|---|---|---|
| Testing only the source tree | Missing files and broken exports ship unnoticed |
Validate the packed tarball |
| Fixtures inside the workspace | Hoisted node_modules hides missing dependencies |
Install fixtures outside the workspace or isolate them |
| Rebuilding between validate and publish | Published artefact differs from the tested one | Pack once, test and publish that tarball |
| Ignoring attw's node16 column | ESM/CJS type mismatches reach consumers | Fail CI on the resolvers you support |
| Matrix only on Linux | Windows shebang and path bugs ship | Include Windows and macOS in the release matrix |
Guides in this topic
Every guide below solves one concrete task or error within Testing and Validating Packages Before Publishing. Start with the one whose symptom matches what you are seeing:
- Checking Published Types with Are the Types Wrong — Are the Types Wrong (attw) answers one question for every entry point of your package: when a consumer imports it under a given TypeScript module resolution mode, does…
- Running a Node.js Version Matrix for a Library — The engines.node field in your package.json is a promise: "this package works on these Node.js versions".
- Smoke-Testing a Tarball with npm pack — The only way to know a package works for consumers is to install it the way they will: from a tarball, into a clean project, with nothing from your repository leaking in.
- Testing Local Packages with npm link and yalc — You are changing a library and want to try it inside an application before publishing.
- Validating Package Exports with publint — publint is a linter for the thing most linters ignore: your published package.json and the files it points at.
Frequently Asked Questions
Do I need all five layers for a small utility package? The three static layers take seconds and catch most publishing bugs, so run them everywhere. A single ESM and CommonJS fixture on your oldest and newest Node.js version covers the rest for most small packages.
Should these checks block pull requests or only releases? Block pull requests on the static layers, because a broken manifest merged today becomes a broken release tomorrow. Fixture and matrix runs can block release branches only if they are too slow for every pull request.
Can I test against the registry instead of a tarball? Publishing to a private staging registry such as Verdaccio and installing from it tests the registry path too, including dist-tags and access. It is a useful extra step for complex releases, but a local tarball install catches the same package-level defects faster.
How do these checks work with Changesets or semantic-release?
Run them before the release tool publishes: in the release job, build, validate and then let the tool publish. Some teams add validate to prepublishOnly as a safety net for manual publishes.
What is the fastest single check to add first?
Pack inspection. A thirty-line script over npm pack --dry-run --json catches missing build output and leaked files — the two most damaging publishing mistakes — and runs in a couple of seconds.
Related
- Core JavaScript Package Workflows covers the full package lifecycle this validation fits into.
- Understanding package.json Fields explains the manifest fields these checks verify.
- TypeScript Declaration Publishing covers producing the declarations that attw inspects.
- npm Registry Publishing Workflows picks up where validation ends, with the publish itself.