mirror of
https://github.com/esphome/esphome.git
synced 2026-05-21 02:01:57 +08:00
[ci] Only run integration tests for changed components (#14776)
This commit is contained in:
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user