Back to core workflows Fix dependency resolution Tune package metadata Validate before publishing

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.

What your tests exercise versus what consumers get Left panel lists the inputs to repository unit tests; right panel lists the inputs a consumer's install and build actually use. Your unit tests src/*.ts via test-runner transform repository tsconfig and paths workspace node_modules (hoisted) one Node.js version no exports map involved A consumer's install dist/ from the packed tarball consumer's moduleResolution strict or flat node_modules consumer's Node.js and bundler exports map and conditions
Repository tests and consumer installs share almost nothing except your source code's logic.

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:

The package validation stack Five layers from inspecting the packed file list, linting the manifest, checking types across resolvers, installing into fixture consumers, and running a runtime matrix. 1 Pack inspection npm pack --dry-run: are the right files in the tarball, and nothing else? 2 Manifest lint publint: exports, main, types, bin and files agree with each other 3 Type resolution Are the Types Wrong: every entry under node10, node16 and bundler 4 Fixture installs install the tarball into ESM, CJS and bundler consumers and import it 5 Runtime matrix run the fixtures on every supported Node.js version and OS
Run cheap static layers on every commit and the install and matrix layers on release branches.
  1. Pack inspection answers "what ships?" — see Smoke-Testing a Tarball with npm pack.
  2. Manifest lint answers "does package.json describe the files correctly?" — see Validating Package Exports with publint.
  3. Type resolution answers "will TypeScript find correct types for every consumer?" — see Checking Published Types with Are the Types Wrong.
  4. Fixture installs answer "does it actually work when installed?" — the same smoke-testing guide covers them.
  5. 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.

Artefact-first validation The build produces dist, npm pack produces a tarball, static tools analyse the tarball, fixture projects install it, and only then is the same tarball published. build dist/ from one commit npm pack tarball = release artefact publint + attw static checks on the tarball fixture installs ESM, CJS, bundler consumers publish tarball the exact file that passed
The tarball you test is the tarball you publish — never rebuild between validating and publishing.

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.json with "type": "module" and a test script that runs node test.mjs, importing every public entry point with static import statements. This proves the import conditions and file extensions are right.
  • Node.js CommonJS — no type field, a test.cjs that calls require() on every entry. On runtimes without require(esm), this is where ESM-only mistakes surface.
  • TypeScript with nodenext — a fixture compiled with tsc --noEmit under module and moduleResolution set to nodenext, 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 your browser conditions 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.

Validation layers compared Compares pack inspection, publint, Are the Types Wrong, fixture installs and a runtime matrix on the defects they catch, cost and when to run them. catches cost run on pack inspection missing or leaked files seconds every PR publint manifest and exports mistakes seconds every PR attw types unreachable or wrong format seconds every PR fixture installs real import failures minutes manifest/build changes runtime matrix version and OS differences most expensive release branches
Static layers are cheap enough for every pull request; install and matrix layers belong on release paths.

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:

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