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.
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
LoginPageand 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 block | JavaScript / TypeScript | Python |
|---|---|---|
| Runtime | Node.js 18+ | Python 3.9+ |
| Vibium | npm install vibium | pip install vibium |
| Test runner | Jest, Mocha, or node --test | pytest |
| Browser | Auto-downloaded by Vibium | Auto-downloaded by Vibium |
| Reporting | Runner's reporter + screenshots | pytest-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.jsonThe 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 failsAny 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 selfThen 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:
| Situation | Use page object + selector | Use check() |
|---|---|---|
| Stable element with a known selector | Yes | — |
| High-volume, speed-critical assertions | Yes | — |
| Visual state (dark mode, layout, spacing) | — | Yes |
| "No error is visible anywhere" | — | Yes |
| Exact text or attribute value | Yes | — |
| Prototyping before selectors are stable | — | Yes |
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.
| Mistake | Why it hurts | The fix |
|---|---|---|
| Selectors in test files | A UI change breaks many tests at once | Keep every locator in a page object |
sleep() before actions | Slow and still flaky | Trust find()'s auto-waiting; delete the sleep |
launch() inside a test | Leaked processes, no isolation | Own the lifecycle in a fixture only |
| Hard-coded URLs and credentials | Can't switch environments; secrets in git | Read both from config / env vars |
| One giant page object | Becomes a dumping ground | One class per screen or component |
Brittle CSS chains (div > ul > li:nth-child(3)) | Snaps on cosmetic refactors | Prefer role, label, testid |
| No screenshot on failure | Red CI runs are undebuggable | Capture 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
Vibium Best Practices: The Complete Guide
Vibium best practices for reliable browser automation: semantic locators, actionability waits, page objects, isolation, CI, and AI checks.
13 min read→Best PracticesA Complete Vibium CI/CD Pipeline
Build a complete Vibium CI/CD pipeline: install, headless run, parallel shards, artifact capture, and quality gates that block bad merges on every push.
12 min read→Best PracticesData-Driven Testing with Vibium
Data-driven testing with Vibium: feed one browser test many rows from arrays, CSV, or JSON, loop over cases, and keep the automation logic in one place.
15 min read→Best PracticesRun Vibium with Docker Compose
Run Vibium with Docker Compose: orchestrate a headless test service, an app-under-test, mounted artifact volumes, healthchecks, and parallel workers.
15 min read→