Organizing Playwright Scripts

A Loadster Playwright script is a single, self-contained file. You write it in one code editor, and Loadster runs that one file with the Playwright Test runner. There’s no file tree, and a script can’t import other files you’ve written—only the Playwright libraries and the @loadster/bot module are available.

For most load tests and monitors, that’s all you need. A script usually represents one focused user journey, and the simplest thing—a single linear test()—is often the best thing. Reach for more structure only when a script grows large enough to actually benefit from it.

The rest of this page walks through ways to organize a script, from simplest to most structured. Start at the top and only go as far down as your script actually needs:

  • Start with a plain, linear test.
  • Extract helper functions when you catch yourself repeating steps.
  • Adopt the Page Object Model (POM) when you want selectors and actions grouped by page.
  • Use fixtures when you want Playwright to hand a ready-made object to each test.

Start with a simple Playwright script

Most Playwright scripts don’t need any special structure at all. Just write the steps of your user journey directly in a single test() block:

import {test, expect} from '@playwright/test';

test('complete checkout', async ({page}) => {
    await page.goto('https://example.com/login');
    await page.getByLabel('Email').fill('demo@example.com');
    await page.getByLabel('Password').fill('secret');
    await page.getByRole('button', {name: 'Sign in'}).click();

    await page.getByRole('link', {name: 'Cart'}).click();
    await page.getByRole('button', {name: 'Checkout'}).click();

    await expect(page).toHaveURL(/order-confirmation/);
});

This is easy to read, easy to debug, and a good fit for load testing and monitoring. If your script is short and the steps only appear once, you can happily stop here.

Extract Playwright helper functions

When you catch yourself repeating the same sequence of steps—logging in is the classic example—pull it into a plain function and call it from your test. This is the lightest-weight way to stay organized, and it’s usually enough:

import {test, expect} from '@playwright/test';
import bot from '@loadster/bot';

async function login(page) {
    const [username, password] = bot.getVariable('credentials');

    await page.goto('https://example.com/login');
    await page.fill('#username', username);
    await page.fill('#password', password);
    await page.click('button[type="submit"]');
    await expect(page).toHaveURL(/dashboard/);
}

test('view account after login', async ({page}) => {
    await login(page);

    await page.click('a.account');
    await expect(page.locator('h1')).toContainText('Account');
});

Page Object Model (POM) in a Playwright script

The Page Object Model (POM) is a popular way to organize larger test suites. It wraps each page (or component) of your application in a class, so that selectors and actions live in one place and your tests read at a high level. In a typical Playwright project each page object is its own file that the test imports:

tests/
  checkout.spec.ts
pages/
  LoginPage.ts      <-- imported by the test
  CartPage.ts       <-- imported by the test

Loadster scripts don’t support that multi-file layout, but the pattern works perfectly well when you define the page object classes in the same file as your test. Just declare the classes above your test() blocks:

import {test, expect, type Page} from '@playwright/test';

class LoginPage {
    constructor(private page: Page) {}

    async goto() {
        await this.page.goto('https://example.com/login');
    }

    async login(username: string, password: string) {
        await this.page.getByLabel('Email').fill(username);
        await this.page.getByLabel('Password').fill(password);
        await this.page.getByRole('button', {name: 'Sign in'}).click();
    }
}

class CartPage {
    constructor(private page: Page) {}

    async checkout() {
        await this.page.getByRole('link', {name: 'Cart'}).click();
        await this.page.getByRole('button', {name: 'Checkout'}).click();
    }
}

test('complete checkout', async ({page}) => {
    const login = new LoginPage(page);
    const cart = new CartPage(page);

    await login.goto();
    await login.login('demo@example.com', 'secret');
    await cart.checkout();

    await expect(page).toHaveURL(/order-confirmation/);
});

You get the same benefits you’d get from a multi-file POM—selectors defined once, tests that read as high-level steps—without needing multiple files. It’s worth the extra structure when a script covers a lot of pages, or when you’re porting an existing POM suite; for a short script, plain helper functions are usually plenty.

Playwright Test fixtures

Playwright Test fixtures also work, and they’re a clean way to hand a ready-to-use page object (or a logged-in session) to each test. Define the fixture with test.extend() at the top of your script:

import {test as base, expect, type Page} from '@playwright/test';

class DashboardPage {
    constructor(private page: Page) {}

    async open() {
        await this.page.goto('https://example.com/dashboard');
    }
}

const test = base.extend<{dashboard: DashboardPage}>({
    dashboard: async ({page}, use) => {
        await use(new DashboardPage(page));
    },
});

test('dashboard loads', async ({dashboard, page}) => {
    await dashboard.open();
    await expect(page.locator('h1')).toBeVisible();
});

Adapting an existing Playwright test suite

If you already maintain a Playwright suite that’s split across page objects, fixtures, and helper files, you can bring an individual test into Loadster by flattening it—inlining the page objects and helpers it depends on into a single script, as shown above. The logic doesn’t change; you’re just collapsing the imports into one file.

A few tips:

  • Bring in one journey at a time. You don’t need your whole suite—pick the specific user journey you want to load test or monitor and flatten just that test plus the page objects it uses.
  • Let an AI assistant do the mechanical work. Flattening a POM test into a single file is exactly the kind of transformation LLMs handle well. Paste your test and its page object files in and ask for a single self-contained Playwright Test script.
  • Swap external config for datasets. Where your suite reads credentials or test data from external files or environment variables, use a dataset and the @loadster/bot module instead so every bot gets its own values.

Reusing logic across Playwright scripts

Because each Playwright script is self-contained, there’s no shared library of page objects that multiple Playwright scripts import from—if two scripts both log in, each carries its own copy of the login logic. (The Include Script feature, which composes one script from another, applies to Protocol Bot and Browser Bot scripts, not Playwright scripts.)

When you want a load test to exercise several different journeys, the Loadster-native approach is to keep each journey as its own focused script and combine them in a load test scenario—add multiple bot groups, each running a different Playwright script, and set the mix of bots across them. That keeps each script small and focused while letting a single test cover many behaviors at once.