From 6b7797639c4202c464bf92a6b948b810f8b37b04 Mon Sep 17 00:00:00 2001 From: Alex Pyrgiotis Date: Wed, 19 Oct 2022 16:50:57 +0300 Subject: [PATCH] tests: Wrap Click results with extra functionality Wrap Click results (`Result`) with a new class (`CLIResult`), which includes: 1. Assertion statements. 2. Logic for formatting and printing a Click result. 3. Invocation arguments, which are missing from the original `Result` class. --- tests/test_cli.py | 118 +++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 107 insertions(+), 11 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index f39a98d..383c847 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,8 +1,12 @@ from __future__ import annotations +import copy import os import shutil +import sys import tempfile +import traceback +from typing import Sequence import pytest from click.testing import CliRunner, Result @@ -24,21 +28,113 @@ from . import TestBase, for_each_doc # FIXME "/" path separator is platform-dependent, use pathlib instead +class CLIResult(Result): + """Wrapper class for Click results. + + This class wraps Click results and provides the following extras: + * Assertion statements for success/failure. + * Printing the result details, when an assertion fails. + * The arguments of the invocation, which are not provided by the stock + `Result` class. + """ + + @classmethod + def reclass_click_result(cls, result: Result, args: Sequence[str]) -> CLIResult: + result.__class__ = cls + result.args = copy.deepcopy(args) # type: ignore[attr-defined] + return result # type: ignore[return-value] + + def assert_success(self) -> None: + """Assert that the command succeeded.""" + try: + assert self.exit_code == 0 + assert self.exception is None + except AssertionError: + self.print_info() + raise + + def assert_failure(self, exit_code: int = None, message: str = None) -> None: + """Assert that the command failed. + + By default, check that the command has returned with an exit code + other than 0. Alternatively, the caller can check for a specific exit + code. Also, the caller can check if the output contains an error + message. + """ + try: + if exit_code is None: + assert self.exit_code != 0 + else: + assert self.exit_code == exit_code + if message is not None: + assert message in self.output + except AssertionError: + self.print_info() + raise + + def print_info(self) -> None: + """Print all the info we have for a CLI result. + + Print the string representation of the result, as well as: + 1. Command output (if any). + 2. Exception traceback (if any). + """ + print(self) + num_lines = len(self.output.splitlines()) + if num_lines > 0: + print(f"Output ({num_lines} lines follow):") + print(self.output) + else: + print("Output (0 lines).") + + if self.exc_info: + print("The original traceback follows:") + traceback.print_exception(*self.exc_info, file=sys.stdout) + + def __str__(self) -> str: + """Return a string representation of a CLI result. + + Include the arguments of the command invocation, as well as the exit + code and exception message. + """ + desc = ( + f" Result: - return CliRunner().invoke(cli_main, *args, **kwargs) + def run_cli(self, args: Sequence[str] | str = ()) -> CLIResult: + """Run the CLI with the provided arguments. + + Callers can either provide a list of arguments (iterable), or a single + argument (str). Note that in both cases, we don't tokenize the input + (i.e., perform `shlex.split()`), as this is prone to errors in Windows + environments [1]. The user must perform the tokenizaton themselves. + + [1]: https://stackoverflow.com/a/35900070c + """ + if isinstance(args, str): + # Convert the single argument to a tuple, else Click will attempt + # to tokenize it. + args = (args,) + result = CliRunner().invoke(cli_main, args) + return CLIResult.reclass_click_result(result, args) class TestCliBasic(TestCli): def test_no_args(self): """``$ dangerzone-cli``""" result = self.run_cli() - assert result.exit_code != 0 + result.assert_failure() def test_help(self): """``$ dangerzone-cli --help``""" result = self.run_cli("--help") - assert result.exit_code == 0 + result.assert_success() def test_display_banner(self, capfd): display_banner() # call the test subject @@ -55,33 +151,33 @@ class TestCliBasic(TestCli): class TestCliConversion(TestCliBasic): def test_invalid_lang(self): result = self.run_cli(f"{self.sample_doc} --ocr-lang piglatin") - assert result.exit_code != 0 + result.assert_failure() @for_each_doc def test_formats(self, doc): result = self.run_cli(f'"{doc}"') - assert result.exit_code == 0 + result.assert_success() def test_output_filename(self): temp_dir = tempfile.mkdtemp(prefix="dangerzone-") result = self.run_cli( f"{self.sample_doc} --output-filename {temp_dir}/safe.pdf" ) - assert result.exit_code == 0 + result.assert_success() def test_output_filename_new_dir(self): result = self.run_cli( f"{self.sample_doc} --output-filename fake-directory/my-output.pdf" ) - assert result.exit_code != 0 + result.assert_failure() def test_sample_not_found(self): result = self.run_cli("fake-directory/fake-file.pdf") - assert result.exit_code != 0 + result.assert_failure() def test_lang_eng(self): result = self.run_cli(f'"{self.sample_doc}" --ocr-lang eng') - assert result.exit_code == 0 + result.assert_success() @pytest.mark.parametrize( "filename,", @@ -96,4 +192,4 @@ class TestCliConversion(TestCliBasic): shutil.copyfile(self.sample_doc, doc_path) result = self.run_cli(doc_path) shutil.rmtree(tempdir) - assert result.exit_code == 0 + result.assert_success()