人们在最大化测试覆盖率的同时最大程度地减少测试重复和重叠的策略是什么(尤其是在单元测试与功能或集成测试之间)?这个问题并不特定于任何特定的语言或框架,仅作为示例,假设您有一个允许用户发表评论的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对象和comment属性调用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


人们如何解决这个问题?还有我没有考虑的另一种方法吗?在尝试实现完整的代码覆盖范围时,我是否会太过分?

最佳答案

我是从.Net / C#角度讲的,但我认为它通常适用。

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

看起来在您上面的原始单元测试示例中,您正在谈论在同一测试中测试所有逻辑(即发布到Facebook,Twitter等)。我要说的是,如果以这种方式编写这些测试,那么实际上它已经是一个功能测试。现在,如果您完全不能修改被测类,那么此时编写单元测试将是不必要的重复。但是,如果您可以修改要测试的类,进行重构以使依赖关系位于接口之后,则可以为每个单独的对象提供一组单元测试,并且可以将一组较小的功能测试一起测试整个系统,这些行为似乎可以正常运行。

我知道您说过无论上面如何重构它们,但对我来说,重构和TDD并存。尝试在不进行重构的情况下进行TDD或单元测试是不必要的痛苦经历,并且会导致更难更改和维护设计。

09-10 07:42
查看更多