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.
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=devmirrors what consumers get: yourdependenciesonly. A module that is imported at runtime but declared as a devDependency fails here.--ignore-scriptsproves 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
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.
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 errors — ERR_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=devand--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.tgzrather 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 combines the smoke test with static checks.
- Choosing Between the files Field and .npmignore controls what the tarball contains.
- Choosing Between prepare, prepack and prepublishOnly makes sure the build runs before packing.
- Dry-Running a Publish Before Release adds registry-side checks after the smoke test.