๐Ÿ”ฅ 0
โญ 0
Lesson 3 of 9 35 min +250 XP

A Suite That Survives: POM + Test Data

The tests in Lesson 2 work, but they won't survive. Every one repeats the same login steps and the same raw selectors. When ShopKart renames a data-test id, you fix it in a dozen files. When you have 200 tests, that's a day of work for one UI tweak.

This lesson is about the two things that separate a suite people trust from one they abandon: structure (Page Objects) and test data (factories and fixtures).

The project layout

Here's the structure the rest of this course uses. It's deliberately boring โ€” boring is maintainable.

project/
โ”œโ”€โ”€ playwright.config.ts
โ”œโ”€โ”€ pages/            โ† where things are, how to interact (one file per page)
โ”‚   โ”œโ”€โ”€ LoginPage.ts
โ”‚   โ”œโ”€โ”€ InventoryPage.ts
โ”‚   โ”œโ”€โ”€ CartPage.ts
โ”‚   โ””โ”€โ”€ CheckoutPage.ts
โ”œโ”€โ”€ fixtures/         โ† test data: users, factories
โ”‚   โ”œโ”€โ”€ users.ts
โ”‚   โ””โ”€โ”€ factories.ts
โ”œโ”€โ”€ data/             โ† seeded data files (CSV/JSON)
โ”‚   โ””โ”€โ”€ products.csv
โ””โ”€โ”€ tests/            โ† what we're testing (the specs)
    โ”œโ”€โ”€ smoke.spec.ts
    โ”œโ”€โ”€ checkout.spec.ts
    โ””โ”€โ”€ validation.spec.ts

The config points every test at the app and turns on the artifacts that make failures debuggable (we'll lean on those in Lesson 4):

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  timeout: 30_000,
  expect: { timeout: 10_000 },        // web-first assertions retry up to 10s
  use: {
    baseURL: 'https://resources.criodo.com',
    trace: 'retain-on-failure',
    screenshot: 'only-on-failure',
  },
  projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
});

> ๐ŸŽฏ Practice: the Locator Playground. Choosing selectors that survive is the skill under every page object. On the [ShopKart Test Range playground](https://resources.criodo.com/shopkart/playground) you type a CSS selector, watch it highlight on the live store, and get an instant robustness score โ€” [data-test="โ€ฆ"] scores Robust, an nth-child chain scores Fragile, with the reason why. Do a few challenges there before writing the page objects below.

Page Objects: one source of truth for the UI

A Page Object wraps one page (or one meaningful part of it). Selectors live inside it. Tests call methods with business names โ€” login(), addToCart() โ€” and never touch a raw selector.

Here are the real page objects for ShopKart. Every selector below is verified against the live app โ€” and they're the same files in the [downloadable starter kit](https://resources.criodo.com/shopkart/shopkart-playwright-starter.zip).

// pages/LoginPage.ts
import { Page, Locator, expect } from '@playwright/test';

export const CREDENTIALS = { username: 'student', password: 'crio123' };

export class LoginPage {
  readonly page: Page;
  readonly username: Locator;
  readonly password: Locator;
  readonly loginButton: Locator;
  readonly error: Locator;

  constructor(page: Page) {
    this.page = page;
    this.username = page.locator('[data-test="username"]');
    this.password = page.locator('[data-test="password"]');
    this.loginButton = page.locator('[data-test="login-button"]');
    this.error = page.locator('[data-test="login-error"]');
  }

  async goto() {
    await this.page.goto('/shopkart/login');
  }

  async login(user = CREDENTIALS.username, password = CREDENTIALS.password) {
    await this.username.fill(user);
    await this.password.fill(password);
    await this.loginButton.click();
  }

  async expectError(message: string | RegExp) {
    await expect(this.error).toContainText(message);
  }
}
// pages/InventoryPage.ts
import { Page, Locator, expect } from '@playwright/test';

export class InventoryPage {
  readonly page: Page;
  readonly items: Locator;
  readonly cartBadge: Locator;
  readonly cartLink: Locator;

  constructor(page: Page) {
    this.page = page;
    this.items = page.locator('[data-test="inventory-item"]');
    this.cartBadge = page.locator('[data-test="cart-badge"]');
    this.cartLink = page.locator('[data-test="cart-link"]');
  }

  // Each add-to-cart id is the product's slug, e.g. 'mi-usb-c-cable':
  //   [data-test="add-to-cart-mi-usb-c-cable"]
  async addToCart(slug: string) {
    await this.page.locator(`[data-test="add-to-cart-${slug}"]`).click();
  }

  async expectLoaded() {
    await expect(this.page).toHaveURL(/inventory/);
    await expect(this.items).toHaveCount(12);   // 12 products, verified
  }

  async goToCart() {
    await this.cartLink.click();
  }
}
// pages/CheckoutPage.ts  โ€” the "your information" form
import { Page, Locator } from '@playwright/test';

export interface Customer {
  firstName: string;
  lastName: string;
  postalCode: string;
}

export class CheckoutPage {
  readonly page: Page;
  readonly firstName: Locator;
  readonly lastName: Locator;
  readonly postalCode: Locator;
  readonly continueButton: Locator;
  readonly error: Locator;

  constructor(page: Page) {
    this.page = page;
    this.firstName = page.locator('[data-test="firstName"]');
    this.lastName = page.locator('[data-test="lastName"]');
    this.postalCode = page.locator('[data-test="postalCode"]');
    this.continueButton = page.locator('[data-test="continue"]');
    this.error = page.locator('[data-test="error"]');
  }

  async fillDetails(c: Customer) {
    await this.firstName.fill(c.firstName);
    await this.lastName.fill(c.lastName);
    await this.postalCode.fill(c.postalCode);
  }

  async continue() { await this.continueButton.click(); }
}
// pages/OverviewPage.ts  โ€” the totals + confirmation step
import { Page, Locator } from '@playwright/test';

export class OverviewPage {
  readonly page: Page;
  readonly subtotal: Locator;
  readonly tax: Locator;
  readonly total: Locator;
  readonly finishButton: Locator;
  readonly completeHeader: Locator;

  constructor(page: Page) {
    this.page = page;
    this.subtotal = page.locator('[data-test="summary-subtotal"]');
    this.tax = page.locator('[data-test="summary-tax"]');
    this.total = page.locator('[data-test="summary-total"]');
    this.finishButton = page.locator('[data-test="finish"]');
    this.completeHeader = page.locator('[data-test="complete-header"]');
  }

  async finish() { await this.finishButton.click(); }
}

Now the checkout test from Lesson 2 reads like the requirement it verifies โ€” this passes against the live app:

// tests/checkout.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { InventoryPage } from '../pages/InventoryPage';
import { CheckoutPage } from '../pages/CheckoutPage';
import { OverviewPage } from '../pages/OverviewPage';
import { makeCustomer } from '../fixtures/factories';

test('checkout: single item shows correct total and confirms', async ({ page }) => {
  const login = new LoginPage(page);
  const inventory = new InventoryPage(page);
  const checkout = new CheckoutPage(page);
  const overview = new OverviewPage(page);

  await login.goto();
  await login.login();
  await inventory.expectLoaded();

  await inventory.addToCart('mi-usb-c-cable');
  await expect(inventory.cartBadge).toHaveText('1');
  await inventory.goToCart();

  await page.locator('[data-test="checkout"]').click();
  await checkout.fillDetails(makeCustomer());
  await checkout.continue();

  await expect(overview.total).toHaveText('โ‚น199.80');
  await overview.finish();
  await expect(overview.completeHeader).toHaveText('Thank you for your order!');
});

When ShopKart renames data-test="login-button", you change one line in LoginPage.ts. Every test that logs in is fixed at once. That's the whole point.

> POM discipline: a page object exposes actions and state, never assertions about business rules. login() and cartBadge belong in the page object; "the total should be โ‚น199.80" belongs in the test. Keep the what in the test and the how in the page object.

Test data: stop hard-coding, start manufacturing

Look at the checkout test โ€” it calls makeCustomer() instead of pasting a customer object. That's a factory: it returns valid-by-default data and lets each test override only the field it cares about.

// fixtures/factories.ts
import { Customer } from '../pages/CheckoutPage';

export function makeCustomer(overrides: Partial<Customer> = {}): Customer {
  return { firstName: 'Ada', lastName: 'Lovelace', postalCode: '560001', ...overrides };
}

Now the negative tests from Lesson 2 say exactly what makes each case special, and nothing else:

makeCustomer({ firstName: '' })   // "no first name" โ€” everything else valid
makeCustomer({ lastName: '' })    // "no last name"
makeCustomer({ postalCode: '' })  // "no postal code"

Why factories beat hard-coded literals:

  • Tests state only what matters. A reader sees { lastName: '' } and instantly knows this test is about the missing last name.
  • One place to change. If the form later requires a phone number, you add it to the factory once โ€” not to 40 test bodies.
  • No accidental coupling. Each test gets a fresh object, so one test can't mutate data another test relies on.

Seeded data: the ShopKart dataset

Factories are great for a handful of fields. But sometimes you need volume โ€” a realistic catalog to validate against, or many customers to drive a report. The storefront you've been automating shows a curated slice of 12 products; behind it is the full ShopKart dataset โ€” real-shaped e-commerce data you can download and drive tests from.

  • products.csv โ€” 600 products (product_id, name, category, brand, price, rating, description, tags)
  • orders.csv โ€” 12,480 line items across 7,828 orders (Janโ€“Jun 2024)
  • customers.csv โ€” 1,500 customers (segment: Regular / Premium)
curl -O https://resources.criodo.com/data/shopkart/products.csv
curl -O https://resources.criodo.com/data/shopkart/orders.csv
curl -O https://resources.criodo.com/data/shopkart/customers.csv

Here's a data-driven catalog contract test โ€” it reads the product file and generates one test per row. This is exactly how you'd validate an imported catalog before a launch. The snippet below passes against the full 600-row file โ€” it's included in the [starter kit](https://resources.criodo.com/shopkart/shopkart-playwright-starter.zip) as tests/catalog-data.spec.ts, with the CSV already in data/, so it runs out of the box:

// tests/catalog-data.spec.ts
import { test, expect } from '@playwright/test';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';

const __dirname = dirname(fileURLToPath(import.meta.url));

interface Product { product_id: string; name: string; category: string; price: number; }

function loadProducts(): Product[] {
  const csv = readFileSync(join(__dirname, '../data/products.csv'), 'utf-8').trim();
  const [header, ...rows] = csv.split('\n');
  const cols = header.split(',');
  return rows.map((line) => {
    // Naive split โ€” fine here because the fields we read (product_id, name,
    // category, price) all come BEFORE the description column, which is the
    // only one that contains commas. Real projects use a CSV parser.
    const cells = line.split(',');
    const rec: any = {};
    cols.forEach((c, i) => (rec[c] = cells[i]));
    return { product_id: rec.product_id, name: rec.name, category: rec.category, price: Number(rec.price) };
  });
}

const products = loadProducts();

// The catalog contract: every product must have a positive price,
// a non-empty category, and a name. One test per product row.
for (const p of products) {
  test(`product ${p.product_id} has a valid price and category`, () => {
    expect(p.price).toBeGreaterThan(0);
    expect(p.category).not.toBe('');
    expect(p.name.length).toBeGreaterThan(0);
  });
}

> Real data is dirty โ€” and that's the lesson. ShopKart's customers.csv has 1,010 Regular and 455 Premium customers... and 35 rows with a blank segment. That's not a mistake in the file; it's on purpose. Real exported data always has holes. A contract test like the one above is exactly what catches them before they break a downstream report. When you write the customer version of this test, decide deliberately: is a blank segment a bug to fail on, or a known case to skip? Writing that decision down is the job.

Data isolation: the rule that prevents mystery failures

The deadliest test-data mistake is shared, mutable state. If test A creates a coupon and test B assumes it doesn't exist, then running them together โ€” or in a different order โ€” breaks one of them. And it breaks randomly, which is the worst kind of broken (that's next lesson).

Three rules keep you safe:

  • Each test sets up its own data. Don't rely on data another test created.
  • Never assert on data you didn't create. "There are 1,500 customers" breaks the moment someone adds one. "The customer I just created exists" is stable.
  • Prefer fresh over shared. A factory that mints a new object per test beats one global object every test mutates.

ShopKart keeps cart state per browser tab (in sessionStorage), so every test context starts clean and isolation is easy here. On a real app with a shared database, this is where most flakiness is born โ€” keep it in mind.

Key takeaways

  • Page Objects put every selector in one place. A UI change becomes a one-file fix, and tests read like business intent.
  • Keep the what (assertions about business rules) in the test and the how (selectors, clicks) in the page object.
  • Factories produce valid-by-default data with per-test overrides โ€” tests state only what matters, and there's one place to change.
  • Seeded data files (like ShopKart) let you drive high-volume, data-driven contract tests; real data is dirty, and catching that is the point.
  • Isolate test data: each test creates its own, asserts only on what it created, and prefers fresh objects over shared mutable ones.

Next lesson

We now have a clean, well-structured suite. So why does it sometimes go red for no reason? Next we tackle the number-one thing that destroys trust in automation: flakiness โ€” using ShopKart's genuinely slow and genuinely broken bug scenarios to see it happen and fix it properly.

From Requirements to Test Cases