Build & Delivery

Testing obfuscated JavaScript: where protection belongs in your test pipeline

Two questions arrive together the first time protection lands in a pipeline. Do the tests still pass? And where in the pipeline does this thing go? The answers are connected, and the common failure is not a broken test — it is a green pipeline that never once executed the bytes being shipped.

One rule, two directions

Unit tests belong before protection. End-to-end tests belong after it. Both halves of that sentence do work.

Unit tests import internal modules and call internal functions by their real names. Those names are precisely what protection exists to destroy. Point a unit suite at protected output and you get a wall of import failures that say nothing about your code — you have written a test for the obfuscator, and a bad one. There is no version of this that becomes useful with more effort.

End-to-end tests are the opposite. They drive the application the way a browser and a user do, through the public surface, and that is the only surface protection promises to preserve. They are also the single test layer capable of catching the failure mode that matters: something in the protected artifact behaving differently from the source it came from. A team running a thorough unit suite and no protected end-to-end pass has excellent coverage of code it does not ship.

The pipeline order

Written out, with the reason each step sits where it does:

lint + typecheck        # source
unit + integration      # source, with coverage
bundle                  # dist/
compat-scan             # cheap lint for protection-hostile patterns
protect                 # dist/ -> dist-protected/
verify-manifest         # nothing leaked, nothing missing
e2e                     # against dist-protected/  <-- the one people skip
sign + ship

Note what is not in that list: a second unit run, a coverage pass over protected code, and any attempt to protect a test file. Test code is never shipped, so protecting it costs build time and buys nothing.

What actually breaks in a test suite

The failures cluster into three groups, and only the first is common.

Reflection on names. Assertions like expect(handler.name).toBe("submitOrder"), or a runtime dispatcher that switches on obj.constructor.name, read identifiers as data. Protection renames identifiers, so the value changes and the assertion fails — correctly, because the production code would have failed too. The CLI's --compat-scan exists for this: it flags name reflection, constructor-name reflection and Function.prototype.toString.call before you spend a build finding out.

Test hooks that are secretly a public API. The window.__APP_STORE__ your end-to-end suite reaches for, the data-cy helper your page object calls, the debug global someone added during an incident. From the tool's perspective these are internals, so they get renamed. From the suite's perspective they are the contract. Either reserve those names deliberately — and accept that a reserved name is readable in the shipped bundle — or rewrite the tests to go through the interface a user has. The second option is better, and it is the one that makes the suite worth keeping.

Timing. Protected code carries real overhead, most visibly where control-flow work or string-table lookups land on a hot path. A test that waits a fixed 200ms for an animation and passes with 40ms of slack unprotected will go flaky. The fix is the fix it always was — wait for a condition, not a duration — but protection is what exposes it.

What does not break is worth stating, because people brace for it: string values survive. Moving literals into a table or encoding them changes how a string is stored, not what it evaluates to, so an assertion on an error message, a label or a URL passes exactly as before. Selectors based on data-testid, visible text or ARIA roles are equally safe, since they live in markup rather than in identifiers.

Polymorphism versus your snapshots

Protected output is deliberately different on every run. Nothing in a source-level test notices, but three things do.

  • Artifact snapshots. Any test that hashes the bundle, diffs it against a committed fixture, or snapshots generated output will fail forever.
  • Visual and bundle-size baselines. Size assertions wobble build to build, so a tight threshold reads as a regression when nothing changed.
  • Task-runner caches. Downstream steps see changed inputs every time and re-run, which is the same mechanism described in obfuscating JavaScript in a monorepo.

Supply a --seed and all three settle: the same input, options and seed produce byte-identical output, so snapshots and diffs mean something again while consecutive releases still differ from each other. Reproducible builds covers what you trade away. For a test pipeline the trade is nearly free, because you want a deterministic artifact per commit anyway.

Keeping CI affordable

Protecting on every push is a poor default: it costs a network round trip and real build minutes, on branches that mostly change things protection cannot see. Split it.

On every pull request, run the cheap local gates. --release-check bundles config validation, dry-run planning and the doctor checks into one preflight with no API call, so a broken config, a glob matching nothing, or a missing credential fails in seconds rather than at release time. Add --compat-scan over the source and you have caught most of what would have broken the protected run.

On release branches and nightly, do the real thing: protect, run the full end-to-end suite against the protected artifact, and keep the manifest and report as build outputs. This is the gate that decides whether a release is shippable.

Before a release goes out, run --verify-manifest with --audit-source-maps. It confirms every file the manifest names is present with the expected hash, and fails if a .map or a sourceMappingURL comment survived into the artifact — which is the difference between shipping protected code and shipping the source alongside it.

When the protected suite goes red

Resist the urge to start disabling transforms at random. Bisect by strength first: re-run against the standard preset, then balanced, then maximum. The preset where it first breaks narrows the cause to a handful of transforms, and often the difference is a single option you can name in the report. From there, the verification ladder walks the rest, and obfuscated JavaScript not working lists the usual culprits — reflection, reserved names, and code that was never valid under strict mode to begin with.

One diagnostic worth knowing: build once with --formatted-output. Protected code that is merely unreadable becomes protected code you can set a breakpoint in, which turns "something is wrong somewhere" into a line number. It is a review build, never a shipping one.

The short version

Unit tests on source, end-to-end tests on the protected artifact, and no exceptions in either direction. Reserve the names your tests reach for, or stop reaching. Seed the build so your snapshots and caches keep working. Keep protection off pull-request builds and on release builds, with --release-check and --compat-scan covering the gap cheaply. Then the artifact you sign is the artifact your tests actually ran, which is the entire point of the exercise.

Frequently asked questions

Should I run unit tests before or after obfuscation?

Before. Unit tests import internal modules and call internal functions by name, and those are exactly the names protection is meant to change. Running them against protected output tests the obfuscator rather than your code, and it fails for reasons that tell you nothing. Unit tests belong on source, end-to-end tests belong on the protected artifact.

Does obfuscation break end-to-end tests?

It should not, and when it does the test was usually reaching into internals. Selectors written against data-testid attributes, visible text or ARIA roles are unaffected, because those live in markup and string values that protection preserves. Tests that read window.__STORE__, assert on a function's name, or depend on a class name your code generates at runtime will break, and each of those is a name you must reserve.

Why do my snapshot tests fail on every CI run after adding obfuscation?

Protected output is polymorphic: the same input produces different bytes on every run by design. Any snapshot taken over the artifact therefore never matches. Supply a seed and the same input, options and seed produce byte-identical output, which makes artifact-level snapshots and diffs meaningful again.

Can I collect code coverage from protected code?

Not usefully. Coverage instrumentation maps executed statements back to source positions, and protection rewrites the structure those positions describe. Collect coverage from the unprotected build in the same run where you execute unit tests, and treat the protected end-to-end pass as a pass/fail gate rather than a coverage source.

Should every pull request build a protected artifact?

Usually not. Protection is a network round trip and real build time, and most pull requests do not change anything protection-relevant. Run a cheap config gate on every pull request with release-check and compat-scan, and run the full protected end-to-end suite on release branches and nightly. What matters is that it runs before a release, not that it runs on every commit.

Related reading