rspec-testing-patterns

📁 kaakati/rails-enterprise-dev 📅 Jan 1, 1970
0
总安装量
0
周安装量
安装命令
npx skills add https://github.com/kaakati/rails-enterprise-dev --skill RSpec Testing Patterns

Skill 文档

RSpec Testing Patterns Skill

This skill provides comprehensive guidance for testing Rails applications with RSpec.

When to Use This Skill

  • Writing new specs (unit, integration, system)
  • Setting up test factories
  • Creating shared examples
  • Mocking external services
  • Testing ViewComponents
  • Testing background jobs

Directory Structure

spec/
├── rails_helper.rb
├── spec_helper.rb
├── support/
│   ├── factory_bot.rb
│   ├── database_cleaner.rb
│   ├── shared_contexts/
│   └── shared_examples/
├── factories/
│   ├── tasks.rb
│   ├── users.rb
│   └── ...
├── models/
├── services/
├── controllers/
├── requests/
├── system/
├── components/
└── jobs/

Basic Spec Structure

# spec/models/task_spec.rb
require 'rails_helper'

RSpec.describe Task, type: :model do
  describe 'associations' do
    it { is_expected.to belong_to(:account) }
    it { is_expected.to belong_to(:merchant) }
    it { is_expected.to have_many(:timelines) }
  end

  describe 'validations' do
    it { is_expected.to validate_presence_of(:status) }
    it { is_expected.to validate_inclusion_of(:status).in_array(Task::STATUSES) }
  end

  describe 'scopes' do
    describe '.active' do
      let!(:pending_task) { create(:task, status: 'pending') }
      let!(:completed_task) { create(:task, status: 'completed') }

      it 'returns only non-completed tasks' do
        expect(Task.active).to include(pending_task)
        expect(Task.active).not_to include(completed_task)
      end
    end
  end

  describe '#completable?' do
    context 'when task is pending' do
      let(:task) { build(:task, status: 'pending') }

      it 'returns true' do
        expect(task.completable?).to be true
      end
    end

    context 'when task is completed' do
      let(:task) { build(:task, status: 'completed') }

      it 'returns false' do
        expect(task.completable?).to be false
      end
    end
  end
end

Factories (FactoryBot)

Basic Factory

# spec/factories/tasks.rb
FactoryBot.define do
  factory :task do
    account
    merchant
    recipient

    sequence(:tracking_number) { |n| "TRK#{n.to_s.rjust(8, '0')}" }
    status { 'pending' }
    description { Faker::Lorem.sentence }
    amount { Faker::Number.decimal(l_digits: 2, r_digits: 2) }

    # Traits
    trait :completed do
      status { 'completed' }
      completed_at { Time.current }
      carrier
    end

    trait :with_carrier do
      carrier
    end

    trait :express do
      task_type { 'express' }
    end

    trait :next_day do
      task_type { 'next_day' }
    end

    trait :with_photos do
      after(:create) do |task|
        create_list(:photo, 2, task: task)
      end
    end

    # Callbacks
    after(:create) do |task|
      task.timelines.create!(status: task.status, created_at: task.created_at)
    end
  end
end

Factory with Associations

# spec/factories/accounts.rb
FactoryBot.define do
  factory :account do
    sequence(:name) { |n| "Account #{n}" }
    subdomain { name.parameterize }
    active { true }
  end
end

# spec/factories/merchants.rb
FactoryBot.define do
  factory :merchant do
    account
    sequence(:name) { |n| "Merchant #{n}" }
    email { Faker::Internet.email }

    trait :with_branches do
      after(:create) do |merchant|
        create_list(:branch, 2, merchant: merchant)
      end
    end
  end
end

Transient Attributes

FactoryBot.define do
  factory :bundle do
    account
    carrier

    transient do
      task_count { 5 }
    end

    after(:create) do |bundle, evaluator|
      create_list(:task, evaluator.task_count, bundle: bundle, account: bundle.account)
    end
  end
end

# Usage
create(:bundle, task_count: 10)

Service Specs

# spec/services/tasks_manager/create_task_spec.rb
require 'rails_helper'

RSpec.describe TasksManager::CreateTask do
  let(:account) { create(:account) }
  let(:merchant) { create(:merchant, account: account) }
  let(:recipient) { create(:recipient, account: account) }

  let(:valid_params) do
    {
      recipient_id: recipient.id,
      description: "Test delivery",
      amount: 100.00,
      address: "123 Test St"
    }
  end

  describe '.call' do
    subject(:service_call) do
      described_class.call(
        account: account,
        merchant: merchant,
        params: valid_params
      )
    end

    context 'with valid params' do
      it 'creates a task' do
        expect { service_call }.to change(Task, :count).by(1)
      end

      it 'returns the created task' do
        expect(service_call).to be_a(Task)
        expect(service_call).to be_persisted
      end

      it 'associates with correct account' do
        expect(service_call.account).to eq(account)
      end

      it 'schedules notification job' do
        expect { service_call }
          .to have_enqueued_job(TaskNotificationJob)
                .with(kind_of(Integer))
      end
    end

    context 'with invalid params' do
      context 'when recipient is missing' do
        let(:valid_params) { super().except(:recipient_id) }

        it 'raises ArgumentError' do
          expect { service_call }.to raise_error(ArgumentError, /Recipient required/)
        end
      end

      context 'when address is missing' do
        let(:valid_params) { super().except(:address) }

        it 'raises ArgumentError' do
          expect { service_call }.to raise_error(ArgumentError, /Address required/)
        end
      end
    end

    context 'with service result pattern' do
      # For services returning ServiceResult
      subject(:result) { described_class.call(...) }

      context 'on success' do
        it 'returns success result' do
          expect(result).to be_success
        end

        it 'includes the task in data' do
          expect(result.data).to be_a(Task)
        end
      end

      context 'on failure' do
        it 'returns failure result' do
          expect(result).to be_failure
        end

        it 'includes error message' do
          expect(result.error).to eq("Expected error message")
        end
      end
    end
  end
end

Request Specs

# spec/requests/api/v1/tasks_spec.rb
require 'rails_helper'

RSpec.describe "Api::V1::Tasks", type: :request do
  let(:account) { create(:account) }
  let(:user) { create(:user, account: account) }
  let(:headers) { auth_headers(user) }

  describe "GET /api/v1/tasks" do
    let!(:tasks) { create_list(:task, 3, account: account) }
    let!(:other_task) { create(:task) }  # Different account

    before { get api_v1_tasks_path, headers: headers }

    it "returns success" do
      expect(response).to have_http_status(:ok)
    end

    it "returns tasks for current account only" do
      expect(json_response['data'].size).to eq(3)
    end

    it "does not include other account tasks" do
      ids = json_response['data'].pluck('id')
      expect(ids).not_to include(other_task.id)
    end
  end

  describe "POST /api/v1/tasks" do
    let(:merchant) { create(:merchant, account: account) }
    let(:recipient) { create(:recipient, account: account) }

    let(:valid_params) do
      {
        task: {
          merchant_id: merchant.id,
          recipient_id: recipient.id,
          description: "New task",
          amount: 50.00
        }
      }
    end

    context "with valid params" do
      it "creates a task" do
        expect {
          post api_v1_tasks_path, params: valid_params, headers: headers
        }.to change(Task, :count).by(1)
      end

      it "returns created status" do
        post api_v1_tasks_path, params: valid_params, headers: headers
        expect(response).to have_http_status(:created)
      end
    end

    context "with invalid params" do
      let(:invalid_params) { { task: { description: "" } } }

      it "returns unprocessable entity" do
        post api_v1_tasks_path, params: invalid_params, headers: headers
        expect(response).to have_http_status(:unprocessable_entity)
      end

      it "returns errors" do
        post api_v1_tasks_path, params: invalid_params, headers: headers
        expect(json_response['errors']).to be_present
      end
    end
  end

  # Helper for JSON response
  def json_response
    JSON.parse(response.body)
  end
end

ViewComponent Specs

# spec/components/metrics/kpi_card_component_spec.rb
require 'rails_helper'

RSpec.describe Metrics::KpiCardComponent, type: :component do
  let(:title) { "Total Orders" }
  let(:value) { 1234 }

  subject(:component) do
    described_class.new(title: title, value: value)
  end

  describe "#render" do
    before { render_inline(component) }

    it "renders the title" do
      expect(page).to have_css("h3", text: title)
    end

    it "renders the value" do
      expect(page).to have_text("1,234")
    end
  end

  describe "#formatted_value" do
    it "formats large numbers with delimiter" do
      component = described_class.new(title: "Test", value: 1234567)
      expect(component.formatted_value).to eq("1,234,567")
    end
  end

  context "with trend" do
    let(:component) do
      described_class.new(title: title, value: value, trend: :up)
    end

    before { render_inline(component) }

    it "shows trend indicator" do
      expect(page).to have_css(".text-green-500")
    end
  end

  context "with content block" do
    before do
      render_inline(component) do
        "Additional content"
      end
    end

    it "renders the block content" do
      expect(page).to have_text("Additional content")
    end
  end
end

System Specs (Capybara)

# spec/system/tasks_spec.rb
require 'rails_helper'

RSpec.describe "Tasks", type: :system do
  let(:account) { create(:account) }
  let(:user) { create(:user, account: account) }

  before do
    sign_in(user)
  end

  describe "viewing tasks" do
    let!(:tasks) { create_list(:task, 5, account: account) }

    it "displays all tasks" do
      visit tasks_path

      tasks.each do |task|
        expect(page).to have_content(task.tracking_number)
      end
    end
  end

  describe "creating a task" do
    let!(:merchant) { create(:merchant, account: account) }
    let!(:recipient) { create(:recipient, account: account) }

    it "creates a new task" do
      visit new_task_path

      select merchant.name, from: "Merchant"
      select recipient.name, from: "Recipient"
      fill_in "Description", with: "Test delivery"
      fill_in "Amount", with: "100.00"

      click_button "Create Task"

      expect(page).to have_content("Task created successfully")
      expect(page).to have_content("Test delivery")
    end
  end

  describe "with Turbo" do
    it "updates task status via Turbo Stream" do
      task = create(:task, account: account, status: 'pending')

      visit tasks_path

      within("#task_#{task.id}") do
        click_button "Start"
      end

      # Wait for Turbo Stream update
      expect(page).to have_css("#task_#{task.id} .status", text: "In Progress")
    end
  end
end

Job Specs

# spec/jobs/task_notification_job_spec.rb
require 'rails_helper'

RSpec.describe TaskNotificationJob, type: :job do
  let(:task) { create(:task) }

  describe "#perform" do
    it "sends SMS notification" do
      expect(SmsService).to receive(:send).with(
        to: task.recipient.phone,
        message: include(task.tracking_number)
      )

      described_class.perform_now(task.id)
    end

    context "when task doesn't exist" do
      it "handles gracefully" do
        expect { described_class.perform_now(0) }.not_to raise_error
      end
    end
  end

  describe "enqueuing" do
    it "enqueues in correct queue" do
      expect {
        described_class.perform_later(task.id)
      }.to have_enqueued_job.on_queue("notifications")
    end
  end
end

Shared Examples

# spec/support/shared_examples/tenant_scoped.rb
RSpec.shared_examples "tenant scoped" do
  describe "tenant scoping" do
    let(:account) { create(:account) }
    let(:other_account) { create(:account) }

    let!(:scoped_record) { create(described_class.model_name.singular, account: account) }
    let!(:other_record) { create(described_class.model_name.singular, account: other_account) }

    it "scopes to current account" do
      Current.account = account
      expect(described_class.all).to include(scoped_record)
      expect(described_class.all).not_to include(other_record)
    end
  end
end

# Usage
RSpec.describe Task do
  it_behaves_like "tenant scoped"
end
# spec/support/shared_examples/api_authentication.rb
RSpec.shared_examples "requires authentication" do
  context "without authentication" do
    let(:headers) { {} }

    it "returns unauthorized" do
      make_request
      expect(response).to have_http_status(:unauthorized)
    end
  end
end

# Usage
RSpec.describe "Api::V1::Tasks" do
  describe "GET /api/v1/tasks" do
    it_behaves_like "requires authentication" do
      let(:make_request) { get api_v1_tasks_path, headers: headers }
    end
  end
end

Shared Contexts

# spec/support/shared_contexts/authenticated_user.rb
RSpec.shared_context "authenticated user" do
  let(:account) { create(:account) }
  let(:user) { create(:user, account: account) }

  before do
    sign_in(user)
    Current.account = account
  end
end

# Usage
RSpec.describe TasksController do
  include_context "authenticated user"

  # tests with authenticated user...
end

Mocking External Services

# spec/support/webmock_helpers.rb
module WebmockHelpers
  def stub_shipping_api_success
    stub_request(:post, "https://shipping.example.com/api/labels")
      .to_return(
        status: 200,
        body: { tracking_number: "SHIP123", label_url: "https://..." }.to_json,
        headers: { 'Content-Type' => 'application/json' }
      )
  end

  def stub_shipping_api_failure
    stub_request(:post, "https://shipping.example.com/api/labels")
      .to_return(status: 500, body: { error: "Server error" }.to_json)
  end
end

RSpec.configure do |config|
  config.include WebmockHelpers
end

# Usage in spec
describe "creating shipping label" do
  before { stub_shipping_api_success }

  it "creates label successfully" do
    # test...
  end
end

Test Helpers

# spec/support/helpers/auth_helpers.rb
module AuthHelpers
  def auth_headers(user)
    token = user.generate_jwt_token
    { 'Authorization' => "Bearer #{token}" }
  end

  def sign_in(user)
    login_as(user, scope: :user)
  end
end

RSpec.configure do |config|
  config.include AuthHelpers, type: :request
  config.include AuthHelpers, type: :system
end

API Testing Comprehensive Patterns

Request Specs for REST APIs

# spec/requests/api/v1/posts_spec.rb
require 'rails_helper'

RSpec.describe 'API V1 Posts', type: :request do
  let(:user) { create(:user) }
  let(:token) { JsonWebTokenService.encode(user_id: user.id) }
  let(:auth_headers) { { 'Authorization' => "Bearer #{token}", 'Content-Type' => 'application/json' } }

  describe 'GET /api/v1/posts' do
    context 'with valid authentication' do
      before do
        create_list(:post, 3, :published)
        create(:post, :draft)
      end

      it 'returns published posts' do
        get '/api/v1/posts', headers: auth_headers

        expect(response).to have_http_status(:ok)
        expect(json_response['posts'].size).to eq(3)
      end

      it 'includes pagination metadata' do
        create_list(:post, 30, :published)

        get '/api/v1/posts', params: { page: 2, per_page: 10 }, headers: auth_headers

        expect(json_response['meta']).to include(
          'current_page' => 2,
          'total_pages' => 3,
          'total_count' => 30,
          'per_page' => 10
        )
      end

      it 'filters by status' do
        create_list(:post, 2, status: 'published')
        create_list(:post, 3, status: 'draft')

        get '/api/v1/posts', params: { status: 'draft' }, headers: auth_headers

        expect(json_response['posts'].size).to eq(3)
      end
    end

    context 'without authentication' do
      it 'returns 401 unauthorized' do
        get '/api/v1/posts'

        expect(response).to have_http_status(:unauthorized)
        expect(json_response['error']).to eq('Unauthorized')
      end
    end

    context 'with invalid token' do
      it 'returns 401 unauthorized' do
        get '/api/v1/posts', headers: { 'Authorization' => 'Bearer invalid' }

        expect(response).to have_http_status(:unauthorized)
      end
    end
  end

  describe 'POST /api/v1/posts' do
    let(:valid_params) do
      {
        post: {
          title: 'Test Post',
          body: 'Test body content',
          published_at: Time.current
        }
      }
    end

    context 'with valid parameters' do
      it 'creates a post' do
        expect {
          post '/api/v1/posts', params: valid_params.to_json, headers: auth_headers
        }.to change(Post, :count).by(1)

        expect(response).to have_http_status(:created)
        expect(json_response['title']).to eq('Test Post')
        expect(response.headers['Location']).to be_present
      end

      it 'returns serialized post' do
        post '/api/v1/posts', params: valid_params.to_json, headers: auth_headers

        expect(json_response).to include(
          'id',
          'title',
          'body',
          'published_at'
        )
        expect(json_response).not_to include('password', 'internal_notes')
      end
    end

    context 'with invalid parameters' do
      let(:invalid_params) { { post: { title: '' } } }

      it 'returns validation errors' do
        post '/api/v1/posts', params: invalid_params.to_json, headers: auth_headers

        expect(response).to have_http_status(:unprocessable_entity)
        expect(json_response['error']['errors']).to have_key('title')
        expect(json_response['error']['errors']['title']).to include("can't be blank")
      end

      it 'does not create post' do
        expect {
          post '/api/v1/posts', params: invalid_params.to_json, headers: auth_headers
        }.not_to change(Post, :count)
      end
    end
  end

  describe 'PATCH /api/v1/posts/:id' do
    let(:post_record) { create(:post, author: user) }
    let(:update_params) { { post: { title: 'Updated Title' } } }

    context 'when user is post author' do
      it 'updates the post' do
        patch "/api/v1/posts/#{post_record.id}",
              params: update_params.to_json,
              headers: auth_headers

        expect(response).to have_http_status(:ok)
        expect(post_record.reload.title).to eq('Updated Title')
      end
    end

    context 'when user is not post author' do
      let(:other_post) { create(:post) }

      it 'returns 403 forbidden' do
        patch "/api/v1/posts/#{other_post.id}",
              params: update_params.to_json,
              headers: auth_headers

        expect(response).to have_http_status(:forbidden)
        expect(json_response['error']).to eq('Forbidden')
      end
    end

    context 'when post does not exist' do
      it 'returns 404 not found' do
        patch '/api/v1/posts/99999',
              params: update_params.to_json,
              headers: auth_headers

        expect(response).to have_http_status(:not_found)
      end
    end
  end

  describe 'DELETE /api/v1/posts/:id' do
    let(:post_record) { create(:post, author: user) }

    it 'deletes the post' do
      delete "/api/v1/posts/#{post_record.id}", headers: auth_headers

      expect(response).to have_http_status(:no_content)
      expect(response.body).to be_empty
      expect(Post.exists?(post_record.id)).to be false
    end
  end

  # Helper method for parsing JSON responses
  def json_response
    JSON.parse(response.body)
  end
end

Testing Rate Limiting

# spec/requests/api/rate_limiting_spec.rb
require 'rails_helper'

RSpec.describe 'API Rate Limiting', type: :request do
  let(:user) { create(:user) }
  let(:token) { JsonWebTokenService.encode(user_id: user.id) }
  let(:auth_headers) { { 'Authorization' => "Bearer #{token}" } }

  before do
    # Use Rack::Attack test mode
    Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new
    Rack::Attack.enabled = true
  end

  after do
    Rack::Attack.cache.store.clear
  end

  it 'allows requests within limit' do
    5.times do
      get '/api/v1/posts', headers: auth_headers
      expect(response).to have_http_status(:ok)
    end
  end

  it 'throttles requests exceeding limit' do
    # Assuming limit is 10 requests per minute
    11.times do |i|
      get '/api/v1/posts', headers: auth_headers
    end

    expect(response).to have_http_status(:too_many_requests)
    expect(response.headers['Retry-After']).to be_present
  end
end

Testing API Versioning

# spec/requests/api/versioning_spec.rb
require 'rails_helper'

RSpec.describe 'API Versioning', type: :request do
  let(:user) { create(:user) }
  let(:token) { JsonWebTokenService.encode(user_id: user.id) }

  describe 'v1 endpoint' do
    it 'returns v1 response format' do
      get '/api/v1/posts', headers: { 'Authorization' => "Bearer #{token}" }

      expect(json_response).to have_key('posts')
      expect(json_response).to have_key('meta')
    end
  end

  describe 'v2 endpoint' do
    it 'returns v2 response format' do
      get '/api/v2/posts', headers: { 'Authorization' => "Bearer #{token}" }

      # v2 might have different structure
      expect(json_response).to have_key('data')
      expect(json_response).to have_key('pagination')
    end
  end

  describe 'header-based versioning' do
    it 'uses v2 with accept header' do
      get '/api/posts',
          headers: {
            'Authorization' => "Bearer #{token}",
            'Accept' => 'application/vnd.myapp.v2+json'
          }

      expect(response).to have_http_status(:ok)
    end
  end
end

Shared Examples for API Responses

# spec/support/shared_examples/api_responses.rb
RSpec.shared_examples 'requires authentication' do |method, path|
  it 'returns 401 without token' do
    send(method, path)
    expect(response).to have_http_status(:unauthorized)
  end

  it 'returns 401 with invalid token' do
    send(method, path, headers: { 'Authorization' => 'Bearer invalid' })
    expect(response).to have_http_status(:unauthorized)
  end
end

RSpec.shared_examples 'paginates results' do
  it 'includes pagination metadata' do
    make_request

    expect(json_response['meta']).to include(
      'current_page',
      'total_pages',
      'total_count',
      'per_page'
    )
  end

  it 'respects per_page parameter' do
    make_request(per_page: 5)

    expect(json_response['meta']['per_page']).to eq(5)
    expect(json_response[collection_key].size).to be <= 5
  end
end

RSpec.shared_examples 'returns JSON API format' do
  it 'sets correct content type' do
    make_request
    expect(response.content_type).to include('application/json')
  end

  it 'returns valid JSON' do
    make_request
    expect { JSON.parse(response.body) }.not_to raise_error
  end
end

# Usage
describe 'GET /api/v1/posts' do
  def make_request(params = {})
    get '/api/v1/posts', params: params, headers: auth_headers
  end

  let(:collection_key) { 'posts' }

  it_behaves_like 'requires authentication', :get, '/api/v1/posts'
  it_behaves_like 'paginates results'
  it_behaves_like 'returns JSON API format'
end

Hotwire Testing Patterns

System Tests for Turbo

# spec/system/turbo_posts_spec.rb
require 'rails_helper'

RSpec.describe 'Turbo Posts', type: :system do
  before do
    driven_by(:selenium_chrome_headless)
  end

  describe 'creating a post with Turbo' do
    it 'creates post without full page reload' do
      visit posts_path

      within '#new_post' do
        fill_in 'Title', with: 'My Turbo Post'
        fill_in 'Body', with: 'Content here'
        click_button 'Create Post'
      end

      # Post appears without page reload
      expect(page).to have_content('My Turbo Post')
      expect(page).to have_current_path(posts_path) # No redirect

      # Form is reset
      expect(find_field('Title').value).to be_blank
    end

    it 'displays validation errors inline' do
      visit posts_path

      within '#new_post' do
        fill_in 'Title', with: ''
        click_button 'Create Post'
      end

      # Error displayed without reload
      within '#new_post' do
        expect(page).to have_content("can't be blank")
      end
    end
  end

  describe 'updating post with Turbo Frame' do
    let!(:post) { create(:post, title: 'Original Title') }

    it 'updates post inline' do
      visit posts_path

      within "##{dom_id(post)}" do
        click_link 'Edit'

        # Edit form loads in frame
        fill_in 'Title', with: 'Updated Title'
        click_button 'Update'

        # Updated content shows in place
        expect(page).to have_content('Updated Title')
        expect(page).not_to have_field('Title') # No longer editing
      end

      # Rest of page unchanged
      expect(page).to have_current_path(posts_path)
    end
  end

  describe 'deleting post with Turbo Stream' do
    let!(:post) { create(:post, title: 'To Delete') }

    it 'removes post from list' do
      visit posts_path

      within "##{dom_id(post)}" do
        accept_confirm do
          click_button 'Delete'
        end
      end

      # Post removed without page reload
      expect(page).not_to have_content('To Delete')
      expect(page).to have_current_path(posts_path)
    end
  end

  describe 'real-time updates with Turbo Streams' do
    it 'shows new posts from other users', :js do
      visit posts_path

      # Simulate another user creating a post
      perform_enqueued_jobs do
        create(:post, title: 'Real-time Post')
      end

      # New post appears automatically
      expect(page).to have_content('Real-time Post')
    end
  end
end

Testing Turbo Frames

# spec/system/turbo_frames_spec.rb
require 'rails_helper'

RSpec.describe 'Turbo Frames', type: :system do
  before do
    driven_by(:selenium_chrome_headless)
  end

  describe 'lazy loading frames' do
    let!(:post) { create(:post) }

    it 'loads frame content when visible' do
      visit post_path(post)

      # Frame starts with loading message
      within 'turbo-frame#comments' do
        expect(page).to have_content('Loading comments...')
      end

      # Wait for lazy load
      sleep 0.5

      # Comments loaded
      within 'turbo-frame#comments' do
        expect(page).not_to have_content('Loading comments...')
        expect(page).to have_selector('.comment', count: post.comments.count)
      end
    end
  end

  describe 'frame navigation' do
    let!(:post) { create(:post) }

    it 'navigates within frame boundary' do
      visit posts_path

      # Click link that targets frame
      within 'turbo-frame#sidebar' do
        click_link 'Categories'

        # Only frame content changes
        expect(page).to have_content('All Categories')
      end

      # Main content unchanged
      expect(page).to have_current_path(posts_path)
    end

    it 'breaks out of frame with data-turbo-frame="_top"' do
      visit posts_path

      within 'turbo-frame#sidebar' do
        click_link 'View All Posts', data: { turbo_frame: '_top' }
      end

      # Full page navigation occurred
      expect(page).to have_current_path(posts_path)
    end
  end
end

Testing Stimulus Controllers

# spec/javascript/controllers/search_controller_spec.js
import { Application } from "@hotwired/stimulus"
import SearchController from "controllers/search_controller"

describe("SearchController", () => {
  let application
  let controller

  beforeEach(() => {
    document.body.innerHTML = `
      <div data-controller="search">
        <input data-search-target="input" type="text">
        <div data-search-target="results"></div>
        <span data-search-target="count"></span>
      </div>
    `

    application = Application.start()
    application.register("search", SearchController)
    controller = application.getControllerForElementAndIdentifier(
      document.querySelector('[data-controller="search"]'),
      "search"
    )
  })

  afterEach(() => {
    application.stop()
  })

  describe("#connect", () => {
    it("initializes with empty results", () => {
      expect(controller.resultsTarget.innerHTML).toBe("")
    })
  })

  describe("#search", () => {
    it("performs search with query", async () => {
      global.fetch = jest.fn(() =>
        Promise.resolve({
          text: () => Promise.resolve("<div class='result'>Result 1</div>")
        })
      )

      controller.inputTarget.value = "test query"
      await controller.search()

      expect(global.fetch).toHaveBeenCalledWith("/search?q=test query")
      expect(controller.resultsTarget.innerHTML).toContain("Result 1")
    })

    it("updates count", async () => {
      global.fetch = jest.fn(() =>
        Promise.resolve({
          text: () => Promise.resolve("<div>1</div><div>2</div>")
        })
      )

      controller.inputTarget.value = "test"
      await controller.search()

      expect(controller.countTarget.textContent).toBe("2")
    })
  })

  describe("#clear", () => {
    it("clears input and results", () => {
      controller.inputTarget.value = "test"
      controller.resultsTarget.innerHTML = "<div>Results</div>"

      controller.clear()

      expect(controller.inputTarget.value).toBe("")
      expect(controller.resultsTarget.innerHTML).toBe("")
    })
  })
})

Testing Turbo Streams in Request Specs

# spec/requests/turbo_streams_spec.rb
require 'rails_helper'

RSpec.describe 'Turbo Streams', type: :request do
  let(:user) { create(:user) }

  before { sign_in user }

  describe 'POST /posts' do
    let(:valid_params) { { post: { title: 'Test', body: 'Content' } } }

    it 'returns turbo stream response' do
      post posts_path, params: valid_params, as: :turbo_stream

      expect(response.media_type).to eq('text/vnd.turbo-stream.html')
      expect(response.body).to include('turbo-stream')
    end

    it 'prepends new post' do
      post posts_path, params: valid_params, as: :turbo_stream

      expect(response.body).to include('action="prepend"')
      expect(response.body).to include('target="posts"')
      expect(response.body).to include('Test')
    end

    it 'resets form' do
      post posts_path, params: valid_params, as: :turbo_stream

      # Check for form reset stream
      expect(response.body).to include('action="replace"')
      expect(response.body).to include('target="post_form"')
    end

    context 'with validation errors' do
      let(:invalid_params) { { post: { title: '' } } }

      it 'returns unprocessable entity status' do
        post posts_path, params: invalid_params, as: :turbo_stream

        expect(response).to have_http_status(:unprocessable_entity)
      end

      it 'replaces form with errors' do
        post posts_path, params: invalid_params, as: :turbo_stream

        expect(response.body).to include('action="replace"')
        expect(response.body).to include("can't be blank")
      end
    end
  end

  describe 'DELETE /posts/:id' do
    let!(:post) { create(:post, author: user) }

    it 'removes post via turbo stream' do
      delete post_path(post), as: :turbo_stream

      expect(response.body).to include('action="remove"')
      expect(response.body).to include(dom_id(post))
    end
  end
end

Integration with Capybara Helpers

# spec/support/turbo_helpers.rb
module TurboHelpers
  def expect_turbo_stream(action:, target:)
    expect(page).to have_selector(
      "turbo-stream[action='#{action}'][target='#{target}']",
      visible: false
    )
  end

  def wait_for_turbo_frame(id, timeout: 5)
    expect(page).to have_selector("turbo-frame##{id}[complete]", wait: timeout)
  end

  def within_turbo_frame(id, &block)
    within("turbo-frame##{id}", &block)
  end
end

RSpec.configure do |config|
  config.include TurboHelpers, type: :system
end

# Usage
it 'loads comments in frame' do
  visit post_path(post)

  wait_for_turbo_frame('comments')

  within_turbo_frame('comments') do
    expect(page).to have_selector('.comment', count: 5)
  end
end

Configuration

# spec/rails_helper.rb
require 'spec_helper'
ENV['RAILS_ENV'] ||= 'test'
require_relative '../config/environment'

abort("Running in production!") if Rails.env.production?

require 'rspec/rails'

Dir[Rails.root.join('spec/support/**/*.rb')].sort.each { |f| require f }

RSpec.configure do |config|
  config.fixture_path = Rails.root.join('spec/fixtures')
  config.use_transactional_fixtures = true
  config.infer_spec_type_from_file_location!
  config.filter_rails_from_backtrace!

  # FactoryBot
  config.include FactoryBot::Syntax::Methods

  # Shoulda matchers
  Shoulda::Matchers.configure do |shoulda_config|
    shoulda_config.integrate do |with|
      with.test_framework :rspec
      with.library :rails
    end
  end
end