Testing HTMX with Rails: RSpec and Capybara for Hypermedia Apps

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
end

RSpec 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
end

Request 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
end

Testing 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
end

Controller 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
end

System 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
end

Testing 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
end

Testing 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"')
end

HelpMeTest 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 occurs

Run 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-Refresh in 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-oob content in response body; test both parts in one request
  • htmx-rails gem: Use respond_to format.htmx and test with HX-Request header

Read more