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

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. npm pack produces exactly the file that npm publish would upload, and a few minutes of scripting turns it into a smoke test that catches missing build output, broken exports, absent dependencies, CLI shebang problems and module-format mistakes before a release. This guide builds that test step by step, explains why each isolation measure matters, and wires it into CI.

What a smoke test catches

The bugs a tarball smoke test finds are the ones consumers report within an hour of a release:

# dist/ was never built before packing
Error: Cannot find module '/tmp/consumer/node_modules/your-lib/dist/index.cjs'

# a dependency is used but only declared as a devDependency
Error [ERR_MODULE_NOT_FOUND]: Cannot find package 'picocolors' imported from /tmp/consumer/node_modules/your-lib/dist/index.js

# the ESM build uses extensionless relative imports
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/tmp/consumer/node_modules/your-lib/dist/utils' imported from .../dist/index.js

None of these appear in the repository's own tests: the build output exists locally, the devDependency is installed in the workspace, and the test runner resolves extensionless imports. The general case for testing the artefact rather than the source is made in Testing and Validating Packages Before Publishing.

Step 1: inspect what npm pack produces

Start with the file list, which needs no install:

npm pack --dry-run
npm notice 📦  your-lib@2.3.0
npm notice Tarball Contents
npm notice 1.1kB  LICENSE
npm notice 2.4kB  README.md
npm notice 14.2kB dist/index.cjs
npm notice 13.8kB dist/index.js
npm notice 6.1kB  dist/index.d.ts
npm notice 6.1kB  dist/index.d.cts
npm notice 1.3kB  package.json
npm notice Tarball Details
npm notice package size: 12.4 kB
npm notice unpacked size: 45.0 kB
npm notice total files: 7

Read it every time you change the build or the files field. For automation, npm pack --dry-run --json returns the same information as JSON, which a script can assert on.

The tarball smoke test Build, pack to a tarball, create an isolated consumer outside the repository, install the tarball, then import every entry point and run the CLI. build + npm pack your-lib-2.3.0.tgz fresh consumer temp dir outside the repo npm install tarball production deps only import + run ESM, CJS, types, bin
Every step works from the tarball; nothing from the repository's node_modules is visible to the consumer.

Step 2: install into an isolated consumer

The consumer must live outside the repository. Node.js resolves packages by walking up parent directories, so a consumer inside your workspace can silently find your root node_modules and satisfy imports a real user would fail.

set -euo pipefail
PKG_DIR=$(pwd)
TARBALL="$PKG_DIR/$(npm pack --silent)"
WORK=$(mktemp -d)

cd "$WORK"
npm init -y >/dev/null
npm install --omit=dev --ignore-scripts "$TARBALL"
  • --omit=dev mirrors what consumers get: your dependencies only. A module that is imported at runtime but declared as a devDependency fails here.
  • --ignore-scripts proves the package works without install scripts, which many consumers now block. Drop the flag only if your package legitimately needs them.
  • A fresh directory per run avoids caching effects from previous installs.

Step 3: exercise every public entry point

Import each entry the way your README documents it, in both module systems:

cat > esm.mjs <<'EOF'
import { createClient } from 'your-lib';
import { formatDate } from 'your-lib/format';
if (typeof createClient !== 'function') throw new Error('createClient missing');
console.log('esm ok', formatDate(new Date(0)));
EOF

cat > cjs.cjs <<'EOF'
const { createClient } = require('your-lib');
const { formatDate } = require('your-lib/format');
if (typeof createClient !== 'function') throw new Error('createClient missing');
console.log('cjs ok', formatDate(new Date(0)));
EOF

node esm.mjs
node cjs.cjs

Add a type check for TypeScript consumers:

npm install --no-save typescript@5
cat > types.mts <<'EOF'
import { createClient, type ClientOptions } from 'your-lib';
const opts: ClientOptions = { baseUrl: 'https://example.test' };
createClient(opts);
EOF
npx tsc --noEmit --strict --module nodenext --moduleResolution nodenext types.mts

And the CLI, if you ship one:

npx your-lib --version
Layout of an isolated smoke-test consumer A temporary directory outside the repository containing a package.json, the installed tarball in node_modules, and ESM, CJS and TypeScript test files. /tmp/tmp.8Qx2/ mktemp -d, outside the repo package.json npm init -y node_modules/your-lib/ from the tarball esm.mjs static imports of every entry cjs.cjs require() of every entry types.mts tsc --noEmit under nodenext
The consumer sits outside the repository so no parent node_modules can satisfy an import by accident.

Step 4: make it a script and a CI job

Wrap the steps in scripts/smoke-test.sh, call it from package.json, and run it in CI after the build:

jobs:
  smoke:
    strategy:
      matrix:
        os: [ubuntu-latest, windows-latest]
        node: [20, 22]
    runs-on: ${{ matrix.os }}
    defaults:
      run: { shell: bash }
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: "${{ matrix.node }}" }
      - run: npm ci
      - run: npm run build
      - run: bash scripts/smoke-test.sh

Running on Windows catches CLI shims and path-separator bugs that Linux never shows. The broader version matrix is covered in Running a Node.js Version Matrix for a Library.

Worked example: a devDependency that was really a runtime dependency

A library's colour-output helper imports picocolors. It was added with npm install -D because it was first used only in a build script. Later, the runtime logger started importing it too. Every test passes because devDependencies are installed in the repository. The smoke test fails on its first run:

Error [ERR_MODULE_NOT_FOUND]: Cannot find package 'picocolors' imported from /tmp/tmp.8Qx2/node_modules/your-lib/dist/logger.js

Moving picocolors from devDependencies to dependencies fixes it. Knip's unlisted and misplaced-dependency checks, covered in Finding Unused Dependencies and Exports with Knip, would flag the same mistake statically; the smoke test proves the fix end to end.

Monorepos and workspace dependencies

In a workspace, a package often depends on siblings through workspace: ranges. A smoke test must install those siblings too, from their own tarballs, or the install fails trying to fetch unpublished versions from the registry. Pack every package in the dependency chain and install them together:

pnpm --filter "your-lib..." exec pnpm pack --pack-destination /tmp/tarballs
cd "$WORK" && npm install --omit=dev /tmp/tarballs/*.tgz

pnpm pack rewrites workspace: ranges to concrete versions in each packed manifest, and installing all the tarballs at once lets npm satisfy those versions locally. This also proves that the published manifests contain no leftover workspace: ranges, the failure described in Fixing 'Unsupported URL Type workspace:' After Publishing.

Failure patterns and where they point

Each smoke-test failure maps cleanly to one layer of the package, which makes triage quick once you know the patterns.

Mapping a smoke-test failure to its cause A failing smoke test points at the build, the dependency declarations, or the exports map depending on the error message. Smoke test fails read the first error Build or files field prepack did not build, or allowlist excludes it missing file Dependency declarations runtime import declared as devDependency missing package exports map missing key, condition or extension exports error
The error text alone usually identifies which part of the package to fix.

Missing files mean the build did not run before packing or the files field excludes the output; check that the build lives in prepack and that npm pack --dry-run lists the file. Missing packages mean a runtime import is declared in the wrong field, or not at all. Subpath and format errorsERR_PACKAGE_PATH_NOT_EXPORTED, ERR_REQUIRE_ESM, SyntaxError: Cannot use import statement outside a module — point at the exports map or file extensions. Type errors in the TypeScript step point at declaration layout and are usually easier to diagnose with Are the Types Wrong, which explains the specific problem. CLI failures on one operating system only are shebang, line-ending or path issues in the bin target.

Keeping the smoke test's output verbose — print each step as it starts — makes CI logs readable enough that the first failing line tells the story without rerunning anything locally.

What the smoke test does not replace

A smoke test proves loading and a couple of representative calls. It does not replace your unit tests, it does not prove every export's behaviour, and it does not check type accuracy beyond a representative usage. Keep it small and fast — a minute or two per run — so it can run on every release branch and ideally every pull request that touches the build. If it grows into a second test suite, split the extra coverage into proper fixture projects and keep the smoke test as the quick gate.

Prevention and CI/CD guardrails

  • Run the smoke test before every publish, ideally in the same job and against the same tarball you upload.
  • Install outside the repository, with --omit=dev and --ignore-scripts.
  • Exercise every documented entry point in both ESM and CommonJS, plus the CLI.
  • Publish the tested tarball with npm publish ./your-lib-2.3.0.tgz rather than rebuilding.

Frequently Asked Questions

Why not just use npm link to test? npm link symlinks your working tree, including files that will not be published and your repository's node_modules. It is useful for development but proves nothing about the published package. See Testing Local Packages with npm link and yalc.

Does pnpm pack produce the same tarball as npm pack? The contents follow the same rules, but pnpm also rewrites workspace: and catalog: ranges in the packed manifest. In a pnpm workspace, pack with pnpm so the tarball matches what pnpm publish uploads.

How long should a smoke test take? Under two minutes for most libraries, dominated by the install. Cache the npm download cache between runs, never the consumer's node_modules.

Should the smoke test use the package's own lockfile? No. Consumers never see your lockfile — only your dependencies ranges, resolved fresh at their install time. Installing without your lockfile is what reveals a dependency range that now resolves to a broken or incompatible release, which is exactly the failure consumers would hit.

Can I run the smoke test in Docker instead of a temp directory? Yes, and it is a good way to test a specific base image or libc. Copy only the tarball into the container, install it there, and run the same scripts.

Related

Testing and Validating Packages Before Publishing