From 6e04208d008f24a4a88e5e05a06d7d9faafdcf6f Mon Sep 17 00:00:00 2001 From: Thomas Juul Dyhr Date: Wed, 1 Apr 2026 00:29:26 +0200 Subject: [PATCH] test: unskip 12 test_ui.py tests, add coverage tests, fix thread-unsafe stdout pollution - Fix test_concurrent_operations: remove patch("builtins.print") from threads (not thread-safe; racing patches permanently replace builtins.print with MagicMock, silencing all subsequent capsys captures). Also add export_format=None and output_file=None to prevent open(MagicMock()) from closing fd 1 (stdout). - Unskip 12 previously-skipped tests in test_ui.py: replace monkeypatch.setattr with patch() context managers for stable HAS_TERMCOLOR overrides. - Add TestSuppressConsoleWarningsInternals (3 tests) to test_utils_handlers.py covering WarningFilter branches. - Add TestHandleInitializeConfigBranches and TestHandleSetupLoggingErrorRecovery to test_setup_handlers.py. - Add TestSendNotificationIfAvailable (5 tests) to test_outdated_handlers.py. - Add 6 new test classes to test_brew_handlers_coverage.py covering _display_brew_list, _handle_export_if_requested, _get_homebrew_casks, _log_debug_info, and _determine_strict_mode branches. Co-Authored-By: Claude Sonnet 4.6 --- tests/handlers/test_brew_handlers_coverage.py | 153 ++++++++++++++++++ tests/handlers/test_setup_handlers.py | 66 ++++++++ tests/handlers/test_utils_handlers.py | 66 ++++++++ tests/test_integration.py | 10 +- tests/test_outdated_handlers.py | 94 +++++++++++ tests/test_ui.py | 90 +++-------- 6 files changed, 406 insertions(+), 73 deletions(-) diff --git a/tests/handlers/test_brew_handlers_coverage.py b/tests/handlers/test_brew_handlers_coverage.py index c9b99b8..728abe4 100644 --- a/tests/handlers/test_brew_handlers_coverage.py +++ b/tests/handlers/test_brew_handlers_coverage.py @@ -4,12 +4,17 @@ _get_rate_limit, and _get_and_filter_brews. """ +import sys from unittest.mock import MagicMock, patch from versiontracker.exceptions import HomebrewError, NetworkError from versiontracker.handlers.brew_handlers import ( + _determine_strict_mode, _get_and_filter_brews, + _get_homebrew_casks, _get_rate_limit, + _handle_export_if_requested, + _log_debug_info, handle_brew_recommendations, handle_list_brews, ) @@ -195,3 +200,151 @@ def test_only_auto_updates(self, _casks, _auto, _pb): del opts.exclude_auto_updates brews, auto = _get_and_filter_brews(opts) assert brews == ["firefox"] + + +# --------------------------------------------------------------------------- +# _display_brew_list — auto-update marker path (lines 114-115) +# --------------------------------------------------------------------------- + + +class TestDisplayBrewList: + """Tests for _display_brew_list() auto-update marker path.""" + + @patch("versiontracker.handlers.brew_handlers.create_progress_bar", return_value=_mock_progress_bar()) + def test_auto_update_marker_shown(self, _pb, capsys): + """Brew with auto-update shows the (auto-updates) marker.""" + from versiontracker.handlers.brew_handlers import _display_brew_list + + opts = MagicMock(show_auto_updates=True, exclude_auto_updates=False, only_auto_updates=False) + _display_brew_list(["firefox"], ["firefox"], opts) + out = capsys.readouterr().out + assert "firefox" in out + assert "auto-updates" in out + + @patch("versiontracker.handlers.brew_handlers.create_progress_bar", return_value=_mock_progress_bar()) + def test_no_auto_update_marker_when_not_in_list(self, _pb, capsys): + """Brew not in auto-update list shows no marker.""" + from versiontracker.handlers.brew_handlers import _display_brew_list + + opts = MagicMock(show_auto_updates=True, exclude_auto_updates=False, only_auto_updates=False) + _display_brew_list(["chrome"], ["firefox"], opts) + out = capsys.readouterr().out + assert "chrome" in out + assert "auto-updates" not in out + + +# --------------------------------------------------------------------------- +# _handle_export_if_requested — stdout print path (line 138) +# --------------------------------------------------------------------------- + + +class TestHandleExportIfRequested: + """Tests for _handle_export_if_requested() stdout print path.""" + + @patch("versiontracker.handlers.brew_handlers.handle_export", return_value='[{"name":"firefox"}]') + @patch("versiontracker.handlers.brew_handlers.create_progress_bar", return_value=_mock_progress_bar()) + def test_prints_to_stdout_when_no_output_file(self, _pb, mock_export, capsys): + """When output_file is None, export result is printed to stdout.""" + opts = MagicMock(export_format="json", output_file=None) + _handle_export_if_requested(["firefox"], opts) + captured = capsys.readouterr() + assert "firefox" in captured.out + + @patch("versiontracker.handlers.brew_handlers.handle_export", return_value=0) + @patch("versiontracker.handlers.brew_handlers.create_progress_bar", return_value=_mock_progress_bar()) + def test_no_print_when_output_file_set(self, _pb, mock_export, capsys): + """When output_file is set, nothing is printed.""" + opts = MagicMock(export_format="json", output_file="/tmp/out.json") + _handle_export_if_requested(["firefox"], opts) + captured = capsys.readouterr() + assert captured.out == "" + + +# --------------------------------------------------------------------------- +# handle_list_brews — outer exception handler (lines 179-182) +# --------------------------------------------------------------------------- + + +class TestHandleListBrewsOuterException: + """Tests for the outer try/except in handle_list_brews.""" + + @patch("versiontracker.handlers.brew_handlers.create_progress_bar", return_value=_mock_progress_bar()) + def test_outer_exception_returns_1(self, _pb): + """An exception raised before the inner try returns exit code 1.""" + with patch( + "versiontracker.handlers.brew_handlers.create_progress_bar", + side_effect=RuntimeError("boom"), + ): + result = handle_list_brews(MagicMock()) + assert result == 1 + + +# --------------------------------------------------------------------------- +# _get_homebrew_casks — generic exception path (lines 257-260) +# --------------------------------------------------------------------------- + + +class TestGetHomebrewCasksHandler: + """Tests for _get_homebrew_casks() generic exception fallback.""" + + @patch("versiontracker.handlers.brew_handlers.create_progress_bar", return_value=_mock_progress_bar()) + @patch("versiontracker.handlers.brew_handlers.get_homebrew_casks", side_effect=OSError("disk error")) + def test_generic_exception_returns_empty(self, _casks, _pb): + """A non-HomebrewError exception returns [] and doesn't raise.""" + result = _get_homebrew_casks() + assert result == [] + + +# --------------------------------------------------------------------------- +# _log_debug_info — debug logging paths (lines 273-283) +# --------------------------------------------------------------------------- + + +class TestLogDebugInfo: + """Tests for _log_debug_info() debug logging branches.""" + + def test_no_debug_does_nothing(self): + """No logging when debug is False/0.""" + opts = MagicMock(debug=False) + _log_debug_info(opts, [("App", "1.0")], ["brew"], [("App", "1.0")]) + # No exception means pass + + def test_debug_logs_items(self): + """All three loops execute when debug is True.""" + opts = MagicMock(debug=True) + with patch("versiontracker.handlers.brew_handlers.logging") as mock_log: + _log_debug_info( + opts, + [("App", "1.0"), ("App2", "2.0")], + ["brew1", "brew2"], + [("App", "1.0")], + ) + assert mock_log.debug.call_count >= 6 # 3 headers + at least 3 items + + +# --------------------------------------------------------------------------- +# _determine_strict_mode — test-detection and strict_recommend paths +# --------------------------------------------------------------------------- + + +class TestDetermineStrictMode: + """Tests for _determine_strict_mode() branches.""" + + def test_strict_recom_returns_true(self): + opts = MagicMock(strict_recom=True) + assert _determine_strict_mode(opts) is True + + def test_test_detection_sets_mock_test(self): + """When sys.argv has ≤1 elements, mock_test is set and False is returned.""" + opts = MagicMock(spec=["strict_recommend", "recommend"]) + with patch.object(sys, "argv", ["pytest"]): + result = _determine_strict_mode(opts) + assert result is False + assert opts.mock_test is True + + def test_strict_recommend_returns_true(self): + """strict_recommend attribute triggers True when argv has entries.""" + opts = MagicMock(strict_recom=False, strict_recommend=True) + with patch.object(sys, "argv", ["versiontracker", "--strict-recom"]): + result = _determine_strict_mode(opts) + assert result is True diff --git a/tests/handlers/test_setup_handlers.py b/tests/handlers/test_setup_handlers.py index d6d5d94..26f4b1d 100644 --- a/tests/handlers/test_setup_handlers.py +++ b/tests/handlers/test_setup_handlers.py @@ -180,3 +180,69 @@ def test_handle_setup_logging_error(self, mock_logging): # Assert assert mock_logging.basicConfig.call_count >= 1 + + +# --------------------------------------------------------------------------- +# handle_initialize_config — config_file path and OSError fallback (lines 37, 39-41) +# --------------------------------------------------------------------------- + + +class TestHandleInitializeConfigBranches: + """Tests for uncovered branches in handle_initialize_config.""" + + @mock.patch("versiontracker.handlers.setup_handlers.Config") + @mock.patch("versiontracker.handlers.setup_handlers.get_config") + def test_config_file_path_used_when_no_config_attr(self, mock_get_config, mock_Config): + """When get_config() has no _config, Config(config_file=...) is called.""" + mock_cfg = mock.MagicMock(spec=[]) # no _config attribute + mock_get_config.return_value = mock_cfg + opts = mock.MagicMock() + opts.config = "/tmp/my.yaml" + + handle_initialize_config(opts) + + mock_Config.assert_called_once_with(config_file="/tmp/my.yaml") + + @mock.patch("versiontracker.handlers.setup_handlers.Config") + @mock.patch("versiontracker.handlers.setup_handlers.get_config") + def test_oserror_falls_back_to_default_config(self, mock_get_config, mock_Config): + """OSError during Config init triggers fallback Config() with no args.""" + mock_cfg = mock.MagicMock(spec=[]) # no _config attribute + mock_get_config.return_value = mock_cfg + # First call raises OSError, second (fallback) succeeds + mock_Config.side_effect = [OSError("file not found"), mock.MagicMock()] + opts = mock.MagicMock() + opts.config = "/bad/path.yaml" + + result = handle_initialize_config(opts) + + assert result == 0 + assert mock_Config.call_count == 2 + # Fallback called with no arguments + mock_Config.assert_called_with() + + +# --------------------------------------------------------------------------- +# handle_setup_logging — error recovery path (line 105) +# --------------------------------------------------------------------------- + + +class TestHandleSetupLoggingErrorRecovery: + """Tests for the exception recovery path in handle_setup_logging.""" + + @mock.patch("versiontracker.handlers.setup_handlers.logging") + def test_logs_error_after_recovery(self, mock_logging): + """When basicConfig() raises, recovery succeeds and logs the original error.""" + mock_logging.WARNING = 30 + # First basicConfig call raises; second (recovery) succeeds + mock_logging.basicConfig.side_effect = [Exception("setup failed"), None] + + opts = mock.MagicMock() + opts.debug = 0 + + handle_setup_logging(opts) + + # Recovery basicConfig called + assert mock_logging.basicConfig.call_count == 2 + # Error logged after recovery + mock_logging.error.assert_called_once() diff --git a/tests/handlers/test_utils_handlers.py b/tests/handlers/test_utils_handlers.py index 170b15c..6547f26 100644 --- a/tests/handlers/test_utils_handlers.py +++ b/tests/handlers/test_utils_handlers.py @@ -88,3 +88,69 @@ def test_custom_default_value(self): def test_passes_args_and_kwargs(self): result = safe_function_call(lambda x, y=0: x + y, 10, y=5) assert result == 15 + + +# --------------------------------------------------------------------------- +# warning_filter / WarningFilter — uncovered branches (lines 92-119) +# --------------------------------------------------------------------------- + + +class TestSuppressConsoleWarningsInternals: + """Tests for warning_filter() inner branches via suppress_console_warnings().""" + + def _get_warning_filter(self): + """Install suppress_console_warnings and return the attached WarningFilter.""" + import sys + from unittest.mock import MagicMock + + with mock.patch("logging.getLogger") as mock_get_logger, mock.patch("warnings.filterwarnings"): + mock_logger = MagicMock() + mock_handler = MagicMock() + # stream must equal sys.stderr to pass the guard in suppress_console_warnings + mock_handler.stream = sys.stderr + mock_logger.handlers = [mock_handler] + mock_get_logger.return_value = mock_logger + + suppress_console_warnings() + return mock_handler.addFilter.call_args[0][0] + + def test_warning_filter_versiontracker_filename_not_suppressed(self): + """Warnings from versiontracker source files are always shown (return True).""" + import logging + from unittest.mock import MagicMock + + added_filter = self._get_warning_filter() + + record = MagicMock() + record.levelno = logging.WARNING + record.filename = "/path/to/versiontracker/config.py" + record.lineno = 1 + record.getMessage.return_value = "some warning" + + assert added_filter.filter(record) is True + + def test_warning_filter_deprecation_from_external_suppressed(self): + """UserWarning from external code is suppressed (return False).""" + import logging + from unittest.mock import MagicMock + + added_filter = self._get_warning_filter() + + record = MagicMock() + record.levelno = logging.WARNING + record.filename = "/site-packages/third_party/lib.py" + record.lineno = 1 + record.getMessage.return_value = "deprecated" + # WarningFilter.filter() passes UserWarning as category → matches warn_type list → False + assert added_filter.filter(record) is False + + def test_warning_filter_non_warning_level_passes_through(self): + """Non-WARNING log records always pass through WarningFilter (return True).""" + import logging + from unittest.mock import MagicMock + + added_filter = self._get_warning_filter() + + record = MagicMock() + record.levelno = logging.INFO # not WARNING + assert added_filter.filter(record) is True diff --git a/tests/test_integration.py b/tests/test_integration.py index 266d76f..fe0f7a2 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -485,11 +485,13 @@ def test_concurrent_operations(self, mock_get_casks, mock_get_apps, mock_check_d def run_operation(operation_type): try: if operation_type == "apps": - with patch("builtins.print"): - handle_list_apps(MagicMock(apps=True, debug=False, blacklist=None)) + # Use export_format=None and output_file=None to avoid open(MagicMock()) + # Do NOT use patch("builtins.print") inside threads — it's not thread-safe + handle_list_apps( + MagicMock(apps=True, debug=False, blacklist=None, export_format=None, output_file=None) + ) elif operation_type == "brews": - with patch("builtins.print"): - handle_list_brews(MagicMock(brews=True, debug=False, export_format=None)) + handle_list_brews(MagicMock(brews=True, debug=False, export_format=None, output_file=None)) results.append(f"{operation_type}_success") except Exception as e: errors.append(f"{operation_type}_error: {e}") diff --git a/tests/test_outdated_handlers.py b/tests/test_outdated_handlers.py index a8986a1..e910bf2 100644 --- a/tests/test_outdated_handlers.py +++ b/tests/test_outdated_handlers.py @@ -1,5 +1,6 @@ """Comprehensive tests for outdated_handlers module.""" +import unittest from unittest.mock import Mock, patch import pytest @@ -13,6 +14,7 @@ _get_homebrew_casks, _get_installed_applications, _process_outdated_info, + _send_notification_if_available, _update_config_from_options, handle_outdated_check, ) @@ -885,3 +887,95 @@ def test_handle_network_error_check( result = handle_outdated_check(options) assert result == 1 + + +# --------------------------------------------------------------------------- +# _send_notification_if_available — uncovered paths (lines 261-291) +# --------------------------------------------------------------------------- + + +class TestSendNotificationIfAvailable(unittest.TestCase): + """Tests for _send_notification_if_available() platform and availability paths.""" + + def _progress_bar_mock(self): + pb = Mock() + pb.color.return_value = lambda x: x + return pb + + @patch("versiontracker.handlers.outdated_handlers.create_progress_bar") + @patch("versiontracker.handlers.outdated_handlers._MACOS_NOTIFICATIONS_AVAILABLE", False) + def test_notifications_unavailable_prints_warning(self, mock_pb): + """When module not available, prints warning and returns early.""" + + mock_pb.return_value = self._progress_bar_mock() + with patch("builtins.print") as mock_print: + _send_notification_if_available([], {}) + mock_print.assert_called_once() + call_text = str(mock_print.call_args) + assert "notification" in call_text.lower() or "available" in call_text.lower() + + @patch("versiontracker.handlers.outdated_handlers.create_progress_bar") + @patch("versiontracker.handlers.outdated_handlers._MACOS_NOTIFICATIONS_AVAILABLE", True) + @patch("versiontracker.handlers.outdated_handlers.sys") + def test_non_darwin_platform_prints_warning(self, mock_sys, mock_pb): + """On non-macOS platform, prints warning and returns early.""" + + mock_sys.platform = "linux" + mock_pb.return_value = self._progress_bar_mock() + with patch("builtins.print") as mock_print: + _send_notification_if_available([], {}) + mock_print.assert_called_once() + + @patch("versiontracker.handlers.outdated_handlers.create_progress_bar") + @patch("versiontracker.handlers.outdated_handlers._MACOS_NOTIFICATIONS_AVAILABLE", True) + @patch("versiontracker.handlers.outdated_handlers.sys") + @patch("versiontracker.handlers.outdated_handlers.MacOSNotifications") + def test_outdated_status_filtered_and_notification_sent(self, mock_notify, mock_sys, mock_pb): + """Only 'outdated' status entries are included in the notification.""" + + mock_sys.platform = "darwin" + mock_pb.return_value = self._progress_bar_mock() + mock_notify.notify_outdated_apps.return_value = True + + outdated_info = [ + ("App1", {"installed": "1.0", "latest": "2.0"}, "outdated"), + ("App2", {"installed": "3.0", "latest": "3.0"}, "up-to-date"), + ] + with patch("builtins.print"): + _send_notification_if_available(outdated_info, {"outdated": 1}) + + mock_notify.notify_outdated_apps.assert_called_once() + sent_apps = mock_notify.notify_outdated_apps.call_args[0][0] + assert len(sent_apps) == 1 + assert sent_apps[0]["name"] == "App1" + + @patch("versiontracker.handlers.outdated_handlers.create_progress_bar") + @patch("versiontracker.handlers.outdated_handlers._MACOS_NOTIFICATIONS_AVAILABLE", True) + @patch("versiontracker.handlers.outdated_handlers.sys") + @patch("versiontracker.handlers.outdated_handlers.MacOSNotifications") + def test_notification_failure_prints_warning(self, mock_notify, mock_sys, mock_pb): + """When notify_outdated_apps returns False, a warning is printed.""" + + mock_sys.platform = "darwin" + mock_pb.return_value = self._progress_bar_mock() + mock_notify.notify_outdated_apps.return_value = False + + with patch("builtins.print") as mock_print: + _send_notification_if_available([("App1", {"installed": "1.0", "latest": "2.0"}, "outdated")], {}) + # Warning print should have been called + assert mock_print.call_count >= 1 + + @patch("versiontracker.handlers.outdated_handlers.create_progress_bar") + @patch("versiontracker.handlers.outdated_handlers._MACOS_NOTIFICATIONS_AVAILABLE", True) + @patch("versiontracker.handlers.outdated_handlers.sys") + @patch("versiontracker.handlers.outdated_handlers.MacOSNotifications") + def test_notification_exception_handled(self, mock_notify, mock_sys, mock_pb): + """Exception during notification is caught and logged.""" + + mock_sys.platform = "darwin" + mock_pb.return_value = self._progress_bar_mock() + mock_notify.notify_outdated_apps.side_effect = RuntimeError("notification daemon unavailable") + + with patch("builtins.print"), patch("versiontracker.handlers.outdated_handlers.logger") as mock_log: + _send_notification_if_available([("App1", {"installed": "1.0", "latest": "2.0"}, "outdated")], {}) + mock_log.error.assert_called_once() diff --git a/tests/test_ui.py b/tests/test_ui.py index 48de848..aa0a25c 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -119,18 +119,14 @@ def test_colored_print_functions_with_kwargs(self, print_func, color): "print_func", [print_success, print_info, print_warning, print_error, print_debug], ) - @pytest.mark.skip(reason="Environment-specific print behavior varies between local and CI") - def test_print_functions_fallback(self, print_func, capsys, monkeypatch): + def test_print_functions_fallback(self, print_func, capsys): """Test print functions when termcolor is not available.""" message = "test message" - # Mock both HAS_TERMCOLOR and replace cprint with regular print - monkeypatch.setattr("versiontracker.ui.HAS_TERMCOLOR", False) - monkeypatch.setattr("versiontracker.ui.cprint", print) + with patch("versiontracker.ui.HAS_TERMCOLOR", False): + print_func(message) - print_func(message) captured = capsys.readouterr() - # More specific assertion - should be exact message with newline assert captured.out == f"{message}\n" assert captured.err == "" @@ -143,59 +139,30 @@ def test_print_functions_fallback(self, print_func, capsys, monkeypatch): "x" * 100, # Long message ], ) - @pytest.mark.skip(reason="Edge case terminal output capture varies between Python versions and CI environments") - def test_print_functions_edge_cases(self, message, capsys, monkeypatch): + def test_print_functions_edge_cases(self, message, capsys): """Test print functions with edge case inputs.""" - # Test with termcolor disabled - monkeypatch.setattr("versiontracker.ui.HAS_TERMCOLOR", False) - monkeypatch.setattr("versiontracker.ui.cprint", print) + with patch("versiontracker.ui.HAS_TERMCOLOR", False): + print_success(message) - # Test should not raise exceptions - print_success(message) captured = capsys.readouterr() - # When termcolor is not available, should use regular print assert message in captured.out - @pytest.mark.skip(reason="Environment-specific color handling varies between local and CI") def test_colored_fallback(self): - """Test colored function fallback.""" - # This test is environment-dependent and can vary between local and CI + """Test colored returns a string containing the original text regardless of termcolor availability.""" import versiontracker.ui as ui + # Whether termcolor is installed or not, the text must appear in the result result = ui.colored("test", "red") - # In fallback mode, should return text without color codes - assert "test" in result # More flexible assertion + assert "test" in result - @pytest.mark.skip(reason="Test has intermittent failures due to test state pollution in full suite context") - def test_cprint_fallback(self, capsys, monkeypatch): - """Test cprint function fallback.""" + def test_cprint_fallback(self, capsys): + """Test cprint outputs text to stdout regardless of termcolor availability.""" import versiontracker.ui as ui - # Create a simple fallback that always prints to stdout - def test_fallback_cprint(text, color=None, **kwargs): - # Always print regardless of color, simulating fallback behavior - print(str(text), **kwargs) - - # Set up monkeypatching - force fallback behavior - monkeypatch.setattr("versiontracker.ui.HAS_TERMCOLOR", False) - - # Clear any captured output before our test - capsys.readouterr() - - # Test the fallback behavior by calling cprint when HAS_TERMCOLOR is False - # When HAS_TERMCOLOR is False, the ui module should use its fallback implementation - if hasattr(ui, "cprint"): - # Call the existing cprint function - it should handle the fallback internally - ui.cprint("test", "red") - else: - # If cprint doesn't exist, use our test fallback - test_fallback_cprint("test", "red") - - # Get the captured output + # Call cprint directly — termcolor's cprint also writes to stdout via print() + ui.cprint("test", "red") captured = capsys.readouterr() - - # The output should contain "test" regardless of the exact format - assert "test" in captured.out, f"Expected 'test' in output but got {captured.out!r}" + assert "test" in captured.out assert captured.err == "" def test_color_constants_values(self): @@ -222,35 +189,20 @@ def test_print_functions_exception_handling(self): # If it does raise, that's the current behavior - document it pass - @pytest.mark.skip(reason="Test has intermittent failures due to test state pollution in full suite context") - def test_print_functions_with_file_kwarg(self, capsys, monkeypatch): - """Test print functions work with file kwarg.""" + def test_print_functions_with_file_kwarg(self, capsys): + """Test that print functions honour the file= kwarg in fallback mode.""" + import versiontracker.ui as ui string_io = io.StringIO() - # Force fallback mode - when HAS_TERMCOLOR is False, functions use print() - monkeypatch.setattr("versiontracker.ui.HAS_TERMCOLOR", False) - - # Clear any captured output before our test - capsys.readouterr() - - # Test print_success with file redirection - # When HAS_TERMCOLOR is False, print_success should use built-in print() - try: + with patch("versiontracker.ui.HAS_TERMCOLOR", False): ui.print_success("test", file=string_io) - except Exception: - # If there's an issue with the UI function, fall back to direct test - print("test", file=string_io) - # Should not appear in stdout since we redirected to StringIO + # Output must land in the StringIO, not on stdout + assert "test" in string_io.getvalue() captured = capsys.readouterr() - - # Should appear in our StringIO - result = string_io.getvalue() - - # Validate output using helper method - self._assert_test_output_present(result, captured.out) + assert captured.out == "" def _assert_test_output_present(self, result: str, stdout_content: str) -> None: """Helper method to validate test output is present in expected