Testing
uni-test on Scala.js includes a component testing kit in wvlet.uni.dom.testing: render a component into a real DOM, fire user events, and query the result. Run the tests in a real browser with the sbt-uni-playwright Plugin, which uses headless Chromium, the same engine as the Electron renderer.
import org.scalajs.dom
import wvlet.uni.dom.all.*
import wvlet.uni.dom.all.given
import wvlet.uni.dom.testing.*
import wvlet.uni.rx.Rx
import scala.language.implicitConversions
class CounterTest extends DomTestSupport:
test("clicking +1 increments the counter") {
val count = Rx.variable(0)
val m = mount(
div(
span(count.map(c => s"count=${c}")),
button(onclick -> { () => count := count.get + 1 }, "+1")
)
)
fireEvent.click(m.getByText("+1"))
m.text shouldContain "count=1"
}DomTestSupport extends UniTest and unmounts every element mount rendered after each test, so tests don't leak DOM into one another. Outside UniTest, call m.unmount() yourself, or mix in DomTestSession and call cleanupMounted().
Querying
mount returns a Mounted handle:
| Method | Returns |
|---|---|
get(css) / query(css) / queryAll(css) | The first match (or fail), Option, or all matches |
getByText(text) / queryByText(text) / queryAllByText(text) | Elements whose trimmed text equals a String or matches a Regex; the deepest match wins |
getByTestId(id) / queryByTestId(id) | Elements with data-testid="id" |
text / html | The rendered textContent / innerHTML |
Firing events
fireEvent dispatches bubbling, cancelable events, the way real input does, and returns false if a handler called preventDefault():
| Kind | Methods |
|---|---|
| Mouse | click, dblclick, mouseDown, mouseUp, mouseMove, mouseOver, mouseOut, mouseEnter, mouseLeave, contextMenu, with optional clientX, clientY, button, and shiftKey/ctrlKey/altKey/metaKey |
| Pointer | pointerDown, pointerMove, pointerUp, pointerCancel, pointerOver, pointerOut, pointerEnter, pointerLeave, with optional clientX, clientY, pointerId, pointerType, and button |
| Keyboard | keyDown, keyUp, keyPress with a key, optional code, modifier keys, and isComposing (a key pressed while an IME composition is open) |
| Form | input(el, value) and change(el, value) set the value first, setChecked(el, checked), submit |
| Focus | focus, blur |
| Other | custom(target, eventType), or dispatch(target, event) for an event you built |
Enter and leave events don't bubble, as in the browser. To simulate a drag, send pointerDown to the handle and pointerMove/pointerUp to whatever listens for them (often dom.window):
fireEvent.pointerDown(m.get("[role=separator]"), clientX = 300)
fireEvent.pointerMove(dom.window, clientX = 350)
fireEvent.pointerUp(dom.window, clientX = 350)Waiting
Rx updates re-render synchronously, so assert right after fireEvent. Two cases need a wait; return the Rx from the test body:
onMountruns a step aftermountreturns (the renderer learns about attachment from aMutationObserver).settle()emits once those callbacks have run:scalaval m = mount(AutoFocusInput()) settle().map { _ => dom.document.activeElement shouldBe m.get("input") }Values that come from browser events, such as
resizeor a media-query change, arrive later.awaitValue(rx)(p)emits the first value that satisfiesp, or fails aftertimeoutMillis(2 s by default).
Testing uni-ui components
uni-ui components render into a real DOM the same way:
import wvlet.uni.ui.*
class SendButtonTest extends DomTestSupport:
test("send runs once per click") {
var sent = 0
val m = mount(Button("Send").onClick(() => sent += 1))
fireEvent.click(m.get("button"))
sent shouldBe 1
}Checking ARIA patterns
checkPattern(root, pattern) checks a widget against its WAI-ARIA Authoring Practices pattern and emits the violations it finds. uni-ui runs it on every component in its own tests; run it on your widgets too, including ones built from the behaviors.
import org.scalajs.dom
import wvlet.uni.test.empty
class MorePatternTest extends DomTestSupport:
test("the More menu follows the menu pattern") {
val open = Rx.variable(false)
mount(Menu(open)(Button("More"))(MenuItem("Rename"), MenuItem("Delete")))
open := true
// An overlay focuses its first item in onMount, so settle before checking
settle()
.flatMap(_ => checkPattern(dom.document.querySelector("[role=menu]"), AriaPattern.Menu))
.map(_ shouldBe empty)
}A failing check lists each violation on one line, with a rule id you can filter on: [menu/item-name] div[role=menuitem]: needs an accessible name (aria-label, aria-labelledby, or text).
- Structure: roles and the roles they own, required states (
aria-selected,aria-expanded,aria-checked,aria-level), one focus model (a single tab stop, oraria-activedescendanton a focusable container), and, stricter than the APG, an accessible name on every widget and id references (aria-controls,aria-labelledby, ...) that point to an element in the document. - Keyboard: then it presses the keys the pattern requires and checks where the focus or the active item went. These keys change state (Escape closes a dialog), so call
checkPatternlast; passkeyboard = falseto check the structure only. In a right-to-left layout it expects ArrowLeft and ArrowRight mirrored.
| Pattern | Pass | uni-ui component |
|---|---|---|
Tabs | the tablist or an element containing it | TabList with TabPanel, Dock |
Menu | the open menu | Menu, ContextMenu |
MenuButton | the closed trigger (it opens and closes the menu) | Menu |
Listbox | the listbox | Select (open), VirtualList |
Combobox | the combobox input | CommandPalette |
Dialog / AlertDialog | the open dialog | Dialog, AlertDialog, CommandPalette |
Tree | the tree | Tree |
Grid | the grid | DataTable |
Tooltip | the shown tooltip | Tooltip |