人们采取什么策略来最大化测试覆盖率,同时最小化测试重复和重叠,尤其是在单元测试和功能测试或集成测试之间?该问题并不特定于任何特定语言或框架,只是举个例子,假设您有一个允许用户发表评论的 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 方法进行单元测试非常简单。您将编写测试来断言:
- 使用给定的属性创建评论
- 用户的 friend 收到有关用户创建评论的通知
- share 方法在 FacebookClient 的实例上调用,具有预期的参数哈希值
- TwitterClient 同上
- 当这是用户的第一条评论时,用户会获得“first_comment”徽章
- 当用户有之前的评论时,他/她不会获得“first_comment”徽章
但是您如何编写功能和/或集成测试以确保 Controller 实际调用此逻辑并在所有不同场景中产生所需的结果?
一种方法是只重现功能和集成测试中的所有单元测试用例。这实现了良好的测试覆盖率,但使测试的编写和维护变得极其繁重,尤其是当您有更复杂的逻辑时。即使对于中等复杂的应用程序,这似乎也不是一种可行的方法。
另一种方法是只测试 Controller 是否使用预期的参数对用户调用 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
也许像通知 friend 、在推特上分享等 Action 在逻辑上也可以重构到它们自己的服务对象中。无论您如何或为何重构,如果您的功能或集成测试之前期望 Controller 调用 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
人们如何解决这个问题?还有其他我没有考虑的方法吗?我是否在尝试实现完整的代码覆盖率方面做得过火了?
最佳答案
我在这里是从 .Net/C# 的角度谈的,但我认为它普遍适用。
对我来说,单元测试只是测试被测对象,而不是任何依赖项。该类经过测试以确保它使用 Mock 对象与任何依赖项正确通信以验证是否进行了适当的调用,并且它以正确的方式处理返回的对象(换句话说,被测类是隔离的)。在上面的示例中,这意味着模拟 facebook/twitter 接口(interface),并检查与接口(interface)的通信,而不是实际的 api 调用本身。
在上面的原始单元测试示例中,您似乎在谈论在同一测试中测试所有逻辑(即发布到 facebook、twitter 等...)。我会说如果这些测试是这样写的,它实际上已经是一个功能测试了。现在,如果您绝对不能修改被测类,此时编写单元测试将是不必要的重复。但是,如果您可以修改被测类,重构出依赖关系在接口(interface)后面,您可以为每个单独的对象进行一组单元测试,并且一组较小的功能测试一起测试整个系统似乎表现正确。
我知道你在上面说过不管它们是如何重构的,但对我来说,重构和 TDD 是齐头并进的。尝试在不重构的情况下进行 TDD 或单元测试是一种不必要的痛苦经历,并且会导致更难更改和维护设计。
关于unit-testing - 最大化测试覆盖率并最小化重叠/重复,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/17753209/