如何在RSpec中优雅实现带不同预期的循环测试?
问题
我有三个模型测试(model specs)代码逻辑相似,仅预期的数值结果略有不同。为避免重复代码,尝试遵循DRY原则实现:
tests = [ {test_name: "this-year", arrival_date: 2.days.ago, ytd: 1, ly: 0}, {test_name: "last-year", arrival_date: 14.months.ago, ytd: 0, ly: 1}, {test_name: "very old", arrival_date: 3.years.ago, ytd: 0, ly: 0}, ] tests.each do |t| it "increments #{t[:test_name]} order correctly" do expect{ order = create(:order, buyer: @bs.buyer, seller: @bs.seller, arrival_date: t[:arrival_date]) @bs.reload }.to change{ @bs.orders_count }.from(0).to(1) .and change{ @bs.orders_count_ytd }.from(0).to(t[:ytd]) .and change{ @bs.orders_count_ly }.from(0).to(t[:ly]) end end
执行时抛出错误:
[Test 1]: expected `@bs.orders_count_ly` to have changed from 0 to 0, but did not change [Test 2]: expected `@bs.orders_count_ytd` to have changed from 0 to 0, but did not change [Test 3]: expected `@bs.orders_count_ytd` to have changed from 0 to 0, but did not change ...and: expected `@bs.orders_count_ly` to have changed from 0 to 0, but did not change
已知的解决方案需要添加冗余的条件判断来调整预期,但会破坏方案的简洁性与清晰度。请问是否有符合规范的优雅实现方式?
优雅解决方案
方法1:动态构建断言链
核心是只对实际需要变化的字段添加验证,跳过0→0的无效断言:
tests.each do |t| it "increments #{t[:test_name]} order correctly" do # 先定义必有的总数变化断言 expectation = expect { create(:order, buyer: @bs.buyer, seller: @bs.seller, arrival_date: t[:arrival_date]) @bs.reload }.to change{ @bs.orders_count }.from(0).to(1) # 仅当预期值非0时,添加对应字段的变化断言 expectation = expectation.and(change{ @bs.orders_count_ytd }.from(0).to(t[:ytd])) if t[:ytd] != 0 expectation = expectation.and(change{ @bs.orders_count_ly }.from(0).to(t[:ly])) if t[:ly] != 0 end end
这种方式既保留了DRY特性,又避免了无效验证,代码简洁性几乎不受影响。
方法2:自定义RSpec匹配器(复用性更强)
如果这类场景频繁出现,封装自定义匹配器能让测试代码更直观:
RSpec::Matchers.define :update_order_counts_to do |expected| match do |block| block.call expect(subject.orders_count).to eq(expected[:total]) expect(subject.orders_count_ytd).to eq(expected[:ytd]) expect(subject.orders_count_ly).to eq(expected[:ly]) true end chain :from do |original| @original = original # 可选:添加初始值验证 expect(subject.orders_count).to eq(original[:total]) expect(subject.orders_count_ytd).to eq(original[:ytd]) expect(subject.orders_count_ly).to eq(original[:ly]) end end # 测试代码简化为: tests.each do |t| it "increments #{t[:test_name]} order correctly" do expect { create(:order, buyer: @bs.buyer, seller: @bs.seller, arrival_date: t[:arrival_date]) @bs.reload }.to update_order_counts_to(total: 1, ytd: t[:ytd], ly: t[:ly]).from(total: 0, ytd: 0, ly: 0) end end
自定义匹配器把复杂断言逻辑封装起来,测试代码可读性拉满,完全符合DRY原则,也不会出现无效验证问题。
方法3:拆分测试关注点(定位问题更精准)
如果希望每个测试的职责更清晰,可以把通用断言和个性化断言分离:
shared_examples "order count updates" do |arrival_date, ytd, ly| it "increments total order count" do expect { create(:order, buyer: @bs.buyer, seller: @bs.seller, arrival_date: arrival_date) @bs.reload }.to change{ @bs.orders_count }.from(0).to(1) end it "sets correct ytd count" do create(:order, buyer: @bs.buyer, seller: @bs.seller, arrival_date: arrival_date) @bs.reload expect(@bs.orders_count_ytd).to eq(ytd) end it "sets correct ly count" do create(:order, buyer: @bs.buyer, seller: @bs.seller, arrival_date: arrival_date) @bs.reload expect(@bs.orders_count_ly).to eq(ly) end end tests.each do |t| describe "#{t[:test_name]} order" do include_examples "order count updates", t[:arrival_date], t[:ytd], t[:ly] end end
这种方式把每个计数的验证拆成独立测试,虽然代码行数略多,但测试失败时能精准定位问题,可读性更强。
内容的提问来源于stack exchange,提问作者sscirrus
相关产品推荐
相关产品推荐

