如何在基于 Click 的 CLI 应用程序的单元测试中模拟组件?

How to mock components within unit tests for Click-based CLI applications?

我不确定这是否最适合这里或 Programmers Stack Exchange,但我会先在这里尝试,如果不合适,请在那边交叉post。

我最近开发了一个 Web 服务,我正在尝试创建一个基于 Python 的命令行界面,以使其更易于交互。我已经使用 Python 一段时间用于简单的脚本编写目的,但我在创建完整的包(包括 CLI 应用程序)方面缺乏经验。

我研究了不同的包来帮助创建 CLI 应用程序,我决定使用 click。我关心的是如何构建我的应用程序以使其彻底 可测试 在我真正开始将它们放在一起之前,以及我如何使用 click 来帮助它。

我已经阅读了 click's documentation on testing as well as examined the relevant part of the API,虽然我设法使用它来测试简单的功能(验证 --version--help 在作为参数传递给我的 CLI 时工作),我我不确定如何处理更高级的测试用例。

我将提供一个具体示例来说明我现在正在尝试测试的内容。我计划我的应用程序具有以下类型的架构...

...其中 CommunicationService 封装了通过 HTTP 连接和直接与 Web 服务通信所涉及的所有逻辑。我的 CLI 为 Web 服务主机名和端口提供默认值,但应该允许用户通过显式命令行参数、编写配置文件或设置环境变量来覆盖它们:

@click.command(cls=TestCubeCLI, help=__doc__)
@click.option('--hostname', '-h',
              type=click.STRING,
              help='TestCube Web Service hostname (default: {})'.format(DEFAULT_SETTINGS['hostname']))
@click.option('--port', '-p',
              type=click.IntRange(0, 65535),
              help='TestCube Web Service port (default: {})'.format(DEFAULT_SETTINGS['port']))
@click.version_option(version=version.__version__)
def cli(hostname, port):
    click.echo('Connecting to TestCube Web Service @ {}:{}'.format(hostname, port))
    pass


def main():
    cli(default_map=DEFAULT_SETTINGS)

我想测试一下,如果用户指定了不同的主机名和端口,那么 Controller 将使用这些设置而不是默认值实例化一个 CommunicationService

我认为最好的方法是按照以下思路:

def test_cli_uses_specified_hostname_and_port():
    hostname = '0.0.0.0'
    port = 12345
    mock_comms = mock(CommunicationService)
    # Somehow inject `mock_comms` into the application to make it use that instead of 'real' comms service.
    result = runner.invoke(testcube.cli, ['--hostname', hostname, '--port', str(port)])
    assert result.exit_code == 0
    assert mock_comms.hostname == hostname
    assert mock_comms.port == port

如果我能得到关于如何正确处理这种情况的建议,我应该能够接受它并使用相同的技术使我的 CLI 的每个其他部分都可测试。

为了它的价值,我目前正在使用 pytest 进行测试,这是我迄今为止进行的测试的范围:

import pytest
from click.testing import CliRunner

from testcube import testcube


# noinspection PyShadowingNames
class TestCLI(object):
    @pytest.fixture()
    def runner(self):
        return CliRunner()

    def test_print_version_succeeds(self, runner):
        result = runner.invoke(testcube.cli, ['--version'])

        from testcube import version
        assert result.exit_code == 0
        assert version.__version__ in result.output

    def test_print_help_succeeds(self, runner):
        result = runner.invoke(testcube.cli, ['--help'])
        assert result.exit_code == 0

我想我找到了一种方法。我偶然发现了 Python 的 unittest.mock 模块,经过一番尝试后,我得到了以下结果。

在我的 'comms' 模块中,我定义 CommunicationService:

class CommunicationService(object):
    def establish_communication(self, hostname: str, port: int):
        print('Communications service instantiated with {}:{}'.format(hostname, port))

这是一个产品class,打印语句最终会被实际的通信逻辑所取代。

在我的主模块中,我让我的顶级命令实例化此通信服务并尝试建立通信:

def cli(hostname, port):
    comms = CommunicationService()
    comms.establish_communication(hostname, port)

然后是有趣的部分。在我的测试套件中,我定义了这个测试用例:

def test_user_can_override_hostname_and_port(self, runner):
    hostname = 'mock_hostname'
    port = 12345

    # noinspection PyUnresolvedReferences
    with patch.object(CommunicationService, 'establish_communication', spec=CommunicationService)\
            as mock_establish_comms:
        result = runner.invoke(testcube.cli,
                               ['--hostname', hostname, '--port', str(port), 'mock.enable', 'true'])

    assert result.exit_code == 0
    mock_establish_comms.assert_called_once_with(hostname, port)

这暂时用 MagicMock 的实例替换了 CommunicationService.establish_communication 方法,它不会执行真正的逻辑,但会记录它被调用了多少次,参数是什么等等。然后我可以调用我的 CLI 并断言它是如何尝试根据提供的命令行参数建立通信的。

我曾参与过主要使用 Java 和 C# 等静态类型语言编写的项目,我从来没有想过我可以对现有产品 classes 的补丁方法进行修改,而不是创建那些 classes 的模拟版本并找到一种方法来替换它们。这非常方便。

现在,如果我不小心做到了,以至于我的 CLI 忽略了用户提供的对主机名和端口的显式覆盖...

def cli(hostname, port):
    comms = CommunicationService()
    comms.establish_communication(DEFAULT_SETTINGS['hostname'], DEFAULT_SETTINGS['port'])

...然后我有方便的测试用例提醒我:

>           raise AssertionError(_error_message()) from cause
E           AssertionError: Expected call: establish_communication('mock_hostname', 12345)
E           Actual call: establish_communication('127.0.0.1', 36364)