学习TDD与Rails时的关联模型创建逻辑困惑求解
嘿,我完全懂你这种纠结——学TDD之前确实很少会细想这种“边界合理性”的问题,毕竟先让功能跑起来是第一要务,但TDD就是逼着我们从业务逻辑出发,把这些模糊的规则明确下来对吧?
针对你提到的“只能通过关联对象创建Attachment,禁止直接创建”的需求,我们可以从模型层验证、代码入口限制、TDD测试加固三个层面来解决:
首先要给Attachment模型加上验证,确保它不能脱离父对象存在,同时也可以限制它不能同时关联两个父对象(如果你的业务不允许的话):
class Attachment < ApplicationRecord belongs_to :project, optional: true belongs_to :mission, optional: true # 确保至少关联一个父对象 validates :project, presence: true, unless: :mission_id? validates :mission, presence: true, unless: :project_id? # 可选:禁止同时关联Project和Mission validate :only_one_parent_association private def only_one_parent_association if project_id.present? && mission_id.present? errors.add(:base, "Attachment只能属于Project或Mission其中一个,不能同时关联两者") end end end
光有验证还不够,我们可以从代码层面彻底封死直接创建Attachment的路径:
方式一:模型层私有化创建方法
把Attachment的new、create等直接创建方法设为私有,只提供通过父对象创建的接口:
class Attachment < ApplicationRecord # ... 之前的关联和验证代码 ... # 禁止外部直接调用创建方法 private_class_method :new, :create, :create! # 提供通过Project创建的接口 def self.for_project(project, attributes) project.attachments.new(attributes) end # 提供通过Mission创建的接口 def self.for_mission(mission, attributes) mission.attachments.new(attributes) end end
这样在业务代码里,你只能通过Attachment.for_project(my_project, {file: ...})或者my_project.attachments.create(...)来创建Attachment,直接调用Attachment.new会报错。
方式二:路由层面只开放嵌套路由
在config/routes.rb里去掉单独的Attachment路由,只保留嵌套在Project和Mission下的创建路由:
# config/routes.rb resources :projects do resources :attachments, only: [:create] end resources :missions do resources :attachments, only: [:create] end # 删掉这行:resources :attachments
然后在AttachmentsController里,必须通过父对象ID找到对应的Project/Mission,再创建Attachment:
class AttachmentsController < ApplicationController def create parent = find_parent @attachment = parent.attachments.build(attachment_params) if @attachment.save redirect_to parent, notice: "附件上传成功" else render "#{parent.class.name.downcase.pluralize}/show" # 回到父对象页面显示错误 end end private def find_parent if params[:project_id].present? Project.find(params[:project_id]) elsif params[:mission_id].present? Mission.find(params[:mission_id]) else # 直接访问/attachments/create的话,抛出404 raise ActiveRecord::RecordNotFound, "找不到父对象,无法创建附件" end end def attachment_params params.require(:attachment).permit(:file, :description) end end
既然你在学TDD,那写测试用例是必不可少的,它能确保这些规则不会被后续代码修改破坏:
# test/models/attachment_test.rb require 'test_helper' class AttachmentTest < ActiveSupport::TestCase test "没有父对象的Attachment不能保存" do attachment = Attachment.new(file: fixture_file_upload('test.pdf')) assert_not attachment.save, "错误地保存了没有父对象的Attachment" end test "关联Project的Attachment可以正常保存" do project = Project.create(name: "测试项目") attachment = project.attachments.new(file: fixture_file_upload('test.pdf')) assert attachment.save end test "关联Mission的Attachment可以正常保存" do mission = Mission.create(name: "测试任务") attachment = mission.attachments.new(file: fixture_file_upload('test.pdf')) assert attachment.save end test "同时关联Project和Mission的Attachment不能保存" do project = Project.create(name: "测试项目") mission = Mission.create(name: "测试任务") attachment = Attachment.new(project: project, mission: mission, file: fixture_file_upload('test.pdf')) assert_not attachment.save, "错误地保存了同时关联两个父对象的Attachment" assert_includes attachment.errors[:base], "Attachment只能属于Project或Mission其中一个,不能同时关联两者" end test "不能直接调用Attachment.new创建对象" do assert_raises(NoMethodError) do Attachment.new(file: fixture_file_upload('test.pdf')) end end end
其实这种“限制不合理操作”的思维,正是TDD带给我们的核心价值之一——它让我们从“只关注功能实现”转向“保障业务逻辑的严谨性”,虽然一开始会觉得有点繁琐,但长期来看能避免很多隐形的逻辑漏洞。
内容的提问来源于stack exchange,提问作者Daniel Costa




