Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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

I'm not sure if this is the best fit for here or the Programmers Stack Exchange, but I'll try here first and cross-post this over there if it's not appropriate.

I've recently developed a web service and I'm trying to create a Python-based command-line interface to make it easier to interact with. I've been using Python for a while for simple scripting purposes but I'm inexperienced at creating full-blown packages, including CLI applications.

I've researched different packages to help with creating CLI apps and I've settled on using click. What I'm concerned about is how to structure my application to make it thoroughly testable before I actually go about putting it all together, and how I can use click to help with that.

I have read click's documentation on testing as well as examined the relevant part of the API and while I've managed to use this for testing simple functionality (verifying --version and --help work when passed as arguments to my CLI), I'm not sure how to handle more advanced test cases.

I'll provide a specific example of what I'm trying to test right now. I'm planning for my application to have the following sort of architecture...

architecture

...where the CommunicationService encapsulates all logic involved in connecting and directly communicating with the web service over HTTP. My CLI provides defaults for the web service hostname and port but should allow users to override these either through explicit command-line arguments, writing config files or setting environment variables:

@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)

I want to test that if the user specifies different hostnames and ports, then Controller will instantiate a CommunicationService using these settings and not the defaults.

I imagine that the best way to do this would be something along these lines:

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

If I can get advice on how to properly handle this case, I should hopefully be able to pick it up and use the same technique for making every other part of my CLI testable.

For what it's worth, I'm currently using pytest for my tests and this is the extent of the tests I've got so far:

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
like image 549
Tagc Avatar asked Aug 27 '16 17:08

Tagc


1 Answers

I think I've found one way of doing it. I stumbled across Python's unittest.mock module, and after a bit of playing around with it I ended up with the following.

In my 'comms' module, I define CommunicationService:

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

This is a production class and the print statement will eventually get replaced with the actual communication logic.

In my main module, I make my top-level command instantiate this communication service and try to establish communications:

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

And then the fun part. In my test suite I define this test case:

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)

This temporarily replaces the CommunicationService.establish_communication method with an instance of MagicMock, which will perform no real logic but will record how many times it's called, with what arguments, etc. I can then invoke my CLI and make assertions about how it tried to establish communications based on the supplied command-line arguments.

Having worked with projects written primarily in statically-typed languages like Java and C#, it never would have occurred to me that I could just monkey patch methods of my existing production classes, rather than create mock versions of those classes and find a way to substitute those in. It's pretty convenient.

Now if I were to accidentally make it so that my CLI ignored explicit user-provided overrides for the hostname and port...

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

...then I have my handy test case to alert me:

>           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)
like image 114
Tagc Avatar answered Oct 01 '22 21:10

Tagc