RSpec疑问:instance_double兼容StandardError却不兼容其他异常类?
问题重现
测试中需要模拟抛出一个自定义异常SomeError(该异常初始化需要多个参数,难以实例化),于是用instance_double创建替身,但运行测试时报错:
TypeError: exception class/object expected
但将代码中的SomeError替换为StandardError后,测试却能正常通过。
报错的测试代码:
class SomeError < StandardError def initialize(some, random, params) # ... end end class SomeClass def some_method mocked_method :ok rescue SomeError :ko end def mocked_method true end end describe SomeClass do subject(:some_class) { described_class.new } describe '#some_method' do subject(:some_method) { some_class.some_method } it { is_expected.to be :ok } context 'when #mocked_method fails' do before do allow(some_class).to receive(:mocked_method) .and_raise(instance_double(SomeError)) end it { is_expected.to be :ko } end end end
替换为StandardError后能运行的代码:
class SomeClass def some_method mocked_method :ok rescue StandardError :ko end def mocked_method true end end describe SomeClass do subject(:some_class) { described_class.new } describe '#some_method' do subject(:some_method) { some_class.some_method } it { is_expected.to be :ok } context 'when #mocked_method fails' do before do allow(some_class).to receive(:mocked_method) .and_raise(instance_double(StandardError)) end it { is_expected.to be :ko } end end end
原因分析
1. Ruby对raise的参数要求
Ruby的raise方法只接受以下几种合法参数:
Exception类(或其子类)本身(会自动调用new实例化)Exception类的实例- 字符串(会被自动包装成
RuntimeError实例) - 响应
exception方法的对象(调用该方法生成异常实例)
而instance_double(SomeError)是RSpec生成的测试替身对象,它本质上属于RSpec::Mocks::InstanceDouble类,既不是SomeError的实例,也不是Exception的子类实例,完全不符合Rubyraise的参数要求,因此抛出TypeError。
2. 为什么StandardError的情况“看似正常”
这其实是个巧合:当你用instance_double(StandardError)执行raise时,Ruby会先抛出TypeError(因为替身不是合法异常对象),而TypeError本身是StandardError的子类,正好被代码中的rescue StandardError捕获,所以测试返回:ko,看起来像是正常运行,但实际上你并没有抛出模拟的StandardError,而是捕获了raise本身抛出的错误。
解决方案
针对难以实例化的自定义异常,推荐以下几种靠谱的测试方式:
方法1:创建测试专用的异常子类
在测试文件中定义一个继承自目标异常的子类,重写initialize方法来规避复杂的参数要求:
# 测试文件中新增 class TestSomeError < SomeError def initialize super(nil, nil, nil) # 用nil填充父类需要的参数,或根据逻辑传合法值 end end # 测试代码中使用 context 'when #mocked_method fails' do before do allow(some_class).to receive(:mocked_method).and_raise(TestSomeError.new) end it { is_expected.to be :ko } end
方法2:用class_double模拟异常类并指定初始化逻辑
如果只需要验证异常类型被捕获,不需要实例的具体行为,可以用class_double来模拟异常类,并指定new方法的返回值:
context 'when #mocked_method fails' do before do error_class = class_double(SomeError) allow(error_class).to receive(:new).and_return(SomeError.new(nil, nil, nil)) allow(some_class).to receive(:mocked_method).and_raise(error_class) end it { is_expected.to be :ko } end
方法3:给替身添加exception方法
利用Rubyraise的特性,给测试替身添加exception方法,让它返回合法的异常实例:
context 'when #mocked_method fails' do before do error_double = double("SomeError") allow(error_double).to receive(:exception).and_return(SomeError.new(nil, nil, nil)) allow(some_class).to receive(:mocked_method).and_raise(error_double) end it { is_expected.to be :ko } end
方法4:直接跳过复杂初始化(不推荐但可用)
如果异常的初始化逻辑不影响测试结果,可以用RSpec的any_instance_of来跳过参数校验:
context 'when #mocked_method fails' do before do allow_any_instance_of(SomeError).to receive(:initialize).with(any_args) allow(some_class).to receive(:mocked_method).and_raise(SomeError) end it { is_expected.to be :ko } end
内容的提问来源于stack exchange,提问作者Richard-Degenne

