mirror of
				https://github.com/esphome/esphome.git
				synced 2025-10-31 07:03:55 +00:00 
			
		
		
		
	CI: Centralize test determination logic to reduce unnecessary job runners (#9432)
This commit is contained in:
		
							
								
								
									
										352
									
								
								tests/script/test_determine_jobs.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										352
									
								
								tests/script/test_determine_jobs.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,352 @@ | ||||
| """Unit tests for script/determine-jobs.py module.""" | ||||
|  | ||||
| from collections.abc import Generator | ||||
| import importlib.util | ||||
| import json | ||||
| import os | ||||
| import subprocess | ||||
| import sys | ||||
| from unittest.mock import Mock, patch | ||||
|  | ||||
| import pytest | ||||
|  | ||||
| # Add the script directory to Python path so we can import the module | ||||
| script_dir = os.path.abspath( | ||||
|     os.path.join(os.path.dirname(__file__), "..", "..", "script") | ||||
| ) | ||||
| sys.path.insert(0, script_dir) | ||||
|  | ||||
| spec = importlib.util.spec_from_file_location( | ||||
|     "determine_jobs", os.path.join(script_dir, "determine-jobs.py") | ||||
| ) | ||||
| determine_jobs = importlib.util.module_from_spec(spec) | ||||
| spec.loader.exec_module(determine_jobs) | ||||
|  | ||||
|  | ||||
| @pytest.fixture | ||||
| def mock_should_run_integration_tests() -> Generator[Mock, None, None]: | ||||
|     """Mock should_run_integration_tests from helpers.""" | ||||
|     with patch.object(determine_jobs, "should_run_integration_tests") as mock: | ||||
|         yield mock | ||||
|  | ||||
|  | ||||
| @pytest.fixture | ||||
| def mock_should_run_clang_tidy() -> Generator[Mock, None, None]: | ||||
|     """Mock should_run_clang_tidy from helpers.""" | ||||
|     with patch.object(determine_jobs, "should_run_clang_tidy") as mock: | ||||
|         yield mock | ||||
|  | ||||
|  | ||||
| @pytest.fixture | ||||
| def mock_should_run_clang_format() -> Generator[Mock, None, None]: | ||||
|     """Mock should_run_clang_format from helpers.""" | ||||
|     with patch.object(determine_jobs, "should_run_clang_format") as mock: | ||||
|         yield mock | ||||
|  | ||||
|  | ||||
| @pytest.fixture | ||||
| def mock_should_run_python_linters() -> Generator[Mock, None, None]: | ||||
|     """Mock should_run_python_linters from helpers.""" | ||||
|     with patch.object(determine_jobs, "should_run_python_linters") as mock: | ||||
|         yield mock | ||||
|  | ||||
|  | ||||
| @pytest.fixture | ||||
| def mock_subprocess_run() -> Generator[Mock, None, None]: | ||||
|     """Mock subprocess.run for list-components.py calls.""" | ||||
|     with patch.object(determine_jobs.subprocess, "run") as mock: | ||||
|         yield mock | ||||
|  | ||||
|  | ||||
| def test_main_all_tests_should_run( | ||||
|     mock_should_run_integration_tests: Mock, | ||||
|     mock_should_run_clang_tidy: Mock, | ||||
|     mock_should_run_clang_format: Mock, | ||||
|     mock_should_run_python_linters: Mock, | ||||
|     mock_subprocess_run: Mock, | ||||
|     capsys: pytest.CaptureFixture[str], | ||||
| ) -> None: | ||||
|     """Test when all tests should run.""" | ||||
|     mock_should_run_integration_tests.return_value = True | ||||
|     mock_should_run_clang_tidy.return_value = True | ||||
|     mock_should_run_clang_format.return_value = True | ||||
|     mock_should_run_python_linters.return_value = True | ||||
|  | ||||
|     # Mock list-components.py output | ||||
|     mock_result = Mock() | ||||
|     mock_result.stdout = "wifi\napi\nsensor\n" | ||||
|     mock_subprocess_run.return_value = mock_result | ||||
|  | ||||
|     # Run main function with mocked argv | ||||
|     with patch("sys.argv", ["determine-jobs.py"]): | ||||
|         determine_jobs.main() | ||||
|  | ||||
|     # Check output | ||||
|     captured = capsys.readouterr() | ||||
|     output = json.loads(captured.out) | ||||
|  | ||||
|     assert output["integration_tests"] is True | ||||
|     assert output["clang_tidy"] is True | ||||
|     assert output["clang_format"] is True | ||||
|     assert output["python_linters"] is True | ||||
|     assert output["changed_components"] == ["wifi", "api", "sensor"] | ||||
|     assert output["component_test_count"] == 3 | ||||
|  | ||||
|  | ||||
| def test_main_no_tests_should_run( | ||||
|     mock_should_run_integration_tests: Mock, | ||||
|     mock_should_run_clang_tidy: Mock, | ||||
|     mock_should_run_clang_format: Mock, | ||||
|     mock_should_run_python_linters: Mock, | ||||
|     mock_subprocess_run: Mock, | ||||
|     capsys: pytest.CaptureFixture[str], | ||||
| ) -> None: | ||||
|     """Test when no tests should run.""" | ||||
|     mock_should_run_integration_tests.return_value = False | ||||
|     mock_should_run_clang_tidy.return_value = False | ||||
|     mock_should_run_clang_format.return_value = False | ||||
|     mock_should_run_python_linters.return_value = False | ||||
|  | ||||
|     # Mock empty list-components.py output | ||||
|     mock_result = Mock() | ||||
|     mock_result.stdout = "" | ||||
|     mock_subprocess_run.return_value = mock_result | ||||
|  | ||||
|     # Run main function with mocked argv | ||||
|     with patch("sys.argv", ["determine-jobs.py"]): | ||||
|         determine_jobs.main() | ||||
|  | ||||
|     # Check output | ||||
|     captured = capsys.readouterr() | ||||
|     output = json.loads(captured.out) | ||||
|  | ||||
|     assert output["integration_tests"] is False | ||||
|     assert output["clang_tidy"] is False | ||||
|     assert output["clang_format"] is False | ||||
|     assert output["python_linters"] is False | ||||
|     assert output["changed_components"] == [] | ||||
|     assert output["component_test_count"] == 0 | ||||
|  | ||||
|  | ||||
| def test_main_list_components_fails( | ||||
|     mock_should_run_integration_tests: Mock, | ||||
|     mock_should_run_clang_tidy: Mock, | ||||
|     mock_should_run_clang_format: Mock, | ||||
|     mock_should_run_python_linters: Mock, | ||||
|     mock_subprocess_run: Mock, | ||||
|     capsys: pytest.CaptureFixture[str], | ||||
| ) -> None: | ||||
|     """Test when list-components.py fails.""" | ||||
|     mock_should_run_integration_tests.return_value = True | ||||
|     mock_should_run_clang_tidy.return_value = True | ||||
|     mock_should_run_clang_format.return_value = True | ||||
|     mock_should_run_python_linters.return_value = True | ||||
|  | ||||
|     # Mock list-components.py failure | ||||
|     mock_subprocess_run.side_effect = subprocess.CalledProcessError(1, "cmd") | ||||
|  | ||||
|     # Run main function with mocked argv - should raise | ||||
|     with patch("sys.argv", ["determine-jobs.py"]): | ||||
|         with pytest.raises(subprocess.CalledProcessError): | ||||
|             determine_jobs.main() | ||||
|  | ||||
|  | ||||
| def test_main_with_branch_argument( | ||||
|     mock_should_run_integration_tests: Mock, | ||||
|     mock_should_run_clang_tidy: Mock, | ||||
|     mock_should_run_clang_format: Mock, | ||||
|     mock_should_run_python_linters: Mock, | ||||
|     mock_subprocess_run: Mock, | ||||
|     capsys: pytest.CaptureFixture[str], | ||||
| ) -> None: | ||||
|     """Test with branch argument.""" | ||||
|     mock_should_run_integration_tests.return_value = False | ||||
|     mock_should_run_clang_tidy.return_value = True | ||||
|     mock_should_run_clang_format.return_value = False | ||||
|     mock_should_run_python_linters.return_value = True | ||||
|  | ||||
|     # Mock list-components.py output | ||||
|     mock_result = Mock() | ||||
|     mock_result.stdout = "mqtt\n" | ||||
|     mock_subprocess_run.return_value = mock_result | ||||
|  | ||||
|     with patch("sys.argv", ["script.py", "-b", "main"]): | ||||
|         determine_jobs.main() | ||||
|  | ||||
|     # Check that functions were called with branch | ||||
|     mock_should_run_integration_tests.assert_called_once_with("main") | ||||
|     mock_should_run_clang_tidy.assert_called_once_with("main") | ||||
|     mock_should_run_clang_format.assert_called_once_with("main") | ||||
|     mock_should_run_python_linters.assert_called_once_with("main") | ||||
|  | ||||
|     # Check that list-components.py was called with branch | ||||
|     mock_subprocess_run.assert_called_once() | ||||
|     call_args = mock_subprocess_run.call_args[0][0] | ||||
|     assert "--changed" in call_args | ||||
|     assert "-b" in call_args | ||||
|     assert "main" in call_args | ||||
|  | ||||
|     # Check output | ||||
|     captured = capsys.readouterr() | ||||
|     output = json.loads(captured.out) | ||||
|  | ||||
|     assert output["integration_tests"] is False | ||||
|     assert output["clang_tidy"] is True | ||||
|     assert output["clang_format"] is False | ||||
|     assert output["python_linters"] is True | ||||
|     assert output["changed_components"] == ["mqtt"] | ||||
|     assert output["component_test_count"] == 1 | ||||
|  | ||||
|  | ||||
| def test_should_run_integration_tests( | ||||
|     monkeypatch: pytest.MonkeyPatch, | ||||
| ) -> None: | ||||
|     """Test should_run_integration_tests function.""" | ||||
|     # Core C++ files trigger tests | ||||
|     with patch.object( | ||||
|         determine_jobs, "changed_files", return_value=["esphome/core/component.cpp"] | ||||
|     ): | ||||
|         result = determine_jobs.should_run_integration_tests() | ||||
|         assert result is True | ||||
|  | ||||
|     # Core Python files trigger tests | ||||
|     with patch.object( | ||||
|         determine_jobs, "changed_files", return_value=["esphome/core/config.py"] | ||||
|     ): | ||||
|         result = determine_jobs.should_run_integration_tests() | ||||
|         assert result is True | ||||
|  | ||||
|     # Python files directly in esphome/ do NOT trigger tests | ||||
|     with patch.object( | ||||
|         determine_jobs, "changed_files", return_value=["esphome/config.py"] | ||||
|     ): | ||||
|         result = determine_jobs.should_run_integration_tests() | ||||
|         assert result is False | ||||
|  | ||||
|     # Python files in subdirectories (not core) do NOT trigger tests | ||||
|     with patch.object( | ||||
|         determine_jobs, | ||||
|         "changed_files", | ||||
|         return_value=["esphome/dashboard/web_server.py"], | ||||
|     ): | ||||
|         result = determine_jobs.should_run_integration_tests() | ||||
|         assert result is False | ||||
|  | ||||
|  | ||||
| def test_should_run_integration_tests_with_branch() -> None: | ||||
|     """Test should_run_integration_tests with branch argument.""" | ||||
|     with patch.object(determine_jobs, "changed_files") as mock_changed: | ||||
|         mock_changed.return_value = [] | ||||
|         determine_jobs.should_run_integration_tests("release") | ||||
|         mock_changed.assert_called_once_with("release") | ||||
|  | ||||
|  | ||||
| def test_should_run_integration_tests_component_dependency() -> None: | ||||
|     """Test that integration tests run when components used in fixtures change.""" | ||||
|     with patch.object( | ||||
|         determine_jobs, "changed_files", return_value=["esphome/components/api/api.cpp"] | ||||
|     ): | ||||
|         with patch.object( | ||||
|             determine_jobs, "get_components_from_integration_fixtures" | ||||
|         ) as mock_fixtures: | ||||
|             mock_fixtures.return_value = {"api", "sensor"} | ||||
|             with patch.object(determine_jobs, "get_all_dependencies") as mock_deps: | ||||
|                 mock_deps.return_value = {"api", "sensor", "network"} | ||||
|                 result = determine_jobs.should_run_integration_tests() | ||||
|                 assert result is True | ||||
|  | ||||
|  | ||||
| @pytest.mark.parametrize( | ||||
|     ("check_returncode", "changed_files", "expected_result"), | ||||
|     [ | ||||
|         (0, [], True),  # Hash changed - need full scan | ||||
|         (1, ["esphome/core.cpp"], True),  # C++ file changed | ||||
|         (1, ["README.md"], False),  # No C++ files changed | ||||
|     ], | ||||
| ) | ||||
| def test_should_run_clang_tidy( | ||||
|     check_returncode: int, | ||||
|     changed_files: list[str], | ||||
|     expected_result: bool, | ||||
| ) -> None: | ||||
|     """Test should_run_clang_tidy function.""" | ||||
|     with patch.object(determine_jobs, "changed_files", return_value=changed_files): | ||||
|         # Test with hash check returning specific code | ||||
|         with patch("subprocess.run") as mock_run: | ||||
|             mock_run.return_value = Mock(returncode=check_returncode) | ||||
|             result = determine_jobs.should_run_clang_tidy() | ||||
|             assert result == expected_result | ||||
|  | ||||
|         # Test with hash check failing (exception) | ||||
|         if check_returncode != 0: | ||||
|             with patch("subprocess.run", side_effect=Exception("Failed")): | ||||
|                 result = determine_jobs.should_run_clang_tidy() | ||||
|             assert result is True  # Fail safe - run clang-tidy | ||||
|  | ||||
|  | ||||
| def test_should_run_clang_tidy_with_branch() -> None: | ||||
|     """Test should_run_clang_tidy with branch argument.""" | ||||
|     with patch.object(determine_jobs, "changed_files") as mock_changed: | ||||
|         mock_changed.return_value = [] | ||||
|         with patch("subprocess.run") as mock_run: | ||||
|             mock_run.return_value = Mock(returncode=1)  # Hash unchanged | ||||
|             determine_jobs.should_run_clang_tidy("release") | ||||
|             mock_changed.assert_called_once_with("release") | ||||
|  | ||||
|  | ||||
| @pytest.mark.parametrize( | ||||
|     ("changed_files", "expected_result"), | ||||
|     [ | ||||
|         (["esphome/core.py"], True), | ||||
|         (["script/test.py"], True), | ||||
|         (["esphome/test.pyi"], True),  # .pyi files should trigger | ||||
|         (["README.md"], False), | ||||
|         ([], False), | ||||
|     ], | ||||
| ) | ||||
| def test_should_run_python_linters( | ||||
|     changed_files: list[str], expected_result: bool | ||||
| ) -> None: | ||||
|     """Test should_run_python_linters function.""" | ||||
|     with patch.object(determine_jobs, "changed_files", return_value=changed_files): | ||||
|         result = determine_jobs.should_run_python_linters() | ||||
|         assert result == expected_result | ||||
|  | ||||
|  | ||||
| def test_should_run_python_linters_with_branch() -> None: | ||||
|     """Test should_run_python_linters with branch argument.""" | ||||
|     with patch.object(determine_jobs, "changed_files") as mock_changed: | ||||
|         mock_changed.return_value = [] | ||||
|         determine_jobs.should_run_python_linters("release") | ||||
|         mock_changed.assert_called_once_with("release") | ||||
|  | ||||
|  | ||||
| @pytest.mark.parametrize( | ||||
|     ("changed_files", "expected_result"), | ||||
|     [ | ||||
|         (["esphome/core.cpp"], True), | ||||
|         (["esphome/core.h"], True), | ||||
|         (["test.hpp"], True), | ||||
|         (["test.cc"], True), | ||||
|         (["test.cxx"], True), | ||||
|         (["test.c"], True), | ||||
|         (["test.tcc"], True), | ||||
|         (["README.md"], False), | ||||
|         ([], False), | ||||
|     ], | ||||
| ) | ||||
| def test_should_run_clang_format( | ||||
|     changed_files: list[str], expected_result: bool | ||||
| ) -> None: | ||||
|     """Test should_run_clang_format function.""" | ||||
|     with patch.object(determine_jobs, "changed_files", return_value=changed_files): | ||||
|         result = determine_jobs.should_run_clang_format() | ||||
|         assert result == expected_result | ||||
|  | ||||
|  | ||||
| def test_should_run_clang_format_with_branch() -> None: | ||||
|     """Test should_run_clang_format with branch argument.""" | ||||
|     with patch.object(determine_jobs, "changed_files") as mock_changed: | ||||
|         mock_changed.return_value = [] | ||||
|         determine_jobs.should_run_clang_format("release") | ||||
|         mock_changed.assert_called_once_with("release") | ||||
		Reference in New Issue
	
	Block a user