← All posts
Concepts

Testing server-driven UIs: a different mental model

Most end-to-end testing advice assumes a client-centric app: the browser holds the state, JavaScript mutates the DOM directly, and a click produces a visible effect on the next tick. That model is wrong for a whole family of frameworks — Wisej.NET, Blazor Server, Vaadin, Phoenix LiveView, and classic ASP.NET postback pages. These are server-driven UIs, and if you test them with client-centric habits, your suite flakes for reasons that make no sense until you understand where the state lives.

Here's the shift. In a server-driven UI, the DOM is not the source of truth — it's a projection of state that lives on the server. The browser is a thin rendering surface: it captures your input, ships it to the server, receives a diff back, and patches itself. The button you see, the value in the grid, the selected tab — none of it is authoritative. It's the last frame the server sent.

Once you internalize that, three testing rules fall out — not tips, consequences of the architecture.

Rule 1: every meaningful action is a network round trip

Click a button in a React SPA and a handler runs synchronously in the browser — by the next line of your test, the state has changed. Click a button in a server-driven UI and something different happens: the click serializes into a message, travels to the server, the server mutates its component tree and computes a diff, and markup comes back for the client to apply. That's a round trip, and it takes however long the network and the server take.

So the cardinal sin is asserting on an immediate DOM effect:

// Wrong for a server-driven UI: nothing has happened locally yet
await page.getByRole('button', { name: 'Apply filter' }).click();
expect(await page.getByRole('row').count()).toBe(12); // reads the OLD frame

That count() runs before the server's response lands. You read the pre-click DOM and assert against it — a race you'll lose intermittently, worse under CI load, which is exactly when people reach for waitForTimeout(1000) to paper over it.

The right tool is the retrying, web-first assertion. expect(locator).toHaveText(...), toBeVisible(), toHaveCount() — these poll until the condition holds or the timeout expires. They are built for waiting out a round trip you can't see:

await page.getByRole('button', { name: 'Apply filter' }).click();
await expect(page.getByText('12 results')).toBeVisible(); // retries until the server frame arrives

Assert on the outcome, never the action. This is the same discipline that makes optimistic-UI testing in SPAs honest — wait for the confirmed state, not the first paint — but in a server-driven UI it's not an edge case, it's every interaction. For the underlying mechanics, web-first assertions is the deeper treatment; the short version is: stop reading the DOM synchronously and let the assertion do the waiting.

Rule 2: widgets are framework-rendered composites, not HTML controls

The second trap is subtler and bites harder. What looks like a <select> is not a <select>. A server-driven framework renders a dropdown as a <div> with a styled button, a popup panel, and a pile of ARIA attributes — and the real state lives in a server-side control object that only reacts to the specific event path the framework wired up.

So this does nothing useful:

// A no-op the server never hears about
await page.selectOption('#statusFilter', 'Active');

selectOption targets a native <option>. There isn't one. Even forcing a value onto the underlying element doesn't help — the framework's client runtime never fires the change event that would ship the update to the server, so the server's model still says "unfiltered." The DOM looks right for a moment and the behavior is completely wrong — the worst kind of false pass.

The correct interaction reproduces the framework's real event path: open the popup with the trigger button, then click the actual option element, so the client runtime fires the event the server is listening for.

await page.getByRole('button', { name: 'Status' }).click();      // open the popup
await page.getByRole('option', { name: 'Active' }).click();       // trusted click → server hears it
await expect(page.getByText('Showing active only')).toBeVisible();

This open-then-select pattern is why treating every dropdown like an HTML <select> fails across the whole family. A Wisej ComboBox, a Blazor InputSelect, a Vaadin combo — each has its own gesture that produces a server-visible event, and a naive locator that skips it yields a spec that passes locally and lies about the app. For Wisej specifically, TestVibe's generation carries a per-control strategy map for exactly this: a ComboBox gets driven through its open button then the option, never a plain select_option call, because that's a server-side no-op — and that lives in a first-party Wisej.NET plugin, dogfooded daily since Ice Tea Group (who make Wisej.NET) build TestVibe on Wisej. Other server-driven frameworks don't have a dedicated plugin yet, but the general mechanisms still cover them: resilient locators anchored to roles and accessible names, real keystroke input where per-keystroke handlers matter, and round-trip-aware assertions. Drive the widget through its real event path, not a shortcut that bypasses the server.

Rule 3: the DOM is a viewport, not the dataset

The third consequence is about data. Server-driven grids, trees, and long lists are almost always virtualized: the server sends only the block of rows currently scrolled into view — maybe twenty out of ten thousand — and the client requests more blocks as you scroll. The rest of the dataset isn't in the DOM. It doesn't exist client-side until you ask for it.

This breaks the most natural assertion in the world:

// Counts the RENDERED window (~20), not the dataset (thousands)
const rows = await page.getByRole('row').count();
expect(rows).toBe(3450); // will never be true

Row-scraping lies. getByRole('row').count() returns the viewport size, and searching for a row far below the fold times out even though the record exists, because that block was never fetched. You end up scrolling-and-screenshotting, guessing pixels, and fighting the classic "I set the filter but the grid shows zero rows" race — which is really the refresh round trip not having landed yet.

The fix follows from the mental model: if the DOM is only a viewport, don't count it — read from the model, or wait out the fetch. Two moves:

Virtualization in a server-driven UI isn't the same problem as a react-window list in an SPA, where the full dataset is at least present in client memory. Here the data isn't in the browser at all — "read the model" means read the server's model. If you've fought virtualized lists in React SPAs, this is that problem with the stakes raised: the missing rows aren't unmounted, they were never sent.

One model, three consequences

None of these are framework-specific tricks. They're one observation stated three ways: the DOM is downstream of server state, so test the state, not the DOM.

Get those three right and server-driven UIs stop being mysteriously flaky. They're not harder to test than SPAs — they're differently hard, and the difference is about where the truth lives. Stop trusting the last frame the server sent, start asserting on the state behind it, and the false failures disappear.

Test your server-driven app without fighting it

TestVibe generates Playwright tests from a plain-language description and verifies each one against your live app before you see it — so a spec that only caught a stale frame, or fired a selectOption the server never heard, doesn't survive the check. Enable the Wisej.NET plugin for grid- and widget-aware helpers, or lean on the round-trip-aware mechanisms for any other server-driven stack.

Get early access, or read how AI generation works in the docs.

Early access

Ready for tests that write themselves?