如何用RSpec测试Rails中的乐观锁实现?
如何用RSpec测试乐观锁的Update动作
首先咱们先梳理下你现有测试代码的问题:
- 你的两次
patch请求都没携带lock_version参数,Rails的乐观锁需要客户端提交当前的lock_version值才会触发版本校验;如果不传这个参数,Rails会直接忽略乐观锁检查,自然不会抛出StaleObjectError。 - 另外你的控制器已经
rescue了StaleObjectError并执行retry,这个异常不会向外抛出,所以测试里用expect(response).to raise_error(...)是捕获不到的——因为控制器已经把异常内部处理掉了。
接下来我给你写两种场景的正确测试用例,覆盖乐观锁的核心逻辑:
场景1:验证过期版本请求会触发重试并最终更新成功
这个测试模拟并发修改场景:先获取初始版本号,用旧版本发起更新触发乐观锁异常,再验证控制器会自动reload模型并重试,最终完成更新。
require 'rails_helper' RSpec.describe OperatorsController, type: :controller do let(:site) { FactoryBot.create(:site) } let!(:operator1) { FactoryBot.create(:operator, site: site) } let(:valid_attributes) { { first_name: "test1", last_name: "test2", employee_number: "test12345", badge: "test215235", suspended: true, site_id: site.id } } it "retries and succeeds when optimistic lock error is raised" do # 记录初始的lock_version initial_lock_version = operator1.lock_version # 第一次请求:用初始版本更新,让lock_version递增 patch :update, params: { id: operator1.id, operator: valid_attributes.merge(lock_version: initial_lock_version) } expect(response).to have_http_status(:ok) operator1.reload expect(operator1.lock_version).to eq(initial_lock_version + 1) # 准备用旧版本号发起第二次请求 stale_attributes = valid_attributes.merge( first_name: "test_fake", lock_version: initial_lock_version # 携带过期的版本号 ) # 验证StaleObjectError确实被触发,且控制器会重试更新 expect_any_instance_of(Operator).to receive(:update_attributes).and_raise(ActiveRecord::StaleObjectError).once expect_any_instance_of(Operator).to receive(:update_attributes).with(stale_attributes.except(:lock_version)).and_return(true).once patch :update, params: { id: operator1.id, operator: stale_attributes } # 验证最终记录被成功更新 operator1.reload expect(operator1.first_name).to eq("test_fake") expect(response).to have_http_status(:ok) end end
场景2:直接测试模型层的乐观锁异常触发逻辑
如果你想单独验证乐观锁的核心机制,绕开控制器的rescue逻辑,直接测试模型层会更直观:
RSpec.describe Operator, type: :model do let(:site) { FactoryBot.create(:site) } let!(:operator) { FactoryBot.create(:operator, site: site) } it "raises StaleObjectError when updating with stale lock_version" do # 创建两个指向同一条记录的实例 operator1 = Operator.find(operator.id) operator2 = Operator.find(operator.id) # 先更新第一个实例,让lock_version递增 operator1.update!(first_name: "updated") expect(operator1.lock_version).to eq(operator.lock_version + 1) # 用第二个实例(仍持有旧lock_version)更新,会触发乐观锁异常 expect { operator2.update!(first_name: "stale_update") }.to raise_error(ActiveRecord::StaleObjectError) end end
额外小提示
你的控制器代码里的retry没有次数限制,可能导致无限重试的风险,建议加上重试次数控制:
retry_count = 0 begin @update_operator = Operator.find(params[:id]) authorize! :update, @update_operator if @update_operator.update_attributes(operator_params) render json: @update_operator, except: :badge else render json: @update_operator.errors, status: :unprocessable_entity end rescue ActiveRecord::StaleObjectError retry_count += 1 if retry_count < 3 @update_operator.reload retry else render json: { error: "Conflict: record was updated by another process" }, status: :conflict end end
内容的提问来源于stack exchange,提问作者Darren
相关产品推荐
相关产品推荐

