Rspec 测试回调方法

Rspec testing callback method

我有一个带有以下回调的模型:

class CheckIn < ActiveRecord::Base
  after_create :send_delighted_survey

  def send_delighted_survey

    position = client&.check_ins&.reverse&.index(self)
    
    if position.present? && type_of_weighin.present?
        survey = SurveyRequirement.find_by(position: [position, "*"], type_of_weighin: [type_of_weighin, "*"])

        if survey.present?
            survey.delighted_survey.sendSurvey(client: self.client, additional_properties: {delay: 3600})
        end
    end
  end
end

我正在尝试测试线路:survey.delighted_survey.sendSurvey(client: self.client, additional_properties: {delay: 3600}) 以确保接收到正确的 delighted_survey sendSurvey

此测试通过:

let!(:week_1_sr) { create(:survey_requirement, :week_1_survey) }

it "should fire a CSAT survey after week 1" do
    expect_any_instance_of(DelightedSurvey).to receive(:sendSurvey).once
    create(:check_in, client_id: client.id, type_of_weighin: "standard")
    create(:check_in, client_id: client.id, type_of_weighin: "standard")
end

但是这个测试失败了,我不明白为什么

let!(:week_1_sr) { create(:survey_requirement, :week_1_survey) }

it "should fire a CSAT survey after week 1" do
    expect(week_1_sr.delighted_survey).to receive(:sendSurvey).once
    create(:check_in, client_id: client.id, type_of_weighin: "standard")
    create(:check_in, client_id: client.id, type_of_weighin: "standard")
end

当我添加打印语句时,它肯定会在 week_1_sr.delighted_survey 上调用 sendsurvey 所以我不明白为什么测试失败。

我应该如何重新安排这个测试?

根据我的经验,这是 expect(..).to receive 在处理 ActiveRecord 时的常见误解。

我们必须记住,我们在测试中创建的 ActiveRecord 对象在数据库中存储了一行,模型中的代码从数据库中加载了该行并填充了一个完全不同的 activerecord 对象,该对象与 activerecord 对象完全不相关在您的测试中,除了它们都引用相同的底层数据库行。

Rspec 对 activerecord 并不“聪明”,你使用的方法 stubbing/expecting 仅适用于你测试中的对象实例。

那么如何解决这个问题。最直接的选择是将代码实际使用的对象存根。然而,这并不容易,因为它是从 SurveyRequirement.find_by(...) 返回的另一个对象的方法返回的对象。你可以用类似的东西来做到这一点:

选项 1 - 存根所有内容

survey_requirement_stub = double(SurveyRequirement)
survey_stub = double(Survey)
allow(SurveyRequirement).to receive(:find_by).and_return(survey_requirement_stub)
allow(survey_requirement_stub).to receive(:delighted_survey).and_return(survey_stub)

expect(survey_stub).to receive(:sendSurvey).once

但是我不推荐这个。它将您的测试与方法的内部实现紧密联系起来。例如添加范围(scoped.find_by 而不是 find_by)会以一种无意义的方式破坏测试。

选项 2 - 测试结果,而不是实施

如果 sendSurvey 的点是 enqueuing a background job or sending an email,那可能是一个更好的地方来测试它是否在做预期的事情,例如:

expect { create_checkin }.to have_enqueued_job(MyEmailJob)
# or, if it sends right away
expect { create_checkin }.to change { ActionMailer::Base.deliveries.count }.by(1)

我认为这种方法没问题,但实施意味着您的代码将在整个测试库中排队作业和发送电子邮件。将无法创建 check-innot 触发这些。

这就是为什么我强烈建议我们的工程师永远对这样的业务逻辑使用activerecord回调。

而是...

选项 3 - 重构以改用服务对象/交互器

随着应用程序的增长,使用 activerecord 回调来创建其他记录、更新记录或触发副作用(如电子邮件)成为重要的反模式。我会以此为契机重构代码,使其更易于测试和 remove business logic from your ActiveRecord objects.

这应该使每个部分都更容易测试(例如,调查要求是否正确查找?是否发送?)。我已经 运行 没时间了,但这是总体思路:

class CheckIn
  def get_survey_requirement
    position = client&.check_ins&.reverse&.index(self)
    return unless position.present? && type_of_weighin.present?

    SurveyRequirement.find_by(position: [position, "*"], type_of_weighin: [type_of_weighin, "*"])
  end
end
class CheckInCreater
  def self.call(params)
    check_in = CheckIn.build(params)
    check_in.save!
    DelightedSurveySender.call(check_in)
  end
end
class DelightedSurveySender
  def self.call(check_in)
    survey = check_in.survey_requirement&.delighted_survey
    return unless survey

    survey.send_survey(client: check_in.client, additional_properties: {delay: 3600})
  end
end

发生这种情况是因为规范中的 week_1_sr.delighted_surveysurvey.delighted_survey 不是同一个实例。是的,两者都是相同 class 的实例,它们都表示数据库中的相同记录和相同的模型行为,但它们没有相同的 object_id.

在您的第一个测试中,您期望 any instance of DelightedSurvey 接收该方法,这确实是正确的。但是在您的第二个规范中,您希望那个确切的实例收到 sendSurvey.

有很多方法可以重新安排您的测试。事实上,如果你问 100 个开发人员如何测试某个东西,你会得到 100 个不同的答案。

有这个方法:

let(:create_week_1_sr) { create(:survey_requirement, :week_1_survey) }

it "should fire a CSAT survey after week 1" do
  week_1_sr = create_week_1_sr # I don't think DRY is the better approach for testing, but it's just my opinion
  allow(SurveyRequirement).to(receive(:find_by).and_return(week_1_sr))
  delighted_survey_spy = instance_spy(DelightedSurvey)
  allow(week_1_sr).to(receive(:delighted_survey).and_return(delighted_survey_spy))

  create(:check_in, client_id: client.id, type_of_weighin: "standard")
  create(:check_in, client_id: client.id, type_of_weighin: "standard")

  expect(delighted_survey_spy).to(have_received(:sendSurvey))
end

关于我写的测试的第一件事:安排、行动和断言。我在哪儿安排考试,在哪儿演戏,在哪儿断言,我一清二楚

但是你可以意识到这个测试被污染了并且有一些偏见的模拟。喜欢:

allow(SurveyRequirement).to(receive(:find_by).and_return(week_1_sr))

它会 return week_1_sr 即使您将错误的参数传递给 find_by (您可以使用 with 解决它,但它会为您的测试添加逻辑).

你可以看到它很难测试,我同意。那么您会考虑将此逻辑删除到服务 class 或其他什么吗?

哦,请注意:即使出于任何原因未提交记录,after_create 也会被触发。所以你可以考虑使用 after_create_commit

(刚做完收到melcher的回答通知,他的比较好)