diff --git a/craft_cli/pytest_plugin.py b/craft_cli/pytest_plugin.py index 1e4174ce..d9229a55 100644 --- a/craft_cli/pytest_plugin.py +++ b/craft_cli/pytest_plugin.py @@ -160,18 +160,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, @@ -186,6 +199,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, @@ -205,6 +232,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, @@ -218,6 +267,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. @@ -227,6 +290,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, @@ -240,6 +317,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, @@ -253,6 +344,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/docs/changelog.rst b/docs/changelog.rst index 950489bb..4c4161fd 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -13,6 +13,9 @@ included in each version. 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. - Add an optional ``pytest`` dependency group (``craft-cli[pytest]``) to track the minimum pytest version required by the ``emitter`` test plugin. - Add a :py:func:`~craft_cli.pytest_plugin.raises_craft_error` helper to assert diff --git a/tests/test_pytest_plugin.py b/tests/test_pytest_plugin.py index 7224d0a5..aa8a0b43 100644 --- a/tests/test_pytest_plugin.py +++ b/tests/test_pytest_plugin.py @@ -154,6 +154,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: