|

Day 6: JavaScript Objects and Multi-Dimensional Arrays in Test Automation

JavaScript copy versus shared object reference

<!– 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): “Primitive vs reference — why your fixture leaked”

LEFT CARD — title “Primitive (copy the value)”:

  • Box “let a = 10”
  • Arrow “let b = a” to a second box “b = 10” (separate copy, gray)
  • Arrow “b = 99” — only the right box changes to amber 99
  • Footer (tiny): “number · string · boolean · null · undefined — a stays 10”

RIGHT CARD — title “Reference (copy the pointer)”:

  • One heap box in teal: “{ val: 10 }”
  • Two stack labels “obj1” and “obj2” with arrows both pointing at that same heap box
  • Annotation “obj2.val = 99” turns the heap box amber: “{ val: 99 }”
  • Footer (tiny): “object · array · function — obj1.val is now 99”

BOTTOM RULE (one line): “toBe = identity (same box). toEqual = shape. A shared fixture object mutates every test that still holds the pointer.” –>

This is Day 6 of the 21-day JS to Playwright Framework series. One lesson a day. We are still in JavaScript on purpose.

Day 1 put values in boxes. Day 2 compared them. Day 3 decided. Day 4 looped a list. Day 5 wrapped work in functions and strings. Today the value *is* a structure — a bag of keys, or a grid of rows.

I am Pramod Dutta. I teach this batch the same way I review a failing suite: open the smallest file, print the value, then ask what Playwright would do with it. If you cannot explain why let b = a turns a login fixture into "fail" for every later test, you are not ready for test.extend, JSON fixtures, or request.post({ data }).

All labs today come from my public batch repo: LearningPlaywrightBatch on branch main. I fetched each file from raw GitHub. I quote it. I do not invent a file that is not in the tree. Some classroom names are misspelled on disk — 112_Object_Property_Desciptor.js, 115_Spead_Objects.js, 122_MD_Array_Funtions.js. I use those names. If a slide said 109_Objects.js, that name is not on main. The file is 109_Objects_Consts.js.

If you want these labs as a live classroom — with the JSON fixtures, Restful Booker bodies, and the framework we assemble on Day 21 — that is Playwright Automation Mastery at The Testing Academy.

JavaScript copy versus shared object reference

Contents

Why Day 6 matters before you touch Playwright

Most “flaky fixture” bugs I review are not Playwright bugs. They are object-reference bugs wearing a test.extend jacket.

Someone writes const user = testData.admin and then user.role = "viewer" inside one spec. The next spec in the worker still sees "viewer". Someone asserts expect(body).toBe(expected) and the shapes match, but the test fails because toBe is identity. Someone spreads { ...baseUser, password: process.env.PASS } and thinks they cloned address. They did not. Nested objects still share memory. Someone reads a web table into a 1D list and then wonders why column 3 of row 2 is the wrong cell.

Playwright’s expect(value).toEqual(expected) walks a structure. expect(value).toBe(expected) asks “is this the same box?” request.post({ data: payload }) serializes an object into a JSON body. test.use({ storageState }) and every custom fixture you will write on Day 16 is an object you pass around. A <table> is a 2D array the moment you ask for row[i] × cell[j].

Until you can see a reference versus a copy in one glance, your framework will keep “fixing” bugs that are just shared memory.

Today you will be able to:

  • Build a JS object and a JSON-looking object, and say which one belongs in a .js fixture and which one belongs on the wire.
  • Read obj.key and obj["key"], including a key you only know at runtime.
  • Explain primitive versus reference the way an SDET should — and stop leaking fixtures.
  • Read a property descriptor: writable, enumerable, configurable.
  • Destructure a response body. Spread a base user into a role variant. Write a getter that looks like a field.
  • Walk Object.keys / values / entries when you assert a payload.
  • Treat a web table, a CSV row-set, and a suite-result grid as a 2D array.
  • Nest loops until a right triangle, an inverted triangle, and a pyramid print — because that is the same muscle as “for each row, for each cell.”

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

You need Node.js 18 or newer. Today we only need node and a terminal. Playwright arrives on Day 10. The object habits you build today are the ones Day 15 (JSON/CSV fixtures) and Day 19 (ApiHelper) will sit on.

From chapter_11_Objects on main:

  • 108_Objects.js
  • 109_Objects_Consts.js (not 109_Objects.js)
  • 110_Objects.js
  • 111_Primitive_Ref.js
  • 112_Object_Property_Desciptor.js (filename is spelled that way)
  • 113_Objects.js
  • 114_Object_Dec.js
  • 115_Spead_Objects.js (filename is spelled that way)
  • 116_GETTER_SETTER.js
  • 117_Objects_methods.js
  • 118_REAL_Object.js
  • 119_Let_const_Objects.js

From chapter_12_Multi_Dimension_Array on main:

  • 120_MD_Array.js
  • 121_MD_Array_Part2.js
  • 122_MD_Array_Funtions.js (filename is spelled that way)
  • 123_MD_Pattern_RIGHT.js
  • 124_MD_Left_hand.js
  • 125_Pyramid_Pattern.js

Those are the files. Eighteen of them. I ran every file with node for this post. Where a comment in the classroom file disagrees with the engine, I say so.

Lab 108 — an object is a bag of keys

File: chapter_11_Objects/108_Objects.js

This is the door. An object is a collection of key → value pairs. The first three students in the file do not even print. They exist to show you that two objects of the “same kind” do not have to carry the same keys.

let student1 = { name: "Amit", age: 65 };
let student2 = { name: "Pramod" };
let student3 = { name: "Pramod", age: 87, phone: 987654320 };

// Key will not be in the doubt quotes
// below key in doubt is actually JSON
let JSON_student4 = { "name": "Pramod", "age": 87, "phone": 987654320 };

student2 has no age. That is legal. JSON fixtures are the same: optional fields are missing keys, not null, unless the contract says null. Day 2 already taught null versus undefined. An absent key is undefined when you read it. A present key with null is an intentional empty.

The comment in the file is the classroom shorthand I use in batch: unquoted keys are a JavaScript object literal. Quoted keys look like JSON. Both are valid in a .js file. JSON_student4 is still a JavaScript object. The quotes do not magically put it on the wire. JSON.stringify does that. JSON.parse does the reverse.

Then the file shows the two ways you read a key:

let a = { status: "pass" };
console.log(a.status);
console.log(a["status"]);

Both print pass. Dot is for a known identifier. Bracket is for a string, including a string you computed. You will live in brackets the moment a fixture key comes from a column name or an env var.

Keys are case sensitive. This is the assertion trap:

let a22 = { status: "pass", Status: "fail" };
console.log(a22["status"]);
console.log(a22["Status"]);

Output: pass, then fail. Two keys. I have seen a Restful Booker helper assert body.Status because the Swagger UI titled the field that way, while the JSON key was status. The test failed on a capital letter. When you dump a body, print Object.keys(body) before you invent a getter.

Now the line that is the rest of the day:

let b = a;  // b copies the REFERENCE, not the object
b.status = "fail";
console.log(a.status);

Output: fail. a changed because b is not a second object. b is a second name for the same box in memory. Two separate objects with the same shape are not the same box:

let c = { status: "pass" };
let d = { status: "pass" };
console.log(c === d);

Output: false. === on objects is identity. Playwright expect(c).toBe(d) fails. expect(c).toEqual(d) passes. Tattoo that. toBe is lab 108’s ===. toEqual is “walk the keys.”

The last block of the file prints t_json and t_js. Both look like { name: 'pramod', age: 10 } in Node. The quotes on the keys do not survive console.log. They are source syntax, not a runtime badge that says “this is JSON.”

Lab 109 — const does not freeze the bag

File: chapter_11_Objects/109_Objects_Consts.js

const user = {
    name: "John",
    age: 30,
    email: "john@example.com"
};

console.log(user);

// Accessing properties
console.log(user.name);
console.log(user["age"]);

// Dynamic property access
const key = "age";
console.log(user[key]);

// Adding/modifying properties
user.city = "NYC";
user.age = 31;

console.log(user);

user is declared with const. We still add city and change age. The last print is { name: 'John', age: 31, email: 'john@example.com', city: 'NYC' }.

const locks the binding. It does not lock the object. Day 1’s const lecture was about reassignment. Day 6 is the follow-up: the bag can grow. If you need the bag frozen, that is Object.freeze — and even then it is shallow. We do not have a freeze lab in this chapter, so I will not pretend there is one. Remember the rule from what *is* in the file: const user = { ... } still mutates.

Dynamic access is the other half. const key = "age"; console.log(user[key]); prints 30. In Playwright you will write this every time a CSV header or a query param decides the field:

const field = process.env.ASSERT_FIELD ?? "email";
expect(body.user[field]).toBeTruthy();

Dot cannot do that. body.user.process.env.ASSERT_FIELD is a different, wrong, key path.

Lab 110 — grow a config, overwrite, delete

File: chapter_11_Objects/110_Objects.js

The whole file:

let config = {};
config.browser = "Chrome";
config.timeout = 3000;
config.timeout = 5000; // latest
console.log(config);
delete config.browser;
console.log(config);

Output:

{ browser: 'Chrome', timeout: 5000 }
{ timeout: 5000 }

Empty object. Add keys after the fact. A second write to timeout wins. delete removes browser. The second print has no browser key — that is undefined when you read it, not null.

This is how a lot of SDET “config builders” start, and it is how they rot. A helper that deletes browser because “API tests do not need a browser” will surprise the next UI spec that reused the same object. Prefer a new object (lab 115’s spread) over mutating a shared config in place. If you must delete, delete on a copy.

Playwright-shaped version of the same file, for later — this is *not* in the repo:

const config = {};
config.timeout = 3000;
config.timeout = Number(process.env.TIMEOUT ?? "5000");
// latest write wins — same rule as lab 110

Lab 111 — primitive versus reference (read this twice)

File: chapter_11_Objects/111_Primitive_Ref.js

This is the diagram at the top of the post, in code.

// Primitive data types - call by value
// Primitive, number, string, boolean, null, undefined
let a = 10;
let b = a;
b = 99;
console.log(a);
console.log(b);
a = 90;
console.log(a);
console.log(b);

console.log("-----")

// Objects — copied by REFERENCE , call by ref.
// Reference - object, array, function
let obj1 = { val: 10 };
let obj2 = obj1;
obj2.val = 99;
console.log(obj1.val);

Actual output:

10
99
90
99
-----
99

b = a copied the number 10 into a new box. Changing b did not touch a. Changing a later did not touch b. That is a primitive: number, string, boolean, null, undefined. (Also bigint and symbol, which this file does not mention, so I will not build a lab around them.)

obj2 = obj1 copied the pointer. There is one heap object. obj2.val = 99 paints that one object. obj1.val is 99. The file does not print obj2.val because it does not need to. They are the same box.

Where this destroys a Playwright suite:

// This is the bug. Not a file in the repo — a pattern I fail in review.
const admin = fixtures.users.admin; // same reference as the module export
await login(page, admin);
admin.role = "viewer"; // the shared fixture is now dirty

The next test that imports fixtures.users.admin is already a viewer. Workers make this worse: one worker, many tests, one mutated object.

The fix is a new object per test. Spread (lab 115) for a shallow copy. structuredClone or JSON.parse(JSON.stringify(...)) when you need a deep copy of JSON-safe data. test.extend should *build* the user, not hand out the module singleton.

The same rule applies to arrays. Lab 111 lists array next to object and function as reference types. let rows2 = rows1; rows2.push(extra) mutates the table you thought you isolated.

expect(obj1).toBe(obj2) is true here, because they are the same box. After a real clone, toBe is false and toEqual is true. That pair of assertions is how I prove a helper cloned.

Lab 112 — a property is more than a value

File: chapter_11_Objects/112_Object_Property_Desciptor.js

The filename is Desciptor, not Descriptor. The whole file:

let obj = { name: "Login" };
console.log(Object.getOwnPropertyDescriptor(obj, "name"));
// {
//   value: "Login",
//   writable: true,       ← can change the value
//   enumerable: true,     ← shows in for...in / Object.keys()
//   configurable: true    ← can delete or redefine
// }

Node prints exactly that shape (without my classroom arrows):

{
  value: 'Login',
  writable: true,
  enumerable: true,
  configurable: true
}

Every own property has a descriptor. The three flags matter in a test framework more than people think.

  • writable — can you assign obj.name = "Logout"? Default yes. A fixture you thought was a constant may still be writable. That is lab 109 again, from the inside.
  • enumerable — does it show up in Object.keys and for...in? Playwright’s page object is full of methods. If you for...in a Page Object that inherited a getter from BasePage, you will iterate things you did not put in the class body. Day 8 is inheritance. The seed is here: “own and enumerable” is not “everything on the object.”
  • configurable — can you delete it or change the descriptor? Lab 110’s delete config.browser works because browser was configurable.

I do not have an Object.defineProperty file in this chapter, so I will not invent one. What I want you to run is the file that exists, then remember the three flags when a key “disappears” from Object.keys or refuses to delete.

SDET use: when you dump Object.keys(response.headers()) you are looking at enumerable own keys. When a library hides a field from that list, the field can still exist. in and obj.field will find it. Object.keys will not. Assert with the tool that matches the contract.

Lab 113 — methods, this, and a chain

File: chapter_11_Objects/113_Objects.js

const user = {
    name: "Pramod",
    age: 43
}

const calculator = {
    value: 0,
    // name : "Pramod",
    add(n) {
        this.value += n;
        // this.name += "Dutta"
        return this;
    },
    substract(n) {
        this.value -= n;
        return this;
    }

}

console.log(calculator.add(5).substract(6));
// { value: 0, add: [Function: add], substract: [Function: substract] }

user is declared and never used. That is fine. The lesson is calculator. Methods are functions stored on the object. this is the object that received the call. return this lets you chain.

The method is spelled substract in the repo. I will not “fix” it in a quote. Classroom files keep their typos so your node path matches GitHub.

The comment under console.log is wrong. I ran the file. Output:

{ value: -1, add: [Function: add], substract: [Function: substract] }

0 + 5 - 6 = -1. The comment says value: 0. Comments lie. node does not. I leave that in every batch on purpose the second someone copies a comment into an assertion.

Chaining is how a fluent Page Object will feel on Day 8 and Day 16:

// Later in the series — not a file in chapter 11.
await loginPage.fillUser("admin").fillPass("pass").submit();

That only works if each method returns this (or a thenable of this). Lab 113 is the JavaScript of that habit. Without return this, add(5).substract(6) throws because add returned undefined.

this is also fragile. Extract const add = calculator.add; add(5) and this is no longer calculator. We do not have a this-binding lab in this chapter, so I will only say: keep the method on the object when you call it. Day 7’s callbacks will try to steal this. That is one reason we will prefer async methods on a page class over loose functions.

Lab 114 — destructure the body, do not pick keys by hand

File: chapter_11_Objects/114_Object_Dec.js

const user = { name1: "John", age: 30, city: "NYC" };

// Basic destructuring
const { name1, age } = user;
console.log(name1);
console.log(age);

// Rename variables
const { name1: userName, age: userAge } = user;
console.log(userName);
console.log(userAge);

// Default values
const { country = "USA" } = user;
console.log(country);

const data = { user: { name: "John", address: { city: "NYC" } } };
const { user: { address: { city } } } = data;

Output: John, 30, John, 30, USA. city is bound and never printed. Nested destructure works; the file just stops.

The key is named name1 because name is easy to shadow in classroom demos. The rename syntax { name1: userName } is what you want in a spec when the API key is ugly and your assertion name should not be.

Defaults: { country = "USA" } fires only when the key is missing or undefined. It does not fire for null. That is the same nullish rule as Day 2’s ??. A JSON body that says "country": null will not become "USA".

This is the Playwright habit I want in every API spec by Day 19:

const body = await response.json();
const { token, user: { role } = {} } = body;
expect(role).toBe("admin");

Nested destructure with a default on user saves you from Cannot read properties of undefined when the 401 body has no user. The classroom file does not default the nest — data.user.address.city is present. In a real suite, default the levels you do not control.

Lab 115 — spread is a shallow copy, and this still exists

File: chapter_11_Objects/115_Spead_Objects.js

Filename: Spead, not Spread.

const obj1 = { a: 1, b: 2 };
const obj2 = { c: 3, d: 4 };

const copy = { ...obj1 };
console.log(copy);
const merged = { ...obj1, ...obj2 };
console.log(merged);

//  this keyword
const user = {
    name: "Pramod",
    saymyName(lastName) {
        this.name += lastName;
        return this.name;
    }
}

console.log(user.saymyName("Dutta"));

Output:

{ a: 1, b: 2 }
{ a: 1, b: 2, c: 3, d: 4 }
PramodDutta

{ ...obj1 } is a new object with the same enumerable own keys. copy === obj1 is false. { ...obj1, ...obj2 } is a merge. Later keys win. If obj2 also had a, obj2.a would overwrite.

This is how you build role variants from a base fixture without leaking (as long as the values are primitives):

const baseUser = { email: "qa@example.com", password: "Secret", role: "viewer" };
const admin = { ...baseUser, role: "admin" };

admin is a new box. baseUser.role is still "viewer". That is the fix for lab 111.

Shallow. If baseUser.address = { city: "Pune" } and you spread, admin.address === baseUser.address. Mutating admin.address.city paints the shared nested object. For JSON-safe fixtures I clone deep. For one-level env overlays ({ ...ENV, TIMEOUT: 8000 }) shallow is enough.

Right-hand overwrite is also how env-specific config should work. Lab 118 will give you the ENV object. Spread the base, then the env overlay, then the CLI overlay. Last write wins — same rule as lab 110, without delete.

The second half of the file is this again. saymyName("Dutta") mutates user.name and returns "PramodDutta" with no space. The getter in lab 116 will do the same concatenation. Two files, one reminder: string join is not formatting. If you want "Pramod Dutta", put the space in yourself.

Lab 116 — getters look like fields, setters look like assignment

File: chapter_11_Objects/116_GETTER_SETTER.js

const user = {
    firstName: "Pramod",
    lastName: "Dutta",
    get fullName() {
        return this.firstName + this.lastName;
    },
    set fullName(value) {
        [this.firstName, this.lastName] = value.split(" ");
    }
};

console.log(user.fullName);
user.fullName = "Amit Sharma";
console.log(user.fullName);

Output:

PramodDutta
AmitSharma

You did not call fullName(). You read it. The engine ran the getter. You did not call a setter function. You assigned a string. The setter split on space and wrote firstName / lastName. The next getter read still has no space, so "AmitSharma".

Object.keys(user) on this object will include firstName and lastName. A getter is enumerable by default when you write it in an object literal, but it has no value field in the descriptor — it has get and set. Run Object.getOwnPropertyDescriptor(user, "fullName") locally if you want to see it. I am not adding a file that is not in the repo; that one-liner is yours.

Playwright use I want you to steal for Day 8 Page Objects:

class LoginPage {
  constructor(page) {
    this.page = page;
  }
  get userInput() {
    return this.page.getByLabel("Email");
  }
}

A getter locates on access. That is better than storing a locator in the constructor that you then reuse after a navigation, *if* you understand it re-queries. It is worse if the getter hides a slow scan you call in a loop. Know what you are hiding.

Setters are how a config object can validate. A timeout setter that throws on NaN is an SDET-friendly guard. The classroom setter does not validate. It splits. If you assign "Amit" with no space, lastName becomes undefined. Try it. Then decide whether your fixture setter should throw.

Lab 117 — keys, values, entries, for...in

File: chapter_11_Objects/117_Objects_methods.js

const obj = { a: 1, b: 2, c: 3 };

console.log(Object.keys(obj));
console.log(Object.values(obj));
console.log(Object.entries(obj));

const user = { name: "John", age: 30 };

for (const key in user) {
    console.log(`${key}: ${user[key]}`);
}

// Object.keys/values/entries
Object.keys(user).forEach(key => {
    console.log(key);
});

Object.entries(user).forEach(([key, value]) => {
    console.log(`${key}: ${value}`);
});

Output, in order:

[ 'a', 'b', 'c' ]
[ 1, 2, 3 ]
[ [ 'a', 1 ], [ 'b', 2 ], [ 'c', 3 ] ]
name: John
age: 30
name
age
name: John
age: 30

Three tools, one object:

CallYou getUse in a test
Object.keys(obj)string[] of enumerable own keys“payload has exactly these fields”
Object.values(obj)the values, same order“every feature flag is boolean”
Object.entries(obj)[key, value][]walk both; destructure in forEach
for...inkeys, including inherited enumerableeasy to over-iterate; prefer Object.keys

for...in is in the file so you can see it. I want Object.keys / Object.entries in Playwright helpers. for...in will pick up inherited keys the day you put a method