The Testing Academy · Class Notes Thursday, 6 August (IST)
Live class · study guide

Closures: the variable that outlives its function

A recap of hoisting and the temporal dead zone, then the rules that decide what a function can see, then closures: three working examples (a counter, a retry limiter, a rate limiter) and an honest note on how rarely you will write one in an automation framework.

By Pramod Dutta, The Testing Academy. Study notes from the live Playwright 3x class, rebuilt from the session recording, with code cross-checked against the batch notes deck. Every snippet on this page was executed on Node before publishing, and the stated outputs are the outputs that were printed.

01

What this class was, and what it was not

Two thirds of the session was a recap. Hoisting and the temporal dead zone had been covered the previous class, and the class walked them again before adding scope and closures on top, because closures do not make sense until you already believe that a variable can exist without being usable.

Some framing before the code, because it changes how you read the rest: closures are rarely used in Playwright automation. They are an interview topic. That was said plainly in class and it is repeated here rather than quietly dropped, because studying this for the wrong reason wastes the weekend. Learn it to answer the question, not because your framework will be full of them.

02

Hoisting, in one table

Hoisting is JavaScript moving declarations to the top of their scope before any line runs. Every declaration is hoisted. What differs is whether it arrives initialised.

Feature var let const function declaration class declaration
Hoisted? Yes Yes Yes Yes, fully Yes
Initialised during hoist? Yes, undefined No, TDZ No, TDZ Yes, full body No, TDZ
Access before declaration undefined ReferenceError ReferenceError Works normally ReferenceError
Scope Function Block Block Function Block
Re-declarable Yes No No Yes No
Re-assignable Yes Yes No Yes Yes
In a TDZ? No Yes Yes No Yes

The temporal dead zone is the stretch between the top of the block and the line where a let or const is actually declared. The binding exists in that stretch. Touching it throws.

1. Memory creation 2. Code execution, line by line var a initialised to undefined console.log(a) → undefined var a = 10 let b hoisted, NOT initialised temporal dead zone console.log(b) throws ReferenceError let b = 10 TDZ ends here function f() hoisted with its whole body f() → works anywhere, even above the declaration
Everything is hoisted. Only var and function declarations arrive usable.

The behaviour, in code that was run rather than asserted:

JavaScript
console.log(a);   // undefined      <- var is hoisted AND initialised
var a = 10;

console.log(b);   // ReferenceError: Cannot access 'b' before initialization
let b = 10;

Read the two error messages carefully, because they are different and the difference is the exam question. An undeclared variable gives b is not defined. A let in its dead zone gives Cannot access 'b' before initialization. The second message is proof the binding exists: JavaScript can only complain about accessing it before initialisation if it already knows it is there.

03

Functions: three forms, three behaviours

JavaScript
// 1. Declaration: fully hoisted, callable from above
greet();                          // works
function greet() { return 'hi'; }

// 2. Expression assigned to var: the variable hoists as undefined
speak();                          // TypeError: speak is not a function
var speak = function () { return 'hi'; };

// 3. Arrow assigned to const: hoisted into the TDZ
shout();                          // ReferenceError: Cannot access 'shout' before initialization
const shout = () => 'hi';

The class kept calling JavaScript a funny language over this, and the joke has a point: three ways of writing the same function fail in three different ways.

The distinction between cases 2 and 3 is worth holding on to. A TypeError means the name resolved and the value was not callable, undefined in this case. A ReferenceError means the name could not be reached at all. So the error type alone tells you whether var or const was used.

typeof is the one surprise in the set. typeof someUndeclared returns the string 'undefined' and never throws, which is why it was long used as an existence check. typeof on a let inside its TDZ does throw. The safe-looking guard is not safe for block-scoped declarations.

Functions inside blocks

JavaScript
// avoid this: behaviour has historically varied between engines
if (true) {
  function helper() { return 'inside if'; }
}

// do this instead
let helper;
if (true) {
  helper = function () { return 'inside if'; };
}

The rule given was blunt: never declare a function inside an if block. Assign a function expression to a variable declared outside.

The rules, and the linter that will enforce them

These were given as the framework's coding standard, to be enforced by ESLint later in the course rather than by memory:

  1. const by default. Switch to let only when you need to reassign. Never var.
  2. Declare at the top of the scope. TDZ protects you; readability still matters.
  3. Function declarations for hoisted utilities, arrow functions with const for everything else.
  4. Never declare a function inside a block.
  5. let in for loops, const in for...of and for...in.
JSON
{
  "rules": {
    "no-var": "error",
    "prefer-const": "error",
    "no-use-before-define": "error",
    "block-scoped-var": "error"
  }
}
04

Scope: what a function can see

Before closures, the plumbing. A variable declared outside every function is global. A variable declared inside a function is local to it.

JavaScript
const environment = 'staging';        // global

function runTest() {
  const timeout = 30000;              // local to runTest
  console.log(environment);           // 'staging'  <- inner can read outer
}

runTest();
console.log(timeout);                 // ReferenceError: timeout is not defined

Nesting extends the rule rather than changing it:

JavaScript
function outer() {
  const x = 10;
  function inner() {
    const y = 20;
    console.log(x);      // 10   <- inner sees outer
  }
  inner();
  // console.log(y);     // ReferenceError: outer cannot see inner
}

The analogy that landed in the room came from a learner, not the slide: the principal can walk into any classroom, a student cannot walk into any class. Inner functions see outward. Outer functions cannot see in.

05

Closures

A closure is when an inner function remembers and can access variables from its outer function, even after the outer function has finished executing.

That last clause is the whole idea. Everything before it is just scope, which you already had.

startBrowser() has returned its call frame is gone let name = "edge" still alive still private closed over installBrowser the returned inner function it is the only door in runTc() prints "edge" The caller can run the behaviour. The caller can never touch name directly.
The outer call is over. The variable it created is not, because something still points at it.

The example from the notes, framed around browsers:

JavaScript
function startBrowser() {
  let name = 'edge';
  function installBrowser() {
    console.log(name);
  }
  return installBrowser;
}

const runTc = startBrowser();
runTc();          // edge

startBrowser finished on the line that assigned runTc. Its local name should be gone. It is not, because installBrowser still refers to it, so the engine keeps it alive.

The motivation offered was hiding: a caller can run the installer without being able to see or change which browser it installs. Collapse the outer function in the editor and the inner one disappears from view entirely. That is encapsulation without a class.

A counter, and the shape you will be asked to write

The interview version returns several functions instead of one, all sharing the same private variable:

JavaScript
function makeCounter(start = 0) {
  let count = start;

  return {
    increment: () => ++count,
    decrement: () => --count,
    get: () => count,
  };
}

const counter = makeCounter(0);
counter.increment();
counter.increment();
counter.increment();
console.log(counter.get());   // 3
counter.decrement();
console.log(counter.get());   // 2

start = 0 is a default parameter, so makeCounter() and makeCounter(0) behave identically.

The part worth slowing down on is that all three arrow functions close over the same count. There is one variable, not three copies. increment moves it, get reads the moved value. Call makeCounter a second time and you get a second, completely independent count.

Retry attempts

The first closure in the session that looks like something you might actually write:

JavaScript
function maxRetry(max) {
  let attempts = 0;

  function tryAgain(testName) {
    attempts++;
    if (attempts > max) {
      return `${testName}: max retries exceeded`;
    }
    return `${testName}: attempt ${attempts}`;
  }

  return tryAgain;
}

const runRetry = maxRetry(3);
console.log(runRetry('login'));   // login: attempt 1
console.log(runRetry('login'));   // login: attempt 2
console.log(runRetry('login'));   // login: attempt 3
console.log(runRetry('login'));   // login: max retries exceeded

attempts survives between calls without being global and without being a property anyone can reach. A counter that nothing else can accidentally reset is the point.

A question from the room asked whether runRetry.tryAgain() would also work. It does not, and the reason is a useful check on whether the shape has landed: maxRetry returns the function itself, so runRetry is tryAgain under a different name. There is no object to reach through.

A rate limiter

The smallest of the three:

JavaScript
function makeLimiter(limit) {
  let calls = 0;
  return function check() {
    return ++calls <= limit;
  };
}

const limiter = makeLimiter(3);
console.log(limiter());   // true
console.log(limiter());   // true
console.log(limiter());   // true
console.log(limiter());   // false

++calls <= limit increments first, then compares. Calls one, two and three give 1 <= 3, 2 <= 3, 3 <= 3, all true. The fourth gives 4 <= 3, false. The <= rather than < is what makes the third call the last allowed one.

Where closures actually turn up in a Playwright project: a fixture that captures configuration, a helper factory that bakes in a base URL, a retry or polling wrapper. That is a short list, and every item is something you write once. This is genuinely an interview topic more than a daily one, which is exactly why it deserves a weekend rather than a career.

06

Strings, the first ten minutes

The class ended with the opening of strings, enough to make the next session continuous. All three quoting styles produce a string, and template literals add interpolation and real multi-line text:

JavaScript
let url     = "https://app.vwo.com";
let status  = 'pass';
let message = `Test completed in ${320}ms`;

Single and double quotes are interchangeable in JavaScript. Pick based on what is inside the string so you can avoid escaping. There is no separate character type: a single character is a string of length one.

Index access, at(), charAt(), charCodeAt(), includes, startsWith, indexOf and lastIndexOf were introduced and then handed to the next class, which covered them properly. Two facts to carry forward: .length is a count, so it starts at one, while an index is an offset, so it starts at zero, and a search that finds nothing returns -1, never undefined.

The full treatment, with every method and its verified output, is in the next class notes.

07

Tasks and announcements

  • The weekend target is 120. That is the 100 JavaScript lab exercises plus at least 20 of the coding questions. Two hours across Saturday and Sunday was the estimate given.
  • Push the exercises to GitHub. Exercises that only exist on your laptop do not count.
  • The coding question bank grows from 89 to 150, curated from questions companies actually asked, not invented for practice.
  • Re-type the three closure examples yourself. Reading them is not the same as writing them, and this is the topic where that gap is widest.
  • Ask in the doubt thread. A thread is open for this session, and it was pointed out more than once that it is underused.
  • Coming next: the rest of strings, then callbacks and promises.
  • Coming this week: Playwright architecture.
  • Later: ESLint rules in the framework to ban var and enforce const with arrow functions.

Interview forms of today's material: what is the difference between b is not defined and Cannot access 'b' before initialization? Why does a function expression assigned to var fail with a TypeError rather than a ReferenceError? Write makeCounter from memory and explain why two calls to it do not share a count. Why is typeof not a safe existence check for a let?