如何修复 RSpec 中 NameError: uninitialized constant Shipment
结论
问题不在 has_many :shipments 关联,而在 shipment_decorator.rb 加载时,Ruby 还没有定义 Spree::Shipment。
装饰器依赖原模型,因此要满足以下条件:
- 使用完整常量名
Spree::Shipment。 - 在执行
class_eval前加载原模型。 - 确保 RSpec 已启动 Rails 环境并加载装饰器。
- 不要把普通 RSpec double 直接放进 Active Record 的
has_many关联。测试回调时,应使用真实记录或单独 stubshipments。
为什么会出现 uninitialized constant Shipment
模型的定义如下:
module Spree
class Shipment
end
end
完整名称是:
Spree::Shipment
装饰器却写成了:
module Spree
Shipment.class_eval do
# ...
end
end
在 module Spree 的词法作用域中,Shipment 通常会解析为 Spree::Shipment,前提是加载装饰器时,这个常量已经存在。
如果 Rails、引擎或测试环境先加载 shipment_decorator.rb,之后才加载 Spree 模型,那么执行 Shipment.class_eval 时就找不到目标常量,并抛出 NameError。
改成 Spree::Shipment.class_eval 可以消除命名空间歧义,但解决不了加载顺序。如果模型仍未加载,错误依然会发生,只是错误信息通常会更明确地指向 Spree::Shipment。
异常最终出现在:
let!(:order) { create(:order, shipments: [shipment]) }
这不表示错误常量是在这一行定义的。let! 会立即创建订单,同时可能触发关联、模型自动加载、初始化器或装饰器加载。真正的异常是在这个过程中由 shipment_decorator.rb 抛出的。
修正装饰器
先加载原模型,再使用完整常量名:
# app/models/spree/shipment_decorator.rb
require_dependency 'spree/shipment'
Spree::Shipment.class_eval do
def after_ship
inventory_units.each(&:ship!)
adjustment.finalize!
touch :shipped_at
end
end
如果当前 Spree 版本的模型文件不在 spree/shipment.rb,需要根据实际路径调整 require_dependency,不要用错误路径加载另一个同名文件。
Rails 应用代码通常更适合使用 require_dependency,而不是 require,因为前者会参与 Rails 的依赖加载和重载机制。
在初始化器中控制加载顺序
如果 Rails 应用需要在开发环境中支持代码重载,可以通过 reloader 加载装饰器:
# config/initializers/spree_decorators.rb
Rails.application.config.to_prepare do
require_dependency 'spree/shipment'
require_dependency 'spree/shipment_decorator'
end
这里要防止装饰逻辑被重复执行。普通方法重复定义通常不会造成大问题,但如果装饰器注册了回调、验证器或状态机事件,重复加载可能导致重复注册。
具体写法还取决于 Rails 和 Spree 的版本。旧版 Rails 常用 config.to_prepare,较新的 Rails 可以写成:
Rails.application.reloader.to_prepare do
require_dependency 'spree/shipment'
require_dependency 'spree/shipment_decorator'
end
如果项目已经通过引擎配置统一加载 decorators,就不应再加载一次。先检查项目中是否已有类似配置。
确保测试加载 Rails 环境
Rails 模型测试必须启动完整的 Rails 测试环境,不能只加载纯 RSpec。
较新的 RSpec Rails 项目通常使用:
require 'rails_helper'
例如:
# spec/models/spree/order_decorator_spec.rb
require 'rails_helper'
RSpec.describe Spree::Order, type: :model do
# ...
end
旧项目可能仍由 spec_helper.rb 加载 Rails 环境。此时应检查其中是否包含类似代码:
ENV['RAILS_ENV'] ||= 'test'
require File.expand_path('../../config/environment', __FILE__)
require 'rspec/rails'
不要同时维护两套互相冲突的加载方式。测试运行时需要完成下面这条加载链:
Rails 测试环境
→ Spree 引擎
→ Spree::Shipment 模型
→ shipment_decorator.rb
→ 测试用例
原测试还有两个独立问题
1. 不要把普通 double 作为 Active Record 关联记录
下面的代码有问题:
let!(:shipment) { double(:shipment, cancel!: '') }
let!(:order) { create(:order, shipments: [shipment]) }
shipments 是 Active Record 的 has_many 关联,其中的元素通常必须是 Spree::Shipment 实例。保存关联时,Rails 可能会调用 new_record?、persisted?、save!,还可能写入外键。普通 double 没有这些行为。
即使常量错误已经修复,这段测试仍可能触发 AssociationTypeMismatch 或 unexpected message。
集成级模型测试应使用真实 factory:
let!(:shipment) { create(:shipment) }
let!(:order) { create(:order, shipments: [shipment]) }
实际使用的 factory 名称和必填关联取决于项目的 FactoryBot 配置,不能只凭这段示例确定。
2. 不要 stub 正在测试的方法
原测试中有:
before do
order.stub(:after_cancel)
end
随后又调用:
order.after_cancel
这样会用 stub 替换真正的 after_cancel,方法体根本不会执行,自然也不会调用 shipment.cancel!。
如果要验证 Spree::Order#after_cancel 会取消 shipment,就应保留真实方法。
使用真实关联记录时,可以这样测试:
require 'rails_helper'
RSpec.describe Spree::Order, type: :model do
describe '#after_cancel' do
let!(:shipment) { create(:shipment) }
let!(:order) { create(:order, shipments: [shipment]) }
it 'cancels its shipments' do
expect(shipment).to receive(:cancel!).once
order.after_cancel
end
end
end
这段示例假定 after_cancel 的真实实现确实会对关联中的 shipment 调用 cancel!,而且 factory 创建的数据满足模型验证条件。
如果只需要隔离的单元测试,可以创建真实的订单对象,再 stub 它的关联返回值:
require 'rails_helper'
RSpec.describe Spree::Order, type: :model do
describe '#after_cancel' do
subject(:order) { described_class.new }
let(:shipment) { instance_double(Spree::Shipment) }
before do
allow(order).to receive(:shipments).and_return([shipment])
allow(shipment).to receive(:cancel!)
end
it 'cancels its shipments' do
expect(shipment).to receive(:cancel!).once
order.after_cancel
end
end
end
instance_double(Spree::Shipment) 同样要求 Spree::Shipment 已经成功加载,同时还能检查 double 上的方法是否真实存在。
推荐的最终调整
装饰器至少应改成:
require_dependency 'spree/shipment'
Spree::Shipment.class_eval do
def after_ship
inventory_units.each(&:ship!)
adjustment.finalize!
touch :shipped_at
end
end
测试中还需要:
- 加载完整的 Rails 测试环境;
- 确认 decorators 已在测试环境中加载;
- 删除
order.stub(:after_cancel); - 使用真实的 shipment factory,或 stub
order.shipments; - 不要把普通 double 直接写入 Active Record 关联。
修复的核心是先加载 Spree::Shipment,再对它进行装饰。let!(:order) 只是触发了有问题的加载链,并不是 Shipment 常量缺失的根本原因。
备注:内容仅供参考。
