Testing command-line scripts ============================ .. invisible-code-block: python try: import loguru except ImportError: loguru = None try: import click except ImportError: click = None .. currentmodule:: testfixtures.command Command line scripts always parse :data:`sys.argv` and exit with an integer return code. Along the way, they often configurate loggers and other services, log their progress or send it to a stream such :data:`~sys.stderr` or :data:`~sys.stdout`. This often involves testing boilerplate which needs to vary slightly across projects, but which has some intricacies that are easy to miss. :class:`Command` aims to make this testing easy and flexible. Take a simple :class:`~argparse.ArgumentParser` script: .. code-block:: python from argparse import ArgumentParser def main() -> None: parser = ArgumentParser() parser.add_argument('message') args = parser.parse_args() print(f"Your message: {args.message}") This can be tested as follows: >>> from testfixtures import Command >>> Command(main).run('my message').check(output='Your message: my message') We can also test what happens if the required argument is not provided. The program name :mod:`argparse` puts in its messages varies with how the tests are run, so we use a :class:`~testfixtures.StringComparison` to match it: >>> from testfixtures import StringComparison as S >>> Command(main).run().check( ... output=S( ... r'usage: .+ \[-h\] message\n' ... r'.+: error: the following arguments are required: message' ... ), ... return_code=2, ... ) .. skip: start if(click is None, reason="No click installed") If we now implement our entrypoint with Click instead: .. code-block:: python import click @click.command() @click.argument('message') def main(message: str) -> None: click.echo(f"Your message: {message}") Our test doesn't change: >>> Command(main).run('my message').check(output='Your message: my message') .. skip: end If logging is used instead of :func:`print`: .. code-block:: python from argparse import ArgumentParser import logging def main() -> None: parser = ArgumentParser() parser.add_argument('message') args = parser.parse_args() logging.info(f"Your message: %s", args.message) Testing is just as simple: >>> Command(main).run('my message').check(logging=[('INFO', 'Your message: my message')]) Customising setup ----------------- Most scripts that log instead of print will configure their logging framework and often offer a way of specifying the log level on the command line: .. code-block:: python from argparse import ArgumentParser import logging def main() -> None: parser = ArgumentParser() parser.add_argument('message') parser.add_argument('-l', '--log-level', default='INFO') args = parser.parse_args() logging.basicConfig(level=getattr(logging, args.log_level.upper())) logging.info(f"Your message: %s", args.message) To test this, we can override :meth:`~AbstractRun.setup_mocks` to intercept and record the configuration call: .. code-block:: python from testfixtures import Run as DefaultRun, Replacer from testfixtures.mock import Mock, call class Run(DefaultRun): @classmethod def setup_mocks(cls, replace: Replacer) -> Mock: mocks = Mock() replace.in_module(logging.basicConfig, mocks.basicConfig) return mocks Now we can check that logging is set up correctly, as well as preventing the configuration call from getting in the way of the logging capture: >>> Command(main, Run).run('my message').check( ... logging=[('INFO', 'Your message: my message')], ... mock_calls=[call.basicConfig(level=20)], ... ) We can also check that the log level option works as intended: >>> Command(main, Run).run('-l', 'warning', 'would not be logged').check( ... logging=[('INFO', 'Your message: would not be logged')], ... mock_calls=[call.basicConfig(level=30)], ... ) .. skip: start if(loguru is None, reason="No loguru installed") If we decided to switch to :doc:`loguru ` for our logging: .. code-block:: python from argparse import ArgumentParser from loguru import logger import sys def main() -> None: parser = ArgumentParser() parser.add_argument('message') parser.add_argument('-l', '--log-level', default='INFO') args = parser.parse_args() logger.add(sys.stderr, format="{time} {level} {message}", level=args.log_level) logger.info(f"Your message: {args.message}") We can additionally override :meth:`~AbstractRun.setup_logging`: .. code-block:: python from testfixtures import Run as DefaultRun, LogCapture, Replacer, like from testfixtures.mock import Mock, call from testfixtures.loguru import LoguruSource from io import StringIO from loguru._logger import Logger class Run(DefaultRun): @classmethod def setup_logging(cls) -> LogCapture: return LogCapture(LoguruSource()) @classmethod def setup_mocks(cls, replace: Replacer) -> Mock: mocks = Mock() replace.on_class(Logger.add, mocks.logger.add) return mocks The checking of logging doesn't change, but the mock calls do: >>> Command(main, Run).run('my message').check( ... logging=[('INFO', 'Your message: my message')], ... mock_calls=[ ... call.logger.add(like(StringIO), format='{time} {level} {message}', level='INFO') ... ], ... ) .. note:: The ``like(StringIO)`` is necessary because by the time the script's :meth:`!logger.add` call is reached, :class:`Command` has already mocked :data:`sys.stderr` with an :class:`~testfixtures.OutputCapture`. .. skip: end Customising checks ------------------ If we have a script that always logs a debug message on startup: .. code-block:: python import logging, sys def main() -> None: logging.debug('starting') logging.info(f'action: {sys.argv[1]}') Our testing can be made more succinct by overriding :meth:`~AbstractRun.check_logging`: .. code-block:: python from collections.abc import Sequence from testfixtures import Run as DefaultRun, LogCapture, like from testfixtures.command import CheckResult, check_logging class Run(DefaultRun): @staticmethod def check_logging( expected: Sequence[tuple[str, ...] | str], logging: LogCapture ) -> CheckResult: return check_logging((('DEBUG', 'starting'),) + tuple(expected), logging) Now we only need to specify the action logging in each test: >>> Command(main, Run).run('wave').check(logging=[('INFO', 'action: wave')]) Similarly, if we have a script that should only ever write to :data:`sys.stdout`: .. code-block:: python import logging, sys def main() -> None: action = sys.argv[1] print(f'action: {action}', file=sys.stderr if action == 'error' else sys.stdout) We can override both :meth:`~AbstractRun.setup_output` and :meth:`~AbstractRun.check_output`: .. code-block:: python from testfixtures import Run as DefaultRun, OutputCapture, like from testfixtures.command import CheckResult, check_logging class Run(DefaultRun): @classmethod def setup_output(cls) -> OutputCapture: return OutputCapture(separate=True) @staticmethod def check_output(expected: str, output: OutputCapture) -> CheckResult: return CheckResult( 'output', output.compare(stdout=expected, stderr='', raises=False) ) Now we can check our script as follows: >>> Command(main, Run).run('wave').check(output='action: wave') But if there is an error, we will be able to see the output in the failure: >>> Command(main, Run).run('error').check(output='action: ?') Traceback (most recent call last): ... AssertionError: output: dict not as expected: values differ: 'stderr': '' (expected) != 'action: error' (actual) 'stdout': 'action: ?' (expected) != '' (actual) While comparing ['stderr']: '' (expected) != 'action: error' (actual) While comparing ['stdout']: 'action: ?' (expected) != '' (actual) Customising everything ---------------------- A script or set of scripts may have a well defined approach that doesn't match the default :meth:`Run.check` signature, such as always allowing the log level to be set, always logging and never writing to :data:`~sys.stderr` or :data:`~sys.stdout`: .. skip: start if(click is None or loguru is None, reason="No click or loguru installed") .. code-block:: python import click, sys from loguru import logger @click.group() @click.option( "--log-level", default="INFO", show_default=True, type=click.Choice(["DEBUG", "INFO", "ERROR"], case_sensitive=False), ) def cli(log_level): logger.remove() logger.add(sys.stderr, level=log_level.upper()) @cli.command() def build(): logger.info("building") @cli.command() def deploy(): logger.info("deploying") For ergonomic testing of this family of commands, in addition to overriding :meth:`~AbstractRun.setup_logging` and :meth:`~AbstractRun.setup_mocks`, we can also implement our own :meth:`~Run.check` method: .. code-block:: python from collections.abc import Sequence from io import StringIO from testfixtures import like, LogCapture, Replacer from testfixtures.command import AbstractRun from testfixtures.mock import Mock, call from testfixtures.loguru import LoguruSource from loguru import logger from loguru._logger import Logger class Run(AbstractRun): @classmethod def setup_logging(cls) -> LogCapture: return LogCapture(LoguruSource()) @classmethod def setup_mocks(cls, replace: Replacer) -> Mock: original_remove = Logger.remove mocks = Mock() def remove(self, handler_id: int = None) -> None: # Allow setup_logging to uninstall its handler: if handler_id: original_remove(self, handler_id) else: mocks.logger.remove() replace.on_class(Logger.remove, remove) replace.on_class(Logger.add, mocks.logger.add) return mocks def check( self, logging: Sequence[tuple[str, str]] = (), log_level: str = 'INFO', return_code: int = 0, ) -> None: self.check_results( self.check_output(output=self.output, expected=''), self.check_return_code(return_code, self.return_code), self.check_logging(logging, self.logging), self.check_mock_calls( [call.logger.remove(), call.logger.add(like(StringIO), level=log_level)], self.mocks ), ) command = Command(cli, runner=Run) Now out individual tests are succinct and easy to read while still testing their full implementation on each call: >>> command.run('build').check(logging=[('INFO', 'building')]) >>> result = command.run('--log-level', 'debug', 'deploy') >>> result.check(logging=[('INFO', 'deploying')], log_level='DEBUG') .. skip: end