Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save PramodDutta/1be14b0f8b243d46b955ad6606c4dc7f to your computer and use it in GitHub Desktop.

Select an option

Save PramodDutta/1be14b0f8b243d46b955ad6606c4dc7f to your computer and use it in GitHub Desktop.

🟩 JavaScript Advanced Topics — TheTestingAcademy

For QA Engineers & SDETs



🟩 TOPIC 1: Callback Functions — Sync, Async, Callback Hell & Pyramid of Doom


What is a Callback Function?

A callback is simply a function that you give to another function, saying "Hey, when you're done, run this."

Think of it like this — you go to a restaurant, give your phone number (callback), and say "Call me when my table is ready." You don't wait at the door. You go shopping. When the table is ready, they CALL YOU BACK. That's a callback.

function placeOrder(item, callback) {
  console.log("Order placed: " + item);
  callback();
}

placeOrder("Burger", function () {
  console.log("Order is ready! Pick it up.");
});

Output:

Order placed: Burger
Order is ready! Pick it up.

In QA terms — when Cypress clicks a button, it waits for the page to load, THEN runs your assertion. That assertion is a callback.


Synchronous Callbacks

Synchronous = runs immediately, line by line, top to bottom. The program WAITS for it to finish before moving to the next line.

Every forEach, map, filter you've used — those are sync callbacks.

let testResults = ["PASS", "FAIL", "PASS", "SKIP"];

testResults.forEach(function (result, index) {
  console.log("Test " + (index + 1) + ": " + result);
});

console.log("All done");

Output:

Test 1: PASS
Test 2: FAIL
Test 3: PASS
Test 4: SKIP
All done

"All done" prints LAST because forEach is synchronous — it finishes all 4 iterations first, then moves on.


Asynchronous Callbacks

Asynchronous = "I'll do this later, you carry on."

Real-world QA example: When you send an API request, you don't freeze your entire test suite waiting for the response. The request goes out, your code continues, and when the response arrives, THEN the callback runs.

To simulate async behavior in plain JS, we use setTimeout(callback, milliseconds). Think of it as setting an alarm — "run this function after X milliseconds."

console.log("Test 1: started");

setTimeout(function () {
  console.log("Test 2: API response received");
}, 2000);

console.log("Test 3: moving to next test");

Output:

Test 1: started
Test 3: moving to next test
Test 2: API response received     ← appears after 2 seconds

Why does Test 3 print before Test 2? Because setTimeout is async — it says "run this after 2 seconds" and JavaScript immediately moves to the next line. It does NOT wait.

This is exactly what happens in real testing:

  • Playwright's page.goto() sends a request (async)
  • API calls with fetch() are async
  • Database queries are async

Callback Hell (Pyramid of Doom)

Now imagine you have 4 steps that MUST run in order, and each step is async. You put callback inside callback inside callback...

Real QA Scenario: E2E Login Flow

function openBrowser(callback) {
  setTimeout(function () {
    console.log("Step 1: Browser opened");
    callback();
  }, 500);
}

function goToLoginPage(callback) {
  setTimeout(function () {
    console.log("Step 2: Login page loaded");
    callback();
  }, 500);
}

function enterCredentials(callback) {
  setTimeout(function () {
    console.log("Step 3: Credentials entered");
    callback();
  }, 500);
}

function clickLogin(callback) {
  setTimeout(function () {
    console.log("Step 4: Login button clicked");
    callback();
  }, 500);
}

// THIS IS CALLBACK HELL 👇
openBrowser(function () {
  goToLoginPage(function () {
    enterCredentials(function () {
      clickLogin(function () {
        console.log("✅ Test complete");
      });
    });
  });
});

Output:

Step 1: Browser opened
Step 2: Login page loaded
Step 3: Credentials entered
Step 4: Login button clicked
✅ Test complete

See how the code shifts RIGHT with every step? That's the Pyramid of Doom. Imagine 10 steps — completely unreadable. This is WHY Promises and async/await were invented.


Pros and Cons of Callbacks

✅ Pros ❌ Cons
Simple for 1-2 async steps Nesting creates unreadable pyramid
No special syntax needed Error handling is a nightmare
Works everywhere (old & new JS) Each level needs its own error check
Lightweight Hard to debug — confusing stack traces
Built into array methods Cannot easily return values between levels

🟩 TOPIC 1 EXERCISES — Callbacks (12 Exercises)


Exercise 1: Basic Callback

function greetTester(name, callback) {
  console.log("Welcome, " + name);
  callback();
}

greetTester("Dev", function () {
  console.log("Let's start testing!");
});

Output:

Welcome, Dev
Let's start testing!

Exercise 2: Callback with Parameters

function runTest(testName, callback) {
  let status = "PASS";
  callback(testName, status);
}

runTest("Login Test", function (name, result) {
  console.log(name + " → " + result);
});

Output:

Login Test → PASS

Exercise 3: Sync Callback — forEach

let bugs = ["UI glitch", "API timeout", "Wrong redirect"];

bugs.forEach(function (bug, i) {
  console.log("Bug #" + (i + 1) + ": " + bug);
});

console.log("Total bugs: " + bugs.length);

Output:

Bug #1: UI glitch
Bug #2: API timeout
Bug #3: Wrong redirect
Total bugs: 3

Exercise 4: Sync Callback — filter

let responseTimes = [120, 450, 200, 800, 90, 350];

let slow = responseTimes.filter(function (time) {
  return time > 300;
});

console.log("Slow responses:", slow);

Output:

Slow responses: [ 450, 800, 350 ]

Exercise 5: Sync Callback — map

let scores = [78, 92, 55, 88, 41];

let grades = scores.map(function (score) {
  if (score >= 90) return "A";
  if (score >= 70) return "B";
  if (score >= 50) return "C";
  return "F";
});

console.log("Grades:", grades);

Output:

Grades: [ 'B', 'A', 'C', 'B', 'F' ]

Exercise 6: Callback That Returns a Value

function calculate(a, b, operation) {
  return operation(a, b);
}

let sum = calculate(10, 5, function (x, y) {
  return x + y;
});

let product = calculate(10, 5, function (x, y) {
  return x * y;
});

console.log("Sum:", sum);
console.log("Product:", product);

Output:

Sum: 15
Product: 50

Exercise 7: Async Callback — setTimeout

console.log("A: Test suite started");

setTimeout(function () {
  console.log("B: Slow API test finished");
}, 1000);

console.log("C: Fast unit test finished");

Output:

A: Test suite started
C: Fast unit test finished
B: Slow API test finished

B prints LAST because setTimeout is async — it goes to a waiting queue and runs after 1 second.


Exercise 8: Execution Order Puzzle

console.log("1");

setTimeout(function () {
  console.log("2");
}, 0);

console.log("3");

Output:

1
3
2

Even with 0ms delay, setTimeout callback waits for ALL sync code to finish first. This is how JavaScript's event loop works.


Exercise 9: Error-First Callback Pattern

function validateEnv(env, callback) {
  let valid = ["dev", "staging", "qa"];

  if (valid.indexOf(env) !== -1) {
    callback(null, env + " is ready");
  } else {
    callback("Invalid: " + env, null);
  }
}

validateEnv("qa", function (err, msg) {
  if (err) console.log("ERROR:", err);
  else console.log("OK:", msg);
});

validateEnv("production", function (err, msg) {
  if (err) console.log("ERROR:", err);
  else console.log("OK:", msg);
});

Output:

OK: qa is ready
ERROR: Invalid: production

This is Node.js convention — first parameter is always error, second is data.


Exercise 10: Nested Callbacks — 3 Levels

function step1(callback) {
  console.log("Open browser");
  callback();
}

function step2(callback) {
  console.log("Navigate to page");
  callback();
}

function step3(callback) {
  console.log("Click button");
  callback();
}

step1(function () {
  step2(function () {
    step3(function () {
      console.log("Done!");
    });
  });
});

Output:

Open browser
Navigate to page
Click button
Done!

Exercise 11: Higher-Order Function with Callback

function repeat(times, callback) {
  for (let i = 1; i <= times; i++) {
    callback(i);
  }
}

repeat(3, function (run) {
  console.log("Test run #" + run);
});

Output:

Test run #1
Test run #2
Test run #3

Exercise 12: Real QA — Sort Test Cases by Priority

let testCases = [
  { name: "Login", priority: 1 },
  { name: "Search", priority: 3 },
  { name: "Checkout", priority: 2 }
];

testCases.sort(function (a, b) {
  return a.priority - b.priority;
});

testCases.forEach(function (tc) {
  console.log("P" + tc.priority + ": " + tc.name);
});

Output:

P1: Login
P2: Checkout
P3: Search

sort() takes a callback that tells it HOW to sort.



🟩 TOPIC 2: Promises — Promise Chain, .then(), .catch(), .finally()


What is a Promise?

A Promise is JavaScript's way of saying "I'll give you the result later — either it'll succeed or it'll fail."

Real-life analogy: You order food on Zomato. The order is a PROMISE.

  • Pending — food is being prepared (not ready yet)
  • Fulfilled (Resolved) — food is delivered ✅
  • Rejected — order cancelled ❌
let order = new Promise(function (resolve, reject) {
  let foodReady = true;

  if (foodReady) {
    resolve("Pizza delivered!");
  } else {
    reject("Order cancelled");
  }
});

console.log(order);

Output:

Promise { 'Pizza delivered!' }

A Promise is an OBJECT. It wraps a value that will be available later.


.then() — What to Do When Promise Succeeds

let apiCall = new Promise(function (resolve, reject) {
  resolve({ status: 200, body: "User data" });
});

apiCall.then(function (response) {
  console.log("Status:", response.status);
  console.log("Body:", response.body);
});

Output:

Status: 200
Body: User data

.then() runs ONLY when the promise resolves successfully.


.catch() — What to Do When Promise Fails

let apiCall = new Promise(function (resolve, reject) {
  reject("500 Server Error");
});

apiCall
  .then(function (data) {
    console.log("Success:", data);
  })
  .catch(function (error) {
    console.log("Error:", error);
  });

Output:

Error: 500 Server Error

.catch() runs ONLY when the promise is rejected. .then() is completely skipped.


.finally() — Runs No Matter What (Pass or Fail)

Perfect for cleanup — closing browser, clearing test data, stopping timers.

let testRun = new Promise(function (resolve, reject) {
  reject("Assertion failed");
});

testRun
  .then(function (msg) {
    console.log("PASS:", msg);
  })
  .catch(function (err) {
    console.log("FAIL:", err);
  })
  .finally(function () {
    console.log("Cleanup: browser closed");
  });

Output:

FAIL: Assertion failed
Cleanup: browser closed

.finally() ALWAYS runs — whether the test passed or failed. Just like afterEach() in Cypress or Playwright.


Promise Chaining — The Solution to Callback Hell

Each .then() returns a NEW Promise, so you can chain them. This keeps the code FLAT instead of nested.

Same login flow, but with Promises instead of callback hell:

function openBrowser() {
  return new Promise(function (resolve) {
    resolve("Browser opened");
  });
}

function goToLogin() {
  return new Promise(function (resolve) {
    resolve("Login page loaded");
  });
}

function enterCredentials() {
  return new Promise(function (resolve) {
    resolve("Credentials entered");
  });
}

function clickLogin() {
  return new Promise(function (resolve) {
    resolve("Logged in successfully");
  });
}

openBrowser()
  .then(function (msg) {
    console.log("Step 1:", msg);
    return goToLogin();
  })
  .then(function (msg) {
    console.log("Step 2:", msg);
    return enterCredentials();
  })
  .then(function (msg) {
    console.log("Step 3:", msg);
    return clickLogin();
  })
  .then(function (msg) {
    console.log("Step 4:", msg);
  })
  .catch(function (err) {
    console.log("Error:", err);
  });

Output:

Step 1: Browser opened
Step 2: Login page loaded
Step 3: Credentials entered
Step 4: Logged in successfully

Compare this to the callback hell version — completely FLAT, easy to read, ONE .catch() handles all errors.


Callback Hell vs Promise Chain

Feature Callback Hell Promise Chain
Shape Pyramid (nested right) Flat (one level)
Error handling Handle at EVERY level Single .catch() at end
Readability Horrible after 3 levels Clean and sequential
Return values Cannot pass between levels .then() passes values forward
Cleanup Manual everywhere .finally() runs once

Promise.all() — Run Multiple Promises Together

When you have independent API calls, run them ALL at once. Only succeeds if ALL succeed.

let checkAuth = Promise.resolve("Auth OK");
let checkDB = Promise.resolve("DB OK");
let checkCache = Promise.resolve("Cache OK");

Promise.all([checkAuth, checkDB, checkCache])
  .then(function (results) {
    console.log("All checks:", results);
  });

Output:

All checks: [ 'Auth OK', 'DB OK', 'Cache OK' ]

If even ONE fails, Promise.all() goes straight to .catch():

Promise.all([
  Promise.resolve("OK"),
  Promise.reject("DB DOWN"),
  Promise.resolve("OK")
])
  .then(function (r) { console.log(r); })
  .catch(function (err) { console.log("Failed:", err); });

Output:

Failed: DB DOWN

Promise.allSettled() — Get Results Even If Some Fail

Unlike Promise.all(), this waits for ALL promises to finish and tells you which passed and which failed.

Promise.allSettled([
  Promise.resolve("Test A passed"),
  Promise.reject("Test B failed"),
  Promise.resolve("Test C passed")
]).then(function (results) {
  results.forEach(function (r, i) {
    console.log("Test " + (i + 1) + ":", r.status, "-", r.value || r.reason);
  });
});

Output:

Test 1: fulfilled - Test A passed
Test 2: rejected - Test B failed
Test 3: fulfilled - Test C passed

This is like a test report — you want results for ALL tests, not just stop at the first failure.


Promise.race() — First One to Finish Wins

let fastServer = new Promise(function (resolve) {
  setTimeout(function () { resolve("Fast: 100ms"); }, 100);
});

let slowServer = new Promise(function (resolve) {
  setTimeout(function () { resolve("Slow: 500ms"); }, 500);
});

Promise.race([fastServer, slowServer]).then(function (winner) {
  console.log("Winner:", winner);
});

Output:

Winner: Fast: 100ms

Useful for timeout checks — "if API doesn't respond in 5 seconds, fail the test."


🟩 TOPIC 2 EXERCISES — Promises (12 Exercises)


Exercise 1: Create a Resolved Promise

let p = new Promise(function (resolve, reject) {
  resolve(42);
});

p.then(function (value) {
  console.log("Answer:", value);
});

Output:

Answer: 42

Exercise 2: Create a Rejected Promise

let p = new Promise(function (resolve, reject) {
  reject("Something broke");
});

p.catch(function (err) {
  console.log("Caught:", err);
});

Output:

Caught: Something broke

Exercise 3: .then() with Transformation

let p = Promise.resolve(5);

p.then(function (val) {
  return val * 10;
}).then(function (val) {
  console.log("Result:", val);
});

Output:

Result: 50

Exercise 4: Chain of .then()

Promise.resolve(1)
  .then(function (val) {
    console.log(val);
    return val + 1;
  })
  .then(function (val) {
    console.log(val);
    return val + 1;
  })
  .then(function (val) {
    console.log(val);
  });

Output:

1
2
3

Exercise 5: Error Stops the Chain

Promise.resolve("start")
  .then(function (val) {
    console.log(val);
    throw new Error("Broke at step 2");
  })
  .then(function () {
    console.log("This will NOT run");
  })
  .catch(function (err) {
    console.log("Caught:", err.message);
  });

Output:

start
Caught: Broke at step 2

Once an error is thrown, the chain skips all .then() and jumps to .catch().


Exercise 6: .finally() Always Runs

Promise.reject("Test failed")
  .then(function (data) {
    console.log("Data:", data);
  })
  .catch(function (err) {
    console.log("Error:", err);
  })
  .finally(function () {
    console.log("Cleanup done");
  });

Output:

Error: Test failed
Cleanup done

Exercise 7: Promise.resolve() and Promise.reject() Shortcuts

Promise.resolve("Quick win").then(function (msg) {
  console.log(msg);
});

Promise.reject("Quick loss").catch(function (msg) {
  console.log(msg);
});

Output:

Quick win
Quick loss

Exercise 8: Promise.all() — All Pass

let t1 = Promise.resolve("Login: PASS");
let t2 = Promise.resolve("Search: PASS");
let t3 = Promise.resolve("Logout: PASS");

Promise.all([t1, t2, t3]).then(function (results) {
  console.log(results);
});

Output:

[ 'Login: PASS', 'Search: PASS', 'Logout: PASS' ]

Exercise 9: Promise.all() — One Fails

let t1 = Promise.resolve("PASS");
let t2 = Promise.reject("FAIL");
let t3 = Promise.resolve("PASS");

Promise.all([t1, t2, t3])
  .then(function (r) { console.log("All:", r); })
  .catch(function (err) { console.log("Stopped:", err); });

Output:

Stopped: FAIL

Exercise 10: Promise.allSettled()

Promise.allSettled([
  Promise.resolve("API 200"),
  Promise.reject("API 500"),
  Promise.resolve("API 201")
]).then(function (results) {
  results.forEach(function (r) {
    let val = r.status === "fulfilled" ? r.value : r.reason;
    console.log(r.status + " → " + val);
  });
});

Output:

fulfilled → API 200
rejected → API 500
fulfilled → API 201

Exercise 11: Chained API Simulation

function getToken() {
  return Promise.resolve("token_abc");
}

function getUser(token) {
  return Promise.resolve({ token: token, name: "Dev" });
}

function getRole(user) {
  return Promise.resolve(user.name + " is Admin");
}

getToken()
  .then(function (token) {
    console.log("Token:", token);
    return getUser(token);
  })
  .then(function (user) {
    console.log("User:", user.name);
    return getRole(user);
  })
  .then(function (role) {
    console.log("Role:", role);
  });

Output:

Token: token_abc
User: Dev
Role: Dev is Admin

Exercise 12: Catch in the Middle of a Chain

Promise.resolve(10)
  .then(function (val) {
    console.log("A:", val);
    throw new Error("Oops");
  })
  .catch(function (err) {
    console.log("B:", err.message);
    return 99;
  })
  .then(function (val) {
    console.log("C:", val);
  });

Output:

A: 10
B: Oops
C: 99

.catch() can RECOVER — it returns a value and the chain continues!



🟩 TOPIC 3: Async/Await — Sequential & Parallel Execution


What is Async/Await?

async/await is a cleaner way to write Promises. Instead of chaining .then().then().then(), you write code that LOOKS synchronous but works asynchronously.

Two keywords:

  • async → put before a function to make it return a Promise
  • await → pause here, wait for the Promise to finish, then give me the value

Analogy: Promises with .then() is like texting instructions. Async/await is like talking face-to-face — more natural.

// WITH .then() chain
getToken()
  .then(function (token) {
    return getUser(token);
  })
  .then(function (user) {
    console.log(user);
  });

// WITH async/await — same thing, much cleaner
async function run() {
  let token = await getToken();
  let user = await getUser(token);
  console.log(user);
}

Basic Async/Await

async function getTestResult() {
  return "PASS";
}

// async function ALWAYS returns a Promise
getTestResult().then(function (result) {
  console.log(result);
});

Output:

PASS

Now with await:

async function runTest() {
  let result = await Promise.resolve("Login test passed");
  console.log(result);

  let result2 = await Promise.resolve("Dashboard test passed");
  console.log(result2);
}

runTest();

Output:

Login test passed
Dashboard test passed

Each await waits for the Promise to resolve, then stores the value in the variable. Clean, simple, readable.


Error Handling — try/catch

With Promises you use .catch(). With async/await you use try/catch — exactly like regular JavaScript error handling.

async function testAPI() {
  try {
    let result = await Promise.reject("503 Service Unavailable");
    console.log("Result:", result);
  } catch (error) {
    console.log("Error:", error);
  } finally {
    console.log("Cleanup done");
  }
}

testAPI();

Output:

Error: 503 Service Unavailable
Cleanup done

try/catch/finally maps directly to .then()/.catch()/.finally() — same logic, cleaner syntax.


Sequential Execution (One After Another)

When Step 2 depends on Step 1's result, you MUST run them sequentially.

QA Example: Login → Get Dashboard → Verify Data

function apiCall(name) {
  return new Promise(function (resolve) {
    setTimeout(function () {
      resolve(name + ": 200 OK");
    }, 1000);
  });
}

async function sequentialTest() {
  console.log("Start");
  let start = Date.now();

  let r1 = await apiCall("Login");
  console.log(r1);

  let r2 = await apiCall("Dashboard");
  console.log(r2);

  let r3 = await apiCall("Report");
  console.log(r3);

  console.log("Time: ~" + (Date.now() - start) + "ms");
}

sequentialTest();

Output:

Start
Login: 200 OK
Dashboard: 200 OK
Report: 200 OK
Time: ~3000ms

3 calls × 1 second each = ~3 seconds. Each waits for the previous one.


Parallel Execution (All at Once)

When tests are INDEPENDENT, run them simultaneously with Promise.all().

QA Example: Health check 3 microservices at the same time

function apiCall(name) {
  return new Promise(function (resolve) {
    setTimeout(function () {
      resolve(name + ": 200 OK");
    }, 1000);
  });
}

async function parallelTest() {
  console.log("Start");
  let start = Date.now();

  let [r1, r2, r3] = await Promise.all([
    apiCall("Auth Service"),
    apiCall("User Service"),
    apiCall("Payment Service")
  ]);

  console.log(r1);
  console.log(r2);
  console.log(r3);
  console.log("Time: ~" + (Date.now() - start) + "ms");
}

parallelTest();

Output:

Start
Auth Service: 200 OK
User Service: 200 OK
Payment Service: 200 OK
Time: ~1000ms

Same 3 calls, but only ~1 second total because they all started at the same time!


When to Use Sequential vs Parallel

Use Sequential When... Use Parallel When...
Step 2 needs Step 1's result Steps are independent
Login → then fetch data Health check 5 APIs
Create user → then verify Validate 3 form fields
Order matters Order doesn't matter
await one by one await Promise.all([...])

🟩 TOPIC 3 EXERCISES — Async/Await (12 Exercises)


Exercise 1: Basic async Function

async function sayHello() {
  return "Hello, QA!";
}

sayHello().then(function (msg) {
  console.log(msg);
});

Output:

Hello, QA!

Exercise 2: await Extracts the Value

async function getStatus() {
  let status = await Promise.resolve(200);
  console.log("Status code:", status);
}

getStatus();

Output:

Status code: 200

Exercise 3: Multiple awaits in Sequence

async function testFlow() {
  let step1 = await Promise.resolve("Opened browser");
  console.log(step1);

  let step2 = await Promise.resolve("Clicked login");
  console.log(step2);

  let step3 = await Promise.resolve("Verified dashboard");
  console.log(step3);
}

testFlow();

Output:

Opened browser
Clicked login
Verified dashboard

Exercise 4: try/catch Error Handling

async function riskyTest() {
  try {
    let data = await Promise.reject("Element not found");
    console.log(data);
  } catch (err) {
    console.log("Test failed:", err);
  }
}

riskyTest();

Output:

Test failed: Element not found

Exercise 5: try/catch/finally

async function apiTest() {
  try {
    let response = await Promise.resolve({ status: 201, body: "Created" });
    console.log("Status:", response.status);
    console.log("Body:", response.body);
  } catch (err) {
    console.log("Error:", err);
  } finally {
    console.log("Test complete");
  }
}

apiTest();

Output:

Status: 201
Body: Created
Test complete

Exercise 6: Execution Order — Sync vs Async

console.log("A");

async function test() {
  console.log("B");
  await Promise.resolve();
  console.log("C");
}

test();
console.log("D");

Output:

A
B
D
C

Inside async, await pauses that function. "D" prints before "C" because the main thread continues while the async function is paused.


Exercise 7: Parallel with Promise.all()

async function runAll() {
  let [a, b, c] = await Promise.all([
    Promise.resolve("Login: OK"),
    Promise.resolve("Cart: OK"),
    Promise.resolve("Checkout: OK")
  ]);

  console.log(a);
  console.log(b);
  console.log(c);
}

runAll();

Output:

Login: OK
Cart: OK
Checkout: OK

Exercise 8: Parallel with allSettled

async function healthCheck() {
  let results = await Promise.allSettled([
    Promise.resolve("Auth: UP"),
    Promise.reject("DB: DOWN"),
    Promise.resolve("Cache: UP")
  ]);

  results.forEach(function (r) {
    let status = r.status === "fulfilled" ? "✅" : "❌";
    let msg = r.value || r.reason;
    console.log(status + " " + msg);
  });
}

healthCheck();

Output:

✅ Auth: UP
❌ DB: DOWN
✅ Cache: UP

Exercise 9: Loop with await (Sequential)

async function checkEndpoints() {
  let endpoints = ["/login", "/users", "/orders"];

  for (let i = 0; i < endpoints.length; i++) {
    let result = await Promise.resolve(endpoints[i] + " → 200");
    console.log(result);
  }

  console.log("All checks done");
}

checkEndpoints();

Output:

/login → 200
/users → 200
/orders → 200
All checks done

Exercise 10: Async IIFE (Immediately Invoked)

(async function () {
  let msg = await Promise.resolve("Quick async test");
  console.log(msg);
})();

console.log("Outside");

Output:

Outside
Quick async test

Exercise 11: Async with Return Value

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

async function main() {
  let result = await add(10, 20);
  console.log("Sum:", result);

  let result2 = await add(result, 30);
  console.log("Total:", result2);
}

main();

Output:

Sum: 30
Total: 60

Exercise 12: Real QA — Retry Pattern with Async/Await

let attempt = 0;

function flakyAPI() {
  attempt++;
  if (attempt < 3) {
    return Promise.reject("Attempt " + attempt + ": failed");
  }
  return Promise.resolve("Attempt " + attempt + ": success!");
}

async function retryTest(maxRetries) {
  for (let i = 1; i <= maxRetries; i++) {
    try {
      let result = await flakyAPI();
      console.log(result);
      return;
    } catch (err) {
      console.log(err);
    }
  }
  console.log("All retries exhausted");
}

retryTest(5);

Output:

Attempt 1: failed
Attempt 2: failed
Attempt 3: success!

This is exactly how Cypress retry logic works — try, fail, try again.



🟩 TOPIC 4: Export/Import, Classes & Objects — Constructor, this, Private, Static, Encapsulation


Export and Import — Sharing Code Between Files

In real QA projects, you don't write everything in one file. You create utility files, page objects, config files — and IMPORT them where needed.

Two types of exports:

Named Export — Export Multiple Things

testUtils.mjs

export let BASE_URL = "https://api.staging.com";

export function formatTestName(name) {
  return "TC_" + name.toUpperCase();
}

test.mjs

import { BASE_URL, formatTestName } from "./testUtils.mjs";

console.log(BASE_URL);
console.log(formatTestName("login"));

Output:

https://api.staging.com
TC_LOGIN

You import by EXACT name inside { }. You can import one, some, or all.


Default Export — Export One Main Thing

Logger.mjs

export default function log(message) {
  console.log("[LOG] " + message);
}

test.mjs

import log from "./Logger.mjs";
log("Test started");

Output:

[LOG] Test started

No { } needed for default imports. You can name it anything you want.


Named vs Default Export

Feature Named Export Default Export
How many per file Unlimited Only 1
Import syntax import { name } from import anyName from
Use case Utilities, constants, helpers Main class or function of a file
Rename import { name as alias } Name it anything

Classes and Objects

A class is a blueprint. An object is a real thing built from that blueprint.

Blueprint: TestCase class defines what a test case looks like. Object: loginTest is an actual test case created from that blueprint.

class TestCase {
  constructor(name, status) {
    this.name = name;
    this.status = status;
  }

  display() {
    console.log(this.name + " → " + this.status);
  }
}

let loginTest = new TestCase("Login Test", "PASS");
let signupTest = new TestCase("Signup Test", "FAIL");

loginTest.display();
signupTest.display();

Output:

Login Test → PASS
Signup Test → FAIL

Same blueprint, two different objects with different data.


Constructor — The Setup Function

The constructor() runs AUTOMATICALLY when you write new ClassName(). It sets up the initial values.

Think of it like Cypress beforeEach() — it runs before anything else to set things up.

class Browser {
  constructor(name) {
    this.name = name;
    this.isOpen = true;
    console.log(name + " launched");
  }
}

let chrome = new Browser("Chrome");
let firefox = new Browser("Firefox");

console.log(chrome.isOpen);

Output:

Chrome launched
Firefox launched
true

The this Keyword

this refers to the CURRENT OBJECT — the one calling the method.

class APIClient {
  constructor(baseURL) {
    this.baseURL = baseURL;
  }

  get(path) {
    return this.baseURL + path;
  }
}

let staging = new APIClient("https://staging.api.com");
let prod = new APIClient("https://prod.api.com");

console.log(staging.get("/users"));
console.log(prod.get("/users"));

Output:

https://staging.api.com/users
https://prod.api.com/users

Same method, but this.baseURL points to different values because they're different objects.


Private Fields (#) — Hidden Data

Prefix a field with # and it becomes INVISIBLE outside the class. Nobody can read or change it directly.

QA Use Case: You don't want anyone accessing the API key directly.

class Credentials {
  #apiKey;

  constructor(user, key) {
    this.user = user;
    this.#apiKey = key;
  }

  getAuthHeader() {
    return "Bearer " + this.#apiKey;
  }
}

let cred = new Credentials("admin", "secret_key_123");

console.log(cred.user);
console.log(cred.getAuthHeader());
console.log(cred.apiKey);
// console.log(cred.#apiKey);  → SyntaxError!

Output:

admin
Bearer secret_key_123
undefined

cred.apiKey is undefined (it doesn't exist). cred.#apiKey would throw a SyntaxError. The ONLY way to access it is through the public method getAuthHeader().


Static Variables and Methods

static means "belongs to the CLASS, not to individual objects."

Think of a test runner that counts how many tests ran. Each test doesn't need its own counter — ONE shared counter on the class itself.

class TestRunner {
  static totalTests = 0;
  static passCount = 0;

  constructor(name, passed) {
    this.name = name;
    TestRunner.totalTests++;
    if (passed) TestRunner.passCount++;
  }

  static summary() {
    return TestRunner.passCount + "/" + TestRunner.totalTests + " passed";
  }
}

new TestRunner("Login", true);
new TestRunner("Signup", false);
new TestRunner("Cart", true);
new TestRunner("Checkout", true);

console.log(TestRunner.summary());

Output:

3/4 passed

You call static with ClassName.method(), NOT object.method().


Encapsulation — Hide the Internals, Expose the Controls

Encapsulation = private data + public methods to access it safely.

Like a car — you use the steering wheel and pedals (public interface), but you can't directly touch the engine (private internals).

class TestConfig {
  #timeout;
  #retries;

  constructor(timeout, retries) {
    this.#timeout = timeout;
    this.#retries = retries;
  }

  getTimeout() {
    return this.#timeout;
  }

  setTimeout(val) {
    if (val > 0 && val <= 30000) {
      this.#timeout = val;
    } else {
      console.log("Invalid timeout: must be 1-30000");
    }
  }

  getRetries() {
    return this.#retries;
  }
}

let config = new TestConfig(5000, 3);
console.log("Timeout:", config.getTimeout());

config.setTimeout(10000);
console.log("New timeout:", config.getTimeout());

config.setTimeout(-5);
console.log("Still:", config.getTimeout());

Output:

Timeout: 5000
New timeout: 10000
Invalid timeout: must be 1-30000
Still: 10000

The setTimeout method VALIDATES before changing the value. Direct access would skip this validation.


🟩 TOPIC 4 EXERCISES — Classes, Export/Import, Encapsulation (12 Exercises)


Exercise 1: Basic Class and Object

class Bug {
  constructor(title, severity) {
    this.title = title;
    this.severity = severity;
  }

  display() {
    console.log("[" + this.severity + "] " + this.title);
  }
}

let b1 = new Bug("Login crash", "Critical");
let b2 = new Bug("Typo in footer", "Low");

b1.display();
b2.display();

Output:

[Critical] Login crash
[Low] Typo in footer

Exercise 2: Constructor with Default Values

class Environment {
  constructor(name = "staging", port = 3000) {
    this.name = name;
    this.port = port;
  }

  getURL() {
    return "http://" + this.name + ":" + this.port;
  }
}

let env1 = new Environment();
let env2 = new Environment("production", 8080);

console.log(env1.getURL());
console.log(env2.getURL());

Output:

http://staging:3000
http://production:8080

Exercise 3: this Refers to Current Object

class User {
  constructor(name) {
    this.name = name;
  }

  greet() {
    console.log("Hi, I am " + this.name);
  }
}

let u1 = new User("Alice");
let u2 = new User("Bob");

u1.greet();
u2.greet();

Output:

Hi, I am Alice
Hi, I am Bob

Exercise 4: Method Chaining with this

class Counter {
  constructor() {
    this.count = 0;
  }

  increment() {
    this.count++;
    return this;
  }

  display() {
    console.log("Count:", this.count);
    return this;
  }
}

new Counter().increment().increment().increment().display();

Output:

Count: 3

Exercise 5: Private Field

class Token {
  #value;

  constructor(val) {
    this.#value = val;
  }

  getToken() {
    return this.#value;
  }

  getMasked() {
    return "***" + this.#value.slice(-4);
  }
}

let t = new Token("abcdef1234");
console.log(t.getMasked());
console.log(t.getToken());
console.log(t.value);

Output:

***1234
abcdef1234
undefined

Exercise 6: Static Counter

class TestCase {
  static count = 0;

  constructor(name) {
    this.name = name;
    TestCase.count++;
  }
}

new TestCase("Login");
new TestCase("Search");
new TestCase("Checkout");

console.log("Total test cases:", TestCase.count);

Output:

Total test cases: 3

Exercise 7: Static Method

class MathHelper {
  static add(a, b) {
    return a + b;
  }

  static multiply(a, b) {
    return a * b;
  }
}

console.log(MathHelper.add(5, 3));
console.log(MathHelper.multiply(5, 3));

// let m = new MathHelper();
// m.add(5, 3);  → TypeError! Static methods can't be called on instances.

Output:

8
15

Exercise 8: Encapsulation with Getter/Setter

class Volume {
  #level;

  constructor(level) {
    this.#level = level;
  }

  get value() {
    return this.#level;
  }

  set value(val) {
    if (val >= 0 && val <= 100) {
      this.#level = val;
    } else {
      console.log("Invalid: 0-100 only");
    }
  }
}

let v = new Volume(50);
console.log(v.value);

v.value = 80;
console.log(v.value);

v.value = 200;
console.log(v.value);

Output:

50
80
Invalid: 0-100 only
80

Exercise 9: Class with Validation

class TestData {
  constructor(key, value) {
    if (!key || key.length === 0) {
      throw new Error("Key cannot be empty");
    }
    this.key = key;
    this.value = value;
  }
}

try {
  let d1 = new TestData("username", "admin");
  console.log(d1.key + ":", d1.value);
} catch (e) {
  console.log("Error:", e.message);
}

try {
  let d2 = new TestData("", "admin");
  console.log(d2.key + ":", d2.value);
} catch (e) {
  console.log("Error:", e.message);
}

Output:

username: admin
Error: Key cannot be empty

Exercise 10: Multiple Objects — Same Class

class Endpoint {
  constructor(method, path) {
    this.method = method;
    this.path = path;
  }

  toString() {
    return this.method + " " + this.path;
  }
}

let apis = [
  new Endpoint("GET", "/users"),
  new Endpoint("POST", "/login"),
  new Endpoint("DELETE", "/users/1")
];

apis.forEach(function (ep) {
  console.log(ep.toString());
});

Output:

GET /users
POST /login
DELETE /users/1

Exercise 11: Private Method

class Reporter {
  #formatLine(label, value) {
    return "[" + label + "] " + value;
  }

  print(name, status) {
    console.log(this.#formatLine(status, name));
  }
}

let r = new Reporter();
r.print("Login Test", "PASS");
r.print("Signup Test", "FAIL");

Output:

[PASS] Login Test
[FAIL] Signup Test

Exercise 12: Full Encapsulated Config Manager

class Config {
  #data = {};

  set(key, value) {
    this.#data[key] = value;
  }

  get(key) {
    return this.#data[key];
  }

  has(key) {
    return key in this.#data;
  }

  getAll() {
    return Object.assign({}, this.#data);
  }
}

let cfg = new Config();
cfg.set("baseURL", "https://qa.api.com");
cfg.set("timeout", 5000);

console.log(cfg.get("baseURL"));
console.log(cfg.has("timeout"));
console.log(cfg.getAll());

Output:

https://qa.api.com
true
{ baseURL: 'https://qa.api.com', timeout: 5000 }


🟩 TOPIC 5: Inheritance, Method Overriding, Multi-Level & Multiple Inheritance


What is Inheritance?

Inheritance means a child class gets everything from its parent class — all properties and methods — for FREE. The child can then ADD new stuff or CHANGE existing stuff.

Real QA Example: In Page Object Model, every page has open() and close(). Instead of writing these in every page class, write them ONCE in BasePage, and let all pages INHERIT.

class BasePage {
  constructor(pageName) {
    this.pageName = pageName;
  }

  open() {
    console.log("Opening: " + this.pageName);
  }

  close() {
    console.log("Closing: " + this.pageName);
  }
}

class LoginPage extends BasePage {
  constructor() {
    super("Login Page");
  }

  login(user) {
    console.log(user + " logged in");
  }
}

let page = new LoginPage();
page.open();
page.login("admin");
page.close();

Output:

Opening: Login Page
admin logged in
Closing: Login Page

LoginPage never defined open() or close() — it got them from BasePage. That's inheritance.

Key Rules:

  • extends → makes a child class
  • super() → calls the parent's constructor (MUST be first line in child constructor)
  • Child gets ALL parent methods automatically

Method Overriding

When a child class creates a method with the SAME NAME as the parent, the child's version wins. This is called overriding.

class BaseTest {
  setup() {
    console.log("Base: open browser");
  }
}

class APITest extends BaseTest {
  setup() {
    console.log("API: initialize HTTP client");
  }
}

let test = new APITest();
test.setup();

Output:

API: initialize HTTP client

Only the child's setup() ran. The parent's version was overridden.


Using super.method() — Call Parent's Version Too

Sometimes you want BOTH — parent's logic AND child's extra logic.

class BaseTest {
  setup() {
    console.log("Base: open browser");
  }

  teardown() {
    console.log("Base: close browser");
  }
}

class UITest extends BaseTest {
  setup() {
    super.setup();
    console.log("UI: maximize window");
  }

  teardown() {
    console.log("UI: take screenshot");
    super.teardown();
  }
}

let test = new UITest();
test.setup();
console.log("---");
test.teardown();

Output:

Base: open browser
UI: maximize window
---
UI: take screenshot
Base: close browser

super.setup() runs the parent's version first, then the child adds its own logic.


Multi-Level Inheritance (A → B → C)

A chain where each level inherits from the one above it.

QA Example: BasePage → AuthPage → AdminPage

class BasePage {
  constructor(name) {
    this.name = name;
  }

  open() {
    console.log("[OPEN] " + this.name);
  }
}

class AuthPage extends BasePage {
  login(user) {
    console.log("[LOGIN] " + user);
  }
}

class AdminPage extends AuthPage {
  constructor() {
    super("Admin Panel");
  }

  manageUsers() {
    console.log("[ADMIN] Managing users");
  }
}

let admin = new AdminPage();
admin.open();
admin.login("superadmin");
admin.manageUsers();

Output:

[OPEN] Admin Panel
[LOGIN] superadmin
[ADMIN] Managing users

AdminPage has 3 methods:

  • open() → from BasePage (grandparent)
  • login() → from AuthPage (parent)
  • manageUsers() → its own

Multiple Inheritance — JavaScript Does NOT Support It Directly

You CANNOT do this:

class C extends A, B { }  // ❌ SyntaxError

But you CAN simulate it using Mixins — functions that wrap a class and add behavior.

// Mixin 1: Adds logging ability
let LoggerMixin = function (Base) {
  return class extends Base {
    log(msg) {
      console.log("[LOG] " + msg);
    }
  };
};

// Mixin 2: Adds screenshot ability
let ScreenshotMixin = function (Base) {
  return class extends Base {
    takeScreenshot() {
      console.log("[SCREENSHOT] captured");
    }
  };
};

// Base class
class TestCase {
  constructor(name) {
    this.name = name;
  }

  run() {
    console.log("Running: " + this.name);
  }
}

// Apply BOTH mixins
class SmartTest extends ScreenshotMixin(LoggerMixin(TestCase)) {
  constructor(name) {
    super(name);
  }
}

let t = new SmartTest("Login Flow");
t.run();
t.log("Test started");
t.takeScreenshot();

Output:

Running: Login Flow
[LOG] Test started
[SCREENSHOT] captured

SmartTest now has methods from:

  • TestCaserun()
  • LoggerMixinlog()
  • ScreenshotMixintakeScreenshot()

That's effectively multiple inheritance.


Exporting and Importing Classes Between Files

This is how real Playwright/Cypress projects are structured.

BasePage.mjs

export class BasePage {
  constructor(name) {
    this.name = name;
  }

  open() {
    console.log("Opening " + this.name);
  }
}

LoginPage.mjs

import { BasePage } from "./BasePage.mjs";

export class LoginPage extends BasePage {
  constructor() {
    super("Login Page");
  }

  login(user) {
    console.log(user + " logged in");
  }
}

test.mjs

import { LoginPage } from "./LoginPage.mjs";

let page = new LoginPage();
page.open();
page.login("admin");

Output:

Opening Login Page
admin logged in

Inheritance Cheat Sheet

Concept Syntax What It Does
Inherit class Child extends Parent Child gets all parent methods
Call parent constructor super() Runs parent's constructor()
Call parent method super.methodName() Runs parent's version of a method
Override Same method name in child Child's version replaces parent's
Multi-level A → B → C Each level inherits from above
Mixins class C extends Mixin(Base) Simulate multiple inheritance
Check type obj instanceof ClassName Returns true/false

🟩 TOPIC 5 EXERCISES — Inheritance (15 Exercises)


Exercise 1: Basic Inheritance

class Animal {
  constructor(name) {
    this.name = name;
  }

  speak() {
    console.log(this.name + " makes a sound");
  }
}

class Dog extends Animal {
  bark() {
    console.log(this.name + " barks");
  }
}

let d = new Dog("Rex");
d.speak();
d.bark();

Output:

Rex makes a sound
Rex barks

Exercise 2: super() Calls Parent Constructor

class Vehicle {
  constructor(type) {
    this.type = type;
    console.log("Vehicle: " + type);
  }
}

class Car extends Vehicle {
  constructor(brand) {
    super("Car");
    this.brand = brand;
    console.log("Brand: " + brand);
  }
}

let c = new Car("Tesla");

Output:

Vehicle: Car
Brand: Tesla

Exercise 3: Method Overriding — Complete Replace

class Shape {
  area() {
    return 0;
  }
}

class Rectangle extends Shape {
  constructor(w, h) {
    super();
    this.w = w;
    this.h = h;
  }

  area() {
    return this.w * this.h;
  }
}

let r = new Rectangle(5, 3);
console.log("Area:", r.area());

Output:

Area: 15

Exercise 4: Method Override with super.method()

class Base {
  greet() {
    return "Hello";
  }
}

class Child extends Base {
  greet() {
    return super.greet() + " World";
  }
}

console.log(new Child().greet());

Output:

Hello World

Exercise 5: instanceof Check

class Vehicle {}
class Car extends Vehicle {}
class Tesla extends Car {}

let t = new Tesla();

console.log(t instanceof Tesla);
console.log(t instanceof Car);
console.log(t instanceof Vehicle);
console.log(t instanceof Object);

Output:

true
true
true
true

instanceof checks the ENTIRE chain — Tesla → Car → Vehicle → Object.


Exercise 6: Multi-Level — Three Levels

class A {
  methodA() { console.log("From A"); }
}

class B extends A {
  methodB() { console.log("From B"); }
}

class C extends B {
  methodC() { console.log("From C"); }
}

let obj = new C();
obj.methodA();
obj.methodB();
obj.methodC();

Output:

From A
From B
From C

Exercise 7: Constructor Chain — super() Goes Up

class Level1 {
  constructor() { console.log("Level 1"); }
}

class Level2 extends Level1 {
  constructor() {
    super();
    console.log("Level 2");
  }
}

class Level3 extends Level2 {
  constructor() {
    super();
    console.log("Level 3");
  }
}

new Level3();

Output:

Level 1
Level 2
Level 3

Constructors run top-down — grandparent first, then parent, then child.


Exercise 8: Override at Different Levels

class A {
  who() { console.log("A"); }
}

class B extends A {
  who() { console.log("B"); }
}

class C extends B { }

class D extends B {
  who() { console.log("D"); }
}

new A().who();
new B().who();
new C().who();
new D().who();

Output:

A
B
B
D

C doesn't override who(), so it uses B's version.


Exercise 9: Mixin — Add Logging

let Logger = function (Base) {
  return class extends Base {
    log(msg) {
      console.log("[LOG] " + msg);
    }
  };
};

class TestCase {
  constructor(name) {
    this.name = name;
  }
}

class LoggableTest extends Logger(TestCase) {
  constructor(name) {
    super(name);
  }
}

let t = new LoggableTest("Login");
console.log(t.name);
t.log("Test started");

Output:

Login
[LOG] Test started

Exercise 10: Two Mixins Combined

let Timestamped = function (Base) {
  return class extends Base {
    getTime() { return "2026-03-26"; }
  };
};

let Tagged = function (Base) {
  return class extends Base {
    constructor(...args) {
      super(...args);
      this.tags = [];
    }
    addTag(tag) { this.tags.push(tag); }
  };
};

class Bug {
  constructor(title) {
    this.title = title;
  }
}

class SmartBug extends Tagged(Timestamped(Bug)) {
  constructor(title) {
    super(title);
  }
}

let b = new SmartBug("Crash on save");
b.addTag("critical");
b.addTag("backend");
console.log(b.title);
console.log(b.tags);
console.log(b.getTime());

Output:

Crash on save
[ 'critical', 'backend' ]
2026-03-26

Exercise 11: Static Method Inheritance

class Parent {
  static greet() { return "Hello from Parent"; }
}

class Child extends Parent { }

class GrandChild extends Parent {
  static greet() { return "Hello from GrandChild"; }
}

console.log(Parent.greet());
console.log(Child.greet());
console.log(GrandChild.greet());

Output:

Hello from Parent
Hello from Parent
Hello from GrandChild

Child inherits the static. GrandChild overrides it.


Exercise 12: Private Fields Are NOT Inherited

class Base {
  #secret = "hidden";

  getSecret() {
    return this.#secret;
  }
}

class Child extends Base {
  tryAccess() {
    // Cannot do: return this.#secret  → SyntaxError
    return this.getSecret();
  }
}

let c = new Child();
console.log(c.tryAccess());

Output:

hidden

Private fields stay private to the class that created them. Child MUST use the parent's public method.


Exercise 13: Polymorphism — Same Method, Different Behavior

class TestType {
  execute() { console.log("Generic test"); }
}

class UnitTest extends TestType {
  execute() { console.log("Running unit test"); }
}

class APITest extends TestType {
  execute() { console.log("Running API test"); }
}

class E2ETest extends TestType {
  execute() { console.log("Running E2E test"); }
}

let tests = [new UnitTest(), new APITest(), new E2ETest()];

tests.forEach(function (t) {
  t.execute();
});

Output:

Running unit test
Running API test
Running E2E test

Same execute() method, but each class implements it differently. This is polymorphism — "many forms."


Exercise 14: Real QA — Page Object Hierarchy

class BasePage {
  constructor(name) { this.name = name; }
  open() { console.log("[OPEN] " + this.name); }
  close() { console.log("[CLOSE] " + this.name); }
}

class AuthPage extends BasePage {
  login(user) {
    console.log("[LOGIN] " + user + " on " + this.name);
  }
}

class AdminPage extends AuthPage {
  constructor() {
    super("Admin Panel");
  }

  login(user) {
    super.login(user);
    console.log("[ADMIN] Elevated access granted");
  }

  manage() {
    console.log("[ADMIN] Managing users");
  }
}

let admin = new AdminPage();
admin.open();
admin.login("superadmin");
admin.manage();
admin.close();

Output:

[OPEN] Admin Panel
[LOGIN] superadmin on Admin Panel
[ADMIN] Elevated access granted
[ADMIN] Managing users
[CLOSE] Admin Panel

Exercise 15: Checking the Prototype Chain

class A {}
class B extends A {}
class C extends B {}

let obj = new C();

console.log("C?", obj instanceof C);
console.log("B?", obj instanceof B);
console.log("A?", obj instanceof A);

console.log("Constructor:", obj.constructor.name);

// Walk the prototype chain
let proto = Object.getPrototypeOf(obj);
let chain = [];
while (proto) {
  if (proto.constructor.name !== "Object") {
    chain.push(proto.constructor.name);
  }
  proto = Object.getPrototypeOf(proto);
}
console.log("Chain:", chain.join(" → "));

Output:

C? true
B? true
A? true
Constructor: C
Chain: C → B → A

This shows exactly how JavaScript walks up the chain when looking for methods.



🟩 MASTER SUMMARY — All 5 Topics at a Glance

# Topic One-Line Summary QA Use Case
1 Callbacks Function passed to another function Array methods, test hooks, event handlers
2 Callback Hell Nested callbacks = unreadable pyramid Sequential E2E steps (the old, bad way)
3 Promises Object representing a future result API calls, async test steps
4 Promise Chain .then().catch().finally() — flat async Multi-step flows without nesting
5 Promise.all Run independent promises in parallel Health check multiple services
6 Async/Await Promises that look like sync code Clean test functions in Cypress/Playwright
7 Sequential await one after another Login → Navigate → Assert
8 Parallel Promise.all() with await Check 5 APIs simultaneously
9 Export/Import Share code between files Page objects, utilities, configs
10 Classes Blueprint for objects Page objects, test helpers
11 Constructor Auto-runs on new — sets up object Initialize browser, URL, config
12 this Refers to current object Different objects, same method
13 Private (#) Hidden data — can't access outside API keys, passwords, tokens
14 Static Belongs to class, not instances Test counters, utility methods
15 Encapsulation Private data + public methods Config manager, credential store
16 Inheritance Child reuses parent's code Page Object Model hierarchy
17 Method Override Child replaces parent's method Custom setup/teardown per test type
18 Multi-Level A → B → C chain BasePage → AuthPage → AdminPage
19 Mixins Simulate multiple inheritance Add logging + screenshots to any test
20 Polymorphism Same method, different implementations Unit/API/E2E tests with shared interface

Total Exercises: 63

  • Topic 1 — Callbacks: 12 exercises
  • Topic 2 — Promises: 12 exercises
  • Topic 3 — Async/Await: 12 exercises
  • Topic 4 — Classes/Export/Encapsulation: 12 exercises
  • Topic 5 — Inheritance: 15 exercises


🟩 JAVASCRIPT vs TYPESCRIPT — Which Topics Belong Where?


The Short Answer

Every single topic covered in this document is 100% JavaScript (ES6+). None of these are TypeScript-specific. TypeScript BUILDS ON TOP of JavaScript — so everything you learn here works in TypeScript too, but TypeScript adds extra features on top.


Complete Classification Table

# Topic Language Introduced In Works In TypeScript?
1 Callback Functions ✅ JavaScript ES5 (2009) — existed since the beginning Yes — works as-is
2 Synchronous Callbacks (forEach, map, filter) ✅ JavaScript ES5 (2009) Yes — works as-is
3 Asynchronous Callbacks (setTimeout) ✅ JavaScript ES1 (1997) — part of Web APIs Yes — works as-is
4 Callback Hell / Pyramid of Doom ✅ JavaScript Pattern (not a feature) — existed since async JS began Same problem exists in TS
5 Promises ✅ JavaScript ES6 (2015) Yes — works as-is
6 Promise Chain (.then/.catch/.finally) ✅ JavaScript ES6 (2015), .finally() in ES2018 Yes — works as-is
7 Promise.all / allSettled / race ✅ JavaScript ES6 (2015), allSettled in ES2020 Yes — works as-is
8 Async/Await ✅ JavaScript ES2017 (ES8) Yes — works as-is
9 Sequential & Parallel Execution ✅ JavaScript ES2017 (pattern using async/await) Yes — works as-is
10 Export / Import (ES Modules) ✅ JavaScript ES6 (2015) Yes — works as-is
11 Classes & Objects ✅ JavaScript ES6 (2015) Yes — TS adds type annotations
12 Constructor ✅ JavaScript ES6 (2015) Yes — works as-is
13 this keyword ✅ JavaScript ES1 (1997) — existed since the beginning Yes — works as-is
14 Private Fields (#) ✅ JavaScript ES2022 Yes — but TS also has its own private keyword
15 Static Variables & Methods ✅ JavaScript ES6 (2015), static fields in ES2022 Yes — works as-is
16 Encapsulation ✅ JavaScript ES2022 (using # private fields) Yes — TS has additional access modifiers
17 Inheritance (extends, super) ✅ JavaScript ES6 (2015) Yes — works as-is
18 Method Overriding ✅ JavaScript ES6 (2015) Yes — TS adds override keyword
19 Multi-Level Inheritance ✅ JavaScript ES6 (2015) Yes — works as-is
20 Mixins (Multiple Inheritance) ✅ JavaScript Pattern (not a feature) Yes — TS has interfaces for this too

So What Does TypeScript ADD That JavaScript Doesn't Have?

TypeScript is a SUPERSET of JavaScript. Think of it like this:

JavaScript = the car TypeScript = the same car + GPS navigation + lane assist + parking sensors

Everything the car does, the upgraded car also does. But the upgraded car has extra safety features.

Here are the features that are ONLY in TypeScript and DO NOT exist in plain JavaScript:

TypeScript-Only Feature What It Does JavaScript Equivalent
type annotations (let x: number = 5) Tells the compiler what type a variable should be None — JS figures it out at runtime
interface Defines the shape/structure of an object None — JS uses duck typing
enum Named set of constants Use const object or Object.freeze()
private / public / protected keywords Access modifiers on class members JS has # for private (ES2022), no protected
readonly Makes a property unmodifiable after creation Use Object.freeze() or const
abstract class Class that cannot be instantiated directly No direct equivalent
generics (<T>) Type-safe reusable code None — JS doesn't have types
type and interface for function signatures Define what parameters and return types a function has None
as type assertion Tell the compiler "trust me, this is type X" None — not needed in JS
override keyword Explicitly mark that a method overrides parent JS overrides implicitly (just use same name)
Decorators (@decorator) Add metadata to classes/methods Stage 3 proposal in JS, not widely supported yet
namespace Organize code into named groups Use ES modules (import/export)
tuple types ([string, number]) Fixed-length array with specific types per position Use regular arrays

The Key Difference for QA/SDET

In your real-world work:

Cypress → Uses JavaScript (can optionally use TypeScript) Playwright → Uses TypeScript by default (but also supports JavaScript) API testing with Axios/Supertest → JavaScript (TypeScript optional) Jest/Mocha → JavaScript (TypeScript optional)

The rule is simple: Learn JavaScript FIRST, TypeScript SECOND. TypeScript is JavaScript + types. If you don't understand JavaScript, TypeScript will confuse you. If you DO understand JavaScript, TypeScript is just adding : type annotations to what you already know.

// JAVASCRIPT — no types
function add(a, b) {
  return a + b;
}

let result = add(5, 3);
// TYPESCRIPT — same thing + type annotations
function add(a: number, b: number): number {
  return a + b;
}

let result: number = add(5, 3);

Same logic. Same output. TypeScript just tells the compiler "a must be a number, b must be a number, and the return value is a number." If you accidentally write add("hello", 3), TypeScript catches it BEFORE you run the code. JavaScript would happily give you "hello3" and you'd find the bug at runtime.


Private Fields — JS (#) vs TypeScript (private)

This is the ONE topic from our document where JS and TS have slightly different approaches to the same problem:

// JAVASCRIPT way — # private field (ES2022)
class User {
  #password;

  constructor(pass) {
    this.#password = pass;
  }

  check(input) {
    return input === this.#password;
  }
}

let u = new User("secret");
// u.#password → SyntaxError at RUNTIME
// TYPESCRIPT way — private keyword
class User {
  private password: string;

  constructor(pass: string) {
    this.password = pass;
  }

  check(input: string): boolean {
    return input === this.password;
  }
}

let u = new User("secret");
// u.password → Error at COMPILE TIME (not runtime)

Key difference: JavaScript # is enforced at RUNTIME (truly private — nobody can access it). TypeScript private is enforced at COMPILE TIME (the compiler warns you, but the JavaScript output has no protection).

For maximum safety, use # even in TypeScript projects.



🟩 ADDITIONAL CONCEPT DEEP-DIVES — 3 Key Points Per Topic


🟩 Topic 1: Callbacks — 3 Additional Key Concepts


1.1 Inversion of Control — The Hidden Risk of Callbacks

When you pass a callback to another function, you're handing over CONTROL. You're trusting that function to:

  • Call your callback at the right time
  • Call it only ONCE (not twice, not zero times)
  • Pass the correct arguments

This is called Inversion of Control — you no longer control when your code runs.

// You're trusting thirdPartyLibrary to call your callback correctly
thirdPartyLibrary.makePayment(amount, function () {
  console.log("Payment processed");
  // What if this gets called TWICE? You charge the customer twice!
  // What if this NEVER gets called? Customer waits forever!
});

QA Impact: In testing frameworks, this is why Cypress built its own command queue instead of relying on raw callbacks. You need GUARANTEES about when and how many times your code runs. Promises solve this — a Promise can only resolve or reject ONCE.


1.2 The Event Loop — Why Async Callbacks Run Later

JavaScript is single-threaded — it can only do one thing at a time. So how does it handle async operations?

The Event Loop is the mechanism:

  1. JavaScript runs all synchronous code first (this is the Call Stack)
  2. Async callbacks (setTimeout, API responses) wait in a Task Queue
  3. When the Call Stack is empty, the Event Loop picks the next callback from the queue

Think of it like a restaurant kitchen. The chef (Call Stack) handles orders one at a time. Completed dishes go to a serving window (Task Queue). The waiter (Event Loop) picks them up ONLY when the chef is free.

console.log("Chef starts cooking");

setTimeout(function () {
  console.log("Dish ready — picked up by waiter");
}, 0);

console.log("Chef finishes current order");

Output:

Chef starts cooking
Chef finishes current order
Dish ready — picked up by waiter

Even with 0ms delay, the callback waits because the Call Stack must be empty first.

QA Impact: This explains why Cypress commands queue up and run sequentially — they use the event loop intelligently. Understanding this prevents you from writing flaky tests that depend on timing.


1.3 Named Functions vs Anonymous Functions as Callbacks

You can pass callbacks in two ways — anonymous (no name) or named (with a name). Named callbacks are better for debugging because they show up in stack traces.

// ANONYMOUS — harder to debug
[1, 2, 3].forEach(function (num) {
  console.log(num);
});

// NAMED — easier to debug, stack trace shows "logNumber"
function logNumber(num) {
  console.log(num);
}

[1, 2, 3].forEach(logNumber);

Output (both produce the same):

1
2
3

QA Impact: When a test fails in CI/CD and you see a stack trace full of <anonymous> — good luck debugging. Name your callbacks, especially in test hooks, custom commands, and utility functions. Your future self will thank you.



🟩 Topic 2: Promises — 3 Additional Key Concepts


2.1 Microtask Queue — Why Promises Run Before setTimeout

Not all async operations are equal. JavaScript has TWO queues:

  • Microtask Queue (higher priority) — Promises, queueMicrotask()
  • Task Queue (lower priority) — setTimeout, setInterval, I/O

After each synchronous task, the engine DRAINS the microtask queue completely BEFORE touching the task queue.

console.log("1: sync");

setTimeout(function () {
  console.log("2: setTimeout (task queue)");
}, 0);

Promise.resolve().then(function () {
  console.log("3: Promise (microtask queue)");
});

console.log("4: sync");

Output:

1: sync
4: sync
3: Promise (microtask queue)
2: setTimeout (task queue)

Promise callback (#3) runs BEFORE setTimeout callback (#2) even though both have 0 delay. Microtasks always go first.

QA Impact: When you mix Promises and timers in tests, execution order matters. In Playwright, page.waitForResponse() (Promise-based) has different timing behavior than a raw setTimeout. This knowledge helps you understand why some tests pass locally but fail in CI — timing and queue priority.


2.2 Promise States Are Immutable — Once Settled, Forever Settled

A Promise can only change state ONCE:

  • Pending → Fulfilled (and stays fulfilled forever)
  • Pending → Rejected (and stays rejected forever)

You CANNOT go from Fulfilled back to Pending, or from Rejected to Fulfilled. This is what makes Promises safer than callbacks.

let p = new Promise(function (resolve, reject) {
  resolve("First");
  resolve("Second");    // IGNORED — already resolved
  reject("Third");      // IGNORED — already resolved
});

p.then(function (val) {
  console.log(val);
});

Output:

First

Only the FIRST resolve() or reject() counts. Everything after is silently ignored.

QA Impact: This solves the "double callback" problem from Topic 1. With callbacks, a buggy library might call your callback twice. With Promises, even if the code tries to resolve twice, only the first one takes effect. This prevents double-counting test results, duplicate API charges, etc.


2.3 Unhandled Promise Rejections — The Silent Test Killer

If a Promise rejects and you DON'T have a .catch(), Node.js will warn you (and in newer versions, crash your process). In test suites, this causes mysterious failures.

// BAD — no .catch(), unhandled rejection
let p = new Promise(function (resolve, reject) {
  reject("Something failed");
});

// GOOD — always handle rejections
let p2 = new Promise(function (resolve, reject) {
  reject("Something failed");
});

p2.catch(function (err) {
  console.log("Handled:", err);
});

Output:

Handled: Something failed

(The first promise also logs an UnhandledPromiseRejection warning in Node.js)

QA Impact: In Playwright and Cypress, unhandled promise rejections can cause your entire test suite to crash without a clear error message. ALWAYS add .catch() to Promise chains, or use try/catch with async/await. In CI pipelines, enable the --unhandled-rejections=strict Node.js flag to catch these early.



🟩 Topic 3: Async/Await — 3 Additional Key Concepts


3.1 Async Functions ALWAYS Return a Promise

Even if you return a plain value, an async function wraps it in a Promise automatically. This catches many beginners off guard.

async function getNumber() {
  return 42;
}

let result = getNumber();
console.log(result);
console.log(typeof result);

// To get the actual value, you must await or use .then()
getNumber().then(function (val) {
  console.log("Actual value:", val);
});

Output:

Promise { 42 }
object
Actual value: 42

getNumber() does NOT return 42 — it returns a Promise that contains 42. You must await it or use .then() to extract the value.

QA Impact: This is why you can't do let title = page.title() in Playwright — you need let title = await page.title(). Every Playwright method is async and returns a Promise. Forgetting await is the #1 beginner bug in Playwright tests.


3.2 await Only Works Inside async Functions (and Top-Level Modules)

You cannot use await in a regular function. It MUST be inside an async function.

// ❌ WRONG — SyntaxError
function test() {
  let data = await Promise.resolve("hello");
}

// ✅ CORRECT
async function test() {
  let data = await Promise.resolve("hello");
  console.log(data);
}

test();

Output:

hello

Exception: In Node.js with ES modules (.mjs files or "type": "module" in package.json), you CAN use await at the top level without wrapping it in an async function. This is called Top-Level Await.

// In an .mjs file — top-level await works
let data = await Promise.resolve("Top level works!");
console.log(data);

QA Impact: In Playwright test files, you CAN use await directly inside test() blocks because Playwright wraps them in async context for you. In Cypress, you DON'T use async/await at all — Cypress has its own command queue. Knowing which framework uses which approach saves hours of confusion.


3.3 for...of Loop with await vs forEach with await

A common mistake — using forEach with await. It does NOT work as expected because forEach doesn't wait for async callbacks.

// ❌ WRONG — forEach doesn't wait
async function wrong() {
  let items = [1, 2, 3];

  items.forEach(async function (item) {
    let result = await Promise.resolve(item * 10);
    console.log(result);
  });

  console.log("Done");
}

wrong();

Output (WRONG order):

Done
10
20
30

"Done" prints FIRST because forEach fires off all three async callbacks and doesn't wait.

// ✅ CORRECT — for...of waits properly
async function correct() {
  let items = [1, 2, 3];

  for (let item of items) {
    let result = await Promise.resolve(item * 10);
    console.log(result);
  }

  console.log("Done");
}

correct();

Output (CORRECT order):

10
20
30
Done

QA Impact: When you need to loop through test data and run async assertions for each item, ALWAYS use for...of, NEVER forEach. This is a very common bug in data-driven tests with Playwright.



🟩 Topic 4: Classes & Objects — 3 Additional Key Concepts


4.1 Classes Are Just Syntactic Sugar Over Prototypes

JavaScript classes look like classes from Java or C#, but under the hood they're still using JavaScript's original prototype system. The class keyword is just a cleaner way to write the same thing.

// MODERN WAY — class syntax
class Dog {
  constructor(name) {
    this.name = name;
  }

  bark() {
    console.log(this.name + " barks");
  }
}

// OLD WAY — prototype syntax (exactly the same thing)
function Cat(name) {
  this.name = name;
}

Cat.prototype.meow = function () {
  console.log(this.name + " meows");
};

let d = new Dog("Rex");
let c = new Cat("Kitty");

d.bark();
c.meow();

console.log(typeof Dog);
console.log(typeof Cat);

Output:

Rex barks
Kitty meows
function
function

Both Dog and Cat are typeof "function" — because classes ARE functions internally. The class keyword just makes them easier to read and write.

QA Impact: When you read older test frameworks or legacy codebases, you'll see the prototype syntax everywhere. Understanding that classes and prototypes are the SAME THING lets you work with both old and new code confidently.


4.2 Getters and Setters — Computed Properties

get and set let you define properties that LOOK like regular properties but actually run a function behind the scenes. You access them WITHOUT parentheses — they pretend to be normal properties.

class TestSuite {
  #tests = [];

  addTest(name) {
    this.#tests.push(name);
  }

  // GETTER — accessed like a property, not a function
  get count() {
    return this.#tests.length;
  }

  get isEmpty() {
    return this.#tests.length === 0;
  }
}

let suite = new TestSuite();
console.log(suite.isEmpty);
console.log(suite.count);

suite.addTest("Login");
suite.addTest("Logout");

console.log(suite.isEmpty);
console.log(suite.count);

Output:

true
0
false
2

Notice: suite.count NOT suite.count(). It looks like a property but runs code.

QA Impact: Playwright uses getters extensively. When you write page.url (no parentheses), that's a getter. When you write page.title() (with parentheses), that's a method. Knowing the difference prevents syntax errors.


4.3 Object Destructuring with Class Instances

You can destructure class instances just like regular objects — pull out specific properties into variables.

class APIResponse {
  constructor(status, body, headers) {
    this.status = status;
    this.body = body;
    this.headers = headers;
  }
}

let response = new APIResponse(200, "User created", { contentType: "json" });

// Destructure — pull out what you need
let { status, body } = response;

console.log("Status:", status);
console.log("Body:", body);

// Destructure with rename
let { status: code, body: data } = response;

console.log("Code:", code);
console.log("Data:", data);

Output:

Status: 200
Body: User created
Code: 200
Data: User created

QA Impact: In Playwright, you destructure API responses constantly:

// Real Playwright pattern
let { status, body } = await request.get("/api/users");
// Instead of:
let response = await request.get("/api/users");
let status = response.status;
let body = response.body;

Destructuring saves lines and makes test code cleaner.



🟩 Topic 5: Inheritance — 3 Additional Key Concepts


5.1 The super() Rule — MUST Be Called Before this

In a child class constructor, you MUST call super() BEFORE you use this. If you don't, JavaScript throws a ReferenceError.

class Parent {
  constructor() {
    this.type = "parent";
  }
}

// ❌ WRONG — using this before super()
class BadChild extends Parent {
  constructor() {
    // this.name = "test";  → ReferenceError!
    // super();
  }
}

// ✅ CORRECT — super() first, then this
class GoodChild extends Parent {
  constructor(name) {
    super();
    this.name = name;
  }
}

let c = new GoodChild("Login Test");
console.log(c.type);
console.log(c.name);

Output:

parent
Login Test

Why? The child object doesn't exist until the parent constructor creates it. super() creates the object, then this becomes available.

QA Impact: When building Page Object Model hierarchies, forgetting super() is the most common error. If your LoginPage extends BasePage and you get a ReferenceError, the first thing to check is whether super() is the first line in your constructor.


5.2 Checking Types with instanceof — Up the Entire Chain

instanceof doesn't just check the direct class — it checks the ENTIRE prototype chain. This is useful for writing conditional logic in test frameworks.

class BasePage {}
class AuthPage extends BasePage {}
class LoginPage extends AuthPage {}
class AdminPage extends AuthPage {}

let login = new LoginPage();
let admin = new AdminPage();

// Check direct type
console.log("login is LoginPage?", login instanceof LoginPage);
console.log("login is AdminPage?", login instanceof AdminPage);

// Check parent types
console.log("login is AuthPage?", login instanceof AuthPage);
console.log("login is BasePage?", login instanceof BasePage);

// Both share a common ancestor
console.log("admin is AuthPage?", admin instanceof AuthPage);

Output:

login is LoginPage? true
login is AdminPage? false
login is AuthPage? true
login is BasePage? true
admin is AuthPage? true

LoginPage is NOT an AdminPage (they're siblings, not parent-child). But both ARE AuthPage and BasePage.

QA Impact: In test frameworks, you might want to check "is this page an auth page?" before running login-specific teardown. instanceof lets you write smart, type-aware test utilities.


5.3 Composition vs Inheritance — When NOT to Use Inheritance

Inheritance is powerful but can be overused. The rule of thumb:

  • Inheritance = "IS A" relationship → LoginPage IS A BasePage ✅
  • Composition = "HAS A" relationship → TestCase HAS A Logger ✅

When something doesn't naturally fit "IS A", use composition instead.

// COMPOSITION — TestCase HAS a logger and a reporter
class Logger {
  log(msg) {
    console.log("[LOG] " + msg);
  }
}

class Reporter {
  report(name, status) {
    console.log("[REPORT] " + name + ": " + status);
  }
}

class TestCase {
  constructor(name) {
    this.name = name;
    this.logger = new Logger();
    this.reporter = new Reporter();
  }

  run() {
    this.logger.log("Running: " + this.name);
    let status = "PASS";
    this.reporter.report(this.name, status);
  }
}

let test = new TestCase("Login Flow");
test.run();

Output:

[LOG] Running: Login Flow
[REPORT] Login Flow: PASS

No inheritance used — TestCase creates instances of Logger and Reporter inside itself. This is MORE FLEXIBLE because you can swap out the logger or reporter without changing the inheritance chain.

QA Impact: Most modern test frameworks prefer composition over deep inheritance. Playwright's fixtures system uses composition — you compose your test with the pieces you need (page, browser, API context) rather than inheriting from a deep class hierarchy. When in doubt, prefer composition.



🟩 FINAL LANGUAGE CLASSIFICATION SUMMARY

┌─────────────────────────────────────────────────────────────────┐
│                     EVERYTHING IN THIS DOCUMENT                  │
│                                                                  │
│  ┌───────────────────────────────────────────────────────────┐  │
│  │                 ✅ JAVASCRIPT (ES6+)                       │  │
│  │                                                           │  │
│  │  Callbacks (ES5)         Promises (ES6)                   │  │
│  │  Callback Hell           Promise Chain                    │  │
│  │  Async/Await (ES2017)    Sequential/Parallel              │  │
│  │  Export/Import (ES6)     Classes & Objects (ES6)          │  │
│  │  Constructor             this keyword                     │  │
│  │  Private # (ES2022)      Static (ES6/ES2022)              │  │
│  │  Encapsulation           Inheritance (ES6)                │  │
│  │  Method Overriding       Multi-Level Inheritance          │  │
│  │  Mixins                  Polymorphism                     │  │
│  │                                                           │  │
│  └───────────────────────────────────────────────────────────┘  │
│                                                                  │
│  ┌───────────────────────────────────────────────────────────┐  │
│  │             ❌ NOT COVERED — TYPESCRIPT ONLY               │  │
│  │                                                           │  │
│  │  Type annotations (:string, :number)                      │  │
│  │  Interfaces                                               │  │
│  │  Enums                                                    │  │
│  │  Generics (<T>)                                           │  │
│  │  private/public/protected keywords                        │  │
│  │  readonly                                                 │  │
│  │  abstract classes                                         │  │
│  │  Type assertions (as)                                     │  │
│  │  override keyword                                         │  │
│  │  Decorators                                               │  │
│  │  Namespaces                                               │  │
│  │  Tuple types                                              │  │
│  │                                                           │  │
│  └───────────────────────────────────────────────────────────┘  │
│                                                                  │
│  Remember: TypeScript = JavaScript + Types                       │
│  Learn JS first → TS becomes easy                                │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment