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. The value that arrives from data, which no
template scan can see, meets a runtime tier: a component built with
an off-list variant or a missing required option raises at
construction in development and test, naming the allowed values
with a did-you-mean, and logs in production while rendering as
before. Mailer templates are the one exception to the raw-color
rule: under a *_mailer/ directory or in the mailer
layout an inline hex is the only paint a mail client honours (no
stylesheet, no tokens), so the check leaves those colors alone and
runs every other rule there as usual. App Ruby that names one of
the family's internal namespaces (a class or module the reference
hides with @api private) gets a warning naming it: the
constant works today and may change without notice. It
parses templates with the herb gem. From Rails 8.2
Action View depends on it, so your bundle has it. On an earlier
Rails poetry:install adds it to your development group,
and an app installed before that only needs
bundle add herb --group development.
The check also compiles the templates that sit beside your
components. Rails renders HTML templates through Herb under its 8.2
defaults, and bin/rails herb:check tells you which views
will not compile. That task walks the view paths, so a template under
app/components is outside its reach. Rails checks views,
Poetry checks components: yours and the ones the gems ship, in the
same posture. A template Herb refuses is a
herb-compile finding, an error when your app renders
through Herb and a warning until then.
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. A
form-builder field renders a Field, so its parts are the ones to
assert on: [data-slot=field-label], the hint at
[data-slot=field-description], the error at
[data-slot=field-error], the control inside.
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.
An application that compiles its templates with Herb's slots gets
more than it wrote: markers around every dynamic part and, in client
mode, the branches that did not render, parked in a
template element.
Poetry::Core::SlotMarkers.strip(html) returns the markup
the template wrote, which is what an assertion means to read. The
Herb and ReActionView guide
has the rest.
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
test "accepting the terms" do
visit signup_path
terms = poetry_checkbox("#user_terms")
terms.check(via: :keyboard)
assert terms.checked?
assert_equal "1", terms.value
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 |
poetry_checkbox(root) |
Checkbox, Switch | check(via:), uncheck(via:), toggle(via:), checked?, mixed?, value (what the form submits) |
Two controls need a word of their own. A Poetry checkbox is a
button[role=checkbox] mirroring a native input that
carries the form value with tabindex="-1", so
Capybara's check "Terms" does not find it by label
the way it finds a native box: drive the button through the
tester above, click the label[for], or reach the
input with find(:checkbox, "Terms", visible: :all)
and read its checked?. The segmented date and time
fields hide their native input once the controller enhances them;
fill_in still sets it through the browser, and a
keyboard test types into the segments (find("[data-slot='date-field-segment']", match: :first))
the way a person does.
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.