The { page, request } in every Playwright test function is destructuring: it pulls those properties from the fixture object the framework passes in automatically. Once that clicks, custom fixtures stop looking like magic and start looking like the same pattern repeated. This guide covers object access, destructuring with renaming and defaults, nested destructuring for API responses, and the shallow-copy behavior of spread that causes test data mutations to bleed across variants.
What Is an Object?
An object is a collection of key-value pairs. Keys are strings; values can be anything:
const user = {
id: 1,
email: 'alice@example.com',
role: 'admin',
isActive: true,
};Access values with dot notation or bracket notation:
console.log(user.email); // 'alice@example.com'
console.log(user['role']); // 'admin'
console.log(user.id); // 1Modify values the same way:
user.email = 'newalice@example.com';
user.role = 'member';Objects in Test Data
Most test data in Playwright tests is expressed as objects or arrays of objects:
const loginCredentials = {
email: 'qa_test@example.com',
password: 'ValidPass123!',
};
await page.fill('[data-testid="email"]', loginCredentials.email);
await page.fill('[data-testid="password"]', loginCredentials.password);Test case collections:
const INVALID_EMAILS = [
{ input: '', description: 'empty' },
{ input: 'not-an-email', description: 'missing @' },
{ input: 'missing@', description: 'missing domain' },
{ input: 'a@b', description: 'no TLD' },
];Each INVALID_EMAILS[0] is an object with input and description properties.
Destructuring: What It Is
Destructuring lets you pull values out of an object (or array) into individual variables in one line, instead of multiple lines.
Without destructuring:const user = { id: 1, name: 'Alice', email: 'alice@test.com', role: 'admin' };
const id = user.id;
const name = user.name;
const email = user.email;
const role = user.role;const { id, name, email, role } = user;Same result — four variables with the same values — but one line instead of four.
Renaming While Destructuring
If you want the variable to have a different name than the key:
const config = {
database_host: 'localhost',
database_port: 5432,
};
const { database_host: host, database_port: port } = config;
console.log(host); // 'localhost'
console.log(port); // 5432Default Values in Destructuring
If a key might not exist, you can provide a default:
const product = { name: 'Laptop', price: 999 };
const { name, price, discount = 0 } = product;
// discount = 0 (wasn't in product, uses default)Destructuring in Function Parameters
The most common place you'll see destructuring in Playwright is in test functions:
// Without destructuring
test('user can log in', async (args) => {
const page = args.page;
const request = args.request;
// ...
});
// With destructuring (standard Playwright pattern)
test('user can log in', async ({ page, request }) => {
// page and request available directly
});This is object destructuring in the function parameter. The { page, request } syntax pulls those properties out of the fixture object Playwright passes in.
This is why Playwright test code looks like it has "magic variables" — they're being destructured from the fixture object automatically.
Custom Fixtures: Destructuring in Practice
When you create custom fixtures, you'll write this pattern:
export const test = base.extend<{ loginPage: LoginPage }>({
loginPage: async ({ page }, use) => {
const loginPage = new LoginPage(page);
await use(loginPage);
},
});
// Then in a test:
test('login works', async ({ page, loginPage }) => {
// ^^^ destructured from fixture object
await loginPage.login('user@test.com', 'pass');
});Understanding destructuring makes fixtures make sense.
Nested Destructuring
Objects can contain other objects. You can destructure multiple levels at once:
const apiResponse = {
status: 200,
data: {
user: {
id: 123,
email: 'alice@test.com',
},
token: 'eyJhbGciOiJIUzI1NiJ9...',
},
};
// Nested destructuring
const { status, data: { user: { id, email }, token } } = apiResponse;
console.log(status); // 200
console.log(id); // 123
console.log(email); // 'alice@test.com'
console.log(token); // 'eyJhbGciOiJIUzI1NiJ9...'This looks complex at first. In practice, you rarely go more than 2 levels deep. If it gets complicated, just destructure in steps:
const { data } = apiResponse;
const { user, token } = data;
const { id, email } = user;Same result, easier to read.
Array Destructuring (Brief)
Arrays use [] instead of {}:
const coordinates = [40.7128, -74.0060];
const [latitude, longitude] = coordinates;
// latitude = 40.7128, longitude = -74.0060Skip elements with commas:
const [first, , third] = ['a', 'b', 'c'];
// first = 'a', third = 'c'The Spread Operator with Objects
The spread operator (...) copies all properties of one object into another:
const baseUser = {
role: 'member',
isActive: true,
};
const adminUser = {
...baseUser, // copies role and isActive
email: 'admin@test.com',
role: 'admin', // overrides the spread value
};
// { role: 'admin', isActive: true, email: 'admin@test.com' }In test data: creating variants
const defaultUser = {
email: 'test@example.com',
password: 'ValidPass1',
role: 'member',
isActive: true,
};
const adminUser = { ...defaultUser, role: 'admin' };
const inactiveUser = { ...defaultUser, isActive: false };
const customEmail = { ...defaultUser, email: 'custom@example.com' };This pattern — "base object + overrides" — is very common for test data factories. You define the default valid state once and spread it to create variants.
Practical Patterns in Playwright Tests
Destructuring API response body
const response = await request.post('/api/users', {
data: { email: 'new@test.com', password: 'ValidPass1' },
});
const { id, email, role, created_at } = await response.json();
expect(id).toBeTruthy();
expect(email).toBe('new@test.com');
expect(role).toBe('member');Passing object parameters to helpers
async function fillLoginForm(page: Page, { email, password }: { email: string; password: string }) {
await page.fill('[data-testid="email"]', email);
await page.fill('[data-testid="password"]', password);
}
// Call site:
await fillLoginForm(page, { email: 'user@test.com', password: 'pass' });Merging test config
const baseConfig = {
baseURL: 'https://lab.becomeqa.com',
timeout: 30000,
};
const ciConfig = {
...baseConfig,
timeout: 60000, // slower on CI
video: 'retain-on-failure',
};Common Mistakes
Destructuring from undefined:const response = await fetch('/api/user');
const { id } = await response.json(); // If response is null/undefined, this throwsAlways check that the value exists before destructuring deeply nested objects.
Modifying spread copies doesn't affect the original:const original = { name: 'Alice', data: { score: 100 } };
const copy = { ...original };
copy.name = 'Bob'; // ✅ original.name still 'Alice'
copy.data.score = 200; // ⚠️ original.data.score also changes!Spread is a shallow copy. Nested objects are still shared references.
Summary
| Syntax | What it does |
|--------|-------------|
| const { a, b } = obj | Extract a and b from obj |
| const { a: x } = obj | Extract a from obj, name it x |
| const { a = 5 } = obj | Extract a, default to 5 if missing |
| async ({ page, request }) => {} | Destructure Playwright fixtures |
| { ...obj, key: 'value' } | Spread obj, override or add key |
Objects and destructuring are everywhere in modern JavaScript and TypeScript. Once the patterns are familiar, you'll find Playwright test code becomes much easier to read and write.
→ See also: JavaScript for QA Engineers: The Minimum You Need to Start Automating | JavaScript Arrays: map, filter, find, and forEach — A QA Engineer's Field Guide | TypeScript for QA: Why Static Types Make Your Tests Better