[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
+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_run_all": true/false,
"integration_test_files": ["tests/integration/test_foo.py", ...],
"clang_tidy": true/false,
"clang_format": true/false,
"python_linters": true/false,
@@ -56,13 +58,13 @@ from helpers import (
core_changed,
filter_component_and_test_cpp_files,
filter_component_and_test_files,
get_all_dependencies,
get_changed_components,
get_component_from_path,
get_component_test_files,
get_components_from_integration_fixtures,
get_components_with_dependencies,
get_cpp_changed_components,
get_fixture_to_test_files,
get_integration_test_files_for_components,
get_target_branch,
git_ls_files,
parse_test_filename,
@@ -143,65 +145,88 @@ MEMORY_IMPACT_PLATFORM_PREFERENCE = [
]
def should_run_integration_tests(branch: str | None = None) -> bool:
"""Determine if integration tests should run based on changed files.
def determine_integration_tests(branch: str | None = None) -> tuple[bool, list[str]]:
"""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
not needed, saving significant CI time and resources.
This function is used by the CI workflow to intelligently skip or filter
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/*)
- Any .cpp, .h, .tcc files in the core directory
- 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)
- Only .py files in the esphome/core/ directory
- 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
- Any file in tests/integration/ directory
- This includes test files themselves and fixture YAML files
- Examples: tests/integration/test_api.py, tests/integration/fixtures/api.yaml
3. Integration test infrastructure files changed
- conftest.py, types.py, const.py, entity_utils.py, state_utils.py, etc.
4. Components used by integration tests (or their dependencies) changed
- The function parses all YAML files in tests/integration/fixtures/
- Extracts which components are used in integration tests
- Recursively finds all dependencies of those components
- If any of these components have changes, tests must run
- Example: If api.yaml uses 'sensor' and 'api' components, and 'api' depends on 'socket',
then changes to sensor/, api/, or socket/ components trigger tests
Returns (run_all=False, [test_files...]) when:
4. Specific integration test files changed
- Only those specific test files are returned
5. Components used by integration tests (or their dependencies) changed
- Only test files whose fixtures use the changed components are returned
Args:
branch: Branch to compare against. If None, uses default.
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)
if core_changed(files):
# If any core files changed, run integration tests
return True
# If any core files changed, run all integration tests
return (True, [])
# Check if any integration test files changed
if any("tests/integration" in file for file in files):
return True
# If infrastructure Python files changed (conftest, utils, etc.), run all tests
# Excludes test files (test_*.py), fixtures, and non-Python files (README.md)
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
fixture_components = get_components_from_integration_fixtures()
all_required_components = get_all_dependencies(fixture_components)
# Collect specific test files that need to run
test_files: set[str] = set()
fixture_to_test_files = get_fixture_to_test_files()
# Check if any required components changed
for file in files:
component = get_component_from_path(file)
if component and component in all_required_components:
return True
for f in files:
if f.startswith("tests/integration/test_") and f.endswith(".py"):
test_files.add(f)
elif f.startswith("tests/integration/fixtures/"):
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
@@ -682,7 +707,10 @@ def main() -> None:
args = parser.parse_args()
# 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_format = should_run_clang_format(args.branch)
run_python_linters = should_run_python_linters(args.branch)
@@ -810,6 +838,8 @@ def main() -> None:
output: dict[str, Any] = {
"integration_tests": run_integration,
"integration_tests_run_all": integration_run_all,
"integration_test_files": integration_test_files,
"clang_tidy": run_clang_tidy,
"clang_tidy_mode": clang_tidy_mode,
"clang_format": run_clang_format,
+118 -14
View File
@@ -700,37 +700,141 @@ def get_all_dependencies(
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]:
"""Extract all components used in integration test fixtures.
Returns:
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
components: set[str] = set()
result: dict[str, set[str]] = {}
fixtures_dir = Path(__file__).parent.parent / "tests" / "integration" / "fixtures"
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:
continue
# 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(".")
)
result[yaml_file.stem] = _extract_components_from_yaml(config)
# Add platform components (e.g., output.template)
for value in config.values():
if not isinstance(value, list):
continue
return result
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: