Dommy
Dommy is a pure Ruby DOM polyfill built on Nokogiri::HTML5, inspired by happy-dom and jsdom. It gives Ruby tests a browser style DOM with events, MutationObserver, Custom Elements, Shadow DOM, the File API, timers, and Storage, without requiring a real browser.
Quick start
require "dommy"
win = Dommy.parse("<div id='root'><button class='primary'>Click me</button></div>")
btn = win.document.query_selector(".primary")
clicks = 0
btn.on("click") { clicks += 1 }
btn.click
clicks #=> 1Installation
# Gemfile
gem "dommy"Highlights
DOM operations
doc = win.document
li = doc.create_element("li")
li.text_content = "added"
doc.body.append_child(li)
doc.query_selector_all("li").length #=> 1Custom Elements + lifecycle callbacks
class MyButton < Dommy::HTMLElement
def self.observed_attributes = ["data-state"]
def connected_callback
end
def attribute_changed_callback(name, old, new)
end
end
win.custom_elements.define("my-button", MyButton)Shadow DOM
host = win.document.create_element("my-card")
sr = host.attach_shadow(mode: "open")
sr.inner_html = "<slot></slot>"
# Outer queries can't reach inside the shadow tree
win.document.query_selector("p") # light DOM onlyForm validation
input = win.document.create_element("input")
input.type = "email"
input.set_attribute("required", "")
input.check_validity #=> false
input.validation_message #=> "Please fill out this field."File API (Blob / File / FormData / DataTransfer)
win = Dommy.parse("<form><input type='file' name='attachment'></form>")
file = Dommy::File.new(["pdf body"], "doc.pdf", "type" => "application/pdf")
# Seed a file input for tests
input = win.document.query_selector("input[type='file']")
input.__set_files__([file])
# FormData picks it up
fd = Dommy::FormData.new(win.document.query_selector("form"))
fd.entries.to_a #=> [["attachment", #<Dommy::File doc.pdf>]]
# Drag-and-drop simulation
dt = Dommy::DataTransfer.new(files: [file])
ev = Dommy::DragEvent.new("drop", "dataTransfer" => dt, "bubbles" => true)
win.document.body.dispatch_event(ev)
# Blob URLs
blob = Dommy::Blob.new(["blob body"], "type" => "text/plain")
url = Dommy::URL.create_object_url(blob) # "blob:dommy/..."Async / Promise#await
Dommy's async surfaces (fetch, custom JS promises) return PromiseValue. Use .await from Ruby to unwrap synchronously:
response = win.__js_call__("fetch", ["/api"]).awaitWarning
Most Dommy accessors (Blob#text, localStorage.get_item) return synchronous Ruby values — not Promises. .await is only for the JS-bridged async surface (e.g., fetch(), window.__js_call__). Methods like Response#text() are Promise-returning and require .await.
Test helpers
Dommy ships test-side modules you can include into RSpec / Minitest. Matchers accept a Dommy::Document / element or a raw HTML string (auto-parsed), matching Capybara's expect(rendered).to ... ergonomics.
Minitest
require "dommy/minitest"
class UserCardTest < Minitest::Test
include Dommy::TestHelpers
include Dommy::Minitest::Assertions
def test_renders
dom = parse_html(render(UserCardComponent.new(name: "Alice")))
assert_dom_contains(dom, "h2", text: "Alice")
assert_dom_contains(dom, "li", count: 3)
end
endAssertions: assert_dom_contains, assert_dom_contains_text, assert_dom_has_attribute, assert_dom_has_class, assert_dom_html_equal (each with a refute_ counterpart).
RSpec — two matcher flavors
require "dommy/rspec" to get both flavors:
1. Dommy::RSpec::Matchers
_dom_ infix names, coexist with Capybara and rails-dom-testing:
expect(rendered).to contain_dom("h2", text: "Alice")
expect(button).to have_dom_attribute("type", "submit")
expect(button).to have_dom_class("primary")2. Dommy::RSpec::CapyStyleMatchers
Capybara-compatible names for drop-in replacement in view / component / request specs:
expect(rendered).to have_selector("h1", text: "Products")
expect(rendered).to have_link("Sign up", href: "/signup")
expect(rendered).to have_button("Submit")
expect(rendered).to have_no_selector(".hidden")Use a type: split to keep real-browser Capybara on feature specs while letting Dommy run the rest:
RSpec.configure do |c|
c.include Capybara::DSL, type: :feature
c.include Capybara::RSpecMatchers, type: :feature
%i[view component request controller helper].each do |t|
c.include Dommy::TestHelpers, type: t
c.include Dommy::RSpec::CapyStyleMatchers, type: t
end
endSupported Capybara-style options: text: / exact: / count: (Integer or Range) / visible: / href: / with: / type:. wait: is accepted and ignored (Dommy is synchronous).
Caution
:visible is HTML-level only. Dommy has no CSS engine, so display: none set via a CSS class is not detected. Detection covers the hidden attribute, <input type=hidden>, non-rendering ancestors (head/script/style/template), and inline style="display: none" / visibility: hidden. If you toggle visibility through a CSS class, assert on the class instead (have_dom_class("hidden")) or keep that spec on Capybara + a real browser.
What's in scope
Implemented:
- Core DOM (Document, Element, Text/Comment/Fragment, NodeList, Attr)
- Specialized HTML and SVG element classes
- events with composedPath / AbortSignal
- MutationObserver (childList / attributes / characterData / subtree)
- Custom Elements lifecycle
- Shadow DOM (open/closed, slots, event composition)
- form validation
- Scheduler (timers + microtasks with
advance_time) - Promise
- Location / History / URL
- Storage
- fetch / XMLHttpRequest stubs
- WebSocket / EventSource / MessageChannel / BroadcastChannel test doubles
- FileReader / Notification / Geolocation /
matchMedia -
requestIdleCallback,structuredClone,URLPattern - Web Crypto, Streams, Compression Streams, Worker
-
performance,cookieStore, Navigator extras - Popover API, Fullscreen API, View Transitions API stub
- Navigator / Clipboard
- TreeWalker / NodeIterator / NodeFilter
- File API (Blob / File / FileList / FormData / DataTransfer)
- IntersectionObserver / ResizeObserver / PerformanceObserver (test-driven
__trigger__) - Range / Selection (DOM-level only, no layout)
- Web Animations API (Animation / KeyframeEffect)
- Extended events: Touch / Clipboard / Composition / Wheel / Focus / BeforeUnload / Input / Pointer / Progress / Drag
For implementation notes and tradeoffs, see design.md.
Important
Out of scope:
- layout and CSS-engine behavior
- JS evaluation
- Canvas / WebGL / media playback
- layout-dependent Range / Selection geometry
- SVG-specific value types
- animation value interpolation
Running the tests
$ bundle install
$ bundle exec rake testLicense
MIT