Level 3: Build Output Verification
Testing built files, SSG output, templates, and bundler configuration.
What Level 3 Tests
Level 3 tests verify build output -- the actual files produced by your build tool. Instead of testing source code directly, you run the build and inspect the results.
Typical targets:
SSG (Static Site Generation) output HTML
Template rendering results
Bundler output (correct chunks, code splitting)
Generated configuration files
Build-time data transforms (MDX compilation, content collections)
Tools
| Tool | Role |
|---|---|
| vitest | Test runner, reading and asserting on files |
| fs/path | Node.js file system APIs to read build output |
| cheerio | Parse and query HTML output |
Run the Build First
Level 3 tests read files under dist/ -- they never trigger a build themselves. Run the build before running the suite, or the tests read stale (or missing) output:
pnpm build && pnpm vitest run --project build--project build scopes the run to a named build project if your Vitest config defines one (see Vitest Patterns); without a named project, a plain vitest run after the build works the same way.
To avoid relying on the developer remembering the build step, wire it into Vitest itself with globalSetup so a bare vitest run always exercises a fresh build:
// vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globalSetup: ["./tests/build-setup.ts"],
},
});// tests/build-setup.ts
import { execSync } from "node:child_process";
export default function setup() {
execSync("pnpm build", { stdio: "inherit" });
}Warning
A green Level 3 suite against a stale dist/ proves nothing -- it asserts on yesterday's output, not the code you just changed. Always rebuild (or use globalSetup) before trusting the result.
A Built Artifact With More Than One Writer Is Shared Mutable State
The Warning above assumes a single writer: you build, then you read. That holds for a solo pnpm build && vitest run and stops holding the moment anything else can write dist/ while the suite reads it. Package lifecycle hooks are the writers people miss, because nobody types them -- they fire as a side effect of commands that look read-only.
Treat a built artifact the way you would treat a shared mutable variable: enumerate every writer before you trust it.
Enumerate the Writers
Before you rely on any build-once/read-many setup, list every process that can write the artifact:
Lifecycle hooks --
prepare,prepack,prepublishOnly,postinstall. Nobody invokes these; the package manager does.Watchers --
tsup --watch,tsc --watch. These write for as long as the dev server is alive.Force-rebuild scripts -- test lanes, workspace builds, local heavy lanes that rebuild unconditionally on every invocation.
The publish path --
npm pack,npm publish, and whatever they run on your behalf.
From a real pnpm monorepo, packages/<pkg>/dist/ had four independent writers -- one from each category:
| Writer | Trigger |
|---|---|
prepare (tsup + tsc -p tsconfig.build.json) | every pnpm install, silently |
dev:js (tsup --watch) + dev:dts (tsc --watch) | for the whole life of pnpm dev |
pnpm test / build:workspace / the local heavy lane | unconditional force-rebuild |
prepack + publish | release |
Nothing mechanically prevented these from overlapping, so the project fell back on a human-remembered rule in its CLAUDE.md -- don't run the test lane while pnpm dev is live. That is the shape of the problem: a race that only discipline prevents. What it produced, over time:
A
--watchbuild wipeddist/and regenerated no.d.tsat all, untilcleanwas made conditional on watch mode (clean: !options.watch).A dead
.d.tswatcher leftdist/frozen while the JS kept updating, so the suite -- in the package's own words -- typechecks cleanly against stale types instead of failing loudly. A green suite against an artifact that no longer matches its source, caused by concurrency, not by forgetting to build.*. d. ts A workspace build guard that, by its own documentation, checks existence rather than freshness: it accepts a stale artifact by design and hands the freshness question back to a human.
Warning
An existence guard is not a cache. if (!existsSync("dist")) build() detects absence, never staleness. A guard that cannot tell a fresh artifact from a stale one is not protecting the suite -- it is only protecting it from a missing artifact, which is the failure mode you would have noticed anyway.
--ignore-scripts Is a Flag, Not a Property
Here is the incident that makes the abstraction concrete. A fast-tier unit test asserted the npm tarball's file list, and passed --ignore-scripts precisely so the call would be read-only:
execFileSync("npm", ["pack", "--dry-run", "--json", "--ignore-scripts"], { cwd: PKG_ROOT });Whether that flag actually suppresses prepare turns out to depend on the npm version:
| npm | ships with | runs prepare despite --ignore-scripts? |
|---|---|---|
| 10.9.4 | Node 22 -- what CI pinned | YES (prepack correctly skipped) |
| 11.0.0 | -- | no |
| 11.9.0 | Node 24 -- what local dev ran | no |
Local dev ran Node 24 and saw a genuinely read-only pack. CI pinned Node 22, so the same command fired prepare, which ran tsup with clean: true, which deleted dist/ outright -- while a sibling spec spawned the package's bin/*.mjs, whose static import of . then failed with ERR_MODULE_NOT_FOUND. Mid-publish. The release stopped after one of three packages.
Warning
A test's read-only-ness must not depend on a CLI flag whose behavior varies by tool version. --ignore-scripts is a flag, not a property: an argument you hope the tool honors, not a guarantee the filesystem gives you. The same reasoning applies to --dry-run, --no-build, and every other opt-out whose semantics live in someone else's changelog.
The Stray Log Line Was the Tell
Months before the release broke, CI had already seen a build's own log line -- [copy-theme-packs] dist/theme-packs/ OK -- printed ahead of the pack's JSON, breaking JSON.parse. That string is emitted only from the build's onSuccess chain, so it was direct proof that the lifecycle was already running inside a supposedly read-only command. The fix applied at the time was a tolerant JSON scanner that skipped past the stray line. The symptom was hidden, the rebuild kept running, and it eventually took down a release.
Tip
A stray log line before your JSON is not a formatting quirk -- it is your build talking. When a read-only command emits output it had no business producing, find out who produced it before you make the parser more forgiving.
The Rule: Own an Immutable Snapshot
A test that reads a built artifact must own an immutable snapshot of it, and any build-freshness key must hash the artifact's own bytes, not just its sources.
Two concrete shapes.
Shape 1 -- snapshot the artifact. Pack it (npm pack, then extract the tarball) or copy {bin,dist} into a scratch directory, and point the test at the copy. Whatever the lifecycle does to the live dist/ afterwards, the test is reading bytes no one else can reach.
// tests/setup/snapshot-package.ts
import { cpSync, mkdtempSync } from "node:fs";
import { join } from "node:path";
const PKG_ROOT = join(__dirname, "../..");
export function snapshotPackage(): string {
// Inside the package root on purpose -- os.tmpdir() breaks bare-specifier
// resolution. Prefix is gitignored and removed in teardown.
const dir = mkdtempSync(join(PKG_ROOT, ".artifact-snapshot-"));
for (const entry of ["bin", "dist", "package.json"]) {
cpSync(join(PKG_ROOT, entry), join(dir, entry), { recursive: true });
}
return dir;
}Warning
Resolution caveat. If anything in the snapshot imports by bare specifier, the snapshot must live inside the package root, not in os.tmpdir(). Node resolves node_modules by walking up the directory tree from the importing file -- starting in a system temp directory, that walk never reaches your project, and the import fails with ERR_MODULE_NOT_FOUND for a reason that has nothing to do with the bug you are chasing.
Shape 2 -- hash the output, not only the input. If you cache builds behind a freshness marker, include the built output in the key. A key derived from sources alone still matches when a concurrent writer truncated, half-wrote, or deleted the very artifact the marker vouches for -- the sources did not change, so the marker happily accepts a dist/ that no longer exists in the state it describes. See Mechanism 2: skip the rebuild when nothing changed for the freshness-hash recipe this extends.
What a Guard Against This Can Honestly Claim
A mechanical guard helps: scan the default lane's specs for launched build or package-lifecycle commands -- npm|pnpm|yarn followed by pack|publish|install|ci|rebuild|run build, a bare tsup, and so on -- and fail any spec that launches one without an explicit annotation.
Document its limit in the same breath. A string scanner catches launched commands only. It does not catch a shell wrapper, a script alias, or a direct rmSync/writeFileSync under dist/. The mechanically enforced rule is therefore narrower than the invariant it protects, and the docs have to say which is which: the guard enforces "no spec launches a build command"; the invariant is "no spec writes the artifact".
Danger
An overclaiming guard is worse than an honest convention. A check that reads as stronger than it is reproduces exactly the failure this section describes -- confidence in a property that nothing actually enforces.
Example: SSG Output Verification
// tests/build-output.test.ts
import { describe, it, expect } from "vitest";
import { readFileSync } from "fs";
import { join } from "path";
const DIST = join(__dirname, "../dist");
describe("build output", () => {
it("generates index.html with correct title", () => {
const html = readFileSync(join(DIST, "index.html"), "utf-8");
expect(html).toContain("<title>My Site</title>");
});
it("generates sitemap.xml", () => {
const sitemap = readFileSync(join(DIST, "sitemap.xml"), "utf-8");
expect(sitemap).toContain("<urlset");
});
it("includes all expected pages", () => {
const pages = ["index.html", "about/index.html", "docs/index.html"];
for (const page of pages) {
const content = readFileSync(join(DIST, page), "utf-8");
// Case-insensitive: toolchains emit the doctype as lowercase `<!doctype html>`.
expect(content.toLowerCase()).toContain("<!doctype html>");
}
});
});Example: MDX Formatter Contract Testing
A real-world pattern from mdx-formatter: the Vitest suite tests the Rust formatting engine by running it on fixture files and comparing output. This is Level 3, not Level 1, because format() is a thin JS wrapper that drives a compiled native engine produced by the build -- the object under test is that build artifact, not hand-written TypeScript logic. (If your formatter were pure JS/TS with no compiled backend, this same idempotency check would belong at Level 1 instead.)
// tests/format.test.ts
import { describe, it, expect } from "vitest";
import { readFileSync } from "fs";
import { join } from "path";
import { format } from "../src/format";
describe("mdx formatting", () => {
it("is idempotent", () => {
const input = readFileSync(join(__dirname, "fixtures/sample.mdx"), "utf-8");
const first = format(input);
const second = format(first);
expect(first).toBe(second);
});
});Tip
Idempotency testing is a powerful invariant for any formatter or transformer: applying the operation twice should produce the same result as applying it once.
Executing Emitted Code
The examples above compare emitted output as static text -- read the file, assert on its contents. But some build tools generate JavaScript that runs: adapters that wrap a user handler, bundler wrappers, scaffolders. For these, a string-equality check proves the template was written -- it does not prove the generated code behaves correctly when executed. To verify that, you have to write the artifact, import it, and run it.
Info
This is a different question from the static-text comparison Level 3 usually covers. There, the assertion is "did the build produce the right file?". Here, it is "does the generated wrapper actually thread its inputs through to the inner handler at runtime?" -- a codegen-threading check you can only answer by executing the emitted module.
The reusable recipe is temp dir + dynamic import + env stub:
Write a synthetic inner bundle into an
mkdtempscratch directory.Run the emit step (
emitWorker()) to produce a real_worker.js.import(pathToFileURL(...))the emitted file, then calldefault.fetch(new Request(...), envStub).Assert that
(env, ctx)are threaded through correctly -- the wrapper your build emits must passenvto the user handler.Clean the scratch directories in
afterEach.
// tests/emit-worker.test.ts
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
import { emitWorker } from "../src/build.js";
let scratchDirs: string[] = [];
afterEach(async () => {
for (const d of scratchDirs) {
await rm(d, { recursive: true, force: true });
}
scratchDirs = [];
});
async function scratch(): Promise<string> {
const d = await mkdtemp(join(tmpdir(), "emit-worker-"));
scratchDirs.push(d);
return d;
}
describe("emitWorker threads env/ctx into the inner bundle", () => {
it("the generated wrapper passes env through to the user handler", async () => {
const dir = await scratch();
// A synthetic inner bundle that reads env from the request scope
// the wrapper sets up (here via AsyncLocalStorage), then echoes it.
const inputPath = join(dir, "inner.mjs");
await writeFile(
inputPath,
`import { AsyncLocalStorage } from "node:async_hooks";
const STORAGE_KEY = "__adapter_als__";
function getStore() {
const g = globalThis;
let als = g[STORAGE_KEY];
if (!als) { als = new AsyncLocalStorage(); g[STORAGE_KEY] = als; }
return als.getStore();
}
export default {
async fetch(request) {
const store = getStore();
const env = store?.env ?? {};
return new Response(
JSON.stringify({
token: env.API_KEY ?? null,
ctxKind: typeof store?.ctx?.waitUntil,
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
},
};
`,
"utf8",
);
// Run the real emit step to produce a real _worker.js.
const out = await emitWorker({
inputBundlePath: inputPath,
outdir: join(dir, "dist"),
});
// Import the emitted artifact as ESM and drive it with a
// synthetic Request + env + ctx -- no wrangler / miniflare needed.
const worker = (await import(pathToFileURL(out.workerPath).href)) as {
default: {
fetch: (
request: Request,
env: Record<string, unknown>,
ctx: { waitUntil: (p: Promise<unknown>) => void },
) => Promise<Response>;
};
};
const envStub = { API_KEY: "sk-test-1234" };
const ctx = { waitUntil: () => {} };
const request = new Request("https://worker.test/api/foo", { method: "POST" });
const response = await worker.default.fetch(request, envStub, ctx);
const body = (await response.json()) as { token: string | null; ctxKind: string };
// The headline check: env actually reached the inner handler.
expect(body.token).toBe("sk-test-1234");
// ctx was threaded too -- its surface matches the runtime shape.
expect(body.ctxKind).toBe("function");
});
});Tip
Keep the inner bundle synthetic and self-contained -- inline the read-side of whatever mechanism the wrapper uses (here, the globalThis-keyed AsyncLocalStorage). You are testing your emitted wrapper's threading, not the real user package, so you do not need to bundle it. The same pattern stubs any binding the wrapper treats opaquely (a database handle, a secret, a queue), because the wrapper just stores env verbatim and never inspects its members.
Warning
This still is not a full runtime test. Importing the emitted module exercises it in Node, not in the real target runtime (Cloudflare Workers, Deno, an edge sandbox). Runtime-specific globals, the actual asset server, and platform request semantics are out of scope -- for those, you need Level 4 (or a real wrangler dev / miniflare run). What this does prove is the contract your codegen is responsible for: inputs in, inputs threaded through.
Blind Spots
Warning
Level 3 tests verify file contents, not runtime behavior. They cannot detect:
JavaScript runtime errors
Client-side hydration issues (correct dist HTML can still mis-nest once an island hydrates -- see the hydration mis-nesting variant, which only a Level 4 post-hydration test catches)
Visual rendering problems
Browser API interactions
Dynamic content loaded after page load
When to Use Level 3
| Scenario | Level 3 Appropriate? |
|---|---|
| SSG page missing from build | Yes |
| Wrong HTML structure in output | Yes |
| Bundle too large / wrong chunks | Yes |
| Hydration mismatch in browser | No -- use Level 4 |
| Page renders blank in browser | No -- use Level 4/5 |
| CSS not applied correctly | No -- use Level 5 |