Usage¶
The Browser class simulates browser interaction for testing. It runs a real V8 engine (via jsrun) with
happy-dom providing the DOM, and lets you drive it from Python — load pages, query and interact
with DOM elements, fill forms, click and trigger events.
If the page includes htmx, it runs normally and htmx requests are awaited automatically.
Creating a browser¶
Create a browser with a context manager:
Browser(...) accepts:
httpx_transport— anhttpx2.AsyncBaseTransport, useful to test an ASGI/WSGI app in-process with no real HTTP server (see below).mounts— adict[str, Path]mapping a URL prefix to a local directory, so<script>tags can load local files (e.g. htmx itself) without a real server.
Async¶
An AsyncBrowser with the same constructor is available for async codebases:
Testing a WSGI/ASGI app in-process¶
You can test your WSGI/ASGI app directly (Django, Flask, FastAPI, etc)
with no HTTP server or network involved, by passing a miniclient.wsgi.WSGITransport to Browser(...).
An example with nanodjango
(and htmx.min.js in the folder "path/to/htmx/dist"):
from pathlib import Path
from miniclient.browser import Browser
from miniclient.wsgi import WSGITransport
from nanodjango import Django
app = Django()
@app.route("/")
def index(request):
return """
<html>
<head>
<script src="http://localhost/static/htmx.min.js"></script>
</head>
<body>
<button hx-get="/hello" hx-target="#result">Say hi</button>
<div id="result"></div>
</body>
</html>
"""
@app.route("/hello")
def hello(request):
return "Hello from Django!"
with Browser(
httpx_transport=WSGITransport(app=app.wsgi),
mounts={"http://localhost/static/": Path("path/to/htmx/dist")},
) as browser:
browser.goto("/")
browser.find("button").click()
print(browser.find("#result").text) # prints "Hello from Django!"
For an ASGI app instead, pass an httpx2.ASGITransport(app=app.asgi) — see
httpx's documentation.
Loading external scripts via mounts¶
Serve local files through mounts so a <script> tag can load them without a real server:
browser = Browser(mounts={"http://localhost/ext/": tmp_path})
browser.eval(
'document.head.innerHTML = \'<script src="http://localhost/ext/external-script.js"></script>\''
)
Loading pages¶
Load content either via a real request, or directly as raw HTML:
# Fetch a URL, load the full document, and process htmx (real request via httpx_transport/network)
browser.goto("http://localhost/page")
# Load raw HTML directly into the document body, no request involved
browser.load("<p id='msg'>hello</p>")
Each call to load() replaces the previous body entirely.
Finding elements¶
Locate elements by CSS selector, returning Element wrappers:
el = browser.find("#msg") # the first match, or None
items = browser.find_all("li") # a list of all matches, possibly empty
Pass text to also filter by contained text (a substring match against textContent):
el = browser.find("li", text="Buy milk") # first <li> containing this text, or None
items = browser.find_all("li", text="urgent") # all <li>s containing this text
Element also exposes find()/find_all(), scoped to that element instead of the whole
document:
row = browser.find("#results li")
label = row.find(".label") # searches only within `row`
badges = row.find_all(".badge", text="new")
Reading elements¶
Element exposes the usual ways to read content and attributes:
el.html # outerHTML — the element's tag plus its content
el.innerHTML # innerHTML — the element's content, without its own tag
el.text # textContent — all text inside, with tags stripped
el.attr("href") # value of the "href" attribute, or None if absent
el.parent # the parent Element, or None if there is no parent
Filling inputs¶
Set an input's value directly:
This works for <input>, <textarea> and <select> elements.
For <select>, this only takes effect if the value matches an existing <option>'s value,
just like in a real browser.
Clicking and triggering events¶
Simulate a click, or dispatch any DOM event:
browser.find("button").click()
browser.find("div").trigger("my-event") # any DOM event, e.g. for hx-trigger="my-event"
Both wait for htmx to settle if the event fires an htmx request.
Submitting forms¶
browser.find(...) returns a FormElement when the match is a <form>, which adds
requestSubmit():
If the form is htmx-wired (hx-post, hx-get, ...), this waits for htmx to settle. If not, it
performs a plain fetch and reloads the page. Clicking a <button type="submit"> or <input type="submit"> inside the form works the same way, through .click().
Executing JavaScript¶
For anything not covered by Browser / Element, evaluate JavaScript directly.
With sync Browser, use eval() (Browser doesn't expose a .runtime property the way
AsyncBrowser does — the raw Runtime isn't thread-safe, so use this method instead):
With AsyncBrowser, use .runtime, which also supports async evaluation:
browser.runtime.eval("document.title")
await browser.runtime.eval_async("fetch('/api/status').then(r => r.json())")
For other uses of the JavaScript runtime, check the jsrun documentation.