RSpec Rails 示例:构建坚如磐石的Rails应用测试体系

RSpec Rails 示例:构建坚如磐石的Rails应用测试体系

【免费下载链接】rspec-rails-examples eliotsykes/rspec-rails-examples: RSpec-Rails-Examples 是一个用于 Rails 应用程序测试的示例库,提供了多种 RSpec 测试的示例和教程,可以用于学习 RSpec 测试框架和 Rails 开发。 【免费下载链接】rspec-rails-examples 项目地址: https://gitcode.com/gh_mirrors/rs/rspec-rails-examples

引言:为什么测试是Rails开发的生命线

你是否曾经历过这样的噩梦:刚修复一个bug,却在上线后发现它引发了三个新问题?或者花费数小时调试,最终发现只是一个简单的参数验证错误?在Rails开发中,缺乏完善测试体系的应用就像在雷区中行走——你永远不知道下一步会触发什么灾难。

RSpec-Rails-Examples项目(仓库地址:https://gitcode.com/gh_mirrors/rs/rspec-rails-examples)为我们提供了一个全面的测试范例集合,展示了如何使用RSpec(Ruby Spec,一种行为驱动开发框架)构建健壮的Rails应用测试。本文将带你深入探索这个宝藏项目,掌握从单元测试到集成测试的全栈测试技巧,让你的Rails应用在任何变更面前都能稳如泰山。

读完本文,你将获得:

  • 一套完整的Rails测试策略,覆盖模型、控制器、API和用户界面
  • 15+实用测试模式,解决90%的常见测试场景
  • 7个核心测试工具的实战配置与应用
  • 5类自定义匹配器,让测试代码更具可读性
  • 一份可直接复用的测试模板,加速你的测试编写流程

RSpec测试金字塔:构建多层次测试防御体系

在深入具体测试示例之前,我们首先需要理解Rails应用的测试金字塔结构。一个健康的测试体系应该像埃及金字塔一样,拥有坚实的基础和合理的层次比例:

mermaid

单元测试:验证独立组件的正确性

单元测试是测试金字塔的基石,专注于验证应用中最小可测试单元的行为。在Rails中,这通常意味着对模型(Model)的测试。让我们通过Subscription模型的测试示例,看看如何构建可靠的单元测试。

模型测试示例:Subscription模型

Subscription模型负责管理用户订阅信息,我们需要验证其数据验证、关联关系和业务逻辑。以下是一个完整的模型测试示例:

require 'rails_helper'

RSpec.describe Subscription, :type => :model do
  # 数据库结构测试
  context "db" do
    context "indexes" do
      it { should have_db_index(:email).unique(true) }
      it { should have_db_index(:confirmation_token).unique(true) }
    end

    context "columns" do
      it { should have_db_column(:email).of_type(:string).with_options(limit: 100, null: false) }
      it { should have_db_column(:confirmation_token).of_type(:string).with_options(limit: 100, null: false) }
      it { should have_db_column(:confirmed).of_type(:boolean).with_options(default: false, null: false) }
      it { should have_db_column(:start_on).of_type(:date) }
    end
  end

  # 属性测试
  context "attributes" do
    it "has email" do
      expect(build(:subscription, email: "x@y.z")).to have_attributes(email: "x@y.z")
    end

    it "has confirmed" do
      expect(build(:subscription, confirmed: true)).to have_attributes(confirmed: true)
    end

    # 时间相关测试
    context "start_on" do
      it "defaults to today" do
        now = Time.zone.now
        today = now.to_date
        travel_to now do  # 时间旅行测试
          expect(build(:subscription).start_on).to eq(today)
        end
      end
    end
  end

  # 验证测试
  context "validation" do
    let(:subscription) { build(:subscription, confirmation_token: "token", email: "a@b.c") }

    it "requires unique email" do
      expect(subscription).to validate_uniqueness_of(:email)
    end

    it "requires email" do
      expect(subscription).to validate_presence_of(:email)
    end
  end

  # 作用域测试
  context "scopes" do
    describe ".confirmation_overdue" do
      before { travel_to Time.zone.now }  # 冻结时间
      after { travel_back }  # 恢复时间

      it "returns unconfirmed subscriptions older than 3 days" do
        overdue = create(:subscription, confirmed: false, created_at: (3.days + 1.second).ago)
        expect(Subscription.confirmation_overdue).to match_array [overdue]
      end

      it "does not return recent unconfirmed subscriptions" do
        create(:subscription, confirmed: false, created_at: 3.days.ago)
        expect(Subscription.confirmation_overdue).to be_empty
      end
    end
  end

  # 方法测试
  describe "#to_param" do
    it "uses confirmation_token as the default identifier for routes" do
      subscription = build(:subscription, confirmation_token: "hello-im-a-token-123")
      expect(subscription.to_param).to eq("hello-im-a-token-123")
    end
  end

  describe ".create_and_request_confirmation(params)" do
    it "creates an unconfirmed subscription with secure token" do
      expect(::SecureRandom).to receive(:hex).with(32).and_call_original
      subscription = Subscription.create_and_request_confirmation(email: "hello@example.tld")
      subscription.reload
      
      expect(subscription.confirmed?).to eq(false)
      expect(subscription.confirmation_token).to match(/\A[a-z0-9]{64}\z/)
    end

    it "emails a confirmation request" do
      expect(SubscriptionMailer)
        .to receive(:send_confirmation_request!)
        .with(Subscription)
      
      Subscription.create_and_request_confirmation(email: "subscriber@somedomain.tld")
    end

    it "doesn't create subscription if emailing fails" do
      expect(SubscriptionMailer)
        .to receive(:send_confirmation_request!)
        .and_raise("Delivery failed!")
      
      expect {
        Subscription.create_and_request_confirmation(email: "subscriber@somedomain.tld")
      }.to raise_error "Delivery failed!"
      
      expect(Subscription.exists?).to eq(false)
    end
  end
end

这个测试示例展示了模型测试的多个维度,包括:

  1. 数据库结构测试:验证数据库索引和字段定义
  2. 属性测试:确保模型属性正确初始化和默认值设置
  3. 验证测试:检查数据验证规则
  4. 作用域测试:测试模型查询作用域
  5. 方法测试:验证模型实例方法和类方法的行为

特别值得注意的是时间相关测试中使用的travel_to方法,这是Rails提供的时间旅行工具,能够冻结或修改测试环境中的当前时间,确保时间敏感代码的测试可靠性。

集成测试:验证组件协作的正确性

集成测试关注不同组件之间的交互,在Rails应用中主要表现为控制器(Controller)测试和API测试。这类测试确保系统各部分能够协同工作,处理请求并返回正确响应。

控制器测试示例:SubscriptionsController

SubscriptionsController处理订阅相关的HTTP请求,我们需要测试其各种动作的行为:

require 'rails_helper'

RSpec.describe SubscriptionsController, :type => :controller do
  context "GET new" do
    it "assigns a blank subscription to the view" do
      get :new
      expect(assigns(:subscription)).to be_a_new(Subscription)
    end
  end

  context "POST create" do
    it "redirects to pending subscriptions page" do
      params = { subscription: { email: "e@example.tld", start_on: "2014-12-31" } }
      post :create, params
      expect(response).to redirect_to(pending_subscriptions_path)
    end

    it "calls Subscription.create_and_request_confirmation(params)" do
      email = "e@example.tld"
      start_on = "2015-02-28"
      
      expect(Subscription).to receive(:create_and_request_confirmation)
        .with({ email: email, start_on: start_on })
      
      post :create, { subscription: { email: email, start_on: start_on } }
    end

    it "raises an error if missing params email" do
      params = { subscription: { start_on: "2015-09-28" } }
      expect {
        post :create, params
      }.to raise_error ActiveRecord::RecordInvalid
    end
  end

  context "GET confirm" do
    it "confirms the subscription" do
      subscription = create(:subscription,
        email: "e@e.tld",
        confirmation_token: Subscription.generate_confirmation_token
      )
      expect(subscription.confirmed?).to eq(false)
      
      params = { confirmation_token: subscription.confirmation_token }
      get :confirm, params
      
      expect(subscription.reload.confirmed?).to eq(true)
      expect(assigns(:subscription)).to eq(subscription)
    end

    it "responds with 404 Not Found for unknown token" do
      params = { confirmation_token: "an-unknown-token" }
      expect {
        get :confirm, params
      }.to raise_error ActiveRecord::RecordNotFound
    end
  end
end

控制器测试的关键点包括:

  • 验证正确的模板渲染或重定向
  • 检查模型方法是否被正确调用
  • 测试参数验证和错误处理
  • 确认实例变量被正确赋值
API测试示例:Token认证API

随着前后端分离架构的普及,API测试变得越来越重要。以下是一个API认证端点的测试示例:

require 'rails_helper'

describe '/api/v1/token' do
  context 'POST' do
    context 'with correct credentials' do
      it 'issues opaque and tamper-resistant access token' do
        user = create(:user)
        parameters = { user: { email: user.email, password: user.password } }.to_json
        
        post '/api/v1/token', parameters, json_request_headers
        
        expect(response).to have_http_status(:created)
        expect(response.content_type).to eq('application/json')
        expect(json.keys).to eq [:access_token]
        expect(json[:access_token]).to be_encrypted_to_hide_token_construction(user.access_tokens.last.unencrypted)
        expect(json[:access_token]).to be_signed_to_resist_tampering
      end

      def be_encrypted_to_hide_token_construction(unencrypted_token)
        satisfy('be encrypted to hide token construction') do |actual_token|
          !actual_token.include?(unencrypted_token) &&
            decrypt_and_verify(actual_token) == unencrypted_token
        end
      end

      def be_signed_to_resist_tampering
        satisfy('be signed to resist tampering') do |token|
          signature_regex = /--[0-9a-z]{40}\z/
          signed = signature_regex =~ token
          token_with_wrong_signature = token.next
          signed && invalid_signature?(token_with_wrong_signature)
        end
      end
    end

    context 'with incorrect credentials' do
      it 'responds with 401 Unauthorized for incorrect password' do
        user = create(:user)
        parameters = {
          user: { email: user.email, password: "not #{user.password}" }
        }.to_json
        
        respond_without_detailed_exceptions do
          post '/api/v1/token', parameters, json_request_headers
        end
        
        expect(response).to have_http_status(:unauthorized)
        expect(response.content_type).to eq('application/json')
        expect(json).to eq(status: '401', error: 'Unauthorized')
        expect(AccessToken.count).to eq 0
      end

      it 'locks user after 10 failed attempts' do
        user = create(:user)
        
        respond_without_detailed_exceptions do
          10.times do
            parameters = { user: { email: user.email, password: SecureRandom.hex } }.to_json
            post '/api/v1/token', parameters, json_request_headers
          end
          
          expect(user.reload.access_locked?).to eq(true)
          
          correct_params = { user: { email: user.email, password: user.password } }.to_json
          post '/api/v1/token', correct_params, json_request_headers
        end
        
        expect(user.reload.failed_attempts).to eq(10)
        expect(response).to have_http_status(:unauthorized)
      end
    end

    context 'with non-JSON MIME type request' do
      it 'responds to wrong Content-Type with 415 Unsupported Media Type' do
        user = create(:user)
        parameters = { user: { email: user.email, password: user.password } }.to_json
        headers = json_request_headers.merge 'Content-Type' => 'text/html; charset=utf-8'
        
        respond_without_detailed_exceptions do
          post '/api/v1/token', parameters, headers
        end
        
        expect(response).to have_http_status(:unsupported_media_type)
        expect(response.content_type).to eq('application/json')
        expect(json).to eq(status: '415', error: 'Unsupported Media Type')
      end
    end
  end
end

API测试需要特别关注:

  • HTTP状态码和响应头
  • JSON响应格式和内容
  • 认证和授权逻辑
  • 请求格式验证
  • 错误处理和安全考虑

功能测试:模拟用户交互流程

功能测试(Feature Test)模拟真实用户与应用的交互,验证关键业务流程的正确性。这类测试通常使用Capybara作为自动化测试工具。

功能测试示例:订阅新闻通讯流程
require 'rails_helper'

feature "Subscribe to newsletter" do
  context "in browser with native date input", driver: driver_with(native_date_input: true) do
    scenario "subscribes user to newsletter" do
      visit_new_subscription
      
      fill_in "Email", with: "buddy@example.tld"
      fill_in "Start date", with: "01/01/2015"
      click_button "Subscribe"
      
      # be_pending_subscription_page是自定义匹配器
      expect(page).to be_pending_subscription_page
      
      expect {
        visit_emailed_confirm_subscription_link("buddy@example.tld")
        expect(page).to be_confirm_subscription_page(Subscription.last)
          .with_subscription_starting_on("January 1st, 2015")
      }.to change { Subscription.where(confirmed: true).count }.from(0).to(1)
    end
  end

  context "in browser without native date input", driver: driver_with(native_date_input: false) do
    scenario "subscribes user to newsletter" do
      visit_new_subscription
      
      fill_in "Email", with: "buddy@example.tld"
      fill_in_start_date_with_picker
      click_button "Subscribe"
      
      expect(page).to be_pending_subscription_page
      
      expect {
        visit_emailed_confirm_subscription_link("buddy@example.tld")
        expect(page).to be_confirm_subscription_page(Subscription.last)
          .with_subscription_starting_on("January 31st, 2015")
      }.to change { Subscription.where(confirmed: true).count }.from(0).to(1)
    end
  end

  private
  def fill_in_start_date_with_picker
    expect(page).not_to have_css("#subscription_start_on + .picker--opened")
    find_field("Start date").click # 打开日期选择器
    expect(page).to have_css("#subscription_start_on + .picker--opened")
    
    within "#subscription_start_on + .picker" do
      find("select[title='Select a year']").select("2015")
      find("select[title='Select a month']").select("January")
      find("[aria-label='2015-01-31']", text: "31").click
    end
    
    expect(page).not_to have_css("#subscription_start_on + .picker--opened")
    expect(page).to have_field("subscription_start_on", with: "2015-01-31")
  end

  def visit_emailed_confirm_subscription_link(recipient)
    open_email recipient, with_subject: "Please confirm"
    visit_in_email "Confirm your subscription"
  end

  def visit_new_subscription
    visit "/"
    click_link "Subscribe to newsletter"
    
    expect(page).to have_title "Subscribe to our newsletter"
    expect(current_path).to eq new_subscription_path
    
    today = Time.zone.today.strftime("%Y-%m-%d")
    expect(page).to have_field "Start date", with: today
  end
end

功能测试的关键要素:

  • 模拟真实用户行为(点击链接、填写表单等)
  • 测试不同浏览器/设备的兼容性
  • 验证电子邮件等异步交互
  • 确认数据库状态在流程结束后正确更新

RSpec测试生态系统:必备工具与配置

一个强大的测试体系离不开完善的工具链。RSpec-Rails-Examples项目展示了如何集成多种测试工具,构建完整的测试生态系统。

测试配置基础:rails_helper.rb

rails_helper.rb是RSpec的核心配置文件,负责加载Rails环境和测试依赖:

ENV["RAILS_ENV"] ||= 'test'
require 'spec_helper'
require File.expand_path("../../config/environment", __FILE__)
require 'rspec/rails'

# 加载spec/support目录下的所有配置文件
Dir[Rails.root.join("spec/support/**/*.rb")].each { |f| require f }

ActiveRecord::Migration.maintain_test_schema!

RSpec.configure do |config|
  # 从文件路径推断测试类型
  config.infer_spec_type_from_file_location!
  
  # 包含Factory Girl语法糖
  config.include FactoryGirl::Syntax::Methods
  
  # 包含Devise测试助手
  config.include Devise::TestHelpers, type: :controller
  
  # 包含自定义匹配器
  config.include Matchers
  
  # 包含JSON请求助手
  config.include JsonHelper, type: :request
  
  # 配置测试顺序随机化
  config.order = :random
  Kernel.srand config.seed
end

测试数据管理:Factory Girl

Factory Girl是生成测试数据的强大工具,相比Rails内置的fixtures更加灵活和可维护:

# spec/factories/subscriptions.rb
FactoryGirl.define do
  factory :subscription do
    email { Faker::Internet.email }
    confirmed false
    confirmation_token { Subscription.generate_confirmation_token }
    start_on { Time.zone.today }
  end
  
  factory :confirmed_subscription, parent: :subscription do
    confirmed true
    confirmed_at { 1.day.ago }
  end
end

# spec/factories/users.rb
FactoryGirl.define do
  factory :user do
    email { Faker::Internet.email }
    password { Faker::Internet.password(8) }
    confirmed_at { Time.zone.now }
    
    trait :admin do
      admin true
    end
    
    trait :locked do
      access_locked true
      failed_attempts 10
    end
  end
end

使用Factory Girl的优势:

  • 支持继承和 trait,减少重复代码
  • 可动态生成符合验证规则的数据
  • 轻松创建关联对象
  • 集成Faker gem生成逼真的测试数据

数据库清理:Database Cleaner

在测试过程中保持数据库清洁是确保测试独立性的关键。Database Cleaner提供了灵活的数据库清理策略:

# spec/support/database_cleaner.rb
RSpec.configure do |config|
  config.before(:suite) do
    DatabaseCleaner.clean_with(:truncation)
  end

  config.before(:each) do
    DatabaseCleaner.strategy = :transaction
  end

  config.before(:each, :js => true) do
    DatabaseCleaner.strategy = :truncation
  end

  config.before(:each) do
    DatabaseCleaner.start
  end

  config.after(:each) do
    DatabaseCleaner.clean
  end
end

Database Cleaner策略选择:

  • Transaction:最快的策略,使用数据库事务回滚,不支持JavaScript驱动
  • Truncation:清空表数据,速度较慢但兼容性好
  • Deletion:删除记录但保留表结构,介于两者之间

外部API测试:VCR

VCR可以录制和回放HTTP请求,让依赖外部API的测试变得可靠和快速:

# spec/support/vcr.rb
VCR.configure do |c|
  c.cassette_library_dir = "spec/support/http_cache/vcr"
  c.hook_into :webmock
  c.ignore_localhost = true
  c.default_cassette_options = {
    record: :new_episodes,
    match_requests_on: [:method, :uri, :body]
  }
  
  # 过滤敏感信息
  c.filter_sensitive_data('<API_KEY>') { ENV['EXTERNAL_API_KEY'] }
end

RSpec.configure do |config|
  config.around(:each, :vcr) do |example|
    name = example.metadata[:full_description].underscore.gsub(/\s+/, '_')
    VCR.use_cassette(name) { example.call }
  end
end

# 使用示例
# spec/jobs/headline_scraper_job_spec.rb
it "emails headlines scraped from given URL" do
  url_to_scrape = "https://eliotsykes.github.io/rspec-rails-examples/"
  recipient_email = "anchor@nightlynews.tld"
  
  assert_performed_with(
    job: HeadlineScraperJob,
    args: [{url: url_to_scrape, recipient: recipient_email}], 
    queue: 'default'
  ) do
    VCR.use_cassette("news_page") do
      HeadlineScraperJob.perform_later url: url_to_scrape, recipient: recipient_email
    end
  end
  
  open_email recipient_email, with_subject: "Today's Headlines"
  
  expected_headlines = [
    "Man Bites Dog (served by VCR)",
    "Dog Presses Charges",
    "Cat Dismissed as Unreliable Witness"
  ]
  
  expected_headlines.each do |expected_headline|
    expect(current_email).to have_body_text expected_headline
  end
end

VCR的主要优势:

  • 加速依赖外部服务的测试
  • 允许在没有网络连接的情况下运行测试
  • 捕获并隔离API变更
  • 保护敏感认证信息

电子邮件测试:Email Spec

Email Spec提供了简洁的语法来测试Rails邮件:

# spec/support/email_spec.rb
RSpec.configure do |config|
  config.include EmailSpec::Helpers
  config.include EmailSpec::Matchers
end

# 使用示例
# spec/mailers/news_mailer_spec.rb
RSpec.describe NewsMailer, :type => :mailer do
  describe ".send_headlines(headlines:, to:)" do
    it "has expected subject when there are headlines" do
      mail = NewsMailer.send_headlines(
        headlines: ["Scaremongering Headline: Be Fearful!"],
        to: "newsguy@goodnews.tld"
      )
      expect(mail).to have_subject("Today's Headlines")
    end

    [nil, []].each do |no_headlines|
      it "has expected subject when there are no headlines '#{no_headlines.inspect}'" do
        NewsMailer.send_headlines(headlines: no_headlines, to: "newsguy@goodnews.tld")
        expect(open_last_email).to have_subject "No Headlines Today :-("
      end
    end

    it "sends from the default email" do
      mail = NewsMailer.send_headlines(headlines: nil, to: "newsguy@goodnews.tld")
      expect(mail).to be_delivered_from("e@rspec-rails-examples.tld")
    end

    context "HTML body" do
      it "has the given headlines" do
        headlines = ["A headline", "Another headline"]
        mail = NewsMailer.send_headlines(headlines: headlines, to: "newsguy@goodnews.tld")
        
        headlines.each do |headline|
          html = %Q|<li>#{headline}</li>|
          expect(mail).to have_body_text(html)
        end
      end
    end
  end
end

Email Spec提供的便捷匹配器:

  • have_subject:验证邮件主题
  • be_delivered_to/be_delivered_from:验证收件人和发件人
  • have_body_text:检查邮件正文内容
  • have_header:验证邮件头

测试简化:Shoulda Matchers

Shoulda Matchers提供了大量预定义的匹配器,大幅减少测试代码量:

# spec/support/shoulda_matchers.rb
Shoulda::Matchers.configure do |config|
  config.integrate do |with|
    with.test_framework :rspec
    with.library :rails
  end
end

# 使用示例
# spec/models/subscription_spec.rb
RSpec.describe Subscription, :type => :model do
  context "db" do
    context "indexes" do
      it { should have_db_index(:email).unique(true) }
      it { should have_db_index(:confirmation_token).unique(true) }
    end
    
    context "columns" do
      it { should have_db_column(:email).of_type(:string).with_options(limit: 100, null: false) }
      it { should have_db_column(:confirmed).of_type(:boolean).with_options(default: false, null: false) }
    end
  end
  
  context "validation" do
    let(:subscription) { build(:subscription, confirmation_token: "token", email: "a@b.c") }
    
    it { should validate_presence_of(:email) }
    it { should validate_uniqueness_of(:email) }
    it { should validate_presence_of(:confirmation_token) }
    it { should validate_uniqueness_of(:confirmation_token) }
  end
end

常用的Shoulda Matchers类别:

  • 数据库匹配器:have_db_columnhave_db_index
  • 验证匹配器:validate_presence_ofvalidate_uniqueness_of
  • 关联匹配器:belong_tohave_manyhave_one
  • 路由匹配器:route_tobe_routed_to

高级测试技巧:自定义匹配器与测试模式

自定义匹配器:提升测试可读性

自定义匹配器可以将复杂的断言逻辑封装为语义化的测试语句,大幅提升测试代码的可读性:

# spec/matchers/be_confirm_subscription_page.rb
module Matchers
  def be_confirm_subscription_page(subscription)
    BeConfirmSubscriptionPage.new(subscription)
  end

  class BeConfirmSubscriptionPage
    def initialize(subscription)
      @subscription = subscription
    end

    def matches?(page)
      @page = page
      page.has_current_path?(confirm_subscription_path(@subscription)) &&
        page.has_content?("Subscription confirmed successfully!") &&
        page.has_content?("Thank you for confirming your subscription.")
    end

    def with_subscription_starting_on(formatted_date)
      @formatted_date = formatted_date
      self
    end

    def matches?(page)
      original_match = super(page)
      return original_match unless @formatted_date
      
      original_match && page.has_content?("Your subscription starts on #{@formatted_date}")
    end

    def failure_message
      if @formatted_date
        "expected page to be confirmation page for subscription ##{@subscription.id} with start date #{@formatted_date}"
      else
        "expected page to be confirmation page for subscription ##{@subscription.id}"
      end
    end

    def failure_message_when_negated
      if @formatted_date
        "expected page not to be confirmation page for subscription ##{@subscription.id} with start date #{@formatted_date}"
      else
        "expected page not to be confirmation page for subscription ##{@subscription.id}"
      end
    end
  end
end

# 错误消息匹配器
# spec/matchers/have_error_messages.rb
module Matchers
  def have_error_messages(*args)
    HaveErrorMessages.new(*args)
  end

  alias have_error_message have_error_messages

  class HaveErrorMessages
    attr_accessor :failure_message

    def initialize(*args)
      @expected_messages = *args
    end

    def matches?(actual_page)
      actual_page.within "#error_explanation" do
        expected_error_count = @expected_messages.size
        expected_error_count_msg = "#{expected_error_count} #{'error'.pluralize(expected_error_count)} prohibited this user from being saved"
        
        unless actual_page.has_content?(expected_error_count_msg)
          self.failure_message = "\nexpected error count message: #{expected_error_count_msg}\n     got error count message: #{actual_page.text}\n"
          return false
        end
        
        actual_page.within "ul" do
          unless actual_page.has_css?("li", count: expected_error_count)
            self.failure_message = "\nexpected error count: #{expected_error_count}\n"
            return false
          end
          
          @expected_messages.each do |expected_msg|
            unless actual_page.has_selector?("li", text: expected_msg)
              self.failure_message = "\nmissing error message: #{expected_msg}\n"
              return false
            end
          end
        end
      end
      true
    end
  end
end

# 自定义匹配器使用示例
# spec/features/subscribe_to_newsletter_spec.rb
scenario "subscribes user to newsletter" do
  visit_new_subscription
  
  fill_in "Email", with: "buddy@example.tld"
  fill_in "Start date", with: "01/01/2015"
  click_button "Subscribe"
  
  expect(page).to be_pending_subscription_page
  
  expect {
    visit_emailed_confirm_subscription_link("buddy@example.tld")
    expect(page).to be_confirm_subscription_page(Subscription.last)
      .with_subscription_starting_on("January 1st, 2015")
  }.to change { Subscription.where(confirmed: true).count }.from(0).to(1)
end

自定义匹配器的优势:

  • 提高测试代码的可读性和可维护性
  • 封装重复的断言逻辑
  • 提供更具表达力的测试失败消息
  • 使测试代码更接近自然语言

常用测试模式:应对复杂测试场景

1. 时间敏感代码测试

许多业务逻辑依赖于当前时间,使用时间旅行可以精确控制测试环境的时间:

# spec/models/subscription_spec.rb
describe ".confirmation_overdue" do
  before do
    # 冻结时间
    travel_to Time.zone.now
  end

  after { travel_back }  # 恢复时间

  it "returns unconfirmed subscriptions older than 3 days" do
    overdue = create(:subscription, confirmed: false, created_at: (3.days + 1.second).ago)
    expect(Subscription.confirmation_overdue).to match_array [overdue]
  end

  it "does not return recent unconfirmed subscriptions" do
    create(:subscription, confirmed: false, created_at: 3.days.ago)
    expect(Subscription.confirmation_overdue).to be_empty
  end
end

Rails提供的时间旅行方法:

  • travel_to:将时间设置为指定时刻
  • travel:相对时间旅行(向前或向后移动指定时间)
  • freeze_time:冻结时间在当前时刻
2. 异步任务测试

Active Job测试需要验证任务是否入队以及执行结果是否正确:

# spec/support/job_helpers.rb
RSpec.configure do |config|
  config.include ActiveJob::TestHelper

  config.after(:each) do
    clear_enqueued_jobs
    clear_performed_jobs
  end
end

# spec/jobs/headline_scraper_job_spec.rb
RSpec.describe HeadlineScraperJob, :type => :job do
  it "emails headlines scraped from given URL" do
    url_to_scrape = "https://eliotsykes.github.io/rspec-rails-examples/"
    recipient_email = "anchor@nightlynews.tld"

    # 验证作业是否入队
    assert_performed_with(
      job: HeadlineScraperJob,
      args: [{url: url_to_scrape, recipient: recipient_email}], 
      queue: 'default'
    ) do
      VCR.use_cassette("news_page") do
        HeadlineScraperJob.perform_later url: url_to_scrape, recipient: recipient_email
      end
    end

    # 验证作业执行结果
    open_email recipient_email, with_subject: "Today's Headlines"
    
    expected_headlines = [
      "Man Bites Dog (served by VCR)",
      "Dog Presses Charges",
      "Cat Dismissed as Unreliable Witness"
    ]
    
    expected_headlines.each do |expected_headline|
      expect(current_email).to have_body_text expected_headline
    end
  end
end

Active Job测试方法:

  • assert_enqueued_with:验证作业是否入队
  • assert_performed_with:验证作业是否执行
  • perform_enqueued_jobs:立即执行所有入队作业
  • perform_enqueued_job:执行指定的入队作业
3. 测试环境差异化配置

不同类型的测试可能需要不同的配置,RSpec标签可以实现条件配置:

# spec/support/capybara.rb
RSpec.configure do |config|
  # 为JavaScript测试配置不同的驱动
  config.before(:each, js: true) do
    Capybara.current_driver = :selenium_chrome_headless
  end

  config.after(:each, js: true) do
    Capybara.use_default_driver
  end
end

# spec/features/subscribe_to_newsletter_spec.rb
feature "Subscribe to newsletter" do
  # 使用原生日期输入的浏览器
  context "in browser with native date input", driver: driver_with(native_date_input: true) do
    scenario "subscribes user to newsletter" do
      # ...测试代码...
    end
  end

  # 不支持原生日期输入的浏览器
  context "in browser without native date input", driver: driver_with(native_date_input: false) do
    scenario "subscribes user to newsletter" do
      # ...测试代码...
    end
  end
end

常用的测试标签:

  • :js:标记需要JavaScript支持的测试

【免费下载链接】rspec-rails-examples eliotsykes/rspec-rails-examples: RSpec-Rails-Examples 是一个用于 Rails 应用程序测试的示例库,提供了多种 RSpec 测试的示例和教程,可以用于学习 RSpec 测试框架和 Rails 开发。 【免费下载链接】rspec-rails-examples 项目地址: https://gitcode.com/gh_mirrors/rs/rspec-rails-examples

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值