2

人们在最大化测试覆盖率同时最小化测试重复和重叠的策略是什么,特别是在单元测试和功能或集成测试之间?该问题并不特定于任何特定的语言或框架,但仅作为示例,假设您有一个允许用户发表评论的 Rails 应用程序。您可能有一个看起来像这样的 User 模型:

class User < ActiveRecord::Base
  def post_comment(attributes)
    comment = self.comments.create(attributes)
    notify_friends('created', comment)
    share_on_facebook('created', comment)
    share_on_twitter('created', comment)
    award_badge('first_comment') unless self.comments.size > 1
  end

  def notify_friends(action, object)
    friends.each do |f|
      f.notifications.create(subject: self, action: action, object: object)
    end
  end

  def share_on_facebook(action, object)
    FacebookClient.new.share(subject: self, action: action, object: object)
  end

  def share_on_twitter(action, object)
    TwitterClient.new.share(subject: self, action: action, object: object)
  end

  def award_badge(badge_name)
    self.badges.create(name: badge_name)
  end
end

顺便说一句,我实际上会使用服务对象,而不是将这种类型的应用程序逻辑放在模型中,但我以这种方式编写示例只是为了保持简单。

无论如何,对 post_comment 方法进行单元测试非常简单。您将编写测试来断言:

  • 使用给定属性创建评论
  • 用户的朋友收到有关用户创建评论的通知
  • 在 FacebookClient 的实例上调用 share 方法,并使用预期的 params 哈希
  • TwitterClient 的同上
  • 当这是用户的第一条评论时,用户将获得“first_comment”徽章
  • 当他/她有以前的评论时,用户没有获得“first_comment”徽章

但是,您如何编写功能和/或集成测试以确保控制器实际调用此逻辑并在所有不同场景中产生所需的结果?

一种方法是在功能和集成测试中重现所有单元测试用例。这实现了良好的测试覆盖率,但使测试的编写和维护非常繁重,尤其是当您有更复杂的逻辑时。即使对于中等复杂的应用程序,这似乎也不是一种可行的方法。

另一种方法是仅测试控制器是否使用预期的参数调用用户的 post_comment 方法。然后就可以依靠 post_comment 的单元测试来覆盖所有相关的测试用例并验证结果。这似乎是实现所需覆盖率的一种更简单的方法,但现在您的测试与底层代码的具体实现相结合。假设您发现您的模型变得臃肿且难以维护,并且您将所有这些逻辑重构为如下服务对象:

class PostCommentService
  attr_accessor :user, :comment_attributes
  attr_reader :comment

  def initialize(user, comment_attributes)
    @user = user
    @comment_attributes = comment_attributes
  end

  def post
    @comment = self.user.comments.create(self.comment_attributes)
    notify_friends('created', comment)
    share_on_facebook('created', comment)
    share_on_twitter('created', comment)
    award_badge('first_comment') unless self.comments.size > 1
  end

  private

  def notify_friends(action, object)
    self.user.friends.each do |f|
      f.notifications.create(subject: self.user, action: action, object: object)
    end
  end

  def share_on_facebook(action, object)
    FacebookClient.new.share(subject: self.user, action: action, object: object)
  end

  def share_on_twitter(action, object)
    TwitterClient.new.share(subject: self.user, action: action, object: object)
  end

  def award_badge(badge_name)
    self.user.badges.create(name: badge_name)
  end
end

也许通知朋友、在 twitter 上分享等操作在逻辑上也会被重构为他们自己的服务对象。无论您如何或为何重构,如果以前期望控制器在 User 对象上调用 post_comment,则现在需要重写您的功能或集成测试。此外,这些类型的断言可能会变得非常笨拙。在这种特定重构的情况下,您现在必须断言 PostCommentService 构造函数是使用适当的 User 对象和评论属性调用的,然后断言 post 方法是在返回的对象上调用的。这变得一团糟。

此外,如果功能和集成测试描述的是实现而不是行为,那么您的测试输出作为文档的用处就会少得多。例如,以下测试(使用 Rspec)并没有太大帮助:

it "creates a PostCommentService object and executes the post method on it" do
  ...
end

我宁愿有这样的测试:

it "creates a comment with the given attributes" do
  ...
end

it "creates notifications for the user's friends" do
  ...
end

人们如何解决这个问题?还有另一种我不考虑的方法吗?我在试图实现完整的代码覆盖率方面是否过火了?

4

1 回答 1

1

我在这里从 .Net/C# 的角度谈论,但我认为它普遍适用。

对我来说,单元测试只是测试被测对象,而不是任何依赖项。对该类进行测试,以确保它使用 Mock 对象与任何依赖项正确通信,以验证是否进行了适当的调用,并以正确的方式处理返回的对象(换句话说,被测类是隔离的)。在上面的示例中,这意味着模拟 facebook/twitter 接口,并检查与接口的通信,而不是实际的 api 调用本身。

看起来在上面的原始单元测试示例中,您正在谈论在相同的测试中测试所有逻辑(即发布到 facebook、twitter 等......)。我想说如果这些测试是这样写的,它实际上已经是一个功能测试了。现在,如果您根本无法修改被测类,那么此时编写单元测试将是不必要的重复。但是,如果您可以修改被测类,重构出依赖关系,以便在接口之后,您可以为每个单独的对象进行一组单元测试,并且一起测试整个系统的一组较小的功能测试似乎表现正确。

我知道你说过不管它们是如何在上面重构的,但对我来说,重构和 TDD 是齐头并进的。尝试在没有重构的情况下进行 TDD 或单元测试是一种不必要的痛苦体验,并导致更难以更改和维护设计。

于 2013-07-19T19:01:58.240 回答