Engineering
Published
Most advice about testing a protected build stops at “make sure it still parses.” That is the least useful check available, because the failures worth fearing all produce output that parses perfectly and then behaves differently. This is a practical ladder for catching them — written from the experience of building the gates that catch them in our own engine, including the ones that caught our own defects.
Why “it parses” proves almost nothing
Every transform an obfuscator applies is a rewrite, and a rewrite can be syntactically valid while being semantically wrong. Three real examples, all of which pass node --check without complaint:
// 1. An arrow loses its lexical `this`
[1].map(() => this.v) // becomes: [1].map(function(){ return this.v })
// parses fine, reads undefined
// 2. A unary minus fuses with the one in front of it
Math.abs(- -x) // becomes: Math.abs(--x)
// parses fine, returns x - 1
// 3. A tagged template stops calling its tag
tag`solo` // becomes: "solo"
// parses fine, the tag never runs
Each of these is a wrong answer, not a crash. Your build succeeds, your bundle ships, and the failure shows up as a subtly wrong number or a component that renders nothing. Note the second one especially: it only fails loudly when the operand is itself a unary (- -(-x) becomes ---x, which will not parse). With a plain variable it is completely silent. A test suite that only checks the build succeeded would pass all three.
The verification ladder
Four rungs, weakest to strongest. Each catches a class the one below cannot see.
1. Parse the output
node --check bundle.js, or feed it to any real parser. This catches output that is outright malformed. It is worth doing because it is free, and it is worth being clear that it is the floor, not the bar.
One practical note: on a minified bundle, node --check reports the error without a position, which is useless on a single 600 KB line. Parsing with acorn instead gives you an exact character offset, which turns a needle-in-a-haystack into a two-minute fix.
2. Scan for leaked internals
Obfuscators use placeholder tokens during transformation. If one survives into the output it becomes an undeclared identifier — valid syntax, guaranteed ReferenceError at runtime. Grep the output for your tool’s internal prefixes. This is a cheap, high-signal check that a parse pass will never flag.
3. Run your existing test suite against the protected build
This is the rung most teams skip, and it is the one that pays. You already have tests. Point them at the protected output rather than the source. If your suite exercises a class hierarchy, a this-capturing callback, or a destructured API response, it will catch the silent classes above without you writing anything new.
The practical obstacle is usually that the test suite imports source modules directly. Adding a build variant that resolves imports to the protected directory is normally a small amount of config, and it converts your entire existing suite into obfuscation verification for free.
4. Differential testing on real code
The strongest rung: run the same input through the pipeline, execute both the original and the protected output, and compare the results — not against expectations you wrote down, but against each other.
This distinction matters more than it sounds. Hand-written expectations encode what you think the code does. We have watched a probe pass by coincidence because the test function returned its own input unchanged, which happened to match the broken output exactly. Diffing against the original has no such blind spot: if protection changed behaviour, the two disagree, whatever you believed.
What to test, if you only have an hour
Not all syntax is equally likely to break. Rewrites go wrong where a construct needs context that a local transformation does not have. In rough order of risk:
- Arrow functions that use
this — the arrow must not become a plain function, or the receiver changes.
- Class inheritance with
super — super.method() in an instance method, in a static method, and a bare super.prop read are three separate paths.
- Getters and setters — an accessor emitted as a plain method returns the function instead of the value, silently.
- Destructuring in every position — parameters, declarations,
for…of heads, and catch clauses are usually handled by different code paths. Include defaults, renames, rest elements, and array holes.
for…of over non-arrays — Set, Map, generators and iterator methods take a different route than arrays.
- Tagged templates — styled-components, GraphQL clients and
String.raw all depend on the tag actually being invoked, and on the strings array being complete.
- Numeric literal forms — separators (
1_000_000), binary and octal (0b1010, 0o17), and BigInt are lexer-level and fail as wrong values rather than errors.
Test on real libraries, not just snippets
Hand-written test cases share an author’s habits, and those habits hide bugs. The clearest example we have seen: a transform that spliced replacement text in place worked on every hand-written sample and broke on real input, because hand-written code puts a space after const and minified code does not. The output turned a whole declaration list into undeclared globals, and the error surfaced far away at an unrelated export statement.
Running a set of real, minified libraries through your pipeline finds this class immediately. It is also the cheapest suite you will ever assemble — the libraries already exist, and you only need them to parse afterwards.
An honest word about what this buys you
Verification tells you the protected build behaves like the original. It does not tell you the protection is strong, and no amount of testing makes obfuscated code impossible to analyse — obfuscation raises the cost of reverse engineering, it does not eliminate it. Those are separate questions, and it is worth keeping them separate when evaluating any tool.
What verification does buy is the ability to ship protection without holding your breath. That is worth more than it sounds, because the alternative — discovering a silent behavioural change from a customer report — is expensive in a way that a failed build never is.
Related reading