You need to enable JavaScript to run this app.
最新活动
大模型
产品
解决方案
定价
生态与合作
支持与服务
开发者
了解我们

学习TDD与Rails时的关联模型创建逻辑困惑求解

嘿,我完全懂你这种纠结——学TDD之前确实很少会细想这种“边界合理性”的问题,毕竟先让功能跑起来是第一要务,但TDD就是逼着我们从业务逻辑出发,把这些模糊的规则明确下来对吧?

针对你提到的“只能通过关联对象创建Attachment,禁止直接创建”的需求,我们可以从模型层验证代码入口限制TDD测试加固三个层面来解决:

1. 先在模型层明确业务规则:Attachment必须依附于Project或Mission

首先要给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
2. 限制直接创建Attachment的入口

光有验证还不够,我们可以从代码层面彻底封死直接创建Attachment的路径:

方式一:模型层私有化创建方法

把Attachment的newcreate等直接创建方法设为私有,只提供通过父对象创建的接口:

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
3. 用TDD测试巩固这些规则

既然你在学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

火山引擎 最新活动