强曰为道
与天地相似,故不违。知周乎万物,而道济天下,故不过。旁行而不流,乐天知命,故不忧.
文档目录

Ruby 入门指南 / 第 15 章:测试驱动开发

第 15 章:测试驱动开发

“不写测试的代码,就是在裸奔。”


15.1 测试概述

15.1.1 为什么要写测试

原因说明
发现 Bug及早发现问题,降低修复成本
重构信心有测试覆盖,重构更安全
文档作用测试就是可执行的文档
设计驱动TDD 驱动出更好的接口设计
回归防护防止新代码破坏已有功能

15.1.2 测试金字塔

       /  E2E  \        少量端到端测试
      /  集成   \       适量集成测试
     /   单元    \      大量单元测试
    /______________\

15.2 Minitest

15.2.1 基础用法

# test/test_calculator.rb
require "minitest/autorun"
require_relative "../lib/calculator"

class Calculator
  def add(a, b)
    a + b
  end

  def subtract(a, b)
    a - b
  end

  def multiply(a, b)
    a * b
  end

  def divide(a, b)
    raise ZeroDivisionError if b.zero?
    a.to_f / b
  end
end

class TestCalculator < Minitest::Test
  def setup
    @calc = Calculator.new
  end

  def test_add
    assert_equal 5, @calc.add(2, 3)
    assert_equal 0, @calc.add(-1, 1)
    assert_equal(-3, @calc.add(-1, -2))
  end

  def test_subtract
    assert_equal 2, @calc.subtract(5, 3)
    assert_equal(-2, @calc.subtract(3, 5))
  end

  def test_multiply
    assert_equal 15, @calc.multiply(3, 5)
    assert_equal 0, @calc.multiply(0, 5)
  end

  def test_divide
    assert_equal 2.5, @calc.divide(5, 2)
    assert_raises(ZeroDivisionError) { @calc.divide(5, 0) }
  end
end
ruby test/test_calculator.rb
# Run options: --seed 12345
# # Running:
# ....
# Finished in 0.001234s, 3242.5139 runs/s, 4863.7709 assertions/s.
# 4 runs, 6 assertions, 0 failures, 0 errors, 0 skips

15.2.2 断言方法

断言说明
assert(test)断言为真
refute(test)断言为假
assert_equal(expected, actual)值相等
assert_nil(obj)为 nil
assert_raises(Ex) { }抛出异常
assert_includes(collection, obj)包含
assert_match(regex, string)匹配正则
assert_instance_of(cls, obj)类型检查
assert_respond_to(obj, method)响应方法
assert_operator(a, op, b)操作符检查
# 更多断言示例
class TestAssertions < Minitest::Test
  def test_assertions
    # 真值/假值
    assert true
    refute false

    # 相等
    assert_equal "hello", "hello"

    # 近似相等(浮点数)
    assert_in_delta 3.14, Math::PI, 0.01

    # nil 检查
    assert_nil nil
    refute_nil "not nil"

    # 类型检查
    assert_instance_of String, "hello"
    assert_kind_of Numeric, 42

    # 响应方法
    assert_respond_to "hello", :upcase

    # 集合包含
    assert_includes [1, 2, 3], 2

    # 正则匹配
    assert_match /hello/, "hello world"

    # 输出捕获
    assert_output("hello\n") { puts "hello" }

    # 异常
    assert_raises(ArgumentError) { Integer("abc") }
  end
end

15.2.3 Minitest::Spec 风格

require "minitest/autorun"
require "minitest/spec"

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

  describe "#add" do
    it "adds two numbers" do
      _(@calc.add(2, 3)).must_equal 5
    end

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

  describe "#divide" do
    it "divides two numbers" do
      _(@calc.divide(10, 3)).must_be_close_to 3.333, 0.01
    end

    it "raises ZeroDivisionError for zero divisor" do
      _ { @calc.divide(5, 0) }.must_raise ZeroDivisionError
    end
  end
end

15.3 RSpec

15.3.1 安装和配置

# Gemfile
group :test do
  gem "rspec"
end

# 安装
bundle install
rspec --init  # 生成 .rspec 和 spec/spec_helper.rb

15.3.2 基础语法

# spec/calculator_spec.rb
require "spec_helper"
require_relative "../lib/calculator"

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

  describe "#add" do
    it "adds two positive numbers" do
      expect(calc.add(2, 3)).to eq(5)
    end

    it "adds negative numbers" do
      expect(calc.add(-1, -2)).to eq(-3)
    end

    it "adds zero" do
      expect(calc.add(0, 5)).to eq(5)
    end
  end

  describe "#divide" do
    it "divides two numbers" do
      expect(calc.divide(10, 2)).to eq(5.0)
    end

    it "raises ZeroDivisionError when dividing by zero" do
      expect { calc.divide(5, 0) }.to raise_error(ZeroDivisionError)
    end

    it "returns float result" do
      expect(calc.divide(10, 3)).to be_a(Float)
    end
  end
end
rspec
rspec spec/calculator_spec.rb
rspec --format documentation

15.3.3 常用匹配器

RSpec.describe "Matchers" do
  # 等值匹配
  it { expect(1 + 1).to eq(2) }
  it { expect("hello").to eql("hello") }
  it { expect(1 + 1).to equal(2) }  # 对象相同

  # 比较匹配
  it { expect(10).to be > 5 }
  it { expect(10).to be_between(1, 100) }

  # 类型匹配
  it { expect("hello").to be_a(String) }
  it { expect(42).to be_an(Integer) }

  # 集合匹配
  it { expect([1, 2, 3]).to include(2) }
  it { expect([1, 2, 3]).to contain_exactly(3, 1, 2) }
  it { expect([1, 2, 3]).to match_array([3, 1, 2]) }

  # 字符串匹配
  it { expect("hello world").to match(/hello/) }
  it { expect("hello").to start_with("hel") }
  it { expect("hello").to end_with("llo") }

  # 布尔匹配
  it { expect(true).to be_truthy }
  it { expect(false).to be_falsey }
  it { expect(nil).to be_nil }

  # 谓词匹配
  it { expect([]).to be_empty }
  it { expect(4).to be_even }
  it { expect(3).to be_odd }

  # 异常匹配
  it { expect { 1 / 0 }.to raise_error(ZeroDivisionError) }
  it { expect { raise "oops" }.to raise_error(RuntimeError, /oops/) }

  # 变化匹配
  it "changes value" do
    x = 0
    expect { x += 1 }.to change { x }.by(1)
    expect { x += 1 }.to change { x }.from(1).to(2)
  end

  # 输出匹配
  it { expect { puts "hello" }.to output("hello\n").to_stdout }
  it { expect { warn "oops" }.to output(/oops/).to_stderr }
end

15.3.4 Hooks 和共享

RSpec.describe User do
  # Hooks
  before(:each) { @user = User.new("Alice") }
  after(:each) { @user.cleanup }
  before(:all) { puts "开始测试" }
  after(:all) { puts "测试结束" }

  # let(惰性求值,每个 example 重新求值)
  let(:user) { User.new("Alice") }
  let(:admin) { User.new("Admin", role: :admin) }

  # let!(立即求值)
  let!(:default_role) { Role.find_by(name: :user) }

  # subject
  subject { User.new("Alice") }

  it "is valid" do
    expect(subject).to be_valid
  end

  # 共享示例
  shared_examples "a timestamped model" do
    it "has created_at" do
      expect(subject).to respond_to(:created_at)
    end

    it "has updated_at" do
      expect(subject).to respond_to(:updated_at)
    end
  end

  it_behaves_like "a timestamped model"
end

# 共享上下文
RSpec.shared_context "authenticated" do
  let(:current_user) { User.create!(name: "Test User") }
  before { sign_in(current_user) }
end

RSpec.describe Dashboard, authenticated: true do
  it "shows user info" do
    visit dashboard_path
    expect(page).to have_content(current_user.name)
  end
end

15.4 Mock 和 Stub

15.4.1 RSpec Mocks

RSpec.describe Order do
  let(:payment_gateway) { instance_double(PaymentGateway) }
  let(:order) { Order.new(total: 100, payment_gateway: payment_gateway) }

  describe "#process" do
    it "calls payment gateway" do
      # 设置期望
      expect(payment_gateway).to receive(:charge).with(100)
      
      order.process
    end

    it "returns success result" do
      # Stub 返回值
      allow(payment_gateway).to receive(:charge).and_return(true)
      
      result = order.process
      expect(result).to be_success
    end

    it "handles payment failure" do
      allow(payment_gateway).to receive(:charge).and_raise(PaymentError)
      
      result = order.process
      expect(result).to be_failure
    end
  end
end

15.4.2 方法 Stub

RSpec.describe WeatherService do
  describe ".current_temperature" do
    it "returns temperature from API" do
      # Stub 类方法
      allow(WeatherAPI).to receive(:fetch).and_return({ temp: 25 })
      
      temp = WeatherService.current_temperature("Beijing")
      expect(temp).to eq(25)
    end

    it "returns cached value" do
      # 有参数的 stub
      allow(Cache).to receive(:fetch).with("weather_beijing").and_return(25)
      
      temp = WeatherService.current_temperature("Beijing")
      expect(temp).to eq(25)
    end
  end
end

15.4.3 Minitest Mock

class TestOrder < Minitest::Test
  def test_process_calls_gateway
    gateway = Minitest::Mock.new
    gateway.expect(:charge, true, [100])
    
    order = Order.new(total: 100, payment_gateway: gateway)
    order.process
    
    gateway.verify  # 验证所有期望都被调用
  end
end

15.5 TDD 实践

15.5.1 TDD 循环

1. Red    - 写一个失败的测试
2. Green  - 写最少的代码让测试通过
3. Refactor - 重构代码,保持测试通过

15.5.2 TDD 示例

# 第一步:写测试(Red)
RSpec.describe FizzBuzz do
  describe ".call" do
    it "returns '1' for 1" do
      expect(FizzBuzz.call(1)).to eq("1")
    end
    
    it "returns 'Fizz' for 3" do
      expect(FizzBuzz.call(3)).to eq("Fizz")
    end
    
    it "returns 'Buzz' for 5" do
      expect(FizzBuzz.call(5)).to eq("Buzz")
    end
    
    it "returns 'FizzBuzz' for 15" do
      expect(FizzBuzz.call(15)).to eq("FizzBuzz")
    end
  end
end

# 第二步:实现(Green)
class FizzBuzz
  def self.call(n)
    case
    when n % 15 == 0 then "FizzBuzz"
    when n % 3 == 0  then "Fizz"
    when n % 5 == 0  then "Buzz"
    else n.to_s
    end
  end
end

# 第三步:重构(Refactor)
class FizzBuzz
  RULES = [
    [15, "FizzBuzz"],
    [3, "Fizz"],
    [5, "Buzz"]
  ].freeze

  def self.call(n)
    RULES.each do |divisor, word|
      return word if (n % divisor).zero?
    end
    n.to_s
  end
end

15.6 测试覆盖率

15.6.1 SimpleCov

# Gemfile
group :test do
  gem "simplecov", require: false
end

# spec/spec_helper.rb
require "simplecov"
SimpleCov.start do
  add_filter "/spec/"
  add_filter "/test/"
  
  add_group "Models", "lib/models"
  add_group "Services", "lib/services"
  
  minimum_coverage 90
end

# 运行测试后查看覆盖率
# open coverage/index.html

15.6.2 覆盖率配置

SimpleCov.start do
  # 排除不需要测试的文件
  add_filter "/vendor/"
  add_filter "/config/"
  add_filter "/db/"
  
  # 分组
  add_group "Controllers", "app/controllers"
  add_group "Models", "app/models"
  add_group "Services", "app/services"
  add_group "Libraries", "lib"
  
  # 最低覆盖率要求
  minimum_coverage 80
  minimum_coverage_by_file 70
  
  # 分支覆盖率(Ruby 2.5+)
  enable_coverage :branch
  
  # 合并多次测试结果
  command_name "RSpec"
  merge_timeout 3600
end

15.7 测试最佳实践

15.7.1 命名和组织

# ✅ 好的测试命名
RSpec.describe User do
  describe "#valid?" do
    context "when name is present" do
      it "returns true" do
        # ...
      end
    end

    context "when name is blank" do
      it "returns false" do
        # ...
      end

      it "adds an error message" do
        # ...
      end
    end
  end
end

# ❌ 不好的测试命名
RSpec.describe User do
  it "works" do
    # 什么都测试,不清楚测试什么
  end
end

15.7.2 测试原则

# 1. 每个测试只测试一个行为
# ✅
it "calculates subtotal" do
  expect(order.subtotal).to eq(100)
end

it "calculates tax" do
  expect(order.tax).to eq(10)
end

# ❌
it "calculates everything" do
  expect(order.subtotal).to eq(100)
  expect(order.tax).to eq(10)
  expect(order.total).to eq(110)
end

# 2. 使用 factory 而非 fixtures
# ✅
let(:user) { create(:user, name: "Alice") }

# ❌
let(:user) { User.find(1) }  # 依赖数据库状态

# 3. 测试边界条件
describe "#divide" do
  it "handles zero dividend" do
    expect(calc.divide(0, 5)).to eq(0)
  end

  it "raises error for zero divisor" do
    expect { calc.divide(5, 0) }.to raise_error(ZeroDivisionError)
  end

  it "handles negative numbers" do
    expect(calc.divide(-10, 2)).to eq(-5)
  end
end

15.8 动手练习

  1. 为 Stack 类写测试
class Stack
  def initialize
    @items = []
  end

  def push(item)
    @items.push(item)
  end

  def pop
    raise "Stack is empty" if empty?
    @items.pop
  end

  def peek
    @items.last
  end

  def empty?
    @items.empty?
  end

  def size
    @items.size
  end
end

# 写完整的测试...
  1. TDD 实现 String Calculator
# String Calculator 规则:
# - 空字符串返回 0
# - 单个数字返回该数字
# - 逗号分隔的数字返回和
# - 支持换行符分隔
# - 支持自定义分隔符
# - 负数会抛出异常

15.9 本章小结

要点说明
MinitestRuby 标准库测试框架
RSpecBDD 风格测试框架,社区主流
TDDRed → Green → Refactor 循环
Mock/Stub隔离外部依赖
覆盖率SimpleCov 测量测试覆盖率
原则每个测试只测试一个行为,测试边界条件

📖 扩展阅读


上一章← 第 14 章:文件与数据 下一章第 16 章:Gem 开发与管理 →