{Never write scripts again}

Python CLI applications & TDD

Swiss Python Summit 2022

{ Peter Bittner }

Developer
of people, companies and code

@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

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

CLI Applications & TDD

  1. What's wrong with scripts?
  2. Coding example #1 (refactoring)
  3. Why CLI applications?
  4. Challenges with writing tests
  5. Coding example #2 (cli & tests)
print("This is important code")

for index, arg in enumerate(sys.argv):
    print(f"[{index}]: {arg}")
# SCRIPTS

What's Wrong with Scripts?

  • Easy to get started
  • Limited possibilities for structure
  • Hard to (unit) test
  • No dependency management
  • Deployment may require care
  • Custom user experience
$ cowsay -e ~~ WHAT IS WRONG WITH SCRIPTS? | lolcat
 _____________________________
< WHAT IS WRONG WITH SCRIPTS? >
 -----------------------------
        \   ^__^
         \  (~~)\_______
            (__)\       )\/\
                ||----w |
                ||     ||
# SCRIPTS

Coding Example #1

Refactor a script

def main():
    print("Usage: foo <bar> --baz")
    
if __name__ == "__main__":
    main()
# CLI APPLICATIONS

Why CLI Applications?

  • Standardized user experience
  • More possibilities for structure
  • Possibilities for all kinds of testing
  • Dependency management
  • Packaging & distribution
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

Argparse

import click

@click.command()
@click.version_option()
@click.argument('filename')
def main(filename):
    click.echo(filename)
# CLI APPLICATIONS

Click

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

Docopt

def test_cli():
    with pytest.raises(SystemExit):
        foobar.cli.main()
        pytest.fail("CLI doesn't abort")
# WRITING TESTS

Challenges with Writing Tests

  • How test our CLI configuration?
  • Control taken away from us
  • Package features require deployment
def a_functional_test():
    result = shell('foobar')
    assert result.exit_code != 0, result.stdout
# WRITING TESTS

CLI Test Strategy

  1. Start from the user interface with functional tests.
  2. Work down towards unit 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
  1. Start from the user interface with functional tests.
def test_entrypoint():
    """Is entrypoint script installed? (setup.py)"""
    result = shell('foobar --help')
    assert result.exit_code == 0
# WRITING TESTS

Functional Tests (User Interface)

"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

Unit 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

Tox

Installs your CLI before running the test suite!

$ cowsay MOO! I ♥ CLI-TEST-HELPERS | lolcat
 ___________________________
< MOO! I ♥ CLI-TEST-HELPERS >
 ---------------------------
        \   ^__^
         \  (oo)\_______
            (__)\       )\/\
                ||----w |
                ||     ||
# WRITING TESTS

Coding Example #2

Write and run tests with cli-test-helpers

Thank you!

for your precious time

Painless Software

Less pain, more fun.

Made with Slides.com