JavaScript scope and hoisting for QA automation engineers
Part 8 for QA learners: global, function, and block scope; var vs let vs const; hoisting; the temporal dead zone; and how these rules keep test data from leaking.
This article is published in English.
Sources: JavaScript For QA Engineers: My Learning Journey — Part 8
Sometimes JavaScript does not fail because the logic is wrong. It fails because a variable is not available where you expect it to be.
The story so far
A typical JavaScript path for QA automation starts with the language choice, then variables, types, arrays, objects, operators, loops, and functions. Scope and hoisting are the layer that explain why a value works in one place and throws in another, and why the engine appears to “see” declarations before the line that writes them.
What is scope?
Scope answers a simple question: where is this variable or function visible?
let browser = "Chrome";
console.log(browser);
//Output: Chrome
Here browser is available in the scope where it was declared.
Declare it inside a function instead:
function runTest() {
let browser = "Chrome";
console.log(browser);
}
runTest();
Now browser exists only inside that function. Reading it outside:
console.log(browser);
throws, because the name belongs to the function’s scope. That difference is why the main scope kinds matter in test code.
Types of scope in JavaScript
The forms that show up most often in automation are:
- Global scope
- Function scope
- Block scope
- Lexical scope
1. Global scope
A variable declared outside every function and block sits in global scope.
const environment = "staging";
function runTest() {
console.log(`Running on: ${environment}`); // Works ✅
}
runTest();
console.log(environment); // Works ✅
Globals suit shared suite values such as base URLs, environment names, and config. Use them carefully: when many helpers mutate the same global, failures become hard to isolate.
2. Function scope
Names declared inside a function are generally visible only there.
function login() {
const password = "Admin@123";
console.log(`Logging in with: ${password}`); // Works ✅
}
login();
console.log(password); // ❌ Error — password is not defined
That containment keeps credentials and per-test data from leaking or being overwritten by unrelated helpers.
Easy way to remember
Declared inside a function → available inside that function.
Reusable login or navigation helpers rely on this boundary.
3. Block scope
A block is anything between { }. With let or const, a name declared in a block lives only inside that block.
if (true) {
let browser = "Chrome";
console.log(browser);
}
//Output:Chrome
Access outside the block:
console.log(browser);
errors, because let and const are block-scoped. That behaviour matters in conditionals and loops inside tests.
Example with a loop
for (let i = 1; i <= 3; i++) {
console.log(i);
}
//Output:
1
2
3
Each iteration’s let binding stays in that iteration’s block, which avoids the classic shared-var surprise when attaching asynchronous handlers in loops.
var vs let vs const and scope
var is function-scoped (or global when declared outside a function). let and const are block-scoped. Prefer const by default, let when reassignment is required, and avoid new var in modern test code. In large Playwright or Cypress projects that rule alone removes an entire class of “works on my machine” leaks where one spec overwrites another’s state through a shared function-scoped var.
if (true) {
var browser = "Chrome";
let version = "Latest";
const environment = "QA";
}
console.log(browser);
//Output:Chrome
console.log(version);
console.log(environment);
const baseURL = "https://myapp.com"; // Global — shared everywhere
What is hoisting?
Hoisting is the engine’s habit of processing declarations before executing the body top to bottom. Declarations are registered early; assignments still happen when their lines run.
Hoisting with var
function loginTest() {
const username = "admin"; // Function scope — only here
const password = "Admin@123"; // Function scope — only here if (username === "admin") {
let accessLevel = "full"; // Block scope — only in this if block
console.log(`Access: ${accessLevel}`);
} console.log(`${baseURL}/login`); // Global still accessible ✅
}
The var name exists from the start of the function/global scope (initialized as undefined) until the assignment line runs.
Hoisting with let
let browser = "Chrome";
function runTest() {
console.log(browser);
}
runTest();
//Output:Chrome
let is also registered early, but you cannot read it before its declaration line. That gap is the temporal dead zone.
What is the temporal dead zone?
The TDZ is the time from scope entry until the let/const declaration executes. Access in the TDZ throws instead of returning undefined.
let environment = "QA";
function runTest() {
let browser = "Chrome";
if (true) {
let testName = "Login Test";
console.log(environment);
console.log(browser);
console.log(testName);
}
}
runTest();
Easy way to remember
varearly access →undefined.let/constearly access → error (TDZ).
Hoisting with function declarations
Function declarations are fully hoisted: you can call them before their appearance in the file.
console.log(browser);
var browser = "Chrome";
// Output:undefined
Function expression and hoisting
Function expressions assigned to const/let follow variable rules: the binding is in the TDZ until the assignment.
var browser;
console.log(browser);
browser = "Chrome";
Function declaration
undefined
Useful for helpers you want callable anywhere in the scope after the scope begins.
Function expression
console.log(browser);
let browser = "Chrome";
Safer when you want to control when the function value appears, often with const.
QA automation example
Scope mistakes show up clearly in Page Object and fixture code.
ReferenceError
{
console.log(testName);
let testName = "Login Test";
}
Keeping selectors and credentials inside the functions or blocks that own them prevents cross-test contamination.
Why scope matters in automation
Shared mutable globals make parallel or shuffled tests flaky. Function and block scope keep each test’s data local. Lexical scope lets nested helpers close over config without re-passing every argument.
let testName = "Login Test";
Lexical scope in practice
Inner functions see bindings from outer scopes where they were defined, not where they were called.
ReferenceError
runTest();
function runTest() {
console.log("Test started");
}
//Output:Test started
That is why a callback defined inside a test can still read the test’s page or user variables. Closures are how fixtures pass context into nested helper functions without globals.
Common mistakes
Reading a let before declaration:
runTest();
const runTest = function() {
console.log("Test started");
};
Assuming var is block-scoped inside an if:
ReferenceError
Forgetting that a loop with var shares one binding across iterations:
runTest();
function runTest() {
console.log("Test started");
}
Debugging scope issues
When a ReferenceError names a variable you “just defined,” check which { } or function owns it, whether you used var vs let, and whether the access sits above the declaration (TDZ).
runTest();
const runTest = function() {
console.log("Test started");
};
Practical rules for test suites
- Prefer
constfor pages, selectors, and config objects. - Use
letonly for values that must change. - Avoid new
var. - Keep secrets inside function scope.
- Do not rely on accidental globals created by missing declarations.
const browser = "Chrome";
function runTest() {
const testName = "Login Test";
console.log(`Running ${testName}`);
console.log(`Browser: ${browser}`);
}
runTest();
//Output:
Running Login Test
Browser: Chrome
let testStatus = "Passed";
function testOne() {
testStatus = "Failed";
}
function testTwo() {
console.log(testStatus);
}
Quick mental model
Scope = where a name is visible. Hoisting = when the declaration is registered. TDZ = the forbidden window before let/const initialize.
const testCases = ["Login", "Search", "Checkout"];
function executeTests() {
for (const testCase of testCases) {
console.log(`Executing: ${testCase}`);
}
}
executeTests();
//Output:
Executing: Login
Executing: Search
Executing: Checkout
Putting it together in a small test helper
let username;
let password;
let browser;
let testStatus;
function login() {
const username = "qa_user";
const password = "Password@123"; console.log(`Logging in as ${username}`);}
With those placeholders in place, the helper’s locals stay inside the helper, suite-level config stays intentional, and early access errors become understandable instead of mysterious. Scope and hoisting stop being trivia and become everyday tools for stable JavaScript automation.
Scope chains during async test steps
Automation rarely stays synchronous. When an async test awaits a click and then reads a variable, the binding still comes from the lexical scope where the function was defined. Hoisting does not move assignments across await points; it only affects how declarations are registered when the function body begins. Keeping fixture data in const bindings at the top of the test function makes that mental model easier to follow when stacks unwind after timeouts.
Why TDZ errors feel harsh—and why they help
Seeing a ReferenceError instead of undefined can surprise engineers coming from older var code. In a test suite that harshness is useful: it fails fast when a helper is reordered above its let setup. Treat TDZ failures as ordering bugs in the test file, not as flaky product behaviour.
Migrating older var suites
When updating legacy automation, replace var with const or let in small steps, run the suite, and watch for block-scope breaks inside if/for bodies that previously leaked. Pair the migration with lint rules that forbid var, so new tests do not reintroduce function-wide leakage.
Shadowing in nested helpers
An inner const browser = "Firefox" inside a helper hides an outer browser for the duration of that block. That is lexical shadowing, not hoisting. In page objects, shadowing can make a test read the wrong base URL if a nested helper redeclares a familiar name. Prefer distinct names (overrideBrowser, tempBrowser) when nesting, or pass values as parameters instead of closing over and redeclaring the same identifier.