[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
outputs:
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-mode: ${{ steps.determine.outputs.clang-tidy-mode }}
python-linters: ${{ steps.determine.outputs.python-linters }}
@@ -210,6 +212,8 @@ jobs:
# Extract individual fields
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-mode=$(echo "$output" | jq -r '.clang_tidy_mode')" >> $GITHUB_OUTPUT
echo "python-linters=$(echo "$output" | jq -r '.python_linters')" >> $GITHUB_OUTPUT
@@ -261,9 +265,20 @@ jobs:
- name: Register matcher
run: echo "::add-matcher::.github/workflows/matchers/pytest.json"
- 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: |
. 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:
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_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:
+171 -46
View File
@@ -29,9 +29,9 @@ 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:
def mock_determine_integration_tests() -> Generator[Mock, None, None]:
"""Mock determine_integration_tests."""
with patch.object(determine_jobs, "determine_integration_tests") as mock:
yield mock
@@ -87,7 +87,7 @@ def clear_determine_jobs_caches() -> None:
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_format: 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
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_format.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)
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_mode"] in ["nosplit", "split"]
assert output["clang_format"] is True
@@ -183,7 +185,7 @@ def test_main_all_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_format: 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
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_format.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)
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_mode"] == "disabled"
assert output["clang_format"] is False
@@ -253,7 +257,7 @@ def test_main_no_tests_should_run(
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_format: 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
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_format.return_value = False
mock_should_run_python_linters.return_value = True
@@ -302,7 +306,7 @@ def test_main_with_branch_argument(
determine_jobs.main()
# 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_format.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)
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_mode"] in ["nosplit", "split"]
assert output["clang_format"] is False
@@ -334,30 +340,33 @@ def test_main_with_branch_argument(
assert output["cpp_unit_tests_components"] == ["mqtt"]
def test_should_run_integration_tests(
def test_determine_integration_tests(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Test should_run_integration_tests function."""
# Core C++ files trigger tests
"""Test determine_integration_tests function."""
# Core C++ files trigger run_all
with patch.object(
determine_jobs, "changed_files", return_value=["esphome/core/component.cpp"]
):
result = determine_jobs.should_run_integration_tests()
assert result is True
run_all, test_files = determine_jobs.determine_integration_tests()
assert run_all is True
assert test_files == []
# Core Python files trigger tests
# Core Python files trigger run_all
with patch.object(
determine_jobs, "changed_files", return_value=["esphome/core/config.py"]
):
result = determine_jobs.should_run_integration_tests()
assert result is True
run_all, test_files = determine_jobs.determine_integration_tests()
assert run_all is True
assert test_files == []
# 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
run_all, test_files = determine_jobs.determine_integration_tests()
assert run_all is False
assert test_files == []
# Python files in subdirectories (not core) do NOT trigger tests
with patch.object(
@@ -365,35 +374,151 @@ def test_should_run_integration_tests(
"changed_files",
return_value=["esphome/dashboard/web_server.py"],
):
result = determine_jobs.should_run_integration_tests()
assert result is False
run_all, test_files = determine_jobs.determine_integration_tests()
assert run_all is False
assert test_files == []
def test_should_run_integration_tests_with_branch() -> None:
"""Test should_run_integration_tests with branch argument."""
def test_determine_integration_tests_with_branch() -> None:
"""Test determine_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")
run_all, test_files = determine_jobs.determine_integration_tests("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:
"""Test that integration tests run when components used in fixtures change."""
def test_determine_integration_tests_component_dependency() -> None:
"""Test that integration tests return specific test files when components used in fixtures change."""
with (
patch.object(
determine_jobs,
"changed_files",
return_value=["esphome/components/api/api.cpp"],
),
patch.object(determine_jobs, "get_fixture_to_test_files") as mock_fixture_map,
patch.object(
determine_jobs, "get_components_from_integration_fixtures"
) as mock_fixtures,
determine_jobs, "get_integration_test_files_for_components"
) as mock_test_files,
):
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
mock_fixture_map.return_value = {}
mock_test_files.return_value = [
"tests/integration/test_api.py",
"tests/integration/test_sensor.py",
]
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(
@@ -538,7 +663,7 @@ def test_count_changed_cpp_files_with_branch() -> None:
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_format: 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
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_format.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(
mock_should_run_integration_tests: Mock,
mock_determine_integration_tests: Mock,
mock_should_run_clang_tidy: Mock,
mock_should_run_clang_format: 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
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_format.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(
mock_should_run_integration_tests: Mock,
mock_determine_integration_tests: Mock,
mock_should_run_clang_tidy: Mock,
mock_should_run_clang_format: 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."""
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_format.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,
files_per_component: int,
expected_mode: str,
mock_should_run_integration_tests: Mock,
mock_determine_integration_tests: Mock,
mock_should_run_clang_tidy: Mock,
mock_should_run_clang_format: 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."""
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_format.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(
mock_should_run_integration_tests: Mock,
mock_determine_integration_tests: Mock,
mock_should_run_clang_tidy: Mock,
mock_should_run_clang_format: 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."""
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_format.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(
tmp_path: Path,
mock_should_run_integration_tests: Mock,
mock_determine_integration_tests: Mock,
mock_should_run_clang_tidy: Mock,
mock_should_run_clang_format: 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}")
# 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_format.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."""
helpers._get_github_event_data.cache_clear()
helpers._get_changed_files_github_actions.cache_clear()
helpers.get_components_per_integration_fixture.cache_clear()
@pytest.mark.parametrize(
@@ -1111,7 +1112,7 @@ def test_get_components_from_integration_fixtures() -> None:
"gpio",
}
mock_yaml_file = Mock()
mock_yaml_file = Mock(stem="test_fixture")
with (
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"}]},
}
mock_yaml_file = Mock()
mock_yaml_file = Mock(stem="test_fixture")
with (
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"}
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(
"output,expected",
[