From 7128f99da2f8b2fa70326643269a6f87c32a4c3e Mon Sep 17 00:00:00 2001 From: jbranchaud Date: Wed, 22 Jul 2026 20:13:32 -0500 Subject: [PATCH] Add Resurface Exceptions Swallowed By Click Under Test as a Python TIL --- README.md | 3 +- ...xceptions-swallowed-by-click-under-test.md | 55 +++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 python/resurface-exceptions-swallowed-by-click-under-test.md diff --git a/README.md b/README.md index 15ce91e..998a19b 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ working across different projects via [VisualMode](https://www.visualmode.dev/). For a steady stream of TILs, [sign up for my newsletter](https://visualmode.kit.com/newsletter). -_1831 TILs and counting..._ +_1832 TILs and counting..._ See some of the other learning resources I work on: @@ -1100,6 +1100,7 @@ If you've learned something here, support my efforts writing daily TILs by - [Override The Boolean Context Of A Class](python/override-the-boolean-context-of-a-class.md) - [Parse Relative Time To datetime Object](python/parse-relative-time-to-datetime-object.md) - [Reclassify Certain Packages As Dev Dependencies](python/reclassify-certain-packages-as-dev-dependencies.md) +- [Resurface Exceptions Swallowed By Click Under Test](python/resurface-exceptions-swallowed-by-click-under-test.md) - [Select Implementation With Class Registry](python/select-implementation-with-class-registry.md) - [Set Up Pyright Type Checking In GitHub](python/set-up-pyright-type-checking-in-github.md) - [Skip Specific Pytest Test Cases](python/skip-specific-pytest-test-cases.md) diff --git a/python/resurface-exceptions-swallowed-by-click-under-test.md b/python/resurface-exceptions-swallowed-by-click-under-test.md new file mode 100644 index 0000000..e6200ee --- /dev/null +++ b/python/resurface-exceptions-swallowed-by-click-under-test.md @@ -0,0 +1,55 @@ +# Resurface Exceptions Swallowed By Click Under Test + +The testing utilities provided by +[Click](https://click.palletsprojects.com/en/stable/) are generally very nice to +work with. One particular aspect of makes the testing feedback loop almost +non-existent. Click stashes any actual exceptions that occur during a run of the +`CliRunner` and instead shows an opaque failed assertion. + +Here is the primary output of a failing test I'm dealing with for +[`py-vmt`](https://github.com/jbranchaud/py-vmt): + +```bash +tests/src/py_vmt/test_cli.py:61: AssertionError +======================================== short test summary info ======================================== +FAILED tests/src/py_vmt/test_cli.py::test_start_status_stop_flow[sqlite] - assert "Tracking 'my-project' for 30m (since 10:05AM)" in '• Not tracking\n' +=========================================== 1 failed in 0.07s =========================================== +``` + +What I'd rather see when this test fails is something a little closer to the +root cause of the failure, like this: + +```bash + result = cursor.execute(fetch_active_session_sql) + if result.fetchone() is None: + return None +> _id, _active, project_name, start_time, end_time = result.fetchone() + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +E TypeError: cannot unpack non-iterable NoneType object + +src/py_vmt/cli.py:46: TypeError +======================================= short test summary info ======================================== +FAILED tests/src/py_vmt/test_cli.py::test_start_status_stop_flow[sqlite] - TypeError: cannot unpack non-iterable NoneType object +========================================== 1 failed in 0.11s =========================================== +``` + +That's more like it. `TypeError: cannot unpack non-iterable NoneType object` and +a specific pointer to the line where this happened is way more helpful. + +To get this improvement in my test failure output, I had to create a small +wrapper around `CliRunner` that re-raises the exception I care about. I add this +in my `conftest.py` in the same directory as my `test_cli.py` file. + +```python +from click.testing import CliRunner + +class BetterCliRunner(CliRunner): + def invoke(self, *args, **kwargs): + result = super().invoke(*args, **kwargs) + if result.exception and not isinstance(result.exception, SystemExit): + raise result.exception + return result +``` + +Then in `test_cli.py` I import the `BetterCliRunner` class and replace all +instantiations of `CliRunner()` with `BetterCliRunner()`.