|

Day 5: JavaScript Functions, Closures, and Strings for SDETs

JavaScript closure makeRetry count

<!– DIAGRAM_PROMPT Compact Excalidraw-style diagram, landscape 16:9, white background, thin black strokes, muted teal / amber / gray fills only. No 3D. No shadows. No stock photos. Small enough to sit above the fold as one blog figure.

Title (hand-lettered, 18px): “A closure keeps the outer variable alive”

LEFT CARD — title “outer() runs once”:

  • Rounded box “makeRetryTracker(3)”
  • Inner teal box “let attempts = 0”
  • Arrow down “returns tryAgain”

RIGHT CARD — title “inner() runs later, same memory”:

  • Four stacked calls labeled retry(“Login”)
  • 1/3 teal, 2/3 teal, 3/3 teal
  • 4th amber: “exceeded max retries (3)”

Footer of right card (tiny): “attempts is not global. The inner function closed over it.”

BOTTOM RULE (one line): “Function + remembered scope = closure. Playwright helpers and custom expect messages live here.” –>

This is Day 5 of the 21-day JS to Playwright Framework series. One lesson a day. JavaScript first. TypeScript next. Playwright after that. A production framework by Day 21.

Day 1 put values in boxes. Day 2 compared them. Day 3 made the script decide. Day 4 walked a list. Today the script learns to reuse work — and to remember work after the outer function has already returned.

A Playwright suite that cannot write a helper is a folder of copy-paste. A helper that cannot close over state is a pile of globals. A failing expect with no message is a screenshot without a caption. Functions, closures, and strings are the three tools that turn “I can click Login” into “I can ship a framework.”

I am Pramod Dutta. I teach this batch the same way I debug a failing pipeline: open the smallest file, run it with node, then ask what Playwright would do with the same idea. Today we work from the real classroom labs in LearningPlaywrightBatch — chapter_09_Functions and chapter_10_Strings. I will quote those files. I will not invent a file that is not on main.

If you want the recorded path with projects, reviews, and the framework we assemble at the end of this series, that is the Playwright Automation Mastery course at The Testing Academy.

JavaScript closure makeRetry count

Contents

Why Day 5 matters before you touch Playwright helpers

Most bloated Playwright repos I review are not locator problems. They are function problems wearing a Playwright jacket.

Someone pastes the same login steps into twelve specs. Someone writes const login = async () => {} and then calls it above the line. Someone puts let attempts = 0 at module scope and two tests share a retry counter. Someone writes expect(text).toBe("welcome") after innerText() returned "Welcome " with a trailing space. Someone ships await expect(locator).toBeVisible() with no second argument, then spends twenty minutes staring at a trace because the failure message is expect.toBeVisible failed.

Playwright’s test("name", async ({ page }) => { ... }) is already a higher-order function. expect(value, "why this must be true").toBe(expected) is already a string problem. Until you can write a function that takes arguments, returns a value, closes over a counter, and builds a message, your Page Object will be a class-shaped copy of the same copy-paste.

Today you will be able to:

  1. Name the four function types from the batch and pick the one a helper actually needs.
  2. Rewrite a declaration as an expression and as an arrow — and know which one hoists.
  3. Use default parameters, rest, and spread the way a retry helper and an API client do.
  4. Draw a closure: outer runs once, inner remembers attempts.
  5. Tell a higher-order function from a callback from a pure function in one glance.
  6. Search, slice, trim, and replace strings so a custom expect message tells CI the truth.

That is the skill. Not the syntax. The skill is extract a helper, remember only what it must remember, and fail with a sentence a human can read.

The labs we are actually using

Clone the repo and stay on main:

git clone https://github.com/PramodDutta/LearningPlaywrightBatch.git
cd LearningPlaywrightBatch
git checkout main

We will run, quote, and adapt these files only.

Chapter 09 — Functions

  • 76_Functions.js
  • 77_Type1_Fn_Basic_Functions.js
  • 78_Type2_Fn_With_Arg_No_Return.js
  • 79_Type3_Fn_without_Arg_Return_Type.js
  • 80_Type4_Fn_With_Arg_With_Return.js
  • 81_Ex.js
  • 82_Fn_Expression.js
  • 83_Fn_Arrow.js
  • 84_Ex_API_Testing.js
  • 85_Fn_IIFE.js
  • 86_IQ.js
  • 87_Default_Parameter.js
  • 88_Rest_Parameters_Fn.js
  • 89_IQ_Fn.JS (filename is capital .JS on GitHub — not .js)
  • 90_Spead_Fn.js (filename is spelled that way in the repo)
  • 91_Return_Fn.js
  • 92_Hoisting_Fn.js
  • 93_Scope_Fn.js
  • 94_Closure.js
  • 95_Closure_Part2.js
  • 96_Closures_Part2.js
  • 97_Closure_Part4.js (there is no 97_Closure_Part3.js on main)
  • 98_Higher_Order_Fn.js
  • 99_Pure_Fn.js
  • 100_Callback_Fn.js
  • 101_Callback_me.js

Chapter 10 — Strings

  • 102_Strings.js
  • 103_String_Properties.js
  • 104_Strings_Search_Check.js
  • 105_Strings_P2.js
  • 106_Transforming_Strings.js
  • 107_String_Conversion.js

Those are the files. The folders also contain a PPTP directory. That is slides, not a lab. I skip it. If a slide mentioned 89_IQ_Fn.js or 90_Spread_Fn.js, those exact names are not on main. We stay with what GitHub actually serves.

You need Node.js 18 or newer. Today we only need node and a terminal. Playwright mappings in this post are the *shape* of helpers you will write from Day 10 onward. Do not skip the node run.

Lab 76 — why functions exist, and the first classroom bug

File: chapter_09_Functions/76_Functions.js

// Without functions — repeated logic

let score1 = 85;
let result1 = score1 >= 70 ? "pass" : "fail";
console.log(result1);

let score2 = 45;
let result2 = score2 >= 70 ? "pass" : "fail";
console.log(result2);

function getResult(scroe) {
    return score2 >= 70 ? "pass" : "fail";
}

getResult(85);  // "pass"
getResult(45);  // "fail"

I leave this file in the batch on purpose. The top half is the lesson: the same ternary twice is a function waiting to be born. The bottom half is the trap.

Read getResult the way an interviewer will. The parameter is spelled scroe. The body never uses it. The body reads score2 from the outer scope. score2 is 45. Both calls return "fail". The comments lie.

This is the exact bug I see in Playwright helpers:

function isSuccess(status) {
  return lastStatus >= 200 && lastStatus < 300; // ignored the argument
}

You passed 201. The helper graded yesterday’s 404. The spec went green or red for the wrong request. A function that ignores its parameter is not a helper. It is a global in a hat.

The repaired shape is Type 4 — arguments in, value out:

function getResult(score) {
  return score >= 70 ? "pass" : "fail";
}

console.log(getResult(85)); // pass
console.log(getResult(45)); // fail

Run the broken file first. Then fix it locally. Do not “know” it. Watch getResult(85) print fail. That sting is the point of Day 5.

The four function types — labs 77 to 80

I teach functions as a 2-by-2 grid. Argument or no argument. Return or no return. Every Playwright helper you write sits in one cell.

Type 1 — no argument, no return

File: chapter_09_Functions/77_Type1_Fn_Basic_Functions.js

// Define
function greet() {
    console.log("Hi");
}

// This is a Basic type-1 function, which means no argument, no return.
// Call
greet();

let a = greet();

greet() prints "Hi". let a = greet() still prints "Hi", and a is undefined. JavaScript does not punish you for capturing a function that returns nothing. It hands you undefined and keeps walking. That is why console.log(a) later looks empty and people add return in the wrong place.

Type 1 in a suite is a side-effect helper: print a banner, seed a clock, call test.info().annotate. Useful. Dangerous if you treat the return as data.

function stampSuite() {
  console.log("Auth suite — staging");
}

const started = stampSuite(); // undefined — do not assert on this

Type 2 — argument, no return

File: chapter_09_Functions/78_Type2_Fn_With_Arg_No_Return.js

function greetByName(name) { // parameter
    console.log("Hi", name);
}

greetByName("Pramod"); // argument
greetByName("Dipak");
greetByName("Meeti");
greetByName("Sangeetha");

function begger(money) {
    console.log("Thanks", money);
}

let returnMesomething = begger(100);
console.log(returnMesomething);

let name1 = greetByName("Sumit");
console.log(name1);

Parameter is the hole in the definition. Argument is the value you pour in at the call. I want that vocabulary in interviews.

begger(100) logs "Thanks 100". returnMesomething is undefined. Same for name1. Type 2 is a logger, a reporter, a page.goto(url) wrapper that does not hand you the response. Fine — as long as you do not write expect(name1).toBe("Hi Sumit").

Playwright mapping:

function logStep(step) {
  console.log(`[step] ${step}`);
}

test("login", async ({ page }) => {
  logStep("open VWO");
  await page.goto("https://app.vwo.com");
  // logStep returns undefined — do not chain it
});

Type 3 — no argument, with return

File: chapter_09_Functions/79_Type3_Fn_without_Arg_Return_Type.js

function sayHello() {
    console.log('Hi');
    return "Hello";
}

let relative = sayHello();
console.log(relative);

This prints Hi, then Hello. The console.log is a side effect. The return is the value. Type 3 is “give me the current env”, “give me today’s ISO date”, “give me the default base URL”. No input. Predictable output — until it reads process.env and stops being pure. We will come back to that in lab 99.

Type 4 — argument and return

File: chapter_09_Functions/80_Type4_Fn_With_Arg_With_Return.js

function sumofTwoNumbers(a, b) {
    return a + b;
}

let c = sumofTwoNumbers(4, 5);
let c2 = sumofTwoNumbers(10, 15);
console.log(c);
console.log(c2);

This is the cell I want 80% of your helpers in. Input in. Value out. No console.log inside if you can help it. Testable with node. Testable later with expect(sumofTwoNumbers(4, 5)).toBe(9).

Lab 81 is the same cell with a template string:

File: chapter_09_Functions/81_Ex.js

function greet(name) {
    return `Hello, ${name}!`;
}

greet("Alice");

greet("Alice") produces "Hello, Alice!". The file does not console.log it. If you run node chapter_09_Functions/81_Ex.js and see nothing, that is not a crash. That is a return value you threw away. Capture it. This is how custom expect messages start:

function greet(name) {
  return `Hello, ${name}!`;
}

// later in a spec
await expect(page.getByRole("heading"), greet("Alice")).toHaveText("Hello, Alice!");

The second argument to expect is a string. Lab 81 already knows how to build one.

Function expressions — lab 82

File: chapter_09_Functions/82_Fn_Expression.js

const greet = function (name) {
    return `Hello, ${name}!`;
};

// Type 4 Function
function greet1(name1) {
    return `Hello, ${name1}!`;
}

// Functions as Expression
const greet2 = function (name1) {
    return `Hello, ${name1}!`;
}

console.log(greet1("Bob"));
console.log(greet2("Bob"));

A declaration is function greet1(...) { }. It is a statement. A function expression is a function that is a *value* — usually assigned to const. Same body. Different birth certificate.

Why SDETs care:

  • Declarations hoist. You can call them above the line. Lab 92 will prove it.
  • Expressions do not hoist as functions. const greet = function ... is in the Temporal Dead Zone until that line. Call it above and you get Cannot access 'greet' before initialization, or in lab 89’s live code, sayHi is not a function.
  • Playwright fixtures, page-object methods assigned as fields, and const login = async ({ page }) => {} are expressions. Treat them like const. Define first. Call second.

greet is declared in this file and never logged — the console.log(greet("Bob")) line is commented. greet1 and greet2 both print Hello, Bob!. Same result. Different hoisting story.

Arrow functions — lab 83

File: chapter_09_Functions/83_Fn_Arrow.js

const greet = function (name1) {
    return `Hello, ${name1}!`;
};

const greet1 = (name2) => `Hello, ${name2}!`;

console.log(greet("Pramod"));
console.log(greet1("Pramod"));

const doubleIt = n => n * 2;
console.log(doubleIt(10));

const getEnv = () => "staging";
console.log(getEnv());

const getResult = (score) => {
    if (score >= 70) return "pass";
    return "fail";
};

The classroom rewrite rule is in the comments: remove function, remove return, remove the curly braces, put =>. That rule is for one expression. greet1 and doubleIt follow it. getEnv needs () because there is no parameter — the parentheses are the empty argument list, not decoration.

Multi-line arrows need { } and an explicit return. getResult is that shape. If you write the same body *without* return inside braces, you return undefined. I have reviewed a Playwright helper that looked like this:

const statusLabel = (code) => {
  if (code >= 200 && code < 300) "success"; // missing return
};

expect(statusLabel(200)).toBe("success") failed with undefined. The arrow had braces. Braces mean “I will return myself.” You forgot.

One-parameter arrows can drop the parens: n => n * 2. Zero parameters cannot. Two parameters cannot. I want const add = (a, b) => a + b, not creative punctuation.

Playwright’s default test callback is an arrow — and it is async, which we fully unpack on Day 7:

test("login heading", async ({ page }) => {
  await page.goto("https://app.vwo.com");
  await expect(page.getByRole("heading")).toBeVisible();
});

That is a function expression stored by the runner. It does not hoist. It does not get its own this the way a method does. For SDET work this week, remember three rules:

  1. One expression → implicit return.
  2. Braces → you must return.
  3. No function keyword → not hoisted. Define it before the spec calls it.

Three styles, one API check — lab 84

File: chapter_09_Functions/84_Ex_API_Testing.js

function validateStatusCode(status) {
    if (status >= 200 && status <= 300) {
        console.log("Request is fine!")
    }
}

const validateStatusCode_Exp = function (status) {
    if (status >= 200 && status <= 300) {
        console.log("Request is fine!")
    }
}

const validateStatusCode_Arrow = (status) => {
    if (status >= 200 && status <= 300) {
        console.log("Request is fine!");
    }
}

validateStatusCode(200);
validateStatusCode_Exp(200);
validateStatusCode_Arrow(200);

Same Type 2 body, three skins: declaration, expression, arrow. All three log "Request is fine!" for 200.

Two classroom nits I want you to fix when you rewrite this as a helper.

First, status <= 300 includes 300. HTTP 2xx is >= 200 && < 300. 300 is Multiple Choices, not success. Lab 91 already uses < 300. Prefer that.

Second, Type 2 only logs. A Playwright helper should return a boolean or a label so expect can see it:

function isSuccessStatus(status) {
  return status >= 200 && status < 300;
}

test("create booking", async ({ request }) => {
  const response = await request.post("/booking", { data: payload });
  const code = response.status();
  expect(isSuccessStatus(code), `create booking returned ${code}`).toBe(true);
});

Declaration, expression, or arrow — I do not care. I care that it returns, that it uses numeric ranges from Day 2–3, and that the expect message includes the actual code.

IIFE — lab 85

File: chapter_09_Functions/85_Fn_IIFE.js

function name1() {
    console.log("Hi")
}

name1();

(function () {
    console.log("Hi")
})();

(function () {
    console.log("Staging")
})();

(() => {
    console.log("Setup complete")
})();

IIFE means Immediately Invoked Function Expression. You do not store it. You do not call it later. The () at the end runs it now.

name1() is the long way: define, then call. The next three blocks are the short way. The last one is an arrow IIFE.

Why would an SDET ever write this?

  • Isolate setup that must not leak let bindings into the module.
  • Run a one-shot env banner when the spec file loads.
  • Wrap a const so two files can both have a timeout without colliding.
const config = (() => {
  const env = process.env.ENV ?? "staging";
  return {
    env,
    baseURL: env === "prod" ? "https://app.vwo.com" : "https://staging.vwo.com",
  };
})();

That IIFE runs once at import. env inside is not global. config.baseURL is what the tests read. Day 17 will put this idea into a real config layer. Today I only want you to see the parentheses: wrap the function, then invoke it.

If you forget the outer (), you have defined a function and thrown it away. If you forget the trailing (), you have an expression that never ran. Run lab 85. You should see Hi, Hi, Staging, Setup complete.

Interview helper — lab 86

File: chapter_09_Functions/86_IQ.js

function runTest(name, status, duration) {
    return `${name}: ${status} (${duration}ms)`;
}

runTest("Login", "pass", 320);
// "Login: pass (320ms)"

Three arguments. One template string. This is the ancestor of every custom expect message in this series.

function runTest(name, status, duration) {
  return `${name}: ${status} (${duration}ms)`;
}

test("login reports a readable line", async ({ page }) => {
  const started = Date.now();
  await page.goto("https://app.vwo.com");
  const line = runTest("Login", "pass", Date.now() - started);
  expect(line, "reporter line must include name, status, duration").toMatch(/Login: pass \(\d+ms\)/);
});

Interviewers love this file because it is small and it forces the word return. If you console.log inside and forget return, the test receives undefined. Lab 78 already burned you on that.

Default parameters — lab 87

File: chapter_09_Functions/87_Default_Parameter.js

function retry(testName, maxRetries = 3, delay = 1000) {
    console.log(`Retrying ${testName} up to ${maxRetries} times, ${delay}ms apart`);
}

retry("Login");
retry("Checkout", 5);
retry("API Test", 2, 500);

Defaults fire when the argument is missing or undefined. They do not fire for 0 or "". That is the Day 2 ?? story in parameter clothing.

  • retry("Login") → 3 retries, 1000 ms.
  • retry("Checkout", 5) → 5 retries, 1000 ms.
  • retry("API Test", 2, 500) → both overrides.

Playwright’s own config uses the same idea: retries: 0 is a real choice. If you write a wrapper, do not default with ||:

function retry(testName, maxRetries = 3, delay = 1000) {
  // maxRetries = maxRetries || 3;  // WRONG — 0 becomes 3
  console.log(`Retrying ${testName} up to ${maxRetries} times, ${delay}ms apart`);
}

retry("flake-prone search", 0, 0); // means: do not retry, do not wait

0 must survive. That is how you disable retries for a known-stable spec without inventing a magic -1.

Rest parameters — lab 88

File: chapter_09_Functions/88_Rest_Parameters_Fn.js

function logResults(suiteName, ...results) {
    console.log(`Suite: ${suiteName}`);
    console.log(`Results: ${results.join(", ")}`);
}

logResults("Auth Suite", "pass", "fail", "pass", "skip");

function add(a, b, c) {
    return a + b + c;
}

...results is rest. It gathers leftover arguments into a real array. First argument is the suite name. Everything after becomes ["pass", "fail", "pass", "skip"].

add in this file is the contrast: fixed arity. Three holes. No rest. Lab 90 will explode an array into those holes.

Rest is how a reporter, a soft-assert collector, or a test.step wrapper accepts “as many results as you have”:

function logResults(suiteName, ...results) {
  const failed = results.filter((r) => r === "fail");
  return {
    suiteName,
    total: results.length,
    failed: failed.length,
  };
}

expect(logResults("Auth Suite", "pass", "fail", "pass", "skip").failed).toBe(1);

Rest must be last. function logResults(...results, suiteName) is a SyntaxError. Remember that for the interview.

Lab 89 — the hoisting TypeError, and the capital .JS

File: chapter_09_Functions/89_IQ_Fn.JS

The filename is 89_IQ_Fn.JS. Capital JS. Linux and GitHub are case-sensitive. 89_IQ_Fn.js is a different path. I am not renaming it in this post.

Most of the file is commented. The live code is the interview:

sayHi("Bob");

const sayHi = function (name) {
    return `Hi, ${name}!`;
};

Run it:

node chapter_09_Functions/89_IQ_Fn.JS

You get TypeError: sayHi is not a function in some engines after a var-style hoist, or more commonly with const, ReferenceError: Cannot access 'sayHi' before initialization. On the main file as written, const sayHi is in the TDZ. The call sits above the binding. Function expressions are not hoisted as callable functions.

The commented block above it is the opposite story — a declaration you *can* call early — and a getStatus / logTest preview of lab 91. Leave the comments as comments. Do not pretend they run. The live lesson is the crash.

Playwright version of the same crash:

test("calls a helper too early", async ({ page }) => {
  await login(page); // ReferenceError if login is a const below
});

const login = async (page) => {
  await page.goto("https://app.vwo.com");
};

Hoist a declaration if you must. Prefer const helpers defined at the top of the file, then tests below. I do the second in every framework I ship.

Spread — lab 90

File: chapter_09_Functions/90_Spead_Fn.js

Yes, the file is named 90_Spead_Fn.js. Spread with a missing r. I will not invent 90_Spread_Fn.js.

function add(a, b, c) {
    return a + b + c;
}

let num = [1, 2, 3];
add(...num);  // sum -> 6

function hasError(...codes) {
    return codes.some(c => c >= 400);
}

let responseCodes = [200, 201, 404];
hasError(...responseCodes); // true

Rest collects. Spread explodes.

add(...num) is add(1, 2, 3). hasError(...responseCodes) is hasError(200, 201, 404). codes.some(c => c >= 400) is a higher-order call we will name in lab 98. 404 makes it true.

Playwright mapping — merge headers, copy a list of status codes, pass fixture arrays without sharing the same reference (Day 6 will go deeper on objects):

function hasError(...codes) {
  return codes.some((c) => c >= 400);
}

test("no 4xx in the waterfall", async ({ page }) => {
  const codes = [];
  page.on("response", (res) => codes.push(res.status()));
  await page.goto("https://app.vwo.com");
  expect(hasError(...codes), `statuses: ${codes.join(",")}`).toBe(false);
});

If you write hasError(codes) without spread, codes is one argument — an array — and c >= 400 compares an array to a number. That is false for the reason you do not want. Spread the list. Or change the helper to accept an array. Do not mix the two.

Return values — lab 91

File: chapter_09_Functions/91_Return_Fn.js

function getStatus(code) {
    if (code >= 200 && code < 300) return "success";
    if (code >= 400 && code < 500) return "client error";
    if (code >= 500) return "server error";
}

getStatus(200);  // "success"
getStatus(404);  // "client error"
getStatus(500);  // "server error"

function logTest(name) {
    console.log(`Running: ${name}`);
}

logTest("Hi this is a a log");

function aaa() {
    return [2, 2, 3, 5, 4];
}

getStatus is the helper I actually want from lab 84. Ranges. Labels. No console.log. 300 is not success here. Good.

What about getStatus(399)? No branch matches. The function returns undefined. Same for 301. A Type 4 helper still needs a default. Day 3 taught you else / default. Put it back:

function getStatus(code) {
  if (code >= 200 && code < 300) return "success";
  if (code >= 400 && code < 500) return "client error";
  if (code >= 500) return "server error";
  return "other";
}

logTest is Type 2. No return. Capturing it gives undefined. aaa shows you can return many values by returning one array (or, as the comment says, one object — the comment’s object literal is not valid JavaScript, and I will not pretend it is). Playwright helpers that need status plus body plus duration should return an object. We build those objects on Day 6.

Function hoisting — lab 92

File: chapter_09_Functions/92_Hoisting_Fn.js

greet("Alice"); // declaration — hoisted

function greet(name) {
    return `Hello, ${name}!`;
}

sayHi("Bob"); // TypeError: sayHi is not a function

const sayHi = function (name) {
    return `Hi, ${name}!`;
};

Day 1 hoisting was var / let / const. Today it is functions.

  • A function declaration is lifted whole. greet("Alice") works above the body.
  • A fun