Skip to content

Ruby 测试驱动开发

测试是软件开发中的重要环节,Ruby社区非常重视测试文化。本章将介绍Ruby中的各种测试框架和测试驱动开发(TDD)的实践方法。

📋 本章内容

  • 测试的重要性和类型
  • Minitest框架
  • RSpec框架
  • 测试驱动开发(TDD)
  • 行为驱动开发(BDD)
  • 测试工具和最佳实践

🎯 为什么要写测试

测试的好处

  • 质量保证:确保代码按预期工作
  • 重构安全:修改代码时有信心
  • 文档作用:测试即是代码的使用说明
  • 设计改进:促进更好的代码设计
  • 回归预防:防止旧bug重现

测试类型

ruby
# 单元测试 - 测试单个方法或类
def test_calculator_add
  assert_equal 4, Calculator.add(2, 2)
end

# 集成测试 - 测试多个组件协作
def test_user_registration_flow
  # 测试用户注册的完整流程
end

# 功能测试 - 测试完整功能
def test_user_can_login_and_access_dashboard
  # 测试用户登录并访问仪表板
end

🧪 Minitest框架

Minitest是Ruby标准库中的测试框架,简单易用。

基本使用

ruby
require 'minitest/autorun'

class CalculatorTest < Minitest::Test
  def setup
    @calculator = Calculator.new
  end

  def test_addition
    result = @calculator.add(2, 3)
    assert_equal 5, result
  end

  def test_subtraction
    result = @calculator.subtract(5, 3)
    assert_equal 2, result
  end

  def test_division_by_zero
    assert_raises(ZeroDivisionError) do
      @calculator.divide(10, 0)
    end
  end

  def teardown
    # 清理工作
  end
end

常用断言方法

ruby
class AssertionExamplesTest < Minitest::Test
  def test_equality_assertions
    assert_equal 4, 2 + 2
    refute_equal 5, 2 + 2
  end

  def test_boolean_assertions
    assert true
    refute false
    assert_nil nil
    refute_nil "not nil"
  end

  def test_numeric_assertions
    assert_in_delta 3.14, Math::PI, 0.01
    assert_operator 5, :>, 3
  end

  def test_string_assertions
    assert_match /hello/, "hello world"
    refute_match /goodbye/, "hello world"
  end

  def test_collection_assertions
    assert_includes [1, 2, 3], 2
    refute_includes [1, 2, 3], 4
    assert_empty []
    refute_empty [1]
  end

  def test_exception_assertions
    assert_raises(ArgumentError) do
      raise ArgumentError, "Invalid argument"
    end

    assert_silent do
      # 不应该有输出的代码
    end
  end
end

Minitest Spec风格

ruby
require 'minitest/autorun'

describe Calculator do
  before do
    @calculator = Calculator.new
  end

  describe "when adding numbers" do
    it "returns the sum of two positive numbers" do
      _(@calculator.add(2, 3)).must_equal 5
    end

    it "handles negative numbers" do
      _(@calculator.add(-2, 3)).must_equal 1
    end
  end

  describe "when dividing numbers" do
    it "raises error for division by zero" do
      _ { @calculator.divide(10, 0) }.must_raise ZeroDivisionError
    end
  end
end

🔍 RSpec框架

RSpec是Ruby中最流行的BDD测试框架,语法更接近自然语言。

安装和配置

bash
# 安装RSpec
gem install rspec

# 初始化RSpec配置
rspec --init

基本语法

ruby
# spec/calculator_spec.rb
require 'spec_helper'
require_relative '../lib/calculator'

RSpec.describe Calculator do
  let(:calculator) { Calculator.new }

  describe '#add' do
    it 'returns the sum of two numbers' do
      expect(calculator.add(2, 3)).to eq(5)
    end

    it 'handles negative numbers' do
      expect(calculator.add(-2, 3)).to eq(1)
    end

    context 'with floating point numbers' do
      it 'returns correct sum' do
        expect(calculator.add(2.5, 3.7)).to be_within(0.1).of(6.2)
      end
    end
  end

  describe '#divide' do
    it 'raises ZeroDivisionError when dividing by zero' do
      expect { calculator.divide(10, 0) }.to raise_error(ZeroDivisionError)
    end

    it 'returns correct quotient' do
      expect(calculator.divide(10, 2)).to eq(5)
    end
  end
end

RSpec匹配器

ruby
RSpec.describe "RSpec Matchers" do
  describe "equality matchers" do
    it "uses eq for value equality" do
      expect(2 + 2).to eq(4)
    end

    it "uses be for identity" do
      a = "hello"
      b = a
      expect(a).to be(b)
    end

    it "uses eql for value and type" do
      expect(2).to eql(2)
      expect(2).not_to eql(2.0)
    end
  end

  describe "comparison matchers" do
    it "compares values" do
      expect(10).to be > 5
      expect(10).to be >= 10
      expect(5).to be < 10
      expect(5).to be <= 5
    end

    it "checks ranges" do
      expect(5).to be_between(1, 10).inclusive
      expect(5).to be_between(1, 10).exclusive
    end
  end

  describe "class and type matchers" do
    it "checks class" do
      expect("hello").to be_a(String)
      expect("hello").to be_an_instance_of(String)
    end

    it "checks response to methods" do
      expect("hello").to respond_to(:upcase)
    end
  end

  describe "collection matchers" do
    let(:array) { [1, 2, 3, 4, 5] }

    it "checks inclusion" do
      expect(array).to include(3)
      expect(array).to include(2, 4)
    end

    it "checks size" do
      expect(array).to have(5).items
      expect(array.size).to eq(5)
    end

    it "checks all elements" do
      expect([2, 4, 6]).to all(be_even)
    end
  end

  describe "string matchers" do
    let(:string) { "Hello, World!" }

    it "matches patterns" do
      expect(string).to match(/Hello/)
      expect(string).to start_with("Hello")
      expect(string).to end_with("World!")
    end
  end

  describe "exception matchers" do
    it "expects exceptions" do
      expect { raise StandardError, "error" }.to raise_error(StandardError)
      expect { raise StandardError, "error" }.to raise_error("error")
      expect { raise StandardError, "error" }.to raise_error(StandardError, "error")
    end
  end
end

测试替身(Test Doubles)

ruby
RSpec.describe "Test Doubles" do
  describe "stubs" do
    it "stubs method calls" do
      user = double("user")
      allow(user).to receive(:name).and_return("John")
      
      expect(user.name).to eq("John")
    end
  end

  describe "mocks" do
    it "expects method calls" do
      user = double("user")
      expect(user).to receive(:save).and_return(true)
      
      user.save
    end
  end

  describe "spies" do
    it "verifies method calls after the fact" do
      user = spy("user")
      
      user.save
      
      expect(user).to have_received(:save)
    end
  end

  describe "partial doubles" do
    it "stubs real objects" do
      user = User.new
      allow(user).to receive(:valid?).and_return(true)
      
      expect(user.valid?).to be true
    end
  end
end

🔄 测试驱动开发(TDD)

TDD是一种开发方法论,遵循"红-绿-重构"循环。

TDD循环

ruby
# 1. 红色阶段:写一个失败的测试
RSpec.describe BankAccount do
  describe '#withdraw' do
    it 'reduces balance by withdrawal amount' do
      account = BankAccount.new(100)
      account.withdraw(30)
      expect(account.balance).to eq(70)
    end
  end
end

# 2. 绿色阶段:写最少的代码让测试通过
class BankAccount
  attr_reader :balance

  def initialize(initial_balance)
    @balance = initial_balance
  end

  def withdraw(amount)
    @balance -= amount
  end
end

# 3. 重构阶段:改进代码质量
class BankAccount
  attr_reader :balance

  def initialize(initial_balance)
    @balance = initial_balance
  end

  def withdraw(amount)
    raise ArgumentError, "Amount must be positive" if amount <= 0
    raise InsufficientFundsError if amount > @balance
    
    @balance -= amount
  end
end

TDD实践示例:待办事项列表

ruby
# spec/todo_list_spec.rb
RSpec.describe TodoList do
  let(:todo_list) { TodoList.new }

  describe '#add_item' do
    it 'adds an item to the list' do
      todo_list.add_item("Buy milk")
      expect(todo_list.items).to include("Buy milk")
    end

    it 'increases the item count' do
      expect { todo_list.add_item("Buy milk") }.to change { todo_list.count }.by(1)
    end
  end

  describe '#remove_item' do
    before do
      todo_list.add_item("Buy milk")
    end

    it 'removes an item from the list' do
      todo_list.remove_item("Buy milk")
      expect(todo_list.items).not_to include("Buy milk")
    end

    it 'decreases the item count' do
      expect { todo_list.remove_item("Buy milk") }.to change { todo_list.count }.by(-1)
    end
  end

  describe '#complete_item' do
    before do
      todo_list.add_item("Buy milk")
    end

    it 'marks an item as completed' do
      todo_list.complete_item("Buy milk")
      expect(todo_list.completed?("Buy milk")).to be true
    end
  end

  describe '#pending_items' do
    before do
      todo_list.add_item("Buy milk")
      todo_list.add_item("Walk dog")
      todo_list.complete_item("Buy milk")
    end

    it 'returns only uncompleted items' do
      expect(todo_list.pending_items).to eq(["Walk dog"])
    end
  end
end

# lib/todo_list.rb
class TodoList
  def initialize
    @items = []
    @completed = Set.new
  end

  def add_item(item)
    @items << item
  end

  def remove_item(item)
    @items.delete(item)
    @completed.delete(item)
  end

  def complete_item(item)
    @completed.add(item) if @items.include?(item)
  end

  def completed?(item)
    @completed.include?(item)
  end

  def items
    @items.dup
  end

  def count
    @items.size
  end

  def pending_items
    @items.reject { |item| completed?(item) }
  end
end

🎭 行为驱动开发(BDD)

BDD关注软件的行为和业务价值。

特性测试示例

ruby
# features/user_authentication.feature (Cucumber格式)
Feature: User Authentication
  As a user
  I want to log in to the system
  So that I can access my account

  Scenario: Successful login
    Given I am on the login page
    When I enter valid credentials
    Then I should be redirected to the dashboard

  Scenario: Failed login
    Given I am on the login page
    When I enter invalid credentials
    Then I should see an error message
ruby
# spec/features/user_authentication_spec.rb (RSpec feature test)
RSpec.feature "User Authentication" do
  scenario "User logs in successfully" do
    user = create(:user, email: "test@example.com", password: "password")
    
    visit login_path
    fill_in "Email", with: "test@example.com"
    fill_in "Password", with: "password"
    click_button "Log In"
    
    expect(page).to have_content("Welcome")
    expect(current_path).to eq(dashboard_path)
  end

  scenario "User fails to log in with invalid credentials" do
    visit login_path
    fill_in "Email", with: "invalid@example.com"
    fill_in "Password", with: "wrongpassword"
    click_button "Log In"
    
    expect(page).to have_content("Invalid credentials")
    expect(current_path).to eq(login_path)
  end
end

🏭 测试工具和辅助库

Factory Bot - 测试数据工厂

ruby
# spec/factories/users.rb
FactoryBot.define do
  factory :user do
    sequence(:email) { |n| "user#{n}@example.com" }
    password { "password123" }
    first_name { "John" }
    last_name { "Doe" }

    trait :admin do
      role { "admin" }
    end

    trait :with_posts do
      after(:create) do |user|
        create_list(:post, 3, author: user)
      end
    end
  end

  factory :post do
    title { "Sample Post" }
    content { "This is a sample post content." }
    association :author, factory: :user
  end
end

# 使用Factory Bot
RSpec.describe User do
  let(:user) { create(:user) }
  let(:admin) { create(:user, :admin) }
  let(:user_with_posts) { create(:user, :with_posts) }

  it "creates a valid user" do
    expect(user).to be_valid
  end

  it "creates an admin user" do
    expect(admin.role).to eq("admin")
  end
end

Faker - 生成假数据

ruby
require 'faker'

RSpec.describe "Faker examples" do
  it "generates fake data" do
    name = Faker::Name.name
    email = Faker::Internet.email
    address = Faker::Address.full_address
    
    expect(name).to be_a(String)
    expect(email).to include("@")
    expect(address).to be_a(String)
  end
end

# 在Factory Bot中使用Faker
FactoryBot.define do
  factory :user do
    first_name { Faker::Name.first_name }
    last_name { Faker::Name.last_name }
    email { Faker::Internet.email }
    phone { Faker::PhoneNumber.phone_number }
    address { Faker::Address.full_address }
  end
end

VCR - HTTP交互录制

ruby
# spec/spec_helper.rb
require 'vcr'

VCR.configure do |config|
  config.cassette_library_dir = "spec/vcr_cassettes"
  config.hook_into :webmock
  config.configure_rspec_metadata!
end

# 使用VCR
RSpec.describe GitHubService, :vcr do
  it "fetches user information" do
    service = GitHubService.new
    user_info = service.get_user("octocat")
    
    expect(user_info["login"]).to eq("octocat")
    expect(user_info["name"]).to eq("The Octocat")
  end
end

Timecop - 时间控制

ruby
require 'timecop'

RSpec.describe "Time-dependent functionality" do
  it "handles time-sensitive operations" do
    Timecop.freeze(Time.local(2023, 1, 1, 12, 0, 0)) do
      order = Order.create(created_at: Time.current)
      expect(order.created_at.hour).to eq(12)
    end
  end

  it "travels through time" do
    Timecop.travel(1.day.from_now) do
      expect(Date.current).to eq(Date.tomorrow)
    end
  end
end

📊 测试覆盖率

SimpleCov - 代码覆盖率

ruby
# spec/spec_helper.rb
require 'simplecov'

SimpleCov.start do
  add_filter '/spec/'
  add_filter '/vendor/'
  
  add_group 'Models', 'app/models'
  add_group 'Controllers', 'app/controllers'
  add_group 'Services', 'app/services'
  
  minimum_coverage 90
end

RSpec.configure do |config|
  # RSpec配置
end

🎯 测试最佳实践

1. 测试结构

ruby
# 好的测试结构
RSpec.describe Calculator do
  describe '#add' do
    context 'with positive numbers' do
      it 'returns the sum' do
        # 测试实现
      end
    end

    context 'with negative numbers' do
      it 'returns the correct result' do
        # 测试实现
      end
    end
  end
end

2. 测试命名

ruby
# 清晰的测试命名
describe '#withdraw' do
  it 'reduces balance by withdrawal amount' do
    # 测试实现
  end

  it 'raises error when insufficient funds' do
    # 测试实现
  end

  it 'raises error when amount is negative' do
    # 测试实现
  end
end

3. 测试数据准备

ruby
# 使用let和before合理组织测试数据
RSpec.describe BankAccount do
  let(:initial_balance) { 1000 }
  let(:account) { BankAccount.new(initial_balance) }

  before do
    # 通用设置
  end

  describe '#withdraw' do
    let(:withdrawal_amount) { 100 }

    it 'reduces balance correctly' do
      account.withdraw(withdrawal_amount)
      expect(account.balance).to eq(initial_balance - withdrawal_amount)
    end
  end
end

4. 避免测试间依赖

ruby
# 错误:测试间有依赖
describe BankAccount do
  let(:account) { BankAccount.new(100) }

  it 'allows withdrawal' do
    account.withdraw(50)
    expect(account.balance).to eq(50)
  end

  it 'allows another withdrawal' do  # 依赖上一个测试
    account.withdraw(25)
    expect(account.balance).to eq(25)  # 错误!
  end
end

# 正确:每个测试独立
describe BankAccount do
  let(:account) { BankAccount.new(100) }

  it 'allows withdrawal' do
    account.withdraw(50)
    expect(account.balance).to eq(50)
  end

  it 'allows multiple withdrawals' do
    account.withdraw(50)
    account.withdraw(25)
    expect(account.balance).to eq(25)
  end
end

🚀 持续集成中的测试

GitHub Actions配置

yaml
# .github/workflows/test.yml
name: Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    
    strategy:
      matrix:
        ruby-version: ['2.7', '3.0', '3.1']
    
    steps:
    - uses: actions/checkout@v2
    
    - name: Set up Ruby
      uses: ruby/setup-ruby@v1
      with:
        ruby-version: ${{ matrix.ruby-version }}
        bundler-cache: true
    
    - name: Run tests
      run: |
        bundle exec rspec
        
    - name: Upload coverage to Codecov
      uses: codecov/codecov-action@v1

通过本章的学习,你已经掌握了Ruby测试的核心概念和实践方法。测试不仅能提高代码质量,还能让你更有信心地进行重构和添加新功能。记住,好的测试是好代码的基础!

本站内容仅供学习和研究使用。