Home / Articles

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.

Publisher: REACTAPP.TOP7 min read1673 words

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:

  1. Global scope
  2. Function scope
  3. Block scope
  4. 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

var early access → undefined. let/const early 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 const for pages, selectors, and config objects.
  • Use let only 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.