9

我可以看到两种不同的方法将模拟注入到我想要测试的 python 代码中:

  1. 依赖注入:

    允许协作类传入被测对象的构造函数,并传入模拟对象(以及必要的工厂,如 Java)

  2. 猴子补丁:

    使用模拟对象工厂将被测模块中的协作类存根(这样构建协作类实际上会创建一个模拟对象)。我不需要让它们通过构造函数注入或创建任何工厂。

python 模拟库(例如moxmock )似乎支持这两种方法。我应该在 Python 中使用哪种方法,其中一种是明智的还是有更好的方法?

4

2 回答 2

12

注意:这个问题的任何答案都将同样适用于传递其他类型的双打(模拟,假货,存根等)。

关于这个话题有很多宗教信仰,所以这个问题的标准答案是:“做对你的应用程序实用的事情,并使用你的代码嗅觉。” 虽然我也倾向于拒绝非此即彼的方法,但这个答案让我觉得对任何真正提出这个问题的人(即我自己)本质上是无用的。这是我正在使用的决策过程,以及我在开发过程中所做的一些考虑:

事先评估

  1. 你的代码能从更多的函数式编程中受益吗?如果您可以排除依赖项和副作用,则无需模拟任何内容。
  2. 可以对单元进行有意义的单元测试吗?也许它需要重构,或者它只是无法进行有效单元测试的“胶水代码”(但必须由系统测试覆盖)。
  3. 真的需要模拟依赖吗?如果运行你的真实代码仍然允许你有意义地测试行为并且既不慢也不破坏性,让它运行。

模拟策略

  • 如果特定的依赖项对单元至关重要,请在产品中对其进行硬编码,并在测试中使用猴子补丁。
    • 例外:如果在测试中使用生产依赖项可能对您的系统有害,请将其作为位置参数注入。
    • 例外:如果猴子补丁存在应用在另一个单元的依赖项中的风险并且这是不可取的,则将其作为位置参数注入。
  • 如果同一类型的依赖项对单元至关重要,则将其作为必需的位置参数注入。
  • 如果依赖项对单元不是必需的,则将其作为可选的关键字参数注入。

术语

依赖注入:在 Python 的上下文中,这个术语通常特指构造函数注入

Monkey Patching:在运行时将名称(在被测代码中)绑定到与模块中绑定的不同对象。在实践中,这通常意味着使用mock.patch.

例子

假设我们有一个在测试期间具有副作用的函数,无论是破坏性的(向我们的生产数据库写入废话)还是烦人的(即,速度慢)。这是后一种情况及其测试的示例:

def foo():
    ...
    time.sleep(5)
    ...
    return "bar"

...

def foo_test():
    assertEqual(foo(), "bar")

我们的测试有效,但至少需要五秒钟。我们可以通过替换为什么都不time.sleep的模拟对象来避免等待。这样做的两种策略是这个问题的主题:

依赖注入

def foo(wait=time.sleep):
    ...
    wait(5)
    ...
    return "bar"

...

def foo_test():
    assertEqual(foo(wait=mock.Mock()), "bar")

猴子补丁

def foo():
    ...
    time.sleep(5)
    ...
    return "bar"

...

@mock.patch('time.sleep')
def foo_test():
    assertEqual(foo(), "bar")

为什么要依赖注入?

我发现猴子补丁的优缺点更直接,因此我将分析重点放在依赖注入上。

混乱的界面只是为了可测试性?

依赖注入非常明确,但需要修改产品代码。猴子补丁不是明确的,但不需要修改产品代码。

程序员的本能反应是在修改产品代码进行测试之前做出许多牺牲。在考虑了注入所有依赖项后您的应用程序会是什么样子,对猴子补丁的偏好似乎是不费吹灰之力。正如迈克尔·福特所说

[E] 甚至内部 API 仍然需要开发人员阅读/使用,我也不喜欢搞砸它们。

...

我的观点是, Python从来不需要仅仅为了可测试性而进行的依赖注入,也很少比其他技术更可取。很多时候,依赖注入本身作为一种结构/架构很有用。

虽然这个话题在编写单元测试时自然会出现,但对那些提倡依赖注入的人的慈善解释得出的结论是,可测试性不是他们的主要动机。Ned Batchelder 发现 (25:30)猴子补丁“使代码有点难以理解,我宁愿在某个地方看到:我们现在正在测试,所以这就是你获得时间的方式。” 他详细阐述(3:14):

当我坐下来查看我的代码并思考“我怎样才能更好地测试它?” 我更改了产品代码以使其更具可测试性,它实际上是更好的产品代码。我认为这是因为,如果你必须写一些东西来做一件事,它可以把那件事做好,但是如果你写的东西能做好两件事,那就更好了。做好测试,除了成为一个好的产品外,还能让代码变得更好。通过有两种用途,您必须真正考虑该 API,并且您必须真正考虑那段代码在做什么。通过让它做好这两件事,你就有了一个更好的、模块化的、更抽象的设计,从长远来看,这对你的产品会更好。

界面污染问题

不,不仅仅是视觉污染。假设我们随着时间的推移意识到,在极端情况下,我们需要一个更复杂的算法来确定foo上面函数中的等待时间:

-- bar = foo()
++ bar = foo(wait=mywait)

但是一段时间后,对于我们的主要用途来说,等待变得不必要了foo。我们一直使用依赖注入模式,因此我们假设我们可以删除命名参数而不会产生任何后果。

-- def foo(wait=time.sleep):
++ def foo():

我们现在需要追踪我们的极端情况以避免 TypeError。看起来即使这些参数只是为了测试目的而添加的,它们将在生产中使用,并且这种方法通过在接口中放置实现细节来限制您重构的能力。

但是Aaron Maxwell 有一个解决方案

在实际代码中,我倾向于用下划线前缀标记“仅测试挂钩”参数 - 所以签名将是: __init__(self, rel_url, _urlopen=urlopen) 然后在该方法的文档字符串中,我明确表示它是一个测试挂钩,可能会在没有警告的情况下消失。(是的,在这种情况下,我一定会写一个文档字符串 :) 下划线只是我以某种方式将参数突出显示为特殊的方式。

当然,那是我希望它仅用于测试的情况。如果这是我们决定要在该上下文之外提供的东西,并承诺保留,我不会贴出这样的“禁止”标志:)

虽然这种方法确实解决了污染问题,但对我来说,所有这些杂乱无章的东西——首先是添加到界面中,其次是确保您实际上不使用界面——都有异味。

但是,Augie Fackler 和 Nathaniel Manista 的立场要求位置参数比可选的关键字参数更安全,这将使污染问题变得毫无意义。他们详细说明

如果它正在控制一个关键的行为,比如它将写入其持久数据的位置,我们发现将其作为必需参数并始终指定它会更安全。我们发现,在对象关系的情况下,第一个对象没有意义,除非它也有第二个对象——因此,用户配置文件没有意义,除非它具有用户凭据——我们发现显式构造参数对我们来说是最强大的解决方案...[可选参数]适用于以微妙方式改变对象行为的事物。

如果不忙于评估他们更广泛的测试策略,我们应该很容易同意的一点是,关键组件不应该作为可选参数传递

但是,我不明白为什么当关系具有 特定依赖项时不应该对“关键”依赖项进行硬编码。抽象的实质是它与其他抽象的关系。因此,如果一个抽象的基本属性是与应用程序中另一个抽象的关系,那么它是硬编码的主要候选者——无论两个抽象中的实现细节有多少变化,它们都是永久耦合的。

部分区别在于对系统构成风险的依赖关系和不构成风险的依赖关系。如果依赖项负责写入数据库、推送给客户端或投下炸弹,那么除非我们正在编写无人机软件,否则我们不能犯错误

值得注意的是,注入位置参数会使“观望”策略代价高昂。如果我们决定有一天需要在构造函数中选择我们的硬编码依赖项之一,那么将其添加为位置参数将破坏向后兼容性。如果我们后来决定删除一个必需的参数,也会遇到类似的问题,因此非必要的依赖项必须是可选参数,这样我们就可以自由地更改接口。

依赖关系不好,可以吗?

构造函数注入是依赖注入的几种方法之一。根据维基百科:“依赖注入是一种实现控制反转并允许程序设计遵循依赖反转原则的软件设计模式。”

控制反转服务于以下设计目的:

  • 将任务的执行与实现分离。
  • 使模块专注于其设计的任务。
  • 将模块从关于其他系统如何做他们所做的事情的假设中解放出来,而是依赖于合同。
  • 防止更换模块时产生副作用。

.

依赖倒置原则的目标是将应用程序胶水代码与应用程序逻辑解耦......

该原则规定:

A. 高级模块不应该依赖于低级模块。两者都应该依赖于抽象。B. 抽象不应该依赖于细节。细节应该取决于抽象。

该原则颠倒了一些人对面向对象设计的看法,规定高级和低级对象都必须依赖于相同的抽象。

考虑到它的 value 存在争议的状态,这与我想用这个术语得到的一样具体。但正是这些类型的担忧激励了 Martelli(尤其是他 2007 年的演讲证明了这一点)。

依赖注入的优点,可以提炼为可重用性。无论是通过全局配置、动态算法还是不断发展的应用程序开发,将函数/方法/类的抽象与其依赖项的实现细节解耦允许这些组件中的每一个(尽管在这种情况下,特别是抽象)都有可能在编写时未计划的组合中耦合,无需修改。测试就是一个很好的例子,因为可测试性就是可重用性

还记得对更改产品代码以满足测试代码需求的直觉反应吗?好吧,您应该对更改抽象以满足生产实现的特定需求也有相同的反应!

所有这些理论的实际收获是将无法进行单元测试的“胶水代码”与您想要进行单元测试的逻辑分开。虽然他们对这一原则的具体实现特别感兴趣,但我认为 Fackler 和 Manista 提供了一个很好的例子。一个人可能有的地方:

class OldClass(object):
    ...
    def EnLolCat(self, misspelt_phrases):
        lolcats = []
        for album in self.albums:
            for photo in album.Photos():
                exif = photo.EXIF()
                for text in misspelt_phrases:
                    geoText = text.Geo(exif)
                    if photo.canCat(geoText)
                        lolcat = photo.Cat(geoText)
                        self.lolcats = lolcats

他们会建议:

def Lol(albums, misspelt_phrases):
    lolcats = []
    for album in albums:
        for photo in album.Photos():
            exif = photo.EXIF()
            for text in misspelt_phrases:
                geoText = text.Geo(exif)
                if photo.canCat(geoText)
                    lolcat = photo.Cat(geoText)
                    return lolcats

class NewClass(object):
    ...
    def EnLolCat(self, misspelt_phrases):
        self.lolcats = Lol(
             self.albums, misspelt_phrases)

我们可能会发现自己为了测试而模拟对象实例EnLolCat,现在我们发现自己在我们的类中使用胶水代码,以及一个我们可以轻松测试的自由函数,因为它没有副作用并且是完全确定的。换句话说,我们正在做更多的函数式编程

NewClass但是在测试方法时,我们的情况不一样吗?不一定

我相信对软件的行为部分进行强大的测试,比如功能、计算、状态变化等事情,我不相信程序集的单元测试:当你启动应用程序时,当你将一个对象连接到另一个对象,或者当您构建某些东西时——您可能会认为它是胶水代码。它很简单,并且您的集成测试将涵盖它。这个当代类示例NewClass(这种方法,所以测试这个实例方法也没有太多的增量好处。

依赖项对被测代码不利,因此如果每个依赖项都使您的代码变得更丑陋一点,这可能是一件好事。丑陋的成本低于紧耦合的成本,但开发人员更有可能认真对待。

最后的笔记

于 2014-04-10T15:30:29.897 回答
3

尽可能多地使用 DI 通常很有用,但有时它只是不可行,因为您:

  • 使用内置函数或对象(如文件)
  • 第三方功能
  • 使用不确定的对象/调用等。

那是你不得不求助于猴子修补的时候。

您应该能够在几乎所有情况下避免它,理论上您可以 100% 避免它,但有时做出猴子补丁例外更为合理。

于 2012-07-12T11:05:23.487 回答