一种用桩替换实际组件的方法

1 投票
2 回答
962 浏览
提问于 2025-04-17 13:24

我在整理代码时遇到了一些问题,想让它更容易测试。我有两个主要模块:缓存生成器和修改器构建器,它们的复杂程度差不多。修改器构建器是在缓存生成器的子对象中的一个方法里使用的。

我已经有了一个完整的测试套件,覆盖了修改器构建器的功能。我想添加一些测试,覆盖缓存生成器的所有功能,但为了大大降低这些测试的复杂性,我需要用一个“替身”来替换修改器构建器,这个替身会根据我传给它的参数返回预设的数据。

我真正的问题在于如何用替身替换掉真实的修改器构建器,既要代码看起来不错,又要方便测试。看看下面的代码:

来自 GitHub

cacheGenerator / generator.py:

class CacheGenerator:
    def __init__(self, logger):
        ...
        self._converter = Converter(logger)

    def run(self, dataHandler):
        ...
        data = self._converter.convert(data)

cacheGenerator / converter.py:

class Converter:
    ...

    def convert(self, data):
        ...
        self._buildModifiers(data)

    def _buildModifiers(self, data):
        ...
        builder = ModifierBuilder(data['expressions'], self._logger)
        ...
           modifiers, buildStatus = builder.buildEffect(...)

替换修改器构建器为替身的方法有哪些呢?我想至少有以下几种选择:

  1. 修改代码:在转换器的 init() 方法中实例化修改器构建器,并将其实例作为对象属性。为了测试,创建一个真实转换器的子类,重写 init(),在这里用替身替换真实的修改器构建器,然后再对子类化缓存生成器,以类似的方式替换真实的转换器。不过,这种方法需要修改修改器构建器:我需要将数据加载从 init() 方法中分离出来,这样做不太理想。
  2. 和第1种方法类似,但将处理修改器构建器的 Converter()._buildModifiers() 方法中的部分代码移动到单独的方法中,以便更容易重写。
  3. 和第1种方法类似,但在清理器的 init() 中只指定修改器构建器的类(而不是实例)。这样可以保持修改器构建器不变。
  4. 从缓存生成器的最上层传递修改器构建器类(这样我们需要替换的类可以通过缓存生成器的实例化来控制)。
  5. 还有其他选择吗?比如一些导入的魔法?

在1到4种选择中,有些看起来可以接受,但理想情况下我希望代码尽量保持接近原样,所以我在寻找替身化子对象的其他方法。

2 个回答

1

当我在测试中需要模拟或伪造对象时,我会使用Fudge这个工具。

在你的情况下,我建议使用patched_context。这个功能可以让你修改对Converter方法的调用。

你可以这样做:

修改对_converter.convert的调用

test.py:

from cacheGenerator.generator import CacheGenerator
from cacheGenerator.converter import Converter
from fudge import patched_context

import unittest

class Test_cacheGenerator(unittest.testCase):

    def test_run(self):

        def fakeData(convertself, data):
            # Create data to be returned to 
            # data = self._converter.convert(data)
            fakedata = ...
            return fakedata


        # We tell Fudge to patch the call to `Converter.convert`
        # and instead call our defined function 
        cache = cacheGenerator(...)
        with patched_context(Converter, 'convert', fakeData)
            cache.run()

或者你也可以修改在Converter内部对self._buildModifiers的调用:

def test_run(self):
        cache = cacheGenerator(...)

        def fakeBuildModifiers(convertself, data):
            # set up variables that convert._buildModifiers usually sets up
            convertself.modifiers = ...
            convertself.buildStatus = ...

        # We tell Fudge to patch the call to `Coverter._buildModifiers`
        # and instead call our defined function 
        cache = cacheGenerator(...)
        with patched_context(Converter, '_buildModifiers', fakeBuildModifiers):
            cache.run()

另外,你还可以使用Fudge伪造对象

from fuge import Fake

...
    def test_run(self):
        cache = cacheGenerator(...)

        fakeData = ...
        fakeConverter = Fake('Converter').provides('convert').returns(fakeData)

        # Fake our `Converter` so that our any calls to `_converter.convert` are
        # made to `fakeConverter.convert` instead.
        cache._converter = fakeConverter

        cache.run()

在这种情况下,因为你是修改整个_converter对象,如果你调用了其他方法,也需要对它们进行修改。

(Fake('Converter'.provides('convert').returns(fakeData)
                 .provides(....).returns()
                 .provides(....).returns()
)
1

我通常更喜欢第二种方法,因为这样可以让意图更清晰,这只是一个小改动,而且可能对其他使用我代码的地方有帮助。

另外,你可以看看依赖注入或者一个工厂模式,它可以为你构建ModifierBuilder

最后,你可以通过导入模块来使用猴子补丁,然后给符号赋一个新值:

import cacheGenerator.converter
cacheGenerator.converter.ModifierBuilder = ...

当然,这样会改变所有人的符号(也就是说,其他所有测试也会受到影响),所以你需要保存旧值,并在测试后恢复它。

如果你对这个解决方案感到不安,那你是对的:这是一种绝望的措施。如果你真的无法更改原始代码,可以把它当作最后的手段来使用。

撰写回答