VLearnVibium

Page Object Model with Vibium: Full Tutorial

A full Page Object Model with Vibium tutorial — build base, login, and dashboard page classes in JavaScript, wire them into tests, and keep locators DRY.

By Pramod Dutta··16 min read·Verified with Vibium 26.2
▶ Animated overview · made with Remotion

The Page Object Model (POM) with Vibium wraps each screen of your app in a class that owns its locators and exposes methods named after what a user does, so your tests read like behavior instead of a pile of find() calls. This full tutorial builds a working suite from scratch in JavaScript: a shared BasePage, a LoginPage, and a DashboardPage, then wires them into runnable tests. You inject Vibium's page handle into each class once, keep every selector for a screen in exactly one file, and let methods return the next page object so flows chain naturally. When the UI changes — a renamed button, a moved field — you edit one method, not fifty tests. Because Vibium's find() already auto-waits for actionability, your page methods stay short and free of manual sleep() calls. By the end you will have a reusable pattern that scales from one script to a hundred-test suite, plus the same structure in Python.

What does the Vibium page object pipeline look like?

The path from an empty folder to a maintainable suite is short and linear. Each stage below builds on the last: install the client, write a base class, model each screen, then drive it all from thin tests.

Every box maps to a section of this tutorial. Install once, write BasePage once, add one class per screen, fill each class with locators and action methods, and finally write tests that call only those methods. That last rule — tests never touch selectors — is what makes the whole thing pay off.

Why use the Page Object Model with Vibium at all?

The Page Object Model exists to give locators a single home, so UI churn stops rippling through your tests. Without it, one renamed field forces a find-and-replace across the repo and risks missing a spot. With it, the change is one line in one class.

The benefits compound as the suite grows:

  • One source of truth for locators. A changed selector is a one-line fix, not a scavenger hunt.
  • Readable tests. loginPage.login('alice', 'secret') beats four raw find() calls a reviewer has to decode.
  • Reuse. The same LoginPage serves every test that needs to authenticate.
  • Safe refactors. Rename a method and your editor finds every call site.

A five-line script does not need this. Anything you plan to keep does. If Vibium itself is new to you, skim what is Vibium first for the mental model, then come back — this guide assumes you know what browser.launch() and find() do.

How do I set up the project?

Start with a fresh folder and install Vibium; its postinstall downloads a matching Chrome build, so the first install takes a minute. This tutorial uses Vibium's synchronous JavaScript API, which reads top to bottom with no await — the best default for learning and for linear tests.

mkdir vibium-pom && cd vibium-pom
npm init -y
npm install vibium

A tidy layout keeps page objects and tests apart from day one. Create two folders:

vibium-pom/
├── pages/          # one file per screen
│   ├── base.page.js
│   ├── login.page.js
│   └── dashboard.page.js
└── tests/
    └── login.test.js

This is the smallest structure that scales. For the fuller version — fixtures, config, CI — see how to structure a Vibium test suite once the pattern clicks. If you hit an install snag, install Vibium covers what lands on disk and how to pre-fetch Chrome.

How do I write the BasePage class?

The base page holds the one thing every screen shares — the Vibium page handle — plus a couple of helpers, so no subclass repeats that plumbing. Inject the handle through the constructor and store it as this.vibe.

// pages/base.page.js
class BasePage {
  constructor(vibe) {
    this.vibe = vibe;
  }
 
  // Navigate to an absolute URL, then hand back `this` for chaining.
  open(url) {
    this.vibe.go(url);
    return this;
  }
 
  // Thin wrappers so subclasses read as intent, not mechanics.
  title() {
    return this.vibe.title();
  }
 
  screenshot() {
    return this.vibe.screenshot();
  }
}
 
module.exports = { BasePage };

Keeping open(), title(), and screenshot() here means every page object inherits them for free. You are not hiding Vibium — this.vibe is always reachable for a one-off call — you are giving the common cases a name. Returning this from open() is what lets a test write new LoginPage(vibe).open(URL).login(...) in one fluent line.

How do I build the LoginPage class?

A page object stores its locators as fields and exposes methods named after user actions, with each method returning the page the user lands on next. Here is LoginPage, subclassing BasePage and built on Vibium's semantic selectors.

// pages/login.page.js
const { BasePage } = require('./base.page');
const { DashboardPage } = require('./dashboard.page');
 
class LoginPage extends BasePage {
  static URL = 'https://example.com/login';
 
  // Locators live in one place — described by intent, not CSS paths.
  get emailField() {
    return this.vibe.find({ label: 'Email' });
  }
 
  get passwordField() {
    return this.vibe.find({ label: 'Password' });
  }
 
  get submitButton() {
    return this.vibe.find({ role: 'button', text: 'Sign in' });
  }
 
  get errorBanner() {
    return this.vibe.find({ testid: 'login-error' });
  }
 
  open() {
    return super.open(LoginPage.URL);
  }
 
  // A user action, named in business terms. Returns the next screen.
  login(email, password) {
    this.emailField.fill(email);
    this.passwordField.fill(password);
    this.submitButton.click();
    return new DashboardPage(this.vibe);
  }
 
  // A query the test can assert on, without knowing the selector.
  errorText() {
    return this.errorBanner.text();
  }
}
 
module.exports = { LoginPage };

Two design choices matter here. First, locators are getters, so each one resolves fresh every call — Vibium re-runs find() at the moment of use and auto-waits for the element, which sidesteps stale-reference bugs. Second, login() returns a DashboardPage: the flow moves forward while each class still owns its own locators. This is the raw login sequence from automate login with Vibium, now wrapped so a test never sees a selector.

How do I build the DashboardPage class?

Model the screen a successful login lands on the same way: locators as getters, actions and queries as methods. The dashboard mostly needs read methods a test can assert against.

// pages/dashboard.page.js
const { BasePage } = require('./base.page');
 
class DashboardPage extends BasePage {
  get welcomeHeading() {
    return this.vibe.find({ role: 'heading' });
  }
 
  get userMenu() {
    return this.vibe.find({ testid: 'user-menu' });
  }
 
  get logoutButton() {
    return this.vibe.find({ role: 'button', text: 'Log out' });
  }
 
  welcomeText() {
    return this.welcomeHeading.text();
  }
 
  isLoaded() {
    return this.userMenu.isVisible();
  }
 
  logout() {
    this.userMenu.click();
    this.logoutButton.click();
    return new LoginPage(this.vibe);
  }
}
 
// Lazy require avoids a circular-import crash between the two files.
const { LoginPage } = require('./login.page');
 
module.exports = { DashboardPage };

isLoaded() returns a boolean straight from el.isVisible(), giving tests a clean readiness check with no manual wait — Vibium's find() already blocks until the element is actionable. Note the LoginPage require sits at the bottom: login.page.js and dashboard.page.js reference each other, and requiring lazily prevents the circular-dependency undefined you would otherwise get in CommonJS.

How does a test use these page objects?

A well-written test reads like a description of user behavior, with zero selectors in sight. It launches a browser, hands the page to the first page object, chains through the flow, and asserts on high-level results.

// tests/login.test.js
const assert = require('node:assert');
const { browser } = require('vibium/sync');
const { LoginPage } = require('../pages/login.page');
 
function run(name, fn) {
  const bro = browser.launch({ headless: true });
  try {
    fn(bro.page());
    console.log(`ok - ${name}`);
  } finally {
    bro.close(); // always tears down, even if an assertion throws
  }
}
 
run('valid login lands on the dashboard', (vibe) => {
  const dashboard = new LoginPage(vibe).open().login('alice@acme.com', 'secret');
 
  assert.ok(dashboard.isLoaded(), 'dashboard should be visible after login');
  assert.match(dashboard.welcomeText(), /welcome/i);
});
 
run('invalid login shows an error', (vibe) => {
  const loginPage = new LoginPage(vibe).open();
  loginPage.login('alice@acme.com', 'wrong-password');
 
  assert.match(loginPage.errorText(), /invalid/i);
});

Run it with node tests/login.test.js. Every line of intent lives in the test; every selector lives in a page class. If the sign-in button's label changes tomorrow, you edit one getter in LoginPage and both tests — plus every future test that logs in — keep passing. That is the entire return on the pattern.

The run() helper here is a five-line stand-in for a real runner. In production you would use Jest, Vitest, or node --test; Vibium is runner-agnostic because it is a browser client, not a test framework. See structure a Vibium test suite for the fixture-based version.

Which locators should page objects use?

Prefer Vibium's semantic selectors — role, label, text, placeholder, and testid — because they identify an element by what it is to a user, not by a brittle CSS path that a redesign can shatter. A find({ role: 'button', text: 'Sign in' }) survives a class-name change; a find({ testid: 'cart-count' }) is immune to layout churn entirely.

// Resilient — describes intent, lives inside the page class
this.vibe.find({ role: 'button', text: 'Sign in' }).click();
this.vibe.find({ label: 'Email' }).fill('alice@acme.com');
this.vibe.find({ testid: 'submit-order' }).click();
 
// Brittle — breaks on a purely cosmetic change
this.vibe.find('div.form > button.btn.btn-primary.mt-3').click();

Vibium exposes one find() that takes either a CSS string or a structured options object, and the object form is combinable — find({ role: 'button', text: 'Submit' }) in a single call, where some tools need chaining. Keep these locators inside the page class and a UI team can rename a CSS class without breaking a single test. For the full selector matrix, see finding elements with find() and the deeper selector best practices.

How do I model reusable components, not just full pages?

Not every page object is a whole screen — headers, modals, and nav bars appear on many pages, so model each as its own small class and compose them. A HeaderComponent that exposes search(term) or openCart() can be reused by every page that renders the header, killing copy-pasted locators.

// pages/header.component.js
class HeaderComponent {
  constructor(vibe) {
    this.vibe = vibe;
  }
 
  search(term) {
    this.vibe.find({ placeholder: 'Search products' }).fill(term);
    this.vibe.find({ role: 'button', text: 'Search' }).click();
  }
 
  openCart() {
    this.vibe.find({ testid: 'cart-icon' }).click();
  }
}
 
module.exports = { HeaderComponent };

A page object then exposes the component as a getter — get header() { return new HeaderComponent(this.vibe); } — so a test writes dashboard.header.search('shoes'). Component objects keep POM honest: each shared UI piece has exactly one class, so redesigning the header is a single edit instead of a hunt across every page that shows it.

Page Object Model vs raw scripts: when is each right?

Page objects are not always the answer — a throwaway script is genuinely faster to write flat. The table below is an honest guide to when the pattern earns its keep.

SituationRaw find() scriptPage Object Model
One-off scrape or checkBest — least ceremonyOverkill
2–3 tests, stable UIFineFine, slight overhead
Growing suite (10+ tests)Locators duplicate fastBest — one home per locator
UI changes oftenEvery change hits many testsOne class edit fixes all
Team / reviewersSelectors clutter intentTests read as behavior
Shared UI (header, modal)Copy-paste locatorsComponent classes reuse them

When to choose raw scripts: you are exploring, scraping once, or writing a handful of tests over a UI that will not move. When to choose POM: the suite will grow, more than one person reads it, or the UI is under active development — which is to say, almost any real project. Vibium's auto-waiting find() makes the page-object methods especially clean, since they carry no manual sleep() calls that bloat other frameworks' page classes. For more on why those waits are unnecessary, read waiting strategies.

Can I write the same page objects in Python?

Yes — the structure is identical in Vibium's Python client, only the naming shifts to snake_case and the import changes. Use from vibium import browser_sync as browser, and methods return the next page object exactly as in JavaScript.

# pages/login_page.py
from vibium import browser_sync as browser
 
 
class BasePage:
    def __init__(self, vibe):
        self.vibe = vibe
 
    def open(self, url):
        self.vibe.go(url)
        return self
 
 
class LoginPage(BasePage):
    URL = "https://example.com/login"
 
    def open(self):
        return super().open(self.URL)
 
    def login(self, email, password):
        self.vibe.find(label="Email").type(email)
        self.vibe.find(label="Password").type(password)
        self.vibe.find(role="button", text="Sign in").click()
        return DashboardPage(self.vibe)
 
 
class DashboardPage(BasePage):
    def is_loaded(self):
        return self.vibe.find(testid="user-menu").is_visible()
 
    def welcome_text(self):
        return self.vibe.find(role="heading").text()

A test drives it the same way, launching with the sync API and tearing down in a finally:

from vibium import browser_sync as browser
 
vibe = browser.launch()
try:
    dashboard = LoginPage(vibe).open().login("alice@acme.com", "secret")
    assert dashboard.is_loaded()
    assert "welcome" in dashboard.welcome_text().lower()
finally:
    vibe.quit()

Note the Python teardown is vibe.quit(), and text entry uses type(). The design — base class, one class per screen, methods returning the next page — carries over untouched. For a Python-first treatment with pytest fixtures, see Vibium with pytest and the companion Page Object Model with Vibium page.

What are the common Page Object Model mistakes to avoid?

Most POM problems come from letting the pattern leak or bloat, not from Vibium itself. This table maps the anti-pattern to the fix.

MistakeWhy it hurtsFix
Assertions inside page objectsCouples the page to one test's expectationsReturn values; assert in the test
Selectors in test filesDefeats the whole patternEvery locator lives in a page class
Storing elements as fields in the constructorGoes stale after navigationUse getters so find() re-runs
One giant page class per appUnreadable, merge-conflict magnetOne class per screen or component
Manual sleep() in methodsSlow and flakyRely on Vibium's auto-wait
Circular imports crash on loadundefined classes at require timeLazy-require the peer page at file bottom

The single most common slip is putting an assert inside a page method. Keep page objects free of test judgments — they describe what the page can do, and the test decides what should be true. Do that, and the same page classes serve success cases, failure cases, and edge cases without change.

How do I debug a failing page object?

When a page method fails, capture a screenshot at the failure point so you can see the state the test saw rather than guessing from a stack trace. Because BasePage already exposes screenshot(), any page object can snapshot itself, and a small wrapper in your test runner turns every failure into an artifact.

const { writeFileSync } = require('node:fs');
 
function run(name, fn) {
  const bro = browser.launch({ headless: true });
  const vibe = bro.page();
  try {
    fn(vibe);
    console.log(`ok - ${name}`);
  } catch (err) {
    // Snapshot the exact failure state for triage.
    writeFileSync(`fail-${name.replace(/\s+/g, '-')}.png`, vibe.screenshot());
    console.error(`not ok - ${name}: ${err.message}`);
    throw err;
  } finally {
    bro.close();
  }
}

This pairs well with Vibium's semantic locators: when a find({ role: 'button', text: 'Sign in' }) fails, the screenshot usually shows immediately whether the button was renamed, hidden, or simply not rendered yet. Run headed (browser.launch({ headless: false })) to watch the flow live while you develop, then flip back to headless for CI. The full capture options — fullPage, clip, element-scoped shots — are in the screenshot reference. Keeping this logic in the runner, not the page objects, is deliberate: page classes stay focused on locators and actions, while cross-cutting concerns like failure capture live in one shared place.

How does the Vibium Page Object Model compare to Playwright and Selenium?

The Page Object Model is a universal pattern — Playwright and Selenium users write page classes too — but the code inside each method differs by tool. This at-a-glance table is honest about where each sits.

AspectVibium POMPlaywright POMSelenium POM
Finding elementsOne find(), CSS or semanticSeveral getBy* locatorsVerbose By locators
Auto-wait in methodsBuilt in — no sleepsBuilt inManual explicit waits
Combined selectorsfind({ role, text }) in one callChain getByRole().filter()Chain / XPath
AI assertions in a methodcheck('...') availableNot built inNot built in
Maturity / ecosystemNewer (currently 26.2)Very matureMost mature, largest base

When Vibium's POM fits best: you want terse page methods, built-in auto-wait so classes carry no manual waits, and the option to drop to an AI check() for a hard-to-select assertion — especially if an LLM also drives the same engine via MCP. When Playwright fits better: you need its very mature ecosystem, built-in test runner, and trace viewer today. When Selenium fits: you have an existing Selenium page-object estate or need its unmatched browser and language breadth.

Be fair to the alternatives: both are more established than Vibium, and a Selenium or Playwright page-object suite is a perfectly good choice. Vibium's edge in this pattern is that methods come out shorter — no explicit waits, one find(), optional AI checks — not that the pattern itself is new. For side-by-side detail, read Vibium vs Playwright and Vibium vs Selenium.

How does POM fit with Vibium's AI methods and MCP?

Page objects and Vibium's AI-native methods are complementary, not rivals. You write deterministic page classes for the stable flows you know, and reach for check() or do() for steps that are easier to describe than to select.

Inside a page method you can drop to this.vibe.check('the dashboard shows 3 widgets') for a plain-English assertion, or this.vibe.do('close the cookie banner') for a fuzzy action — both take identical code paths to the find/click calls your other methods use, because do() is AI planning over Vibium's own API, not separate puppeteering. And since Vibium ships a built-in MCP server, an LLM agent in Claude Code can drive the very same engine your page objects use. Keep high-value journeys as page objects for refactor safety; use the AI layer for the parts that change often. To wire the agent side up, see Vibium MCP in Claude Code.

Next steps

Frequently asked questions

What is the Page Object Model in Vibium?

The Page Object Model is a pattern where each screen becomes a class that owns its locators and exposes methods named after user actions. Tests call those methods instead of raw find() calls, so a UI change is fixed in one class rather than across every test in the suite.

How do I build a page object in Vibium step by step?

Create a BasePage class holding the Vibium page handle, subclass it per screen, store locators as fields, and expose action methods that return the next page object. Then have your test launch a browser, hand the page to the class, and assert on high-level results — no selectors in the test.

Do I need a base page class in Vibium?

A base page is optional but recommended once you have more than two screens. It holds the shared Vibium page handle and common helpers like open() or waitForVisible(), so every page object inherits them instead of repeating the same constructor and navigation code in each class.

Which locators should Vibium page objects use?

Prefer Vibium's semantic selectors — role, label, text, placeholder, and testid — because they describe an element by what it is to a user, not by a fragile CSS path. Keep them inside the page class so a renamed class name or moved field is a one-line fix, not a suite-wide edit.

Does the Page Object Model slow down Vibium tests?

No. Page objects are thin wrappers over the same find() and click() calls, so they add no runtime cost. Because Vibium's find() already auto-waits for actionability, your page methods stay short and need no manual sleeps, keeping the suite both readable and fast.

Can I use the Page Object Model with Vibium in Python too?

Yes. The same structure — a base class, one class per screen, methods returning the next page — works in Vibium's Python client using from vibium import browser_sync as browser. The naming differs slightly (snake_case methods) but the design and benefits are identical to the JavaScript version.

Vibium is created by Jason Huggins. This is an independent tutorial — see the official Vibium site and GitHub repo for canonical docs.

Related guides