Python CLI applications & TDD
Swiss Python Summit 2022
@peterbittner, django@bittner.it
Automated tests are important
but ...
... in my case ...
they don't make all that sense.
I need to move fast!
The only way to go fast
is to go well.--- Robert C. Martin
Source: Technology and Friends, Episode 354, 2015
from application import cli
from cli_test_helpers import shell
def test_cli_entrypoint():
result = shell("python-summit --help")
assert result.exit_code == 0
# AGENDA
print("This is important code")
for index, arg in enumerate(sys.argv):
print(f"[{index}]: {arg}")
# SCRIPTS
$ cowsay -e ~~ WHAT IS WRONG WITH SCRIPTS? | lolcat
_____________________________
< WHAT IS WRONG WITH SCRIPTS? >
-----------------------------
\ ^__^
\ (~~)\_______
(__)\ )\/\
||----w |
|| ||
# SCRIPTS
Refactor a script
def main():
print("Usage: foo <bar> --baz")
if __name__ == "__main__":
main()
# CLI APPLICATIONS
import argparse
from . import __version__
def parse_arguments():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--version', action='version',
version=__version__)
parser.add_argument('filename')
args = parser.parse_args()
return args
def main():
args = parse_arguments()
...
# CLI APPLICATIONS
import click
@click.command()
@click.version_option()
@click.argument('filename')
def main(filename):
click.echo(filename)
# CLI APPLICATIONS
import click
@click.command()
@click.version_option()
@click.argument('filename', type=click.Path(exists=True))
def main(filename):
click.echo(filename)
import click
@click.command()
@click.version_option()
@click.argument('infile', type=click.File())
def main(infile):
click.echo(infile.read())
"""Foobar
Usage:
foobar (-h | --help | --version)
foobar [-s | --silent] <file>
foobar [-v | --verbose] <file>
Positional arguments:
file target file path name
Optional arguments:
-h, --help show this help message and exit
-s, --silent don't show progress output
-v, --verbose explain progress verbosely
--version show program's version number and exit
"""
from docopt import docopt
from . import __version__
def parse_arguments():
args = docopt(__doc__, version=__version__)
return dict(
file=args['<file>'],
silent=args['-s'] or args['--silent'],
verbose=args['-v'] or args['--verbose'],
)
# CLI APPLICATIONS
def test_cli():
with pytest.raises(SystemExit):
foobar.cli.main()
pytest.fail("CLI doesn't abort")
# WRITING TESTS
def a_functional_test():
result = shell('foobar')
assert result.exit_code != 0, result.stdout
# WRITING TESTS
def a_functional_test():
result = shell('foobar')
assert result.exit_code != 0, result.stdout
def a_unit_test():
with ArgvContext('foobar', 'myfile', '--verbose'):
args = foobar.cli.parse_arguments()
assert args['verbose'] == True
def test_entrypoint():
"""Is entrypoint script installed? (setup.py)"""
result = shell('foobar --help')
assert result.exit_code == 0
# WRITING TESTS
"Is the entrypoint script installed?"
"Can package be run as a Python module?"
"Is positional argument <foo> available?"
"Is optional argument --bar available?"
Drive the code from "outside"
import foobar
from cli_test_helpers import ArgvContext
from unittest.mock import patch
@patch('foobar.command.process')
def test_process_is_called(mock_command):
with ArgvContext('foobar', 'myfile', '-v'):
foobar.cli.main()
assert mock_command.called
assert mock_command.call_args.kwargs == dict(
file='myfile', silent=False, verbose=True)
# WRITING TESTS
Stay in the Python code
[tox]
envlist = py{38,39,310}
[testenv]
description = Unit tests
deps =
cli-test-helpers
coverage[toml]
pytest
commands =
coverage run -m pytest {posargs}
coverage xml
coverage report
# WRITING TESTS
Installs your CLI before running the test suite!
$ cowsay MOO! I ♥ CLI-TEST-HELPERS | lolcat
___________________________
< MOO! I ♥ CLI-TEST-HELPERS >
---------------------------
\ ^__^
\ (oo)\_______
(__)\ )\/\
||----w |
|| ||
# WRITING TESTS
Write and run tests with cli-test-helpers
Less pain, more fun.