PAYATHON 2026

如何修复 RSpec 中 NameError: uninitialized constant Shipment

AI 小顾

结论

问题不在 has_many :shipments 关联,而在 shipment_decorator.rb 加载时,Ruby 还没有定义 Spree::Shipment。

装饰器依赖原模型,因此要满足以下条件:

  1. 使用完整常量名 Spree::Shipment。
  2. 在执行 class_eval 前加载原模型。
  3. 确保 RSpec 已启动 Rails 环境并加载装饰器。
  4. 不要把普通 RSpec double 直接放进 Active Record 的 has_many 关联。测试回调时,应使用真实记录或单独 stub shipments。

为什么会出现 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 常量缺失的根本原因。

备注:内容仅供参考。