Drop-in RSpec matchers for hotwired/stimulus-rails — stop hand-rolling data-controller assertions and test your Stimulus wiring with expressive, purpose-built matchers.
- Request/controller specs —
have_stimulus_controller,have_stimulus_action,have_stimulus_target,have_stimulus_value,have_stimulus_class,have_stimulus_outlet - System/feature specs — Capybara matchers:
have_stimulus_controller,have_stimulus_action,have_stimulus_target,have_stimulus_value,have_stimulus_class,have_stimulus_outlet - Auto-included — zero setup required when
stimulus-railsis in your bundle - Configurable — disable auto-include when you need manual control
Companion gem to turbo_rspec — together they cover the full Hotwire testing stack.
- Zero magic by default. Auto-include only when it's unambiguous (Rails request specs with
stimulus-railspresent). Everything else is opt-in. - Fail loudly with useful output. A cryptic failure message is a bug. Show what was expected, what was found, and the relevant HTML.
- Stay close to Stimulus conventions. Matcher names and arguments mirror Stimulus's
data-*attribute conventions so the docs cross-reference naturally. - Pure test helper. No Rails engine, no generators, no runtime code — just matchers you include in your specs.
Add to your application's Gemfile:
group :test do
gem "stimulus_spec"
endNo setup needed. When stimulus-rails is in your bundle:
StimulusSpec::Matchersis automatically included intype: :requestandtype: :controllerexample groupsStimulusSpec::Capybara::Matchersis automatically included intype: :systemandtype: :featureexample groups whencapybarais also present
For non-Rails projects or custom contexts, include the matchers explicitly:
# spec/spec_helper.rb
RSpec.configure do |config|
config.include StimulusSpec::Matchers # request/controller specs
config.include StimulusSpec::Capybara::Matchers # system/feature specs
end# spec/support/stimulus_spec.rb
StimulusSpec.configure do |config|
config.auto_include = false # disable automatic inclusion
endAssert that rendered HTML contains a data-controller attribute with the given controller name.
expect(response).to have_stimulus_controller("hello")
# Assert multiple controllers on a single element
expect(response).to have_stimulus_controller("hello", "clipboard")
# Negation
expect(response).not_to have_stimulus_controller("missing")Uses space-separated token matching (~=), so it works correctly when multiple controllers are declared on a single element and won't partially match.
Assert that rendered HTML contains a data-action attribute with the given action descriptor.
# Full descriptor
expect(response).to have_stimulus_action("click->hello#greet")
# Shorthand — matches any event
expect(response).to have_stimulus_action("hello#greet")
# Negation
expect(response).not_to have_stimulus_action("hello#disconnect")Assert that rendered HTML contains a data-{controller}-target attribute with the given target name.
expect(response).to have_stimulus_target("hello", "name")
expect(response).to have_stimulus_target("hello", "output")
# Negation
expect(response).not_to have_stimulus_target("hello", "missing")Assert that rendered HTML contains a data-{controller}-{name}-value attribute, optionally with a specific value.
# Assert the value attribute exists
expect(response).to have_stimulus_value("search", "url")
# Assert a specific value
expect(response).to have_stimulus_value("search", "url", "/results")
# Negation
expect(response).not_to have_stimulus_value("search", "url")Assert that rendered HTML contains a data-{controller}-{name}-class attribute, optionally with a specific class.
# Assert the class attribute exists
expect(response).to have_stimulus_class("search", "loading")
# Assert a specific class value
expect(response).to have_stimulus_class("search", "loading", "opacity-50")
# Negation
expect(response).not_to have_stimulus_class("search", "loading")Assert that rendered HTML contains a data-{controller}-{outlet}-outlet attribute with a CSS selector.
# Assert the outlet attribute exists
expect(response).to have_stimulus_outlet("search", "results")
# Assert a specific selector
expect(response).to have_stimulus_outlet("search", "results", "#results-list")
# Negation
expect(response).not_to have_stimulus_outlet("search", "results")All matchers support .within(selector) to restrict matching to a specific part of the page:
# Request/controller specs — search within a CSS selector
expect(response).to have_stimulus_controller("search").within(".search-form")
expect(response).to have_stimulus_action("click->search#query").within(".search-form")
expect(response).to have_stimulus_target("search", "input").within(".search-form")
# System/feature specs — same API
expect(page).to have_stimulus_controller("search").within(".search-form")RSpec.describe "Search", type: :request do
describe "GET /search" do
it "wires up the search controller" do
get search_path
expect(response).to have_stimulus_controller("search")
expect(response).to have_stimulus_action("input->search#query")
expect(response).to have_stimulus_target("search", "input")
end
end
endRSpec.describe "Search", type: :system do
it "has the search controller wired up" do
visit search_path
expect(page).to have_stimulus_controller("search")
expect(page).to have_stimulus_action("input->search#query")
expect(page).to have_stimulus_target("search", "input")
end
endturbo_rspec includes basic Stimulus matchers (have_stimulus_controller, have_stimulus_action, have_stimulus_target). stimulus_spec goes deeper with value, class, and outlet matchers, plus richer failure messages and Stimulus-specific configuration. If you only need basic controller/action/target assertions alongside your Turbo matchers, turbo_rspec has you covered. If you want comprehensive Stimulus testing, use stimulus_spec.
Both gems can coexist — they use separate namespaces and won't conflict.
Bug reports and pull requests are welcome on GitHub. See CONTRIBUTING.md for setup instructions, branch conventions, CHANGELOG requirements, and the PR checklist.
The gem is available as open source under the MIT License.