class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
respond_to do |format|
if @comment.save
format.turbo_stream
format.html { redirect_to @post, notice: "Comment added." }
else
format.turbo_stream do
render turbo_stream: turbo_stream.replace(
"new_comment",
partial: "comments/form",
locals: { post: @post, comment: @comment }
), status: :unprocessable_entity
end
format.html { render "posts/show", status: :unprocessable_entity }
end
end
end
private
def set_post
@post = Post.find(params[:post_id])
end
def comment_params
params.require(:comment).permit(:body)
end
end
<%= turbo_stream.append "comments" do %>
<%= render partial: "comments/comment", locals: { comment: @comment } %>
<% end %>
<%= turbo_stream.update "new_comment" do %>
<%= render partial: "comments/form", locals: { post: @post, comment: @post.comments.build } %>
<% end %>
<%= turbo_stream.update "comments_count", @post.comments.count.to_s %>
require "rails_helper"
RSpec.describe "Comments", type: :request do
let(:post_record) { create(:post) }
def turbo_headers
{ "Accept" => "text/vnd.turbo-stream.html" }
end
describe "POST /posts/:post_id/comments" do
context "with valid params" do
it "renders the create turbo stream" do
post post_comments_path(post_record),
params: { comment: { body: "Nice write-up" } },
headers: turbo_headers
expect(response).to have_http_status(:ok)
expect(response.media_type).to eq("text/vnd.turbo-stream.html")
html = Capybara.string(response.body)
expect(html).to have_selector(
"turbo-stream[action='append'][target='comments']", text: "Nice write-up"
)
expect(html).to have_selector("turbo-stream[action='update'][target='new_comment']")
expect(html).to have_selector("turbo-stream[action='update'][target='comments_count']")
end
end
context "with invalid params" do
it "replaces the form with validation errors" do
post post_comments_path(post_record),
params: { comment: { body: "" } },
headers: turbo_headers
expect(response).to have_http_status(:unprocessable_entity)
expect(response.media_type).to eq("text/vnd.turbo-stream.html")
html = Capybara.string(response.body)
expect(html).to have_selector("turbo-stream[action='replace'][target='new_comment']")
expect(html).not_to have_selector("turbo-stream[action='append']")
end
end
end
end
Turbo Streams let a Rails controller respond to a form submission with fragment updates instead of a full page reload, but that behavior is easy to break silently: a wrong target, a stale partial, or a missing format.turbo_stream branch will simply fall back to HTML without any error. This snippet shows how to lock that behavior down with a request spec that asserts on the actual text/vnd.turbo-stream.html payload.
The CommentsController in the first tab is the subject under test. Its create action saves a comment and then uses respond_to so the same endpoint serves both classic HTML and Turbo. The format.turbo_stream branch renders create.turbo_stream.erb, while format.html keeps a working non-JS fallback via redirect_to. Writing the controller this way means progressive enhancement is real, not aspirational — the HTML path still works if JavaScript is disabled.
The create.turbo_stream.erb template in the second tab is where the DOM diff is declared. turbo_stream.append targets the DOM id comments and renders the comments/comment partial, while a second turbo_stream.update swaps the new_comment form container to reset it. Each action serializes to a <turbo-stream> element that the Turbo runtime applies on the client. These target ids are a contract, so the test needs to verify them explicitly.
The comments request spec in the third tab drives the endpoint with post and sets the Accept header so Rails picks the Turbo variant. The response Content-Type is asserted to be text/vnd.turbo-stream.html, which confirms the correct respond_to branch fired. Because the body is XML-ish markup, the spec parses it with Capybara.string and uses have_selector with the turbo-stream element and its action and target attributes — checking behavior rather than brittle string matching.
The key pitfall this guards against is the invisible HTML fallback: without the Accept header the request returns a redirect and the assertions on target never run. A second example covers validation failure, asserting the stream re-renders the form with errors rather than appending an invalid comment. Testing at the request level keeps these specs fast and free of a browser while still exercising the real routing, respond_to, and template layers where Turbo bugs actually live.
Related snips
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
class Post < ApplicationRecord
belongs_to :author, class_name: 'User'
has_many :comments, dependent: :destroy
scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
scope :draft, -> { where(published_at: nil) }
ActiveRecord scopes for reusable query logic
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
<form data-controller="query-sync" data-action="change->query-sync#apply">
<select name="status" class="rounded border p-2">
<option value="">Any</option>
<option value="open">Open</option>
<option value="closed">Closed</option>
</select>
Filter UI that syncs query params via Stimulus (no front-end router)
Share this code
Here's the card — post it anywhere.