Testing HTMX with Rails: RSpec and Capybara for Hypermedia Apps
Rails and HTMX are a natural fit — ERB templates serve HTML partials, and Rails' request specs test them efficiently. Testing HTMX in Rails means adding the HX-Request header to request specs, using Capybara for browser tests, and validating HTMX response headers like HX-Trigger and HX-Redirect.
Key Takeaways
Use headers: { 'HX-Request' => 'true' } in request specs. Rails request specs let you set arbitrary headers. This simulates HTMX requests to your controller actions.
respond_to with format.html.htmx pattern. The htmx-rails gem adds format.htmx to respond_to blocks — test both branches.
Capybara + capybara-select2 or plain rack_test work. For HTMX interaction tests that need a real browser, use Capybara.javascript_driver = :playwright or :selenium_chrome_headless.
Test Turbo/HTMX coexistence carefully. If you use HTMX alongside Turbo Drive, responses need the correct headers to avoid conflicts.
System specs are the highest-fidelity HTMX tests. They run a full browser (Capybara + driver) and test real HTMX swap behavior, CSS transitions, and DOM mutations.
Project Structure
A typical HTMX Rails app structure:
app/
controllers/
tasks_controller.rb
views/
tasks/
index.html.erb # Full page
_task.html.erb # Task partial
_task_list.html.erb # List partial (returned for HTMX requests)Controller Pattern
# app/controllers/tasks_controller.rb
class TasksController < ApplicationController
def index
@tasks = Task.incomplete.order(created_at: :desc)
if request.headers['HX-Request']
render partial: 'task_list', locals: { tasks: @tasks }
else
render :index
end
end
def create
@task = Task.new(task_params)
if @task.save
render partial: 'task', locals: { task: @task }, status: :ok
else
render partial: 'form', locals: { task: @task }, status: :unprocessable_entity
end
end
def destroy
@task = Task.find(params[:id])
@task.destroy
head :ok # Empty 200 — HTMX removes the element
end
endRSpec Setup
# Gemfile
group :test do
gem 'rspec-rails'
gem 'factory_bot_rails'
gem 'capybara'
gem 'selenium-webdriver' # or 'playwright-ruby-client'
end# spec/rails_helper.rb
RSpec.configure do |config|
config.include FactoryBot::Syntax::Methods
end# spec/factories/tasks.rb
FactoryBot.define do
factory :task do
title { "Test task" }
completed { false }
end
endRequest Specs for HTMX Endpoints
# spec/requests/tasks_spec.rb
require 'rails_helper'
RSpec.describe 'Tasks', type: :request do
let(:htmx_headers) { { 'HX-Request' => 'true' } }
describe 'GET /tasks' do
let!(:task) { create(:task, title: 'Write specs') }
context 'without HTMX header (full page)' do
it 'returns 200 with full page layout' do
get tasks_path
expect(response).to have_http_status(:ok)
expect(response.body).to include('<!DOCTYPE html>')
expect(response.body).to include('Write specs')
end
end
context 'with HTMX header (partial)' do
it 'returns 200 with partial only' do
get tasks_path, headers: htmx_headers
expect(response).to have_http_status(:ok)
expect(response.body).not_to include('<!DOCTYPE html>')
expect(response.body).to include('Write specs')
expect(response.body).to include('hx-delete')
end
end
end
describe 'POST /tasks' do
context 'with valid params' do
it 'creates a task and returns the partial' do
expect {
post tasks_path, params: { task: { title: 'New task' } }, headers: htmx_headers
}.to change(Task, :count).by(1)
expect(response).to have_http_status(:ok)
expect(response.body).to include('New task')
expect(response.body).not_to include('<!DOCTYPE html>')
end
end
context 'with invalid params' do
it 'returns 422 with form errors' do
post tasks_path, params: { task: { title: '' } }, headers: htmx_headers
expect(response).to have_http_status(:unprocessable_entity)
expect(response.body).to include("can't be blank")
end
end
end
describe 'DELETE /tasks/:id' do
let!(:task) { create(:task) }
it 'deletes the task and returns empty 200' do
expect {
delete task_path(task), headers: htmx_headers
}.to change(Task, :count).by(-1)
expect(response).to have_http_status(:ok)
expect(response.body).to be_empty
end
it 'returns 404 for nonexistent task' do
delete task_path(id: 99999), headers: htmx_headers
expect(response).to have_http_status(:not_found)
end
end
endTesting HTMX Response Headers
# spec/requests/tasks_htmx_headers_spec.rb
RSpec.describe 'HTMX response headers', type: :request do
let(:htmx_headers) { { 'HX-Request' => 'true' } }
let!(:task) { create(:task) }
describe 'HX-Trigger header' do
it 'sends taskDeleted event on delete' do
delete task_path(task), headers: htmx_headers
expect(response.headers['HX-Trigger']).to be_present
trigger_data = JSON.parse(response.headers['HX-Trigger'])
expect(trigger_data).to have_key('taskDeleted')
end
end
describe 'HX-Redirect header' do
it 'sends redirect after login via HTMX' do
post sessions_path, params: { email: 'alice@example.com', password: 'secret' },
headers: htmx_headers
expect(response).to have_http_status(:ok)
expect(response.headers['HX-Redirect']).to eq('/dashboard')
end
end
describe 'HX-Refresh header' do
it 'triggers full page refresh when session expires' do
# Simulate expired session
get protected_path, headers: htmx_headers
expect(response.headers['HX-Refresh']).to eq('true')
end
end
endController Specs for HTMX Actions
# spec/controllers/tasks_controller_spec.rb
RSpec.describe TasksController, type: :controller do
let!(:task) { create(:task) }
describe '#index' do
it 'renders partial template for HTMX requests' do
request.headers['HX-Request'] = 'true'
get :index
expect(response).to render_template(partial: '_task_list')
end
it 'renders full template for regular requests' do
get :index
expect(response).to render_template(:index)
end
end
describe '#create' do
it 'renders task partial on success' do
request.headers['HX-Request'] = 'true'
post :create, params: { task: { title: 'New task' } }
expect(response).to render_template(partial: '_task')
end
end
endSystem Specs with Capybara
# spec/system/tasks_spec.rb
require 'rails_helper'
RSpec.describe 'Task management', type: :system do
before do
driven_by :selenium, using: :headless_chrome
# Or: driven_by :playwright
end
it 'adds a task via HTMX without page reload' do
visit tasks_path
# Track navigation to verify no page reload
page_url_before = current_url
fill_in 'Title', with: 'My new task'
click_button 'Add Task'
# Wait for HTMX to inject the new task
expect(page).to have_css('#task-list li', text: 'My new task')
# URL should not change (no page navigation)
expect(current_url).to eq(page_url_before)
end
it 'removes a task when delete is clicked' do
task = create(:task, title: 'Task to delete')
visit tasks_path
expect(page).to have_content('Task to delete')
find("[data-task-id='#{task.id}'] button.delete").click
expect(page).not_to have_content('Task to delete')
expect(Task.find_by(id: task.id)).to be_nil
end
it 'shows inline form validation errors' do
visit tasks_path
fill_in 'Title', with: ''
click_button 'Add Task'
# HTMX swaps the form with error state
expect(page).to have_css('.error', text: "can't be blank")
end
it 'shows loading indicator during slow requests' do
# Simulate slow server
allow_any_instance_of(TasksController).to receive(:create) do |controller|
sleep 0.5
controller.send(:original_create)
end
visit tasks_path
fill_in 'Title', with: 'Slow task'
click_button 'Add Task'
expect(page).to have_css('.htmx-request') # HTMX adds this class during request
end
endTesting htmx-rails Gem Patterns
If using the htmx-rails gem:
# config/application.rb
require 'htmx'
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
include Htmx::Controller
end# Controller using htmx-rails helpers
def index
@tasks = Task.incomplete
respond_to do |format|
format.htmx { render partial: 'task_list' }
format.html { render :index }
end
end# Testing with htmx-rails
RSpec.describe 'Tasks with htmx-rails', type: :request do
it 'uses htmx format for HTMX requests' do
get tasks_path, headers: { 'HX-Request' => 'true' }
# htmx-rails sets correct content type
expect(response.content_type).to include('text/html')
expect(response.body).not_to include('<html')
end
endTesting Out-of-Band Swaps in Rails
# views/tasks/create.html.erb (or create.htmx.erb)
<%= render 'task', task: @task %>
<div id="task-counter" hx-swap-oob="true">
<span><%= Task.incomplete.count %></span>
</div>it 'includes OOB counter update in response' do
post tasks_path, params: { task: { title: 'New' } },
headers: { 'HX-Request' => 'true' }
expect(response.body).to include('hx-swap-oob="true"')
expect(response.body).to include('id="task-counter"')
endHelpMeTest for HTMX Rails Apps
RSpec covers server-side logic. HelpMeTest covers what happens in the browser:
When the user adds a task
Then the task appears at the top of the list within 500ms
And the task counter in the header increments
And no full page reload occursRun these tests against your staging environment without writing Capybara setup.
Summary
Testing HTMX with Rails:
- Request specs: Set
headers: { 'HX-Request' => 'true' }to test partial vs full responses - Response headers: Assert
HX-Trigger,HX-Redirect,HX-Refreshin response headers - Form errors: Return 422 with error partials; test with invalid params
- System specs: Use Capybara + headless Chrome/Playwright for browser-level HTMX behavior
- OOB swaps: Include
hx-swap-oobcontent in response body; test both parts in one request - htmx-rails gem: Use
respond_to format.htmxand test withHX-Requestheader