Rails使用RSpec mock单例类方法完成控制器测试方案
RSpec Mock单例模块类方法的正确实现
问题根因
报错核心原因是mock目标选择错误:
find_by_id是定义在class << self块内的模块单例方法(类方法),调用形式为EstablishmentsQueryService.find_by_id(Ruby生态里用.前缀标记类/单例方法)。allow_any_instance_of仅用于给模块/类的实例方法打桩,对应方法用#前缀标记,和你要mock的单例方法完全是两个独立方法,所以RSpec会抛出EstablishmentsQueryService does not implement #find_by_id错误,mock配置完全不生效,代码会直接执行真实数据库查询逻辑。- 你在PUT用例里直接
allow(EstablishmentsQueryService)的方向是正确的,但将allow对象拆分存到let中再链式调用容易出现上下文失效问题,加上代码中存在的笔误、请求缺参问题,最终导致测试一直无法通过。
可直接复用的正确Mock写法
1. GET场景修复(存在对应记录)
直接对模块本身的单例方法打桩即可,不需要使用any_instance相关方法,同时修正原有代码的笔误:
context 'when has establishment' do let(:establishment_id) { SecureRandom.uuid } let(:establishment) { build(:establishment, id: establishment_id, created_at: DateTime.now, updated_at: DateTime.now) } before do # 核心:直接mock模块的单例方法,可加with参数校验入参 allow(EstablishmentsQueryService).to receive(:find_by_id) .with(establishment_id) .and_return(establishment) get "/api/v1/establishments/#{establishment_id}" end it 'then http status is ok' do expect_status_is_ok end it 'has body equal to expected' do expect_field_by_field(establishment, body_to_open_struct, %i[id name cnpj description]) end end
2. GET场景修复(记录不存在)
不需要真的查库触发异常,直接让mock方法抛出业务定义的异常即可:
context 'when has no establishment' do let(:non_exist_id) { SecureRandom.uuid } before do allow(EstablishmentsQueryService).to receive(:find_by_id) .with(non_exist_id) .and_raise(EstablishmentNotFoundError.new(non_exist_id)) get "/api/v1/establishments/#{non_exist_id}" end it 'then http status is not_found' do expect_status_is_not_found end end
说明:参数非法(非UUID格式)的场景不需要mock query service,因为
validate_id的before_action会在load_establishment之前执行,直接返回400,原有代码逻辑可正常运行。
3. PUT场景修复
不要把allow对象拆分存到let中,直接在before块内完成打桩,同时补全缺失的请求参数、修正笔误:
context 'when updated with success' do let(:establishment) { build(:establishment) } let(:target_id) { SecureRandom.uuid } let(:mock_loaded_establishment) { build(:establishment, id: target_id, created_at: DateTime.now, updated_at: DateTime.now) } before do # Mock查询服务 allow(EstablishmentsQueryService).to receive(:find_by_id) .with(target_id) .and_return(mock_loaded_establishment) # Mock保存服务 allow(EstablishmentService).to receive(:save) do |record| record.updated_at = DateTime.now record end # 补全PUT请求必填的establishment参数 put "/api/v1/establishments/#{target_id}", params: { establishment: { name: establishment.name, cnpj: establishment.cnpj, description: establishment.description } } end it 'then http status is ok' do expect_status_is_ok end it 'has body equal to expected' do actual = body_to_open_struct expect_field_by_field(establishment, actual, %i[name cnpj description]) expect(actual.id).to eq(target_id) end end
通用注意事项
- 所有定义在
class << self块内、或者用def self.xxx形式定义的方法,都属于类/模块的单例方法,mock时直接使用allow(类名/模块名).to receive(方法名)即可,禁止用allow_any_instance_ofmock这类方法。 - 原有测试代码存在几处会导致失败的笔误:
id: p中的p是未定义变量、except_field_by_field应为expect_field_by_field、expected(actual.id)应为expect(actual.id)、PUT/POST请求未传必填的establishment参数,需要一并修正。 - 如果需要校验控制器确实调用了对应服务,可以在测试中增加断言:
expect(EstablishmentsQueryService).to have_received(:find_by_id).with(target_id),避免逻辑走偏。
内容的提问来源于stack exchange,提问作者Jose Luiz Junior
相关产品推荐
相关产品推荐

