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 动手练习
- 为 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
# 写完整的测试...
- TDD 实现 String Calculator
# String Calculator 规则:
# - 空字符串返回 0
# - 单个数字返回该数字
# - 逗号分隔的数字返回和
# - 支持换行符分隔
# - 支持自定义分隔符
# - 负数会抛出异常
15.9 本章小结
| 要点 | 说明 |
|---|
| Minitest | Ruby 标准库测试框架 |
| RSpec | BDD 风格测试框架,社区主流 |
| TDD | Red → Green → Refactor 循环 |
| Mock/Stub | 隔离外部依赖 |
| 覆盖率 | SimpleCov 测量测试覆盖率 |
| 原则 | 每个测试只测试一个行为,测试边界条件 |
📖 扩展阅读
上一章:← 第 14 章:文件与数据
下一章:第 16 章:Gem 开发与管理 →