"Prefer pure functions, they're easier to test" is advice you'll hear in nearly every discussion of testable code, usually stated as received wisdom without the mechanism behind it ever getting named. The mechanism has a name — referential transparency — and it's precise enough to use as an actual diagnostic tool, not just a vague preference.
The Definition, Precisely
A function is pure if it satisfies two conditions: its return value depends only on its input arguments, and it produces no side effects — no writing to a file, no network call, no mutating a variable outside its own scope, no reading from anything other than what was explicitly passed in.
// Pure: output depends only on input, nothing outside is touched
function calculateTax(subtotal: number, rate: number): number {
return subtotal * rate;
}
// Impure: reads a value from OUTSIDE its arguments
let currentTaxRate = 0.08;
function calculateTaxImpure(subtotal: number): number {
return subtotal * currentTaxRate; // depends on external state, not just the argument
}
// Impure: writes to something outside its own scope
function logAndCalculateTax(subtotal: number, rate: number): number {
console.log(`Calculating tax for ${subtotal}`); // a side effect — I/O
return subtotal * rate;
}
The second function is impure specifically because its output can change for the same argument depending on what currentTaxRate happens to be at call time — that dependency on hidden, external state is exactly what "depends only on its input" rules out. The third is impure because it does something to the outside world (writes to the console) as a byproduct of computing its result, even though the returned number itself is still correctly computed from the input.
Referential Transparency: The Actual Payoff
An expression is referentially transparent if you can replace it with its result, anywhere it appears in the program, without changing what the program does. This is the actual property purity buys you, stated concretely: calculateTax(100, 0.08) can be replaced with 8 — the literal number — wherever it appears, and nothing about the program's behavior changes, because that call will produce exactly 8 every single time, unconditionally, forever.
This isn't true of the impure version. calculateTaxImpure(100) cannot be safely replaced with a fixed number anywhere in the code, because its result depends on currentTaxRate's value at the moment it's called — which might be different the next time the same line executes. The function's output isn't really a property of its input alone; it's a property of the input and the entire program's state at that instant, which is a much bigger, much less predictable thing to have to account for.
Why This Directly Makes Testing Easier
A test for a pure function needs exactly one thing: known inputs, and the expected output for those inputs. Nothing else.
test('calculates 8% tax on a $100 subtotal', () => {
expect(calculateTax(100, 0.08)).toBe(8);
});
This test can never flake for a reason unrelated to the function's actual logic. It doesn't matter what order it runs in relative to other tests, what ran before it, what time it is, or what's in a database somewhere — the function's referential transparency guarantees the same input always produces the same output, full stop.
Testing the impure version honestly requires controlling for the hidden dependency too, or the test is incomplete:
test('calculates tax using the current rate', () => {
currentTaxRate = 0.08; // have to manually set up hidden state the function depends on
expect(calculateTaxImpure(100)).toBe(8);
// if another test changed currentTaxRate and didn't reset it, this test
// can now fail or pass depending on execution ORDER — a real race condition,
// in the sense covered in a separate post on this blog, between tests
});
The test now has to know about, set up, and often tear down state that has nothing to do with the specific behavior it's trying to verify — and if it forgets to reset currentTaxRate afterward, it silently poisons whichever test happens to run next. This is precisely the mechanism behind a whole category of flaky test suite: hidden dependence on external, mutable state, which purity directly eliminates by construction.
This Explains Why UI Automation Is Structurally Harder to Test Than Business Logic
page.click() is about as impure as a function gets — it doesn't return a value that depends only on its arguments at all, it reaches out and mutates the actual state of a real browser, a piece of the world entirely external to your test process. This isn't a flaw in Playwright, Cypress, or WebdriverIO; it's an unavoidable structural fact about what browser automation fundamentally is — code whose entire purpose is producing side effects in something outside itself. It's worth naming honestly, because it explains, precisely, why UI tests are inherently more prone to timing issues and environmental flakiness than a unit test of a pure calculation function ever will be: they're not doing the same kind of work. A pure function's test only has to reason about inputs and outputs. A UI test has to reason about the entire state of a real, external, asynchronously-updating system — which is a fundamentally larger and less predictable problem, no matter how well-written the test is.
The Practical Rule
You can't make browser interaction pure — clicking a button is, definitionally, a side effect. What you can do is push as much of your actual logic — calculating a discount, validating a form field, deciding whether a button should be enabled — into small, pure functions that get unit-tested directly, with the impure browser-interaction code reduced to the thin layer that calls them and reports the result. This is the real, mechanical reason "extract the logic out of the UI layer" is good advice, and it's not a testing-specific rule either — it's the same principle, from a different angle, as the Single Responsibility Principle covered in an earlier SOLID post: a function that mixes pure computation with impure I/O has two genuinely different reasons to change, and testing it cleanly requires it to have only one.
