PlayForge
← Course

3. Functions

Package steps into reusable blocks, the building block of every test helper.

11m read8m practice · 2 exercises19m in total

Chapter film (about 7m) is coming. The written chapter below is complete.

Checkpoint 1

Functions Deep Dive

In plain English. A function is a named, reusable set of steps, like a test case you write once and run with different data. You give it inputs, called parameters (a username and password, say), it does its work, and it can hand back a result. Tests lean on functions constantly: one that logs in, one that formats a date, one you pass to another piece of code to run later. That last kind is a callback: a function handed to another function, which calls it when it is ready. You will also meet arrow functions, a shorter way to write them. A closure is a function that remembers the variables around it where it was created, even after that outer code has finished.

The Power of Functions

JavaScript treats functions as first-class citizens, meaning they can be assigned to variables, passed as arguments, and returned from other functions. A function does not have to belong to a class. You can define functions using standard declarations (function foo() {}) or expressions (const foo = function() {}). Function declarations are hoisted to the top of their scope, allowing them to be called before they are defined, whereas expressions are not.

Arrow Functions

Arrow functions (() => {}) provide a more concise syntax. The critical distinction is how they handle the this keyword. Traditional functions have their own this context depending on how they are called, while arrow functions inherit this lexically from their enclosing scope. This makes arrow functions useful for callbacks, especially in object-oriented patterns or asynchronous testing with Playwright, where you want to maintain the class context without calling .bind(this).

Parameters and Arguments

JavaScript also offers default parameters and rest parameters (...args), making function signatures highly flexible. Default parameters allow you to specify fallback values directly in the signature. The rest parameter syntax captures any number of arguments into a real array, so array destructuring and the functional array methods work on it directly.

Closures

Finally, closures are a fundamental concept where a function retains access to its lexical scope, even after the outer function has returned. The captured variable is live: the closure reads and writes the same binding rather than a copy of its value at the time the outer function returned. Closures are widely used to emulate private variables, create factory functions, or maintain state across asynchronous test steps.

javascript
const greet = function(name = 'Guest') { console.log(`Hello, ${name}!`); }; const add = (a, b) => a + b; function sumAll(...numbers) { return numbers.reduce((acc, curr) => acc + curr, 0); } function createCounter() { let count = 0; return () => ++count; } greet(); greet('Alice'); console.log('Arrow function:', add(5, 3)); console.log('Rest parameters:', sumAll(1, 2, 3, 4)); const counter = createCounter(); console.log('Closure count 1:', counter()); console.log('Closure count 2:', counter());

Sign in to track your progress

Checkpoint 2

Method Overloading in JavaScript

In plain English. Test helpers often need to cope with different inputs: login() for the default user, login('admin') for a named one, or a version where you set every detail yourself. Overloading means writing several versions of one function under the same name, and having the right one picked from what you pass in. JavaScript does not do this, and this lesson shows the patterns it uses instead. You will write them in your own helpers, and you will see the options object all over Playwright. Below, a default parameter is a fallback value used when the caller leaves an input out. An options object bundles many optional settings into one { } value, so the caller names only the settings they care about.

Method overloading means declaring several functions that share one name but differ in their parameter signatures, and letting the language work out which one a given call meant. JavaScript has no such mechanism. A function name is an ordinary binding, and there is no signature to dispatch on. If you define two functions with the same name, the last one defined simply overwrites the first — but only where the language lets you write that at all. At the top level of a CommonJS module or a classic script, duplicate function declarations are allowed and the last wins. Inside an ES module, or inside any block in strict mode, they are lexically declared, and a duplicate is an outright SyntaxError: Identifier 'greet' has already been declared — the file will not even parse. Since Playwright projects routinely use ES modules and TypeScript, the realistic outcome of "overloading" in JS is not a silent overwrite but a build failure.

Why JS Lacks Overloading

JavaScript relies on dynamic typing, and the runtime engine does not inspect argument types or counts to route function calls. Every JS function can accept any number of arguments, regardless of how many it was defined with. Extra arguments are ignored (or captured), and missing arguments are set to undefined.

function greet(name) { return 'Hello ' + name; } function greet(name, age) { return name + ' is ' + age; } console.log(greet('Alice')); // 'Alice is undefined' - second declaration won // The SAME two lines inside an ES module (.mjs, or .js with "type": "module"): // SyntaxError: Identifier 'greet' has already been declared

Alternative 1: Argument Checking

The most traditional way to simulate overloading in JS is by inspecting the arguments you were handed — their count or their typeof. Older code reads the array-like arguments object for this; prefer a rest parameter (...args), which gives you a real array and, unlike arguments, is also available inside arrow functions (arrow functions have no arguments binding of their own).

function processInput(input) { if (typeof input === 'string') { return 'String: ' + input; } else if (typeof input === 'number') { return 'Number: ' + (input * 2); } } console.log(processInput('test')); // String: test console.log(processInput(5)); // Number: 10

Alternative 2: Default Parameters

Instead of writing several functions to cover the cases where a parameter is missing, JS lets you assign default values to parameters directly in the signature. If a parameter is omitted or passed as undefined, the default is used.

function connect(host = 'localhost', port = 8080) { return `Connecting to ${host}:${port}`; } console.log(connect()); // Connecting to localhost:8080 console.log(connect('db.local', 5432)); // Connecting to db.local:5432

Alternative 3: The Options Object Pattern

When functions require many optional arguments, relying on positional defaults becomes cumbersome. The Options Object pattern replaces long parameter lists with a single configuration object, heavily used in Playwright APIs.

function launchBrowser({ headless = true, slowMo = 0 } = {}) { // Destructuring defaults apply when the property is missing or `undefined`. // Do NOT write `options.slowMo || 0` here: `||` also swallows a deliberate 0, // and `headless: false` would be thrown away the same way. Use `??` if you // need a fallback expression rather than a destructuring default. return `Launched headless=${headless}, slowMo=${slowMo}`; } console.log(launchBrowser({ headless: false })); // Launched headless=false, slowMo=0

Alternative 4: Rest Parameters

For variadic functions — functions that take an arbitrary number of arguments — JavaScript uses the rest parameter syntax ...args. This collects all remaining arguments into an array.

function sumAll(...numbers) { return numbers.reduce((acc, curr) => acc + curr, 0); } console.log(sumAll(1, 2, 3, 4)); // 10

Default parameters, rest parameters and the options object cover between them everything overloading is normally used for.

javascript
function initApp(options = {}) { const config = Object.assign({ port: 3000, host: 'localhost' }, options); console.log(`Starting on ${config.host}:${config.port}`); } initApp(); initApp({ port: 8080 }); function dynamicAdd(...args) { return args.reduce((sum, n) => sum + n, 0); } console.log('Sum:', dynamicAdd(10, 20, 30));

Sign in to track your progress

Checkpoint 3

Return Types & Patterns

In plain English. When a function finishes its job, it can hand a result back to whoever called it. That result is the return value, and the word return is how you send it back. Your test helpers live on this: a helper that builds test data, reads a price off a page, or picks a user returns the thing your test then checks. JavaScript does not police what comes back, so one function can return text on one path and nothing at all on another, and that is where bugs hide. Below, undefined is JavaScript's word for "no value here", and a Promise is a value that will only arrive later. The async chapter covers Promises properly.

A JavaScript function has no declared return type and nothing enforces what comes out of it. A single function can return a string, a number, an object, or another function, and which one it returns can depend on runtime conditions.

Implicit Undefined Returns

If a function in JavaScript doesn't explicitly return a value, it returns undefined. If you simply write return; to exit early, it evaluates to undefined as well.

function doNothing() { // No return statement } console.log(doNothing()); // undefined

Arrow Functions and Implicit Returns

Arrow functions in JavaScript introduce a concise syntax for implicit returns. If an arrow function has a single expression and no curly braces { ... }, the result of that expression is returned automatically. If you add curly braces, you must explicitly use the return keyword, otherwise it behaves like a normal function and returns undefined.

const add = (a, b) => a + b; console.log(add(2, 3)); // 5 // Explicit Return required with braces const multiply = (a, b) => { return a * b; };

Returning Objects from Arrow Functions

When returning an object literal from an implicit arrow function, you must wrap the object in parentheses ({ ... }). Without parentheses, JavaScript interprets the curly braces as the start of the function body, leading to syntax errors or undefined returns.

const createUser = (name) => ({ id: 1, name: name }); console.log(createUser('Alice')); // { id: 1, name: 'Alice' }

Returning Promises

In Playwright and modern JS, you frequently return Promises. An async function implicitly wraps its return value in a Promise.

async function fetchTitle() { return "Playwright Page Title"; } fetchTitle().then(console.log);

Returning Multiple Values

To return more than one value, return an Array or an Object and unpack it at the call site with destructuring syntax. No wrapper type is needed.

function getCoordinates() { return { x: 10, y: 20 }; } const { x, y } = getCoordinates(); console.log(`x: ${x}, y: ${y}`);

Having no enforced return type makes utility functions flexible, and it puts the discipline on you: a function that returns different shapes on different paths produces bugs that only appear at runtime, at whichever call site got the shape it did not expect.

javascript
const add = (a, b) => a + b; console.log(add(2, 3)); const getUser = (name) => ({ id: 123, name }); console.log(getUser('Bob')); function getMultiple() { return [42, 'Answer']; } const [num, str] = getMultiple(); console.log(num, str);

Sign in to track your progress

Checkpoint 4

Higher-Order Functions

In plain English. In JavaScript a function is a value, just like a number or a piece of text. You can store it, pass it into another function, or get one back as a result. A higher-order function is a function that does one of those: it takes a function in, or hands one out. You have already used some every time you wrote .map() or .filter(). Testers use this to add behaviour around an action without rewriting the action: log every step, time it, or retry a flaky one. A callback is the function you pass in, for the receiving function to call when it is ready. Every Playwright test you write is a callback, handed to test() to run.

A higher-order function is any function that either takes one or more functions as arguments, or returns a function as its result. In JavaScript this needs no special type or wrapper: functions are first-class values, so a function can be stored in a variable, handed to another function, and returned like any other value.

Functions as Arguments (Callbacks)

The most common use of higher-order functions in JS is passing callbacks. Arrays in JavaScript have built-in higher-order functions like .map(), .filter(), and .reduce() that apply a callback function to their elements. This functional programming style is heavily favored in modern JS.

const numbers = [1, 2, 3, 4, 5]; const evens = numbers.filter(n => n % 2 === 0); const doubled = evens.map(n => n * 2); console.log(doubled); // [4, 8]

Function Factories (Returning Functions)

A function factory is a function that generates and returns another function, often locking in some configuration. Because JS maintains scope closures, the returned function remembers the environment in which it was created.

function createMultiplier(multiplier) { return function(value) { return value * multiplier; }; } const double = createMultiplier(2); console.log(double(10)); // 20

Decorators and Wrappers

You can wrap an existing function with additional behavior (like logging, timing, or retrying) without modifying the original function. This is a common pattern in automation frameworks.

function withLogging(fn) { return function(...args) { console.log(`Calling function with args: ${args}`); const result = fn(...args); console.log(`Result: ${result}`); return result; }; } const loggedAdd = withLogging((a, b) => a + b); loggedAdd(3, 4);

The withRetry Wrapper Example

In automation, flakes occur. You can build a higher-order function that attempts an action multiple times before failing. This isolates the retry logic from the core business logic.

async function withRetry(asyncFn, retries = 3) { for (let i = 0; i < retries; i++) { try { return await asyncFn(); } catch (e) { if (i === retries - 1) throw e; console.log(`Retry ${i + 1} failed, trying again...`); } } }

Playwright Use Cases

Playwright heavily utilizes higher-order functions. When you define tests using test('name', async ({ page }) => { ... }), you are passing an async callback to the test function. Playwright's test.extend() is also a sophisticated higher-order function that builds new customized test runners based on existing ones.

javascript
function createGreeter(greeting) { return (name) => `${greeting}, ${name}!`; } const sayHi = createGreeter('Hi'); console.log(sayHi('Alice')); const nums = [1, 2, 3, 4]; const squared = nums.map(x => x * x); console.log(squared); function timeIt(fn) { return (...args) => { const start = Date.now(); const res = fn(...args); console.log(`Execution took ${Date.now() - start}ms`); return res; }; } const fastFunc = timeIt(() => 42); fastFunc();

Sign in to track your progress