| name | write-tests |
| description | Author good Biloba specs in your own Ginkgo/Gomega suite — the dual immediate/matcher API (act now vs. return a matcher you poll with Eventually), first-vs-all naming, the navigate-then-readiness-anchor shape, selecting elements (CSS targeting stable hooks as the default, semantic role/text/label locators, the >>> piercing combinator, XPath), the interaction vocabulary (click variants, drag, scroll, tap), realistic mode for occlusion/hover smoke tests, hermetic tests via request stubbing/aborting/modifying, multi-tab flows, and seeding state. Use when writing or reviewing Biloba browser tests. |
Writing Biloba specs
Assumes Biloba is already wired into the suite (biloba:setup) and you know the principles (biloba:overview). For the full method list see biloba:api; for XPath see biloba:xpath. Docs: https://onsi.github.io/biloba/#working-with-the-dom.
RULE — two decisions to get right on the first draft
- Selecting elements. Interactions and user-facing things → a Locator by role/name/text (
b.ByRole("button").WithName("Save"), b.ByText("Sign in")), which doubles as an a11y guard. Structural/state hooks you own → CSS on a stable #id/[data-testid], never a styling class.
- Assert observable outcomes — visible text, counts, URL/title, network effects — not internal class/structure.
Smells to catch in your own draft (the wrong-from-generic-automation-muscle-memory moves): positional/styling-class CSS (:nth-of-type, .btn-primary); text-matching XPath where b.ByText/b.ByRole().WithName fits; reinventing a matcher with b.Run (querySelectorAll(...).length instead of b.HaveCount); IIFE-wrapping a script for return (use b.RunAsync); SetValue when you meant keystrokes (use b.Type); a single-shot read — b.Run(expr, &x) immediately followed by Expect(x) — which races any async settle (poll it: Eventually(b.Run).WithArguments(expr); see flaky-specs below).
The one pattern to internalize: dual immediate/matcher
Most DOM methods have two forms keyed on argument count:
- Under-applied → returns a Gomega matcher that you poll. This is the default — use it for essentially every interaction. Biloba never polls itself;
Eventually does.
Eventually("#go").Should(b.Click())
Eventually("#name").Should(b.SetValue("Jane"))
Eventually("#title").Should(b.HaveInnerText("Welcome"))
- Fully-applied → acts immediately, fails the spec on error. Acts now, never polls.
b.Click("#go")
b.SetValue("#name", "Jane")
text := b.InnerText("#title")
Default to the matcher form for every interaction. Eventually(sel).Should(b.Click()) folds readiness-waiting into the action: it polls until the element exists and is clickable (visible + enabled), dispatches exactly one atomic click on the first success, then succeeds and stops. It does not re-click on later polls, so it is safe even on a toggle — there is no oscillation, because the successful dispatch is what ends the poll. The matcher form has no downside; the immediate form does.
The immediate form is a silent foot-gun — reach for it only when you've just proven readiness on the line above. A fully-applied b.Click(sel)/b.Tap(sel)/b.SetValue(...)/b.SelectText(...) acts now; fired a frame too early (right after a re-render, a list load, an injected node) it no-ops or hits a stale element — and the spec does not fail at the interaction. It fails downstream, at the Eventually(...) that depended on it, with nothing pointing back. So even when you go immediate, gate first (Eventually(sel).Should(b.BeClickable()) then b.Click(sel)) — and when in doubt, the matcher form is strictly safer. Full catalog of flake smells + fixes: biloba:flaky-specs.
First-vs-all naming. A bare method acts on the first match; the ForEach/Each sibling acts on all matches (returning/asserting slices, empty when nothing matches): InnerText vs InnerTextForEach/EachHaveInnerText; GetProperty vs GetPropertyForEach/EachHaveProperty; Click vs ClickEach. The name tells you which.
The spec shape
Navigate, gate on a readiness anchor, then exercise behavior:
var _ = Describe("the search page", func() {
BeforeEach(func() {
b.Navigate("http://localhost:8080/search")
Eventually("#results").Should(b.Exist())
})
It("finds matches", func() {
Eventually("#q").Should(b.SetValue("biloba"))
Eventually(b.ByRole("button").WithName("Search")).Should(b.Click())
Eventually(".result").Should(b.HaveCount(BeNumerically(">", 0)))
})
})
b.Navigate(url) also asserts the response was 200 (use NavigateWithStatus for other codes).
- Pick a stable, meaningful anchor (a heading, a key container) —
b.Exist() or b.BeVisible().
- Assert on observable outcomes, not implementation: visible text (
HaveInnerText/HaveText), counts (HaveCount), URL/title (HaveURL/HaveTitle), or network effects (HaveMadeRequest).
Pocket matcher cheat-sheet — reach here before b.Run:
| Want to assert… | Matcher |
|---|
| element is present / visible | b.Exist() / b.BeVisible() |
| how many match | b.HaveCount(BeNumerically(">", 0)) |
| visible text | b.HaveInnerText("…") / b.HaveText(…) (textContent) |
| a DOM/JS property | b.HaveProperty("href", …) / b.HaveClass("active") |
| it's actually clickable (visible+enabled+topmost) | b.BeClickable() |
| form value | b.HaveValue(…) (also b.HaveSpawnedTab, b.HaveURL, b.HaveTitle) |
| a network request was made | Eventually(b).Should(b.HaveMadeRequest(…)) |
| an arbitrary JS expression | Eventually(expr).Should(b.EvaluateTo(matcher)) |
EvaluateTo/Run JSON-decode numbers to float64 — assert with BeNumerically("==", n), not Equal(intLiteral).
Selecting elements — the vocabulary
The decision is the RULE above; here are the mechanics. A selector is a CSS string (fastest; :has(); pierces shadow/iframe via >>>), a semantic Locator (b.By*; most resilient, slowest — full-document ARIA scan), or an XPath value (biloba:xpath; axis/ordinal/text() queries; pierces neither boundary).
b.Click("#go")
b.Click("[data-testid=save]")
Eventually("tr:has(td.overdue)").Should(b.Exist())
b.Click(b.ByRole("button").WithName("Save"))
b.Click(b.ByText("Submit"))
b.SetValue(b.ByLabel("Email"), "jane@acme.com")
b.Click(b.XPath("li").WithText("OK").Ancestor("ul"))
Locator constructors (each text-valued one has a *Contains variant): b.ByRole, b.ByText, b.ByLabel, b.ByPlaceholder, b.ByAltText, b.ByTitle, b.ByTestID (attr = biloba.TestIDAttribute, default data-testid). Refine a role with .WithName(n), .Level(n) (heading), or ARIA states .Checked()/.Disabled()/.Expanded()/.Pressed()/.Selected().
Locators compose — and the filters/combinators accept any selector (CSS/XPath/Locator), so pathways mix:
b.ByRole("listitem").ContainingText("Product 2")
b.ByRole("listitem").Containing(b.ByText("Delete"))
b.ByRole("button").And(".primary")
b.ByRole("button").WithName("Delete").Within("#dialog")
b.ByText("Item").Nth(2)
Locators pierce open shadow roots automatically (no >>>); CSS needs the >>> combinator (one boundary each, open shadow / same-origin iframe only); XPath crosses neither.
b.Click("my-widget >>> button.submit")
Eventually("#editor-frame >>> .toolbar .save").Should(b.Click())
Never fetch-then-act — always pass the selector into the action so find-and-act is one atomic JS snippet.
The interaction vocabulary
b.Click is the everyday verb (dual: b.Click(sel) acts; Eventually(sel).Should(b.Click()) polls). The fuller set — all dual unless noted, all working on both the fast and realistic tracks:
b.DblClick(sel); b.RightClick(sel); b.MiddleClick(sel)
b.Click(sel, b.At(x, y))
b.Click(sel, b.Shift(), b.Meta())
b.DragTo(source, target)
b.ScrollWheel(sel, deltaX, deltaY)
b.Tap(sel)
b.Type(sel, "abc"); b.SendKeys(biloba.Keys.Enter)
b.SendKeys("textarea", biloba.Keys.Enter, b.Shift())
A few interactions have no matcher form — gate them by hand. SendKeys and the *Each verbs (ClickEach, SetPropertyForEach) act immediately with nothing to fold readiness into (SendKeys's keys-only form is reserved for the focused element). When their target appears asynchronously, put an explicit gate on the line above — Eventually(sel).Should(b.BeEnabled() /* or b.Exist()/b.BeVisible() */) then act:
Eventually("input.search").Should(b.BeEnabled()); b.SendKeys("input.search", biloba.Keys.Enter)
b.At(x,y) / b.Shift() / b.Ctrl() / b.Alt() / b.Meta() (⌘/Win) are pointer options accepted by Click/DblClick/RightClick/MiddleClick/Tap — after the selector (immediate) or in place of it (matcher: Eventually(sel).Should(b.Click(b.At(x,y), b.Shift()))). In fast mode any option switches a click off native el.click() to a synthetic event carrying the coords/flags. The modifiers double as keyboard modifiers: pass b.Shift()/b.Ctrl()/b.Alt()/b.Meta() to b.Type/b.SendKeys (in any position) for Shift-Enter, ⌘-A, etc.
Fast interactions act in place — no scroll, no focus move. A fast b.Click/b.Tap is element.click() after a visibility check; it does not scrollIntoView and does not move focus, so it never shifts the page out from under a scroll/layout assertion. Scroll-into-view comes only from b.Realistic() (deliberately) and from focus-bearing ops — b.Focus/b.SetValue/b.Type/b.SendKeys — because the browser's .focus() scrolls its target into view. If a scroll position moves around a fast click, the cause is app-side, not Biloba.
b.SetValue and frameworks. SetValue writes through the input's native value setter, so it drives controlled React/Vue/Solid inputs (whose value is bound to state) — onChange fires and state updates; no need to make an input uncontrolled for Biloba's sake. For text inputs it focuses + dispatches input/change but does not blur — an onBlur commit/inline-edit-unmount handler won't fire from SetValue; pair with b.Blur(sel) when you want it (b.SetValue("#name","New"); b.Blur("#name")).
<select> form values. b.SetValue(sel, v) matches v against the option's underlying value, not its visible label: b.SetValue("#model", "claude-sonnet-4-6") (and b.SetValue on a native <select> already fires input+change with bubbles:true, so React onChange runs — no realistic mode needed). To pick by the label the user sees, wrap it: b.SetValue("#model", b.ValueLabel("Sonnet")). Assert labels via option.textContent and the selection via the <select>'s value (b.HaveProperty("value", id)).
Selecting text (highlight / annotation / editor UIs)
For "highlight text → floating menu → Define"-style interactions, use the first-class selection primitives — no Range/getSelection archaeology needed. Each produces a genuine window.getSelection() range and dispatches a mouseup so selection-driven toolbars fire:
b.SelectText("#passage")
Eventually("#passage").Should(b.SelectText())
b.SelectRange("#passage", 4, 9)
Eventually("#passage").Should(b.SelectRange(4, 9))
b.ClearSelection()
Assert on what's selected by reading it back: Eventually("window.getSelection().toString()").Should(b.EvaluateTo("quick")).
b.SelectText does not poll (like every immediate form — Biloba never polls itself). Against content that appears asynchronously or nondeterministically (e.g. streamed/agent output) a bare b.SelectText(".blocks p") matches zero elements on the runs where it lost the race. Gate on a guaranteed-present anchor first — Eventually(<selector>).Should(b.Exist()) — then select, or use the matcher form inside Eventually.
Realistic mode — for a handful of smoke tests
By default every interaction is a fast, atomic simulation (element.click() after synchronous visibility/enabled checks — no scroll, no occlusion test, no real :hover; see biloba:overview principle 2). That's what you want for the overwhelming bulk of specs.
b.Realistic() returns a *Biloba view of the same tab whose interactions run through real Chrome DevTools Protocol input instead. A realistic click scrolls the element into view, waits for it to stop moving, refuses to click through an occluding overlay, moves the real pointer (so hover-gated clicks fire and CSS :hover activates), and dispatches a genuine mouse/touch/key event. The whole interaction vocabulary above works on both tracks.
It's opt-in because it costs real round-trips and can reintroduce timing flake — quarantine it to a handful of smoke tests that guard the realism the fast path trades away (a drag, an overlay, a :hover menu). There is deliberately no per-call decorator; the handle is the one seam. It composes at three scopes:
b.Realistic().Click("#submit")
rb := b.Realistic(); rb.Hover(".menu"); rb.Click(".menu .item")
var _ = Describe("checkout (realistic)", Label("realistic"), func() {
var rb *biloba.Biloba
BeforeEach(func() { rb = b.Realistic() })
})
With a Label("realistic"), ginkgo --label-filter='realistic' runs only the realistic lane and --label-filter='!realistic' keeps it out of the fast inner loop. For the full fast-vs-realistic capability matrix (what each track actually does, per interaction) and the deep dive, see biloba:realistic-mode and https://onsi.github.io/biloba/#realistic-interactions. To merely assert an element isn't occluded without paying for realistic mode, use the deterministic b.BeClickable() matcher (visible + enabled + topmost-at-its-center).
Run with real backends. But stub the network if all else fails.
Favor testing against real backends whenever possible and focus on fixing flakes and performance there. But, if you must stub, stub the endpoints you don't want to depend on; everything unmatched passes through to the real network (#stubbing-and-observing-the-network):
b.StubRequest(ContainSubstring("/api/users"), biloba.StubResponse{
Body: `[{"name":"Jane"},{"name":"Bob"}]`,
Headers: map[string]string{"Content-Type": "application/json"},
})
b.Navigate("/app")
Eventually(".user").Should(b.HaveCount(2))
Stubs are per-tab and reset by Prepare(). Beyond StubRequest you can b.AbortRequest(url) (fail it), b.ModifyRequest(url).WithURL/.WithMethod/.WithHeader/.WithBody(...) (continue with overrides), and b.ModifyResponse(url).WithStatus/.WithHeader/.WithBody/.Using(func(biloba.InterceptedResponse) biloba.StubResponse) (rewrite a real response) — all share one first-match-wins handler list with StubRequest. Observe requests with Eventually(b).Should(b.HaveMadeRequest(...)) and wait for quiet with Eventually(b).Should(b.BeNetworkIdle()).
Seed state to skip slow flows
Set an auth cookie or localStorage to jump past login (navigate to a real origin first — about:blank can't hold cookies/storage):
b.Navigate("/home")
b.SetCookie(biloba.Cookie{Name: "user", Value: "Joe"})
DeferCleanup(b.ClearCookies)
Or shortcut straight through your app's JS API (#running-arbitrary-javascript):
b.Run(`app.load(` + jsonFixture + `); app.redraw()`)
Eventually("#doc-name").Should(b.HaveInnerText("My Fixture Data"))
b.Run returns the decoded value directly — n := b.Run("app.users.length") feeds Gomega without a wrapper, and b.Run("expr", &typed) decodes into a pointer. Don't write runInt/runStr helpers. b.Run is a synchronous expression, so a top-level return is illegal — use b.RunAsync (which wraps a function body, so return/await work) for fetch/await. b.EvaluateTo asserts on a JS expression directly. Remember numbers decode to float64 (use BeNumerically).
Poll a b.Run read instead of snapshotting it. b.Run is a plain func(string, ...any) any, so it drops straight into Eventually — this is the antidote to the single most common flake (a one-shot read that races an async settle), and it needs no wrapper closure for a scalar expr:
Eventually(b.Run).WithArguments(`isReady()`).Should(BeTrue())
Eventually(b.Run).WithArguments(`document.querySelectorAll(".card").length`).Should(BeNumerically("==", 3))
Eventually(b.Run).WithArguments(`document.title`).Should(Equal("Done"))
For an interpolated/multi-line expr, pre-build the string (expr := fmt.Sprintf(...); Eventually(b.Run).WithArguments(expr)…) or poll a closure that returns the decoded value. See biloba:flaky-specs for why single-shot reads flake.
Multi-tab flows
tab := b.NewTab()
login(b, "sally"); login(tab, "jane")
Eventually(userXPath.WithText("Jane")).Should(b.HaveClass("online"))
Tabs opened by the page (e.g. target="_blank") are spawned tabs — find them with the HaveSpawnedTab/AllSpawnedTabs (or HaveTab/AllTabs) queries:
tab.Click(linkXPath)
Eventually(tab).Should(tab.HaveSpawnedTab().WithURL("https://youtube.com/..."))
yt := tab.AllSpawnedTabs().Find(tab.TabMatching().WithURL("https://youtube.com/..."))
A DOM method always operates on the tab it's invoked on (tab.Click, not b.Click). Dialogs and downloads are per-tab too — register dialog handlers before the action that triggers them.
When Biloba can't express it
For realism (occlusion, scroll-into-view, real CSS :hover) reach for b.Realistic() (above) before chromedp. For everything else — cross-origin frames, geolocation, any CDP feature without a wrapper — drop to chromedp via b.Context (the escape hatch in biloba:overview). For real keystrokes use b.Type/b.SendKeys rather than SetValue.
Propose opening an issue if a common pattern is missing.