rspec-testing-patterns
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