From a05a1ce6ffeb4cb165adf178811f8a2bdc043ff4 Mon Sep 17 00:00:00 2001 From: Imani Pelton Date: Thu, 8 Oct 2026 18:09:07 -0400 Subject: [PATCH 1/2] feat(pytest-plugin): negative assertion methods --- craft_cli/pytest_plugin.py | 113 ++++++++++++++++++++++++++++++++++-- tests/test_pytest_plugin.py | 27 +++++++++ 2 files changed, 136 insertions(+), 4 deletions(-) diff --git a/craft_cli/pytest_plugin.py b/craft_cli/pytest_plugin.py index 46446266..f0cb8abf 100644 --- a/craft_cli/pytest_plugin.py +++ b/craft_cli/pytest_plugin.py @@ -100,18 +100,31 @@ def _check( expected_text: str, method_name: str, regex: bool, # noqa: FBT001 + *, + invert: bool = False, **kwargs: Any, ) -> Any: """Really verify messages.""" if regex: expected_text = _RegexComparingText(expected_text) expected_call = call(method_name, expected_text, **kwargs) + found = None for stored_call in self.interactions: if stored_call == expected_call: - return stored_call.args[1] - raise AssertionError( - f"Expected call {expected_call} not found in {self.interactions}" - ) + found = stored_call.args[1] + break + + if invert and found is not None: + raise AssertionError( + f"Found call {expected_call} when it should have been absent from {self.interactions}." + ) + + if not invert and found is None: + raise AssertionError( + f"Expected call {expected_call} not found in {self.interactions}" + ) + + return found def assert_message( self, @@ -126,6 +139,20 @@ def assert_message( """ return self._check(expected_text, "message", regex) + def assert_no_message( + self, + expected_text: str, + *, + regex: bool = False, + ) -> Any: + """Check the 'message' method was not used. + + It verifies that the method was never called with the expected text. + + If 'regex' is True, the expected text will be used as a regular expression. + """ + return self._check(expected_text, "message", regex, invert=True) + def assert_progress( self, expected_text: str, @@ -145,6 +172,28 @@ def assert_progress( result = self._check(expected_text, "progress", regex, permanent=permanent) return result + def assert_no_progress( + self, + expected_text: str, + permanent: bool | None = None, # noqa: FBT001 + *, + regex: bool = False, + ) -> Any: + """Check the 'progress' method was not used. + + It verifies that the method was never called with the expected text (with + the given 'permanent' flag). + + If 'regex' is True, the expected text will be used as a regular expression. + """ + if permanent is None: + result = self._check(expected_text, "progress", regex, invert=True) + else: + result = self._check( + expected_text, "progress", regex, permanent=permanent, invert=True + ) + return result + def assert_verbose( self, expected_text: str, @@ -158,6 +207,20 @@ def assert_verbose( """ return self._check(expected_text, "verbose", regex) + def assert_no_verbose( + self, + expected_text: str, + *, + regex: bool = False, + ) -> Any: + """Check the 'verbose' method was not used. + + It verifies that the method was never called with the expected text. + + If 'regex' is True, the expected text will be used as a regular expression. + """ + return self._check(expected_text, "verbose", regex, invert=True) + def assert_warning(self, expected_text: str, *, regex: bool = False) -> Any: """Check the 'warning' method was properly used. @@ -167,6 +230,20 @@ def assert_warning(self, expected_text: str, *, regex: bool = False) -> Any: """ return self._check(expected_text, "warning", regex) + def assert_no_warning( + self, + expected_text: str, + *, + regex: bool = False, + ) -> Any: + """Check the 'warning' method was not used. + + It verifies that the method was never called with the expected text. + + If 'regex' is True, the expected text will be used as a regular expression. + """ + return self._check(expected_text, "warning", regex, invert=True) + def assert_debug( self, expected_text: str, @@ -180,6 +257,20 @@ def assert_debug( """ return self._check(expected_text, "debug", regex) + def assert_no_debug( + self, + expected_text: str, + *, + regex: bool = False, + ) -> Any: + """Check the 'debug' method was not used. + + It verifies that the method was never called with the expected text. + + If 'regex' is True, the expected text will be used as a regular expression. + """ + return self._check(expected_text, "debug", regex, invert=True) + def assert_trace( self, expected_text: str, @@ -193,6 +284,20 @@ def assert_trace( """ return self._check(expected_text, "trace", regex) + def assert_no_trace( + self, + expected_text: str, + *, + regex: bool = False, + ) -> Any: + """Check the 'trace' method was not used. + + It verifies that the method was never called with the expected text. + + If 'regex' is True, the expected text will be used as a regular expression. + """ + return self._check(expected_text, "trace", regex, invert=True) + def assert_messages(self, texts: list[str]) -> None: """Check that the 'message' method was called several times with the given texts. diff --git a/tests/test_pytest_plugin.py b/tests/test_pytest_plugin.py index 24f70216..56d09913 100644 --- a/tests/test_pytest_plugin.py +++ b/tests/test_pytest_plugin.py @@ -152,6 +152,33 @@ def test_emitter_record_trace_regex(emitter): emitter.assert_trace("[fx]oo.*", regex=True) +@pytest.mark.parametrize( + "emit_method", ["message", "progress", "verbose", "warning", "debug", "trace"] +) +def test_emitter_record_no_call_plain(emitter, emit_method): + """Can verify that a call was never made.""" + emit_fn = getattr(messages.emit, emit_method) + assert_fn = getattr(emitter, f"assert_no_{emit_method}") + + emit_fn("something else we don't care about") + assert_fn("foobar") + + emit_fn("foobar") + with pytest.raises(AssertionError): + assert_fn("foobar") + + +def test_emitter_record_no_progress_permanent(emitter): + """Can verify that `progress` was never called with the given permanent flag.""" + messages.emit.progress("foobar", permanent=True) + + emitter.assert_no_progress("foobar", permanent=False) + # no permanent filter means only matching calls without the flag + emitter.assert_no_progress("foobar") + with pytest.raises(AssertionError): + emitter.assert_no_progress("foobar", permanent=True) + + def test_emitter_record_progress_bar_ok(emitter): """Calls to `progress_bar` are recorded.""" with messages.emit.progress_bar("title", 20, delta=True) as progress_bar: From 592b4c614395dd13fa006587ac46aeed5428c7f9 Mon Sep 17 00:00:00 2001 From: Imani Pelton Date: Fri, 9 Oct 2026 11:12:42 -0400 Subject: [PATCH 2/2] docs: add changelog entry --- docs/changelog.rst | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 5d1cbb8b..0b016777 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -11,6 +11,12 @@ included in each version. 3.4.2 (unreleased) ------------------ +New features: + +- Add negative assertion methods to the ``emitter`` test fixture, such as + :py:meth:`.RecordingEmitter.assert_no_message`, to verify that a message was + never emitted. + Bug fixes: - The ``emitter`` test fixture now rejects a negative ``advance`` amount,