[ci] Only run integration tests for changed components (#14776)

This commit is contained in:
J. Nick Koston
2026-03-13 13:20:35 -10:00
committed by GitHub
parent 22062d79a2
commit 56f7b3e61b
5 changed files with 400 additions and 100 deletions
+16 -1
View File
@@ -170,6 +170,8 @@ jobs:
- common - common
outputs: outputs:
integration-tests: ${{ steps.determine.outputs.integration-tests }} integration-tests: ${{ steps.determine.outputs.integration-tests }}
integration-tests-run-all: ${{ steps.determine.outputs.integration-tests-run-all }}
integration-test-files: ${{ steps.determine.outputs.integration-test-files }}
clang-tidy: ${{ steps.determine.outputs.clang-tidy }} clang-tidy: ${{ steps.determine.outputs.clang-tidy }}
clang-tidy-mode: ${{ steps.determine.outputs.clang-tidy-mode }} clang-tidy-mode: ${{ steps.determine.outputs.clang-tidy-mode }}
python-linters: ${{ steps.determine.outputs.python-linters }} python-linters: ${{ steps.determine.outputs.python-linters }}
@@ -210,6 +212,8 @@ jobs:
# Extract individual fields # Extract individual fields
echo "integration-tests=$(echo "$output" | jq -r '.integration_tests')" >> $GITHUB_OUTPUT echo "integration-tests=$(echo "$output" | jq -r '.integration_tests')" >> $GITHUB_OUTPUT
echo "integration-tests-run-all=$(echo "$output" | jq -r '.integration_tests_run_all')" >> $GITHUB_OUTPUT
echo "integration-test-files=$(echo "$output" | jq -c '.integration_test_files')" >> $GITHUB_OUTPUT
echo "clang-tidy=$(echo "$output" | jq -r '.clang_tidy')" >> $GITHUB_OUTPUT echo "clang-tidy=$(echo "$output" | jq -r '.clang_tidy')" >> $GITHUB_OUTPUT
echo "clang-tidy-mode=$(echo "$output" | jq -r '.clang_tidy_mode')" >> $GITHUB_OUTPUT echo "clang-tidy-mode=$(echo "$output" | jq -r '.clang_tidy_mode')" >> $GITHUB_OUTPUT
echo "python-linters=$(echo "$output" | jq -r '.python_linters')" >> $GITHUB_OUTPUT echo "python-linters=$(echo "$output" | jq -r '.python_linters')" >> $GITHUB_OUTPUT
@@ -261,9 +265,20 @@ jobs:
- name: Register matcher - name: Register matcher
run: echo "::add-matcher::.github/workflows/matchers/pytest.json" run: echo "::add-matcher::.github/workflows/matchers/pytest.json"
- name: Run integration tests - name: Run integration tests
env:
INTEGRATION_TEST_FILES: ${{ needs.determine-jobs.outputs.integration-test-files }}
INTEGRATION_TESTS_RUN_ALL: ${{ needs.determine-jobs.outputs.integration-tests-run-all }}
run: | run: |
. venv/bin/activate . venv/bin/activate
pytest -vv --no-cov --tb=native -n auto tests/integration/ if [[ "$INTEGRATION_TESTS_RUN_ALL" == "true" ]]; then
echo "Running all integration tests"
pytest -vv --no-cov --tb=native -n auto tests/integration/
else
# Parse JSON array into bash array to avoid shell expansion issues
mapfile -t test_files < <(echo "$INTEGRATION_TEST_FILES" | jq -r '.[]')
echo "Running ${#test_files[@]} specific integration tests"
pytest -vv --no-cov --tb=native -n auto "${test_files[@]}"
fi
cpp-unit-tests: cpp-unit-tests:
name: Run C++ unit tests name: Run C++ unit tests
+67 -37
View File
@@ -6,6 +6,8 @@ what files have changed. It outputs JSON with the following structure:
{ {
"integration_tests": true/false, "integration_tests": true/false,
"integration_tests_run_all": true/false,
"integration_test_files": ["tests/integration/test_foo.py", ...],
"clang_tidy": true/false, "clang_tidy": true/false,
"clang_format": true/false, "clang_format": true/false,
"python_linters": true/false, "python_linters": true/false,
@@ -56,13 +58,13 @@ from helpers import (
core_changed, core_changed,
filter_component_and_test_cpp_files, filter_component_and_test_cpp_files,
filter_component_and_test_files, filter_component_and_test_files,
get_all_dependencies,
get_changed_components, get_changed_components,
get_component_from_path, get_component_from_path,
get_component_test_files, get_component_test_files,
get_components_from_integration_fixtures,
get_components_with_dependencies, get_components_with_dependencies,
get_cpp_changed_components, get_cpp_changed_components,
get_fixture_to_test_files,
get_integration_test_files_for_components,
get_target_branch, get_target_branch,
git_ls_files, git_ls_files,
parse_test_filename, parse_test_filename,
@@ -143,65 +145,88 @@ MEMORY_IMPACT_PLATFORM_PREFERENCE = [
] ]
def should_run_integration_tests(branch: str | None = None) -> bool: def determine_integration_tests(branch: str | None = None) -> tuple[bool, list[str]]:
"""Determine if integration tests should run based on changed files. """Determine which integration tests should run based on changed files.
This function is used by the CI workflow to intelligently skip integration tests when they're This function is used by the CI workflow to intelligently skip or filter
not needed, saving significant CI time and resources. integration tests, saving significant CI time and resources.
Integration tests will run when ANY of the following conditions are met: Returns (run_all=True, []) when ANY of the following conditions are met:
1. Core C++ files changed (esphome/core/*) 1. Core C++ files changed (esphome/core/*)
- Any .cpp, .h, .tcc files in the core directory - Any .cpp, .h, .tcc files in the core directory
- These files contain fundamental functionality used throughout ESPHome - These files contain fundamental functionality used throughout ESPHome
- Examples: esphome/core/component.cpp, esphome/core/application.h
2. Core Python files changed (esphome/core/*.py) 2. Core Python files changed (esphome/core/*.py)
- Only .py files in the esphome/core/ directory - Only .py files in the esphome/core/ directory
- These are core Python files that affect the entire system - These are core Python files that affect the entire system
- Examples: esphome/core/config.py, esphome/core/__init__.py
- NOT included: esphome/*.py, esphome/dashboard/*.py, esphome/components/*/*.py
3. Integration test files changed 3. Integration test infrastructure files changed
- Any file in tests/integration/ directory - conftest.py, types.py, const.py, entity_utils.py, state_utils.py, etc.
- This includes test files themselves and fixture YAML files
- Examples: tests/integration/test_api.py, tests/integration/fixtures/api.yaml
4. Components used by integration tests (or their dependencies) changed Returns (run_all=False, [test_files...]) when:
- The function parses all YAML files in tests/integration/fixtures/
- Extracts which components are used in integration tests 4. Specific integration test files changed
- Recursively finds all dependencies of those components - Only those specific test files are returned
- If any of these components have changes, tests must run
- Example: If api.yaml uses 'sensor' and 'api' components, and 'api' depends on 'socket', 5. Components used by integration tests (or their dependencies) changed
then changes to sensor/, api/, or socket/ components trigger tests - Only test files whose fixtures use the changed components are returned
Args: Args:
branch: Branch to compare against. If None, uses default. branch: Branch to compare against. If None, uses default.
Returns: Returns:
True if integration tests should run, False otherwise. Tuple of (run_all, test_files) where:
- run_all: True if all integration tests should run
- test_files: List of specific test file paths to run (empty if run_all
is True, or if no tests need to run)
""" """
files = changed_files(branch) files = changed_files(branch)
if core_changed(files): if core_changed(files):
# If any core files changed, run integration tests # If any core files changed, run all integration tests
return True return (True, [])
# Check if any integration test files changed # If infrastructure Python files changed (conftest, utils, etc.), run all tests
if any("tests/integration" in file for file in files): # Excludes test files (test_*.py), fixtures, and non-Python files (README.md)
return True if any(
f.startswith("tests/integration/")
and f.endswith(".py")
and not f.startswith("tests/integration/test_")
and "/fixtures/" not in f
for f in files
):
return (True, [])
# Get all components used in integration tests and their dependencies # Collect specific test files that need to run
fixture_components = get_components_from_integration_fixtures() test_files: set[str] = set()
all_required_components = get_all_dependencies(fixture_components) fixture_to_test_files = get_fixture_to_test_files()
# Check if any required components changed for f in files:
for file in files: if f.startswith("tests/integration/test_") and f.endswith(".py"):
component = get_component_from_path(file) test_files.add(f)
if component and component in all_required_components: elif f.startswith("tests/integration/fixtures/"):
return True if f.endswith(".yaml"):
# Fixture YAML changed - add corresponding test file(s)
test_files.update(fixture_to_test_files.get(Path(f).stem, ()))
else:
# Non-YAML fixture file changed (e.g., external_components/)
# Run all tests since we can't determine which tests are affected
return (True, [])
return False # Find test files whose fixtures use any of the changed components
changed_component_set = {
component for file in files if (component := get_component_from_path(file))
}
if changed_component_set:
test_files.update(
get_integration_test_files_for_components(changed_component_set)
)
if test_files:
return (False, sorted(test_files))
return (False, [])
@cache @cache
@@ -682,7 +707,10 @@ def main() -> None:
args = parser.parse_args() args = parser.parse_args()
# Determine what should run # Determine what should run
run_integration = should_run_integration_tests(args.branch) integration_run_all, integration_test_files = determine_integration_tests(
args.branch
)
run_integration = integration_run_all or bool(integration_test_files)
run_clang_tidy = should_run_clang_tidy(args.branch) run_clang_tidy = should_run_clang_tidy(args.branch)
run_clang_format = should_run_clang_format(args.branch) run_clang_format = should_run_clang_format(args.branch)
run_python_linters = should_run_python_linters(args.branch) run_python_linters = should_run_python_linters(args.branch)
@@ -810,6 +838,8 @@ def main() -> None:
output: dict[str, Any] = { output: dict[str, Any] = {
"integration_tests": run_integration, "integration_tests": run_integration,
"integration_tests_run_all": integration_run_all,
"integration_test_files": integration_test_files,
"clang_tidy": run_clang_tidy, "clang_tidy": run_clang_tidy,
"clang_tidy_mode": clang_tidy_mode, "clang_tidy_mode": clang_tidy_mode,
"clang_format": run_clang_format, "clang_format": run_clang_format,
+118 -14
View File
@@ -700,37 +700,141 @@ def get_all_dependencies(
return all_components return all_components
def _extract_components_from_yaml(config: dict) -> set[str]:
"""Extract component names from a parsed YAML config.
Args:
config: Parsed YAML configuration dictionary
Returns:
Set of component names found in the config
"""
components: set[str] = set()
# Add all top-level component keys (skip YAML anchor keys starting with '.')
components.update(k for k in config if isinstance(k, str) and not k.startswith("."))
# Add platform values from list entries (e.g., sensor -> platform: template adds "template")
for value in config.values():
if isinstance(value, list):
components.update(
item["platform"]
for item in value
if isinstance(item, dict) and "platform" in item
)
return components
def get_components_from_integration_fixtures() -> set[str]: def get_components_from_integration_fixtures() -> set[str]:
"""Extract all components used in integration test fixtures. """Extract all components used in integration test fixtures.
Returns: Returns:
Set of component names used in integration test fixtures Set of component names used in integration test fixtures
""" """
return {
comp
for components in get_components_per_integration_fixture().values()
for comp in components
}
@cache
def get_components_per_integration_fixture() -> dict[str, set[str]]:
"""Extract components used in each integration test fixture.
Returns:
Dictionary mapping fixture name (stem) to set of component names
"""
from esphome import yaml_util from esphome import yaml_util
components: set[str] = set() result: dict[str, set[str]] = {}
fixtures_dir = Path(__file__).parent.parent / "tests" / "integration" / "fixtures" fixtures_dir = Path(__file__).parent.parent / "tests" / "integration" / "fixtures"
for yaml_file in fixtures_dir.glob("*.yaml"): for yaml_file in fixtures_dir.glob("*.yaml"):
config: dict[str, any] | None = yaml_util.load_yaml(yaml_file) config: dict[str, Any] | None = yaml_util.load_yaml(yaml_file)
if not config: if not config:
continue continue
# Add all top-level component keys (skip YAML anchor keys starting with '.') result[yaml_file.stem] = _extract_components_from_yaml(config)
components.update(
k for k in config if isinstance(k, str) and not k.startswith(".")
)
# Add platform components (e.g., output.template) return result
for value in config.values():
if not isinstance(value, list):
continue
for item in value:
if isinstance(item, dict) and "platform" in item:
components.add(item["platform"])
return components _TEST_FUNC_RE = re.compile(r"async def (test_\w+)")
@cache
def get_fixture_to_test_files() -> dict[str, frozenset[str]]:
"""Map integration test fixture names to the test files that use them.
Returns:
Dictionary mapping fixture name to frozenset of test file paths
(relative to repo root)
"""
integration_dir = Path(__file__).parent.parent / "tests" / "integration"
result: dict[str, set[str]] = {}
for test_file in integration_dir.glob("test_*.py"):
content = test_file.read_text(encoding="utf-8")
rel_path = test_file.relative_to(Path(__file__).parent.parent).as_posix()
for func in _TEST_FUNC_RE.findall(content):
base_name = func.replace("test_", "").partition("[")[0]
result.setdefault(base_name, set()).add(rel_path)
return {k: frozenset(v) for k, v in result.items()}
@cache
def _get_component_to_integration_test_files() -> dict[str, frozenset[str]]:
"""Build index mapping each component to the test files that depend on it.
Resolves full dependency trees once per fixture, then inverts the mapping
so lookups are O(1) per component.
Returns:
Dictionary mapping component name to frozenset of test file paths
"""
fixture_components = get_components_per_integration_fixture()
fixture_to_test_files = get_fixture_to_test_files()
result: dict[str, set[str]] = {}
for fixture_name, components in fixture_components.items():
test_files = fixture_to_test_files.get(fixture_name)
if not test_files:
continue
# Get full dependency tree for this fixture's components
all_deps = get_all_dependencies(components)
for dep in all_deps:
result.setdefault(dep, set()).update(test_files)
return {k: frozenset(v) for k, v in result.items()}
def get_integration_test_files_for_components(
changed_components: set[str],
) -> list[str]:
"""Get integration test file paths that use any of the given components.
Uses a precomputed component → test files index for O(C) lookup
where C is the number of changed components.
Args:
changed_components: Set of component names that have changed
Returns:
Sorted list of test file paths relative to repo root
(e.g., ["tests/integration/test_api.py", ...])
"""
component_to_tests = _get_component_to_integration_test_files()
return sorted(
{
test_file
for component in changed_components
for test_file in component_to_tests.get(component, ())
}
)
def filter_component_and_test_files(file_path: str) -> bool: def filter_component_and_test_files(file_path: str) -> bool:
+171 -46
View File
@@ -29,9 +29,9 @@ spec.loader.exec_module(determine_jobs)
@pytest.fixture @pytest.fixture
def mock_should_run_integration_tests() -> Generator[Mock, None, None]: def mock_determine_integration_tests() -> Generator[Mock, None, None]:
"""Mock should_run_integration_tests from helpers.""" """Mock determine_integration_tests."""
with patch.object(determine_jobs, "should_run_integration_tests") as mock: with patch.object(determine_jobs, "determine_integration_tests") as mock:
yield mock yield mock
@@ -87,7 +87,7 @@ def clear_determine_jobs_caches() -> None:
def test_main_all_tests_should_run( def test_main_all_tests_should_run(
mock_should_run_integration_tests: Mock, mock_determine_integration_tests: Mock,
mock_should_run_clang_tidy: Mock, mock_should_run_clang_tidy: Mock,
mock_should_run_clang_format: Mock, mock_should_run_clang_format: Mock,
mock_should_run_python_linters: Mock, mock_should_run_python_linters: Mock,
@@ -100,7 +100,7 @@ def test_main_all_tests_should_run(
# Ensure we're not in GITHUB_ACTIONS mode for this test # Ensure we're not in GITHUB_ACTIONS mode for this test
monkeypatch.delenv("GITHUB_ACTIONS", raising=False) monkeypatch.delenv("GITHUB_ACTIONS", raising=False)
mock_should_run_integration_tests.return_value = True mock_determine_integration_tests.return_value = (True, [])
mock_should_run_clang_tidy.return_value = True mock_should_run_clang_tidy.return_value = True
mock_should_run_clang_format.return_value = True mock_should_run_clang_format.return_value = True
mock_should_run_python_linters.return_value = True mock_should_run_python_linters.return_value = True
@@ -152,6 +152,8 @@ def test_main_all_tests_should_run(
output = json.loads(captured.out) output = json.loads(captured.out)
assert output["integration_tests"] is True assert output["integration_tests"] is True
assert output["integration_tests_run_all"] is True
assert output["integration_test_files"] == []
assert output["clang_tidy"] is True assert output["clang_tidy"] is True
assert output["clang_tidy_mode"] in ["nosplit", "split"] assert output["clang_tidy_mode"] in ["nosplit", "split"]
assert output["clang_format"] is True assert output["clang_format"] is True
@@ -183,7 +185,7 @@ def test_main_all_tests_should_run(
def test_main_no_tests_should_run( def test_main_no_tests_should_run(
mock_should_run_integration_tests: Mock, mock_determine_integration_tests: Mock,
mock_should_run_clang_tidy: Mock, mock_should_run_clang_tidy: Mock,
mock_should_run_clang_format: Mock, mock_should_run_clang_format: Mock,
mock_should_run_python_linters: Mock, mock_should_run_python_linters: Mock,
@@ -196,7 +198,7 @@ def test_main_no_tests_should_run(
# Ensure we're not in GITHUB_ACTIONS mode for this test # Ensure we're not in GITHUB_ACTIONS mode for this test
monkeypatch.delenv("GITHUB_ACTIONS", raising=False) monkeypatch.delenv("GITHUB_ACTIONS", raising=False)
mock_should_run_integration_tests.return_value = False mock_determine_integration_tests.return_value = (False, [])
mock_should_run_clang_tidy.return_value = False mock_should_run_clang_tidy.return_value = False
mock_should_run_clang_format.return_value = False mock_should_run_clang_format.return_value = False
mock_should_run_python_linters.return_value = False mock_should_run_python_linters.return_value = False
@@ -233,6 +235,8 @@ def test_main_no_tests_should_run(
output = json.loads(captured.out) output = json.loads(captured.out)
assert output["integration_tests"] is False assert output["integration_tests"] is False
assert output["integration_tests_run_all"] is False
assert output["integration_test_files"] == []
assert output["clang_tidy"] is False assert output["clang_tidy"] is False
assert output["clang_tidy_mode"] == "disabled" assert output["clang_tidy_mode"] == "disabled"
assert output["clang_format"] is False assert output["clang_format"] is False
@@ -253,7 +257,7 @@ def test_main_no_tests_should_run(
def test_main_with_branch_argument( def test_main_with_branch_argument(
mock_should_run_integration_tests: Mock, mock_determine_integration_tests: Mock,
mock_should_run_clang_tidy: Mock, mock_should_run_clang_tidy: Mock,
mock_should_run_clang_format: Mock, mock_should_run_clang_format: Mock,
mock_should_run_python_linters: Mock, mock_should_run_python_linters: Mock,
@@ -266,7 +270,7 @@ def test_main_with_branch_argument(
# Ensure we're not in GITHUB_ACTIONS mode for this test # Ensure we're not in GITHUB_ACTIONS mode for this test
monkeypatch.delenv("GITHUB_ACTIONS", raising=False) monkeypatch.delenv("GITHUB_ACTIONS", raising=False)
mock_should_run_integration_tests.return_value = False mock_determine_integration_tests.return_value = (False, [])
mock_should_run_clang_tidy.return_value = True mock_should_run_clang_tidy.return_value = True
mock_should_run_clang_format.return_value = False mock_should_run_clang_format.return_value = False
mock_should_run_python_linters.return_value = True mock_should_run_python_linters.return_value = True
@@ -302,7 +306,7 @@ def test_main_with_branch_argument(
determine_jobs.main() determine_jobs.main()
# Check that functions were called with branch # Check that functions were called with branch
mock_should_run_integration_tests.assert_called_once_with("main") mock_determine_integration_tests.assert_called_once_with("main")
mock_should_run_clang_tidy.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_clang_format.assert_called_once_with("main")
mock_should_run_python_linters.assert_called_once_with("main") mock_should_run_python_linters.assert_called_once_with("main")
@@ -312,6 +316,8 @@ def test_main_with_branch_argument(
output = json.loads(captured.out) output = json.loads(captured.out)
assert output["integration_tests"] is False assert output["integration_tests"] is False
assert output["integration_tests_run_all"] is False
assert output["integration_test_files"] == []
assert output["clang_tidy"] is True assert output["clang_tidy"] is True
assert output["clang_tidy_mode"] in ["nosplit", "split"] assert output["clang_tidy_mode"] in ["nosplit", "split"]
assert output["clang_format"] is False assert output["clang_format"] is False
@@ -334,30 +340,33 @@ def test_main_with_branch_argument(
assert output["cpp_unit_tests_components"] == ["mqtt"] assert output["cpp_unit_tests_components"] == ["mqtt"]
def test_should_run_integration_tests( def test_determine_integration_tests(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
"""Test should_run_integration_tests function.""" """Test determine_integration_tests function."""
# Core C++ files trigger tests # Core C++ files trigger run_all
with patch.object( with patch.object(
determine_jobs, "changed_files", return_value=["esphome/core/component.cpp"] determine_jobs, "changed_files", return_value=["esphome/core/component.cpp"]
): ):
result = determine_jobs.should_run_integration_tests() run_all, test_files = determine_jobs.determine_integration_tests()
assert result is True assert run_all is True
assert test_files == []
# Core Python files trigger tests # Core Python files trigger run_all
with patch.object( with patch.object(
determine_jobs, "changed_files", return_value=["esphome/core/config.py"] determine_jobs, "changed_files", return_value=["esphome/core/config.py"]
): ):
result = determine_jobs.should_run_integration_tests() run_all, test_files = determine_jobs.determine_integration_tests()
assert result is True assert run_all is True
assert test_files == []
# Python files directly in esphome/ do NOT trigger tests # Python files directly in esphome/ do NOT trigger tests
with patch.object( with patch.object(
determine_jobs, "changed_files", return_value=["esphome/config.py"] determine_jobs, "changed_files", return_value=["esphome/config.py"]
): ):
result = determine_jobs.should_run_integration_tests() run_all, test_files = determine_jobs.determine_integration_tests()
assert result is False assert run_all is False
assert test_files == []
# Python files in subdirectories (not core) do NOT trigger tests # Python files in subdirectories (not core) do NOT trigger tests
with patch.object( with patch.object(
@@ -365,35 +374,151 @@ def test_should_run_integration_tests(
"changed_files", "changed_files",
return_value=["esphome/dashboard/web_server.py"], return_value=["esphome/dashboard/web_server.py"],
): ):
result = determine_jobs.should_run_integration_tests() run_all, test_files = determine_jobs.determine_integration_tests()
assert result is False assert run_all is False
assert test_files == []
def test_should_run_integration_tests_with_branch() -> None: def test_determine_integration_tests_with_branch() -> None:
"""Test should_run_integration_tests with branch argument.""" """Test determine_integration_tests with branch argument."""
with patch.object(determine_jobs, "changed_files") as mock_changed: with patch.object(determine_jobs, "changed_files") as mock_changed:
mock_changed.return_value = [] mock_changed.return_value = []
determine_jobs.should_run_integration_tests("release") run_all, test_files = determine_jobs.determine_integration_tests("release")
mock_changed.assert_called_once_with("release") mock_changed.assert_called_once_with("release")
assert run_all is False
assert test_files == []
def test_should_run_integration_tests_component_dependency() -> None: def test_determine_integration_tests_component_dependency() -> None:
"""Test that integration tests run when components used in fixtures change.""" """Test that integration tests return specific test files when components used in fixtures change."""
with ( with (
patch.object( patch.object(
determine_jobs, determine_jobs,
"changed_files", "changed_files",
return_value=["esphome/components/api/api.cpp"], return_value=["esphome/components/api/api.cpp"],
), ),
patch.object(determine_jobs, "get_fixture_to_test_files") as mock_fixture_map,
patch.object( patch.object(
determine_jobs, "get_components_from_integration_fixtures" determine_jobs, "get_integration_test_files_for_components"
) as mock_fixtures, ) as mock_test_files,
): ):
mock_fixtures.return_value = {"api", "sensor"} mock_fixture_map.return_value = {}
with patch.object(determine_jobs, "get_all_dependencies") as mock_deps: mock_test_files.return_value = [
mock_deps.return_value = {"api", "sensor", "network"} "tests/integration/test_api.py",
result = determine_jobs.should_run_integration_tests() "tests/integration/test_sensor.py",
assert result is True ]
run_all, test_files = determine_jobs.determine_integration_tests()
assert run_all is False
assert test_files == [
"tests/integration/test_api.py",
"tests/integration/test_sensor.py",
]
def test_determine_integration_tests_component_only_affected_tests() -> None:
"""Test that only tests using the changed component are returned."""
with (
patch.object(
determine_jobs,
"changed_files",
return_value=["esphome/components/modbus/modbus.cpp"],
),
patch.object(determine_jobs, "get_fixture_to_test_files", return_value={}),
patch.object(
determine_jobs, "get_integration_test_files_for_components"
) as mock_test_files,
):
mock_test_files.return_value = [
"tests/integration/test_uart_mock_modbus.py",
]
run_all, test_files = determine_jobs.determine_integration_tests()
assert run_all is False
assert test_files == ["tests/integration/test_uart_mock_modbus.py"]
# Verify it was called with the right component
mock_test_files.assert_called_once_with({"modbus"})
def test_determine_integration_tests_infra_file_runs_all() -> None:
"""Test that changing infrastructure files (conftest.py, etc.) runs all tests."""
with patch.object(
determine_jobs,
"changed_files",
return_value=["tests/integration/conftest.py"],
):
run_all, test_files = determine_jobs.determine_integration_tests()
assert run_all is True
assert test_files == []
def test_determine_integration_tests_readme_does_not_run_all() -> None:
"""Test that changing README.md does not trigger integration tests."""
with patch.object(
determine_jobs,
"changed_files",
return_value=["tests/integration/README.md"],
):
run_all, test_files = determine_jobs.determine_integration_tests()
assert run_all is False
assert test_files == []
def test_determine_integration_tests_changed_test_file() -> None:
"""Test that changing a specific test file only runs that test."""
with (
patch.object(
determine_jobs,
"changed_files",
return_value=["tests/integration/test_syslog.py"],
),
patch.object(determine_jobs, "get_fixture_to_test_files", return_value={}),
patch.object(
determine_jobs,
"get_integration_test_files_for_components",
return_value=[],
),
):
run_all, test_files = determine_jobs.determine_integration_tests()
assert run_all is False
assert test_files == ["tests/integration/test_syslog.py"]
def test_determine_integration_tests_changed_fixture_yaml() -> None:
"""Test that changing a fixture YAML runs the corresponding test file."""
with (
patch.object(
determine_jobs,
"changed_files",
return_value=["tests/integration/fixtures/uart_mock_modbus.yaml"],
),
patch.object(determine_jobs, "get_fixture_to_test_files") as mock_fixture_map,
patch.object(
determine_jobs,
"get_integration_test_files_for_components",
return_value=[],
),
):
mock_fixture_map.return_value = {
"uart_mock_modbus": frozenset(
{"tests/integration/test_uart_mock_modbus.py"}
),
}
run_all, test_files = determine_jobs.determine_integration_tests()
assert run_all is False
assert test_files == ["tests/integration/test_uart_mock_modbus.py"]
def test_determine_integration_tests_non_yaml_fixture_runs_all() -> None:
"""Test that non-YAML changes under fixtures/ (e.g., external_components) run all tests."""
with patch.object(
determine_jobs,
"changed_files",
return_value=[
"tests/integration/fixtures/external_components/test_component/__init__.py"
],
):
run_all, test_files = determine_jobs.determine_integration_tests()
assert run_all is True
assert test_files == []
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -538,7 +663,7 @@ def test_count_changed_cpp_files_with_branch() -> None:
def test_main_filters_components_without_tests( def test_main_filters_components_without_tests(
mock_should_run_integration_tests: Mock, mock_determine_integration_tests: Mock,
mock_should_run_clang_tidy: Mock, mock_should_run_clang_tidy: Mock,
mock_should_run_clang_format: Mock, mock_should_run_clang_format: Mock,
mock_should_run_python_linters: Mock, mock_should_run_python_linters: Mock,
@@ -551,7 +676,7 @@ def test_main_filters_components_without_tests(
# Ensure we're not in GITHUB_ACTIONS mode for this test # Ensure we're not in GITHUB_ACTIONS mode for this test
monkeypatch.delenv("GITHUB_ACTIONS", raising=False) monkeypatch.delenv("GITHUB_ACTIONS", raising=False)
mock_should_run_integration_tests.return_value = False mock_determine_integration_tests.return_value = (False, [])
mock_should_run_clang_tidy.return_value = False mock_should_run_clang_tidy.return_value = False
mock_should_run_clang_format.return_value = False mock_should_run_clang_format.return_value = False
mock_should_run_python_linters.return_value = False mock_should_run_python_linters.return_value = False
@@ -631,7 +756,7 @@ def test_main_filters_components_without_tests(
def test_main_detects_components_with_variant_tests( def test_main_detects_components_with_variant_tests(
mock_should_run_integration_tests: Mock, mock_determine_integration_tests: Mock,
mock_should_run_clang_tidy: Mock, mock_should_run_clang_tidy: Mock,
mock_should_run_clang_format: Mock, mock_should_run_clang_format: Mock,
mock_should_run_python_linters: Mock, mock_should_run_python_linters: Mock,
@@ -649,7 +774,7 @@ def test_main_detects_components_with_variant_tests(
# Ensure we're not in GITHUB_ACTIONS mode for this test # Ensure we're not in GITHUB_ACTIONS mode for this test
monkeypatch.delenv("GITHUB_ACTIONS", raising=False) monkeypatch.delenv("GITHUB_ACTIONS", raising=False)
mock_should_run_integration_tests.return_value = False mock_determine_integration_tests.return_value = (False, [])
mock_should_run_clang_tidy.return_value = False mock_should_run_clang_tidy.return_value = False
mock_should_run_clang_format.return_value = False mock_should_run_clang_format.return_value = False
mock_should_run_python_linters.return_value = False mock_should_run_python_linters.return_value = False
@@ -999,7 +1124,7 @@ def test_detect_memory_impact_config_with_variant_tests(tmp_path: Path) -> None:
def test_clang_tidy_mode_full_scan( def test_clang_tidy_mode_full_scan(
mock_should_run_integration_tests: Mock, mock_determine_integration_tests: Mock,
mock_should_run_clang_tidy: Mock, mock_should_run_clang_tidy: Mock,
mock_should_run_clang_format: Mock, mock_should_run_clang_format: Mock,
mock_should_run_python_linters: Mock, mock_should_run_python_linters: Mock,
@@ -1010,7 +1135,7 @@ def test_clang_tidy_mode_full_scan(
"""Test that full scan (hash changed) always uses split mode.""" """Test that full scan (hash changed) always uses split mode."""
monkeypatch.delenv("GITHUB_ACTIONS", raising=False) monkeypatch.delenv("GITHUB_ACTIONS", raising=False)
mock_should_run_integration_tests.return_value = False mock_determine_integration_tests.return_value = (False, [])
mock_should_run_clang_tidy.return_value = True mock_should_run_clang_tidy.return_value = True
mock_should_run_clang_format.return_value = False mock_should_run_clang_format.return_value = False
mock_should_run_python_linters.return_value = False mock_should_run_python_linters.return_value = False
@@ -1065,7 +1190,7 @@ def test_clang_tidy_mode_targeted_scan(
component_count: int, component_count: int,
files_per_component: int, files_per_component: int,
expected_mode: str, expected_mode: str,
mock_should_run_integration_tests: Mock, mock_determine_integration_tests: Mock,
mock_should_run_clang_tidy: Mock, mock_should_run_clang_tidy: Mock,
mock_should_run_clang_format: Mock, mock_should_run_clang_format: Mock,
mock_should_run_python_linters: Mock, mock_should_run_python_linters: Mock,
@@ -1076,7 +1201,7 @@ def test_clang_tidy_mode_targeted_scan(
"""Test clang-tidy mode selection based on files_to_check count.""" """Test clang-tidy mode selection based on files_to_check count."""
monkeypatch.delenv("GITHUB_ACTIONS", raising=False) monkeypatch.delenv("GITHUB_ACTIONS", raising=False)
mock_should_run_integration_tests.return_value = False mock_determine_integration_tests.return_value = (False, [])
mock_should_run_clang_tidy.return_value = True mock_should_run_clang_tidy.return_value = True
mock_should_run_clang_format.return_value = False mock_should_run_clang_format.return_value = False
mock_should_run_python_linters.return_value = False mock_should_run_python_linters.return_value = False
@@ -1123,7 +1248,7 @@ def test_clang_tidy_mode_targeted_scan(
def test_main_core_files_changed_still_detects_components( def test_main_core_files_changed_still_detects_components(
mock_should_run_integration_tests: Mock, mock_determine_integration_tests: Mock,
mock_should_run_clang_tidy: Mock, mock_should_run_clang_tidy: Mock,
mock_should_run_clang_format: Mock, mock_should_run_clang_format: Mock,
mock_should_run_python_linters: Mock, mock_should_run_python_linters: Mock,
@@ -1135,7 +1260,7 @@ def test_main_core_files_changed_still_detects_components(
"""Test that component changes are detected even when core files change.""" """Test that component changes are detected even when core files change."""
monkeypatch.delenv("GITHUB_ACTIONS", raising=False) monkeypatch.delenv("GITHUB_ACTIONS", raising=False)
mock_should_run_integration_tests.return_value = True mock_determine_integration_tests.return_value = (True, [])
mock_should_run_clang_tidy.return_value = True mock_should_run_clang_tidy.return_value = True
mock_should_run_clang_format.return_value = True mock_should_run_clang_format.return_value = True
mock_should_run_python_linters.return_value = True mock_should_run_python_linters.return_value = True
@@ -1604,7 +1729,7 @@ def test_detect_platform_hint_from_filename_case_insensitive(
def test_component_batching_beta_branch_40_per_batch( def test_component_batching_beta_branch_40_per_batch(
tmp_path: Path, tmp_path: Path,
mock_should_run_integration_tests: Mock, mock_determine_integration_tests: Mock,
mock_should_run_clang_tidy: Mock, mock_should_run_clang_tidy: Mock,
mock_should_run_clang_format: Mock, mock_should_run_clang_format: Mock,
mock_should_run_python_linters: Mock, mock_should_run_python_linters: Mock,
@@ -1628,7 +1753,7 @@ def test_component_batching_beta_branch_40_per_batch(
(comp_dir / "test.esp32-idf.yaml").write_text(f"# Test for {comp}") (comp_dir / "test.esp32-idf.yaml").write_text(f"# Test for {comp}")
# Setup mocks # Setup mocks
mock_should_run_integration_tests.return_value = False mock_determine_integration_tests.return_value = (False, [])
mock_should_run_clang_tidy.return_value = False mock_should_run_clang_tidy.return_value = False
mock_should_run_clang_format.return_value = False mock_should_run_clang_format.return_value = False
mock_should_run_python_linters.return_value = False mock_should_run_python_linters.return_value = False
+28 -2
View File
@@ -36,6 +36,7 @@ def clear_helpers_cache() -> None:
"""Clear cached functions before each test.""" """Clear cached functions before each test."""
helpers._get_github_event_data.cache_clear() helpers._get_github_event_data.cache_clear()
helpers._get_changed_files_github_actions.cache_clear() helpers._get_changed_files_github_actions.cache_clear()
helpers.get_components_per_integration_fixture.cache_clear()
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -1111,7 +1112,7 @@ def test_get_components_from_integration_fixtures() -> None:
"gpio", "gpio",
} }
mock_yaml_file = Mock() mock_yaml_file = Mock(stem="test_fixture")
with ( with (
patch("pathlib.Path.glob") as mock_glob, patch("pathlib.Path.glob") as mock_glob,
@@ -1133,7 +1134,7 @@ def test_get_components_from_integration_fixtures_skips_yaml_anchors() -> None:
".binary_filters": {"filters": [{"settle": "50ms"}]}, ".binary_filters": {"filters": [{"settle": "50ms"}]},
} }
mock_yaml_file = Mock() mock_yaml_file = Mock(stem="test_fixture")
with ( with (
patch("pathlib.Path.glob") as mock_glob, patch("pathlib.Path.glob") as mock_glob,
@@ -1148,6 +1149,31 @@ def test_get_components_from_integration_fixtures_skips_yaml_anchors() -> None:
assert components == {"sensor", "esphome", "template"} assert components == {"sensor", "esphome", "template"}
def test_get_integration_test_files_for_components_real_fixtures() -> None:
"""Test that component changes map to the correct real integration test files.
This test uses real fixtures to verify the mapping stays correct
as new tests are added.
"""
# modbus should include at least the modbus test
modbus_tests = helpers.get_integration_test_files_for_components({"modbus"})
assert "tests/integration/test_uart_mock_modbus.py" in modbus_tests
# ld2410 should include at least the ld2410 test
ld2410_tests = helpers.get_integration_test_files_for_components({"ld2410"})
assert "tests/integration/test_uart_mock_ld2410.py" in ld2410_tests
# syslog should include at least the syslog test
syslog_tests = helpers.get_integration_test_files_for_components({"syslog"})
assert "tests/integration/test_syslog.py" in syslog_tests
# A component not used by any fixture should return nothing
fake_tests = helpers.get_integration_test_files_for_components(
{"nonexistent_component_xyz"}
)
assert fake_tests == []
@pytest.mark.parametrize( @pytest.mark.parametrize(
"output,expected", "output,expected",
[ [