Testing
Test at the cheapest tier that can catch the bug, and assert the contract rather than the classes, so a failing test names the fix instead of the symptom.
The doctrine
Most UI bugs are structural: a wrong attribute, a missing wiring token, a control that never joined the form. Those belong in fast tests that render markup and assert it. Reserve a real browser for what genuinely needs a layout engine. Poetry holds itself to the same split, so every component you install arrives already covered by wiring, headless behavior, browser, and automated accessibility suites; your tests cover your app, not the library.
Lint first: Poetry check
Before anything renders, poetry:check parses your ERB
and validates every Poetry surface against the component registry
and the controllers manifest: unknown component, option, or variant
(with a did-you-mean), unknown Stimulus controller, action, target,
or value (and a value literal that cannot be its declared type),
whether hand-written or passed through a Poetry helper's
data: keywords, raw color classes, and icon
declarations in app Ruby. A rename in your wiring can never silently
strand a controller. It
needs the herb gem to parse templates; adding it to
your Gemfile's development group is enough.
bin/rails poetry:check # default sweep: app views + app Ruby
bundle exec rake "poetry:check[app/views/checkout/**/*.html.erb]" # scope with a glob
POETRY_CHECK_JSON=1 bin/rails poetry:check # JSON for editors / CI
POETRY_CHECK_DESIGN=1 bin/rails poetry:check # add the design-slop warnings
It exits non-zero on any error, and every finding names the fix. Run it in CI and wire it into your agent loop: it is the cheapest, most self-correctable signal you have.
Tier 1: wiring tests
Render the view and assert the contract: the data-slot
parts, the ARIA attributes, the form participants, and the Stimulus
wiring. These run in milliseconds and catch the bulk of UI bugs.
class Settings::NotificationsViewTest < ActionView::TestCase
test "the terms checkbox is wired and participates in the form" do
render partial: "settings/notifications"
assert_select '[data-slot="checkbox"][role="checkbox"]'
assert_select 'input[type="checkbox"][name="terms"]',
{ count: 1 }, "a native input participates in the form"
end
end
Assert against data-slot, never against Tailwind
classes. Classes are an implementation detail that a token retune or
a primitive-vocabulary change will move; the slot vocabulary is the
stable contract. When you must select by a Poetry class that
contains a colon, use the token-safe attribute form
[class~="data-open:..."] rather than escaping the
class in a bare selector; it has identical specificity and no
escaping hazard.
Tier 2: behavior
The middle tier answers interaction questions without a browser: a click flips state, arrows move focus, a form serializes. Poetry runs this tier on every component itself, driving real markup, real compiled CSS, and real controllers through a headless DOM, so a component's internal behavior is already tested when you install it. In your app, behavior questions are usually either wiring questions (tier 1) or flow questions (tier 3). Write your interaction coverage as system tests through the testers below, and keep them few.
Tier 3: the browser pass
Reserve real-browser tests for what only a layout engine can answer: pointer drag geometry, focus return across layers, scroll-linked effects, and full user flows. Platform truths the fast tiers hide:
- A programmatic click does not move focus. Behavior that depends on focus needs the keyboard path.
- Press-to-open surfaces respond to the pointer going down, not to a completed click.
-
Assert the controllers' actual state vocabulary: open surfaces
wear
data-open, triggersdata-popup-open, committed optionsdata-selected, togglesdata-pressed.
Before a browser pass, refresh the safelist and rebuild Tailwind; a stale host safelist purges newly used dictionary classes.
bin/rails generate poetry:install # refresh the safelist
bin/rails tailwindcss:build # rebuild before the browser pass
The system-test helpers
Poetry::Ui::Testing ships consumer-facing interaction
testers that drive components through their real keyboard and
pointer sequences in a Capybara system test. Hand a tester the
component root (a CSS selector or a Capybara node); it locates
parts by data-slot, waits with Capybara's own retry
discipline (no sleeps), and asserts against the public attribute
contract. via: swaps the entire event sequence,
because the mouse and keyboard paths exercise different controller
seams.
require "poetry/ui/testing"
class PlanSettingsTest < ApplicationSystemTestCase
include Poetry::Ui::Testing
test "picking a plan from the keyboard" do
visit settings_path
plan = poetry_select("#plan")
plan.select_option("Pro", via: :keyboard)
assert_equal "pro", plan.value
confirm = poetry_dialog("[data-component='dialog']")
confirm.open(via: :keyboard)
assert_equal "Change plan?", confirm.title
confirm.close
assert_poetry_controllers_registered
end
test "assigning from a filtered combobox" do
visit board_path
assignee = poetry_combobox("#assignee")
assignee.filter("Ada").select_option("Ada Lovelace")
assert_equal "ada", assignee.value
actions = poetry_dropdown_menu("#row-actions")
actions.choose("Archive", via: :keyboard)
end
end
| Entry point | Drives | Key methods |
|---|---|---|
poetry_select(root) |
Select | select_option(text, via:), value (the submitted value, read from the native select), text, options, open, close |
poetry_combobox(root) |
Combobox, single or multiple | filter(query), select_option(text, via:), value, chips, remove_chip(text) |
poetry_dropdown_menu(root) |
DropdownMenu | choose(text, via:), items, open, close |
poetry_dialog(root) |
Dialog | open(via:), close, open?, title |
assert_poetry_controllers_registered is the page-wide
guard: every Poetry controller on the page must be registered on
the Stimulus application (window.Stimulus by default).
Stimulus never errors on an identifier nothing registered, and one
failed import in your controllers graph silently takes every Poetry
controller down with it, so nothing else surfaces that class of bug.
The failure names the identifiers; the registrar also warns once per
identifier in the browser console. Composing your own controllers
with Poetry's is the Stimulus guide.
Each tester is an executable spec of its component's interaction
contract. Select commits sync the native select and close with
focus back on the trigger. A single-select combobox closes on
commit; multiple keeps the popover open and grows chips, so assert
on value and chips instead. The dialog
root is the element carrying
data-component="dialog" (trigger plus content).
Failures name the fix: a wrong root reads as "no
[data-slot=select-trigger] under this root", not as an opaque
timeout, and a mistyped option label raises with the list of
options actually present.
What to assert, in one line
Assert the contract (slots, ARIA, form participation, wiring), never the classes. The contract is what Poetry promises to keep stable across token retunes and primitive-vocabulary shifts; the classes are free to move under it.
Automated checks of any kind catch only part of accessibility. The keyboard and screen-reader protocol for verifying your app lives in the accessibility guide.