VLearnVibium

Mixing API + Web Testing with Vibium

Mix API and web testing with Vibium — assert on backend JSON with waitForResponse and route while driving the real UI, in one script.

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

To mix API and web testing with Vibium, drive the real UI with find() and click(), then read the exact network calls the page fires with waitForResponse() and assert on their status() and json() — all in one script. Vibium is AI-native browser automation built on WebDriver BiDi, so a single session controls both the browser and the network layer beneath it. That means you can click a "Load more" button, wait for the precise /api/items response it triggers, assert the backend returned 200 with the right payload, and then confirm the UI rendered those items — without a second tool, a second process, or a brittle sleep(). You can also go the other way and mock a response with route() to test error states the real backend rarely produces. Created by Jason Huggins, co-creator of Selenium and Appium, Vibium collapses the usual split between an API suite and a UI suite into one deterministic flow that catches the integration bugs living in the gap between them.

Why test the API and the UI in the same script?

Testing the API and the UI together catches the class of bug that neither suite finds alone: the integration seam where the backend and frontend disagree. A pure API test confirms /api/cart returns the right total; a pure UI test confirms a number appears on screen. Neither proves the number on screen is that total.

Real defects hide in that gap. A UI that swallows a 500 and shows stale data. A response schema that changed a field name, so the component silently renders undefined. A race where the page reads before the fetch resolves. Vibium sees both layers at once, so one assertion covers the round trip.

This is possible because Vibium is not a wrapper around an HTTP client bolted onto a browser driver. The same WebDriver BiDi session that clicks buttons also streams every request and response the page makes. You assert on real production traffic — the actual XHR your app fires — not a synthetic call you hand-crafted that may drift from what ships.

ApproachCatches UI bugsCatches API bugsCatches integration bugsTools needed
UI suite onlyYesNoNo1
API suite only (axios/requests)NoYesNo1
Two separate suitesYesYesRarely (no shared context)2+
Vibium — API + UI in one flowYesYesYes1

What does the combined API + UI script look like?

A combined test acts on the UI, waits for the network call that action triggers, asserts on the response, then asserts on what the page rendered. Here it is end to end in JavaScript:

const { browser } = require('vibium/sync')
 
const bro = browser.launch()
const page = bro.page()
 
page.go('https://shop.example.com/products')
 
// 1. Act on the UI — this click fires an XHR to the backend.
page.find('#load-more').click()
 
// 2. Wait for the exact API call the click triggered.
const res = page.waitForResponse('**/api/products*')
 
// 3. Assert on the API layer.
if (res.status() !== 200) throw new Error(`API failed: ${res.status()}`)
const data = res.json()
if (data.items.length === 0) throw new Error('API returned no items')
 
// 4. Assert on the UI layer — the DOM rendered what the API returned.
const rows = page.findAll('[data-testid="product-row"]')
if (rows.length !== data.items.length) {
  throw new Error(`UI shows ${rows.length} rows, API sent ${data.items.length}`)
}
 
console.log(`OK — API and UI agree on ${rows.length} products`)
bro.close()

The final check is the one a single-layer test cannot make: it proves the count the backend sent equals the count the frontend painted. That cross-layer assertion is the whole point of mixing the two.

How does each step work?

The pattern is always the same four beats — act, wait, assert-API, assert-UI — regardless of the app:

  1. page.find('#load-more').click() — drives the real UI. Vibium auto-waits for the element to be actionable before clicking, so you never race a button that is not yet enabled.
  2. page.waitForResponse(pattern) — blocks until a response whose URL matches the glob pattern arrives, then hands back a response object. This is an event-driven wait: it takes exactly as long as the call takes, no more.
  3. res.status() / res.json() — reads the HTTP status and parses the body so you can assert the backend behaved. json() is a convenience over body() that parses for you.
  4. page.findAll(...) — reads the rendered DOM so you can assert the frontend behaved. Comparing the two is the integration check.

Because waitForResponse() replaces guesswork with a precise signal, the script is stable on a slow CI machine and fast on a quick one — it self-adjusts to the real latency of each call.

How do I wait for the exact API call a click triggers?

Register the wait around the action so you never miss a fast response. The safe order is: kick off the action, then await the response the action produces.

const { browser } = require('vibium/sync')
 
const bro = browser.launch()
const page = bro.page()
page.go('https://app.example.com/search')
 
// Type a query and submit — this fires GET /api/search?q=...
page.find('input[name="q"]').type('vibium')
page.find('button[type="submit"]').click()
 
// Wait for that specific search response and inspect it.
const res = page.waitForResponse('**/api/search*')
console.log('status:', res.status())
console.log('result count:', res.json().results.length)
 
bro.close()

The glob **/api/search* matches the search endpoint regardless of query string. Once it resolves, res.json() gives you the parsed payload — so you can assert the API returned the number of results you expect before you check they rendered.

Here is the same flow in Python, which uses the sync client the rest of this site favours:

from vibium import browser_sync as browser
 
vibe = browser.launch()
vibe.go("https://app.example.com/search")
 
vibe.find('input[name="q"]').type("vibium")
vibe.find('button[type="submit"]').click()
 
res = vibe.wait_for_response("**/api/search*")
print("status:", res.status())
print("result count:", len(res.json()["results"]))
 
vibe.quit()

Both clients wrap the same Go engine, so wait_for_response() behaves identically to waitForResponse() — only the naming convention differs (snake_case in Python, camelCase in JS).

How do I assert on the response body and status together?

Combine both checks into one guard so a failure tells you which layer broke. Read the status first (did the call even succeed?), then the body (did it return the right shape?):

const res = page.waitForResponse('**/api/checkout')
 
// Layer 1: did the request succeed at the HTTP level?
if (res.status() !== 200) {
  throw new Error(`Checkout API returned ${res.status()}`)
}
 
// Layer 2: is the payload shaped the way the UI expects?
const body = res.json()
if (!body.orderId || body.total == null) {
  throw new Error('Checkout response missing orderId or total')
}
 
// Layer 3: does the confirmation screen show the same order id?
const shown = page.find('[data-testid="order-id"]').text()
if (shown !== String(body.orderId)) {
  throw new Error(`UI shows order ${shown}, API returned ${body.orderId}`)
}

The three tiers read like a checklist: the network succeeded, the data is correct, and the screen matches the data. A break at any tier points straight at the responsible layer, which turns a vague "checkout is broken" into a precise diagnosis.

How do I mock an API to test UI edge cases?

Use page.route(pattern, handler) to intercept a request before it leaves the browser, then route.fulfill() to return a canned response. This lets you drive the UI into states the real backend rarely produces — empty lists, server errors, slow-but-valid payloads.

const { browser } = require('vibium/sync')
 
const bro = browser.launch()
const page = bro.page()
 
// Intercept the products endpoint and return an empty list.
page.route('**/api/products*', (route) => {
  route.fulfill({
    status: 200,
    contentType: 'application/json',
    body: JSON.stringify({ items: [] })
  })
})
 
page.go('https://shop.example.com/products')
 
// Now assert the UI shows its empty state, not a crash.
const empty = page.find('[data-testid="empty-state"]')
if (!empty.isVisible()) throw new Error('Empty state did not render')
 
bro.close()

Mocking flips the relationship: instead of asserting the UI matches a real response, you assert it handles a controlled one. Return status: 500 to prove the error banner appears; return a huge list to check virtualisation. Because route() intercepts over BiDi, the page genuinely believes it talked to the server — the component code runs unchanged.

TechniqueWhen to use itWhat it proves
waitForResponse()The action hits a real backend you trustUI and live API agree on real data
onResponse()You want to log or audit all trafficNo request silently failed (e.g. a background 4xx)
route() + fulfill()You need a state the backend rarely returnsUI handles empty / error / edge payloads
route() + continue()You want to tweak a header or let it passUI behaves under modified request conditions

How do I catch silent background failures?

Attach a response listener with onResponse() before navigating, and collect anything that failed. Pages fire dozens of calls; a background 4xx or 5xx the UI ignores can still break the experience later.

const { browser } = require('vibium/sync')
 
const bro = browser.launch()
const page = bro.page()
 
const failures = []
page.onResponse((res) => {
  if (res.status() >= 400) failures.push([res.status(), res.url()])
})
 
page.go('https://app.example.com/dashboard')
page.find('#refresh').click()
page.waitForResponse('**/api/**')  // let the refresh calls settle
 
if (failures.length) {
  throw new Error('Failed requests: ' + JSON.stringify(failures))
}
bro.close()

Register the listener before go() — callbacks only capture traffic that happens after they attach. This one assertion guards the whole session against the class of bug where a 500 from an analytics or preferences endpoint never surfaces in the UI but quietly corrupts state.

How do I inspect the request the UI sent, not just the response?

Read the request object to assert your frontend sent the right payload — the correct headers, method, and query. A UI bug often lives in the request, not the response: a missing auth header, a wrong content type, a stale query parameter. onRequest() exposes each outgoing call before it leaves.

const { browser } = require('vibium/sync')
 
const bro = browser.launch()
const page = bro.page()
 
let authHeader = null
page.onRequest((req) => {
  if (req.url().includes('/api/')) {
    // Assert the UI attached the auth token it should have.
    authHeader = req.headers()['authorization']
  }
})
 
page.go('https://app.example.com/dashboard')
page.find('#load-data').click()
page.waitForResponse('**/api/**')
 
if (!authHeader || !authHeader.startsWith('Bearer ')) {
  throw new Error('UI sent an API request without a Bearer token')
}
bro.close()

Verifying the request closes a blind spot that response-only testing leaves open: you confirm not just that the server answered correctly, but that the client asked correctly. req.method(), req.url(), and req.headers() all read out of the box; reading a request body (postData()) can require a data collector, since WebDriver BiDi does not always capture POST payloads by default — check the network monitoring guide for that setup rather than assuming the body is present.

How do I seed state through the API, then test it through the UI?

Drive setup through the fast layer and verification through the real one: prime data with a route() stub or a direct navigation, then let the UI render it and assert on the result. This hybrid keeps tests fast and honest — you skip slow UI setup steps without faking the thing you actually want to verify.

A common shape is stubbing the list endpoint so the UI has predictable data to act on, while letting the mutation hit reality:

const { browser } = require('vibium/sync')
 
const bro = browser.launch()
const page = bro.page()
 
// Seed: the list endpoint returns two known todos.
page.route('**/api/todos', (route) => {
  if (route.request().method() === 'GET') {
    route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify([
        { id: 1, title: 'Write tests', done: false },
        { id: 2, title: 'Ship feature', done: false }
      ])
    })
  } else {
    route.continue()  // let POST/PATCH hit the real backend
  }
})
 
page.go('https://app.example.com/todos')
 
// The UI now renders exactly the two seeded rows.
const rows = page.findAll('[data-testid="todo-row"]')
if (rows.length !== 2) throw new Error(`Expected 2 seeded rows, got ${rows.length}`)
 
// Act through the UI; this mutation is NOT stubbed, so it exercises the real API.
page.find('[data-testid="todo-row"]').first().find('input[type="checkbox"]').click()
const res = page.waitForResponse('**/api/todos*')
if (res.status() !== 200) throw new Error(`Toggle failed: ${res.status()}`)
 
bro.close()

Inspecting route.request().method() inside the handler lets one route split read from write — stub the deterministic read, pass the meaningful write through. You get a controlled starting state without mocking away the behaviour under test, which is the balance most flaky end-to-end suites miss.

Can I type the response in TypeScript?

Yes — cast res.json() to your API's response type so the compiler catches schema drift in your test. Vibium ships first-class TypeScript types, so a mixed test reads like typed application code:

import { browser } from 'vibium/sync'
 
interface Product { id: number; name: string; price: number }
interface ProductsResponse { items: Product[] }
 
const bro = browser.launch()
const page = bro.page()
page.go('https://shop.example.com/products')
 
page.find('#load-more').click()
const res = page.waitForResponse('**/api/products*')
 
const data = res.json() as ProductsResponse
const total = data.items.reduce((sum, p) => sum + p.price, 0)
 
// Assert the UI's displayed total matches the summed API prices.
const shown = page.find('[data-testid="cart-total"]').text()
if (shown !== `$${total.toFixed(2)}`) {
  throw new Error(`UI total ${shown} != API total $${total.toFixed(2)}`)
}
bro.close()

Typing the payload turns a runtime surprise into a compile-time error: if the backend renames price to unitPrice, your test stops compiling instead of silently reducing undefined. See Vibium vs Playwright for how the two compare on TypeScript ergonomics and the network API.

Where do Vibium's AI-native checks fit in?

Vibium's page.check() lets you assert on the rendered result in plain English, which pairs naturally with a hard API assertion. Use the precise network check for the data contract, and the AI check for the human-visible outcome:

// Hard assertion on the API contract.
const res = page.waitForResponse('**/api/cart')
if (res.json().count !== 3) throw new Error('Cart API count wrong')
 
// Plain-English assertion on what the user sees.
const result = page.check('the cart icon shows 3 items')
if (!result.passed) throw new Error(result.reason)

The two are complementary, not redundant: res.json() proves the backend is correct to the byte, while check() proves the pixels a user actually looks at match. Together they close the loop from database to screen. See what Vibium is for more on how the deterministic and AI-native APIs coexist.

When should I keep API and UI tests separate?

Mix the two layers only when they interact; keep them apart when they do not. The table below is a quick decision guide.

SituationRecommended approach
A UI action triggers an API call you must verifyMixclick() then waitForResponse()
Testing a component's empty / error stateMix with mockingroute() + fulfill()
Pure backend contract test, no browser neededSeparate — a plain HTTP client is simpler and faster
Load-testing an endpointSeparate — use a dedicated load tool, not a browser
End-to-end user journey with real dataMix — assert both layers along the journey

The honest rule: a browser is heavier than a bare HTTP request, so do not spin one up just to hit an endpoint. Reach for Vibium's combined flow when the interaction between the UI and the API is the thing under test — which, for real user-facing features, is most of the time.

Tips for reliable API + web testing

  • Kick off the action, then await the response — issue the click first, then call waitForResponse() so a fast reply is not missed.
  • Attach onResponse() before go() — listeners only capture traffic that occurs after they are registered.
  • Match endpoints with globs**/api/search* survives changing query strings and hostnames.
  • Assert status and body — a 200 with the wrong payload still breaks the UI; check both.
  • Mock the states the backend rarely returns — use route() + fulfill() for empty lists, 500s, and slow payloads instead of hoping they occur.
  • Compare counts across layersres.json().items.length versus findAll().length is the assertion that proves integration.
  • Run headless in CI with browser.launch({ headless: true }) for speed, headed locally to watch the run.

Next steps

Frequently asked questions

Can Vibium do API testing and web testing in the same script?

Yes. Vibium drives a real Chrome browser and, over WebDriver BiDi, also exposes the network layer. You click UI elements with find() and click(), then read the exact XHR or fetch the page fired with waitForResponse() and assert on its status() and json() — all in one script, one process.

How do I assert on an API response after a UI action in Vibium?

Trigger the action (a click or form submit), then call page.waitForResponse(pattern) to block until the matching response arrives. It returns a response object, so you read res.status() for the HTTP code and res.json() for the parsed body. This replaces fixed sleeps with a precise, event-driven wait.

How do I mock an API in Vibium while testing the UI?

Use page.route(pattern, handler) to intercept a request before it hits the network, then call route.fulfill() to return a canned JSON body and status. The UI renders against your stub, so you can test empty states, error banners, and edge cases deterministically without touching the real backend.

Why test the API and the UI together instead of separately?

Separate suites miss integration bugs — a UI that silently swallows a 500, or a schema change that breaks rendering. Testing both in one Vibium flow lets you assert the backend returned correct data AND that the component painted it correctly, catching the gap between the two layers where real defects hide.

Do I need a separate HTTP client like axios or requests with Vibium?

Usually not, for tests tied to the UI. Vibium reads the live requests the page already makes, so you assert on real production traffic instead of a synthetic call. For pure backend checks with no browser involved, a plain HTTP client is still simpler — mix only when the UI and API interact.

Does mixing API and web testing work in JavaScript and Python?

Yes. Vibium ships first-class JavaScript/TypeScript and Python clients over the same Go engine, so route(), waitForResponse(), onResponse(), and the deterministic find/click API behave identically. Pick the language your app is written in; the network-plus-UI pattern is the same in both.

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

Related guides