VLearnVibium

Build a Vibium Test Framework From Scratch

Build a Vibium test framework from scratch: project layout, config, page objects, fixtures, reporting, and CI — a step-by-step JS and Python blueprint.

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

Building a Vibium test framework from scratch means stacking four small layers on top of Vibium's browser engine: configuration, a browser fixture, page objects, and the tests themselves — then bolting on reporting and CI. You start with an empty folder, run npm install vibium (or pip install vibium) alongside a test runner, and add each layer in order. Vibium handles the hard parts — launching Chrome, speaking WebDriver BiDi, and auto-waiting for elements to be actionable — so your framework code stays thin. There is no driver to install and no browser version to match, which removes the most common source of flaky end-to-end suites. Vibium is Jason Huggins' AI-native automation tool (he co-created Selenium and Appium), and this guide is an independent, practical blueprint. By the end you will have a maintainable structure that scales from one test to hundreds, works headless in CI and headed on your machine, and mixes deterministic page objects with Vibium's plain-English check() assertions.

What does a Vibium test framework pipeline look like?

A Vibium framework is a short, linear pipeline: configuration feeds a fixture, the fixture hands a page to your tests, tests drive page objects, and results flow into a report. Each stage has one job, which is what keeps the whole thing easy to reason about.

The stages map directly to the sections below. Config centralizes launch options and the base URL. The fixture owns the browser lifecycle so tests never call launch() themselves. Page objects hold every locator and user action for a screen. Tests describe behavior and assert on outcomes. Finally, report/CI turns green-or-red results into artifacts you can trust. Because Vibium's engine does the waiting and element resolution, none of these layers need retry loops or sleep() calls.

Why build a framework instead of writing scripts?

A framework exists to stop duplication and to make change cheap as a suite grows. A one-off script is fine for a spike, but the moment you have ten tests sharing a login flow, copy-pasted find() calls become a maintenance tax.

The concrete payoffs:

  • One place per concern. A renamed button is a one-line page-object fix, not a hunt across fifty tests.
  • Readable tests. login_page.login("alice", "secret") beats four raw selector calls.
  • Config, not edits. Switching from staging to production or headed to headless is an environment-variable change.
  • Reuse. The same LoginPage and browser fixture serve every test in the suite.

If Vibium itself is new to you, read what is Vibium for the mental model and how Vibium works for the architecture, then come back for the framework.

What do I need before I start?

You need three things: Node.js or Python, the vibium package, and a test runner. Vibium's install step auto-downloads a matching Chrome for Testing build, so there is nothing else to provision.

Building blockJavaScript / TypeScriptPython
RuntimeNode.js 18+Python 3.9+
Vibiumnpm install vibiumpip install vibium
Test runnerJest, Mocha, or node --testpytest
BrowserAuto-downloaded by VibiumAuto-downloaded by Vibium
ReportingRunner's reporter + screenshotspytest-html + screenshots

The choice of runner is yours; this guide uses the async client with a plain runner in JavaScript and pytest in Python. For the full install walkthrough see install Vibium.

How do I lay out the project folders?

Separate the four layers into their own folders so each kind of change lands in one predictable place. A flat pile of test files works until it does not; this structure scales cleanly.

vibium-framework/
├── config.js            # launch options, base URL, timeouts
├── fixtures/
│   └── browser.js       # browser + page lifecycle
├── pages/
│   ├── login.page.js    # locators + actions for the login screen
│   └── dashboard.page.js
├── tests/
│   └── login.test.js
├── artifacts/           # screenshots, traces (gitignored)
└── package.json

The rule that makes this pay off: tests never touch selectors, and page objects never call launch(). Tests import page objects; page objects receive a ready page from the fixture. This same shape works in Python with config.py, conftest.py, pages/, and tests/. For a deeper tour of what Vibium puts on disk, see the Vibium folder structure.

How do I centralize configuration?

Put every launch option, the base URL, and any timeouts in a single config module that reads from environment variables. This is the switch that lets one suite run headed while you debug and headless in CI without editing a single test.

// config.js
module.exports = {
  headless: process.env.VIBE_HEADLESS === 'true',
  baseURL: process.env.VIBE_BASE_URL || 'https://example.com',
  viewport: { width: 1280, height: 800 },
  artifactsDir: 'artifacts',
};
# config.py
import os
 
HEADLESS = os.getenv("VIBE_HEADLESS", "false") == "true"
BASE_URL = os.getenv("VIBE_BASE_URL", "https://example.com")
VIEWPORT = {"width": 1280, "height": 800}
ARTIFACTS_DIR = "artifacts"

With config isolated, moving the whole suite to a new environment is VIBE_BASE_URL=https://staging.example.com, and going headless is VIBE_HEADLESS=true. Nothing in pages/ or tests/ changes. Keep secrets like credentials in environment variables too, never hard-coded in the repo.

How do I build the browser fixture?

The fixture owns the browser lifecycle — launch before tests, close after — so no test ever leaks a browser process. In JavaScript with the async client, a tiny helper plus the runner's hooks does the job.

// fixtures/browser.js
const { browser } = require('vibium');
const config = require('../config');
 
async function newSession() {
  const bro = await browser.launch({
    headless: config.headless,
    viewport: config.viewport,
  });
  const vibe = await bro.page();
  return { bro, vibe };
}
 
module.exports = { newSession };
// tests/login.test.js (node --test)
const test = require('node:test');
const assert = require('node:assert');
const { newSession } = require('../fixtures/browser');
 
test('user can log in', async () => {
  const { bro, vibe } = await newSession();
  try {
    await vibe.go('https://example.com/login');
    assert.match(await vibe.title(), /Login/);
  } finally {
    await bro.close();  // always clean up, even on failure
  }
});

The try/finally guarantees the browser closes even when an assertion throws — the single most important habit for a stable suite. In pytest, a conftest.py fixture expresses the same lifecycle more elegantly with yield.

# conftest.py
import pytest
from vibium import browser_sync as browser
import config
 
 
@pytest.fixture
def vibe():
    bro = browser.launch(headless=config.HEADLESS)
    page = bro.page()
    yield page          # test runs here
    bro.quit()          # teardown, runs even if the test fails

Any pytest test that lists vibe as an argument now receives a ready page and never worries about setup or teardown. This mirrors the pattern in structure your test suite.

How do I write page objects in Vibium?

A page object is a class that owns the locators and user actions for one screen, exposing methods named after what a user does rather than how the DOM is queried. Tests call login(), not four find() calls.

// pages/login.page.js
class LoginPage {
  constructor(vibe) {
    this.vibe = vibe;
  }
 
  async open() {
    await this.vibe.go('https://example.com/login');
    return this;
  }
 
  async login(username, password) {
    await this.vibe.find('#username').fill(username);
    await this.vibe.find('#password').fill(password);
    await this.vibe.find({ role: 'button', text: 'Sign in' }).click();
  }
 
  async errorMessage() {
    return this.vibe.find('.error').text();
  }
}
 
module.exports = { LoginPage };

The Python version uses Vibium's verified sync API and keyword-based semantic selectors, which read like the page itself.

# pages/login_page.py
from vibium import browser_sync as browser  # imported where the page is used
 
 
class LoginPage:
    def __init__(self, vibe):
        self.vibe = vibe
 
    def open(self):
        self.vibe.go("https://example.com/login")
        return self
 
    def login(self, username, password):
        self.vibe.find("#username").type(username)
        self.vibe.find("#password").type(password)
        self.vibe.find(role="button", text="Sign in").click()
 
    def error_message(self):
        return self.vibe.find(".error").text()

Because Vibium's find() already auto-waits for actionability, page methods stay short and free of manual waits. Prefer semantic selectors — role, label, testid — over brittle CSS paths so locators survive cosmetic refactors. The full pattern, including returning the next page object for fluent chaining, is covered in the Page Object Model with Vibium, and selector strategy in selector best practices.

How do I keep URLs out of the page objects?

Pass the base URL in from config and build each page's path from it, so the same page object works against localhost, staging, and production without edits. The hard-coded https://example.com/login in the examples above is fine for a demo but becomes a liability the moment you have more than one environment.

# pages/login_page.py — path relative to a base URL
class LoginPage:
    PATH = "/login"
 
    def __init__(self, vibe, base_url):
        self.vibe = vibe
        self.base_url = base_url
 
    def open(self):
        self.vibe.go(self.base_url + self.PATH)
        return self

Then feed config.BASE_URL through the fixture so tests never assemble URLs themselves:

# conftest.py
import pytest
import config
 
 
@pytest.fixture
def base_url():
    return config.BASE_URL
 
 
# tests/test_login.py
from pages.login_page import LoginPage
 
 
def test_login(vibe, base_url):
    LoginPage(vibe, base_url).open().login("alice", "secret")
    assert "Welcome" in vibe.find(".welcome").text()

Now VIBE_BASE_URL=https://staging.example.com pytest runs the identical suite against staging. Each page object owns only its path (/login, /dashboard), and the environment owns the host. This small indirection is what turns a demo framework into one you can point at any deployment.

How do I write tests against the page objects?

A test reads like a description of user behavior: get a page from the fixture, drive it through page objects, and assert on the outcome. No selectors appear in the test file.

# tests/test_login.py
from pages.login_page import LoginPage
 
 
def test_valid_login_reaches_dashboard(vibe):
    LoginPage(vibe).open().login("alice", "secret")
    assert "Welcome, Alice" in vibe.find(".welcome").text()
 
 
def test_invalid_login_shows_error(vibe):
    login = LoginPage(vibe).open()
    login.login("alice", "wrong-password")
    assert "Invalid credentials" in login.error_message()

The vibe argument is injected by the pytest fixture, so each test starts with a fresh browser and cleans up automatically. The tests are short and declarative because the page object carries the locators and the fixture carries the lifecycle — that division is what keeps a large suite readable. For the raw flow these tests wrap, see how to automate a login flow.

When should I use Vibium's AI check() instead of a selector?

Reach for check() when the state you want to verify is easier to describe in words than to select — a visual layout, a sorted order, an absence of errors. It complements page objects; it does not replace them.

// Deterministic assertion — you know the selector
assert.strictEqual(await vibe.find('.cart-count').text(), '0');
 
// AI assertion — describe intent, let Vibium judge
const { passed } = await vibe.check('the shopping cart is empty');
assert.ok(passed);

The decision comes down to what is clearer and what needs to be fast:

SituationUse page object + selectorUse check()
Stable element with a known selectorYes
High-volume, speed-critical assertionsYes
Visual state (dark mode, layout, spacing)Yes
"No error is visible anywhere"Yes
Exact text or attribute valueYes
Prototyping before selectors are stableYes

A healthy framework leans on deterministic page objects for the bulk of coverage — they are fast and repeatable — and uses check() where intent beats a selector. Both are first-class in Vibium and coexist by design. See find an element for the deterministic side and take a screenshot for capturing evidence.

How do I add reporting and failure screenshots?

Capture a screenshot on every failure so a red CI run is debuggable without re-running it locally. Vibium's screenshot() returns PNG bytes you write to your artifacts folder, keyed to the test name.

# conftest.py — extend the fixture with on-failure capture
import os
import pytest
from vibium import browser_sync as browser
import config
 
 
@pytest.fixture
def vibe(request):
    bro = browser.launch(headless=config.HEADLESS)
    page = bro.page()
    yield page
    if request.node.rep_call.failed:
        os.makedirs(config.ARTIFACTS_DIR, exist_ok=True)
        name = request.node.name
        png = page.screenshot()
        with open(f"{config.ARTIFACTS_DIR}/{name}.png", "wb") as f:
            f.write(png)
    bro.quit()
 
 
@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item, call):
    outcome = yield
    setattr(item, f"rep_{call.when}", outcome.get_result())

For a browsable HTML report, add pytest-html and run pytest --html=report.html; in JavaScript, most runners emit JUnit XML that CI dashboards ingest directly. For richer step-by-step debugging, Vibium can also record a trace of the session — a timeline of actions and screenshots — which you attach as a CI artifact. Keep the artifacts/ folder gitignored so evidence never lands in version control.

How do I run the framework in CI?

The suite drops into CI cleanly because the only environment-specific knobs — headless mode and base URL — already come from environment variables. Vibium auto-downloads Chrome, so the runner needs no driver setup.

# .github/workflows/tests.yml
name: vibium-tests
on: [push, pull_request]
jobs:
  e2e:
    runs-on: ubuntu-latest
    env:
      VIBE_HEADLESS: 'true'
      VIBE_BASE_URL: 'https://staging.example.com'
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - run: npm ci
      - run: node --test tests/
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: vibium-artifacts
          path: artifacts/

The if: failure() upload attaches your screenshots and traces only when something breaks, so debugging a red build is a download away. On Linux runners you may need a few shared libraries for Chrome; the full setup, caching, and a Python matrix live in running Vibium in CI/CD with GitHub Actions.

How do I keep the framework fast and stable as it grows?

Speed and stability come from three habits, not from adding retries. First, isolate state with a fresh context per test instead of relaunching the whole browser — each Vibium context has its own cookies and storage, giving isolation without paying the launch cost every time. Second, lean on auto-waiting: because find() waits for actionability, deleting sleep() calls usually makes a suite both faster and less flaky, as detailed in waiting strategies and flake-free tests. Third, run tests in parallel once you have enough of them for wall-clock time to matter.

# A shared, session-scoped browser with a fresh context per test
@pytest.fixture(scope="session")
def shared_browser():
    bro = browser.launch(headless=config.HEADLESS)
    yield bro
    bro.quit()
 
 
@pytest.fixture
def vibe(shared_browser):
    ctx = shared_browser.new_context()   # isolated cookies/storage
    page = ctx.new_page()
    yield page
    ctx.close()

This session-browser-plus-function-context pattern is the sweet spot: real isolation, minimal launch overhead. Add parallel workers with pytest -n auto (pytest-xdist) and scale up as coverage grows — see parallelize your tests for the details and the trade-offs.

What mistakes should I avoid when building the framework?

Most framework pain comes from a handful of avoidable habits, and naming them upfront saves hours later. These are the ones that bite new Vibium projects most often.

MistakeWhy it hurtsThe fix
Selectors in test filesA UI change breaks many tests at onceKeep every locator in a page object
sleep() before actionsSlow and still flakyTrust find()'s auto-waiting; delete the sleep
launch() inside a testLeaked processes, no isolationOwn the lifecycle in a fixture only
Hard-coded URLs and credentialsCan't switch environments; secrets in gitRead both from config / env vars
One giant page objectBecomes a dumping groundOne class per screen or component
Brittle CSS chains (div > ul > li:nth-child(3))Snaps on cosmetic refactorsPrefer role, label, testid
No screenshot on failureRed CI runs are undebuggableCapture in the fixture teardown

The deepest of these is the instinct to add a sleep() when a click "sometimes" fails. In Vibium that is almost always the wrong fix — the engine already waits for the element to be visible, stable, and enabled before acting. If something still fails intermittently, the cause is usually a selector that matches the wrong element or state, not a timing gap. Diagnose it as a locator problem, and the suite gets both faster and more reliable. Flake-free tests walks through this reasoning in depth.

A second subtle trap is over-reaching with check(). Its plain-English assertions are powerful, but they are slower and less deterministic than a direct selector, so using them for every assertion makes a suite sluggish and occasionally non-repeatable. Treat check() as a scalpel for visual or intent-based states, and keep the high-volume, exact-value assertions on page objects.

What does the finished framework give you?

You end with a layered structure where every kind of change has one home: launch options in config, lifecycle in the fixture, locators in page objects, and behavior in tests. It runs headed on your machine and headless in CI from the same code, captures evidence on failure, and mixes deterministic assertions with Vibium's plain-English check(). Because Vibium's Go engine handles browser management, the BiDi protocol, and auto-waiting, your framework code stayed thin the whole way — which is exactly why it will keep scaling without collapsing under its own weight.

For a guided path through all of this, the Master Vibium course and the 45-day roadmap sequence these pieces into a curriculum, and the glossary defines any term that is new.

Next steps

Frequently asked questions

How do I build a test framework with Vibium from scratch?

Start with an empty project, install vibium and a test runner, then add four layers: config for launch options and base URL, a browser fixture for lifecycle, page objects for locators, and tests for behavior. Wire in reporting and CI last. Each layer stays small because Vibium's engine handles waiting.

Do I need a separate test runner to use Vibium?

Yes, for anything beyond a script. Vibium drives the browser but does not run or report tests, so pair it with Jest, Mocha, or the Node test runner in JavaScript, or pytest in Python. The runner gives you assertions, fixtures, parallelism, and failure reports; Vibium supplies the browser automation underneath.

What folder structure works best for a Vibium framework?

Keep tests, page objects, fixtures, and config in separate folders. A typical layout is tests/, pages/, fixtures/, and a single config file at the root. This separation means a UI change touches one page object, a launch-option change touches one config file, and tests stay short and readable as the suite grows.

How do I make Vibium tests run headless in CI but headed locally?

Read the headless flag from an environment variable and pass it to browser.launch(). Default to headed for local debugging and set the variable in CI so the same suite runs both ways. Because Vibium auto-downloads Chrome for Testing, there is no driver to install or version-match on the CI runner.

Should I use page objects or Vibium's AI check() method?

Use both. Page objects hold deterministic locators and actions for stable, fast tests. Vibium's check('the cart is empty') adds plain-English visual assertions for states that are awkward to select. A good framework leans on page objects for the bulk of coverage and uses check() where intent is clearer than a selector.

How long does it take to set up a Vibium test framework?

A working skeleton — install, config, one fixture, one page object, and a passing test — takes under an hour because there is no driver setup and no browser download step to manage manually. Growing it into a full suite with reporting and CI is incremental; each layer is added independently without reworking the ones below.

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

Related guides