最大化测试覆盖率并最小化重叠/重复

时间:2013-07-19 18:36:28

标签: unit-testing testing tdd integration-testing functional-testing

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

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,则现在需要重写您的功能或集成测试。此外,这些类型的断言可能变得相当笨拙。在这种特殊的重构的情况下,您现在必须声明使用适当的User对象和注释属性调用PostCommentService构造函数,然后断言在返回的对象上调用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

人们如何解决这个问题?还有其他方法我不考虑吗?我是否会过度尝试实现完整的代码覆盖?

1 个答案:

答案 0 :(得分:1)

和我一起,我在这里谈论.Net / C#的观点,但我认为它普遍适用......

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

在您上面的原始单元测试示例中,您正在讨论在相同测试中测试所有逻辑(即发布到Facebook,Twitter等...)。我想说如果这些测试是用这种方式编写的,它实际上已经是一个功能测试了。现在,如果你完全无法修改被测试的类,那么在这一点上编写单元测试将是不必要的重复。但是,如果你可以修改被测试的类,重构所以依赖关系在接口后面,你可以为每个单独的对象进行一组单元测试,并且一组较小的功能测试一起测试整个系统似乎表现正常。 / p>

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