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
endMinitest 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
endRSpec匹配器
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
endTDD实践示例:待办事项列表
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 messageruby
# 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
endFaker - 生成假数据
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
endVCR - 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
endTimecop - 时间控制
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
end2. 测试命名
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
end3. 测试数据准备
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
end4. 避免测试间依赖
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测试的核心概念和实践方法。测试不仅能提高代码质量,还能让你更有信心地进行重构和添加新功能。记住,好的测试是好代码的基础!