mirror of
https://github.com/esphome/esphome.git
synced 2026-01-10 04:00:51 -07:00
C++ components unit test framework (#9284)
Co-authored-by: J. Nick Koston <nick@home-assistant.io> Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
172
script/cpp_unit_test.py
Executable file
172
script/cpp_unit_test.py
Executable file
@@ -0,0 +1,172 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from helpers import get_all_components, get_all_dependencies, root_path
|
||||
|
||||
from esphome.__main__ import command_compile, parse_args
|
||||
from esphome.config import validate_config
|
||||
from esphome.core import CORE
|
||||
from esphome.platformio_api import get_idedata
|
||||
|
||||
# This must coincide with the version in /platformio.ini
|
||||
PLATFORMIO_GOOGLE_TEST_LIB = "google/googletest@^1.15.2"
|
||||
|
||||
# Path to /tests/components
|
||||
COMPONENTS_TESTS_DIR: Path = Path(root_path) / "tests" / "components"
|
||||
|
||||
|
||||
def hash_components(components: list[str]) -> str:
|
||||
key = ",".join(components)
|
||||
return hashlib.sha256(key.encode()).hexdigest()[:16]
|
||||
|
||||
|
||||
def filter_components_without_tests(components: list[str]) -> list[str]:
|
||||
"""Filter out components that do not have a corresponding test file.
|
||||
|
||||
This is done by checking if the component's directory contains at
|
||||
least a .cpp file.
|
||||
"""
|
||||
filtered_components: list[str] = []
|
||||
for component in components:
|
||||
test_dir = COMPONENTS_TESTS_DIR / component
|
||||
if test_dir.is_dir() and any(test_dir.glob("*.cpp")):
|
||||
filtered_components.append(component)
|
||||
else:
|
||||
print(
|
||||
f"WARNING: No tests found for component '{component}', skipping.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return filtered_components
|
||||
|
||||
|
||||
def create_test_config(config_name: str, includes: list[str]) -> dict:
|
||||
"""Create ESPHome test configuration for C++ unit tests.
|
||||
|
||||
Args:
|
||||
config_name: Unique name for this test configuration
|
||||
includes: List of include folders for the test build
|
||||
|
||||
Returns:
|
||||
Configuration dict for ESPHome
|
||||
"""
|
||||
return {
|
||||
"esphome": {
|
||||
"name": config_name,
|
||||
"friendly_name": "CPP Unit Tests",
|
||||
"libraries": PLATFORMIO_GOOGLE_TEST_LIB,
|
||||
"platformio_options": {
|
||||
"build_type": "debug",
|
||||
"build_unflags": [
|
||||
"-Os", # remove size-opt flag
|
||||
],
|
||||
"build_flags": [
|
||||
"-Og", # optimize for debug
|
||||
],
|
||||
"debug_build_flags": [ # only for debug builds
|
||||
"-g3", # max debug info
|
||||
"-ggdb3",
|
||||
],
|
||||
},
|
||||
"includes": includes,
|
||||
},
|
||||
"host": {},
|
||||
"logger": {"level": "DEBUG"},
|
||||
}
|
||||
|
||||
|
||||
def run_tests(selected_components: list[str]) -> int:
|
||||
# Skip tests on Windows
|
||||
if os.name == "nt":
|
||||
print("Skipping esphome tests on Windows", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Remove components that do not have tests
|
||||
components = filter_components_without_tests(selected_components)
|
||||
|
||||
if len(components) == 0:
|
||||
print(
|
||||
"No components specified or no tests found for the specified components.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 0
|
||||
|
||||
components = sorted(components)
|
||||
|
||||
# Obtain possible dependencies for the requested components:
|
||||
components_with_dependencies = sorted(get_all_dependencies(set(components)))
|
||||
|
||||
# Build a list of include folders, one folder per component containing tests.
|
||||
# A special replacement main.cpp is located in /tests/components/main.cpp
|
||||
includes: list[str] = ["main.cpp"] + components
|
||||
|
||||
# Create a unique name for this config based on the actual components being tested
|
||||
# to maximize cache during testing
|
||||
config_name: str = "cpptests-" + hash_components(components)
|
||||
|
||||
config = create_test_config(config_name, includes)
|
||||
|
||||
CORE.config_path = COMPONENTS_TESTS_DIR / "dummy.yaml"
|
||||
CORE.dashboard = None
|
||||
|
||||
# Validate config will expand the above with defaults:
|
||||
config = validate_config(config, {})
|
||||
|
||||
# Add all components and dependencies to the base configuration after validation, so their files
|
||||
# are added to the build.
|
||||
config.update({key: {} for key in components_with_dependencies})
|
||||
|
||||
print(f"Testing components: {', '.join(components)}")
|
||||
CORE.config = config
|
||||
args = parse_args(["program", "compile", str(CORE.config_path)])
|
||||
try:
|
||||
exit_code: int = command_compile(args, config)
|
||||
|
||||
if exit_code != 0:
|
||||
print(f"Error compiling unit tests for {', '.join(components)}")
|
||||
return exit_code
|
||||
except Exception as e:
|
||||
print(
|
||||
f"Error compiling unit tests for {', '.join(components)}. Check path. : {e}"
|
||||
)
|
||||
return 2
|
||||
|
||||
# After a successful compilation, locate the executable and run it:
|
||||
idedata = get_idedata(config)
|
||||
if idedata is None:
|
||||
print("Cannot find executable")
|
||||
return 1
|
||||
|
||||
program_path: str = idedata.raw["prog_path"]
|
||||
run_cmd: list[str] = [program_path]
|
||||
run_proc = subprocess.run(run_cmd, check=False)
|
||||
return run_proc.returncode
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Run C++ unit tests for ESPHome components."
|
||||
)
|
||||
parser.add_argument(
|
||||
"components",
|
||||
nargs="*",
|
||||
help="List of components to test. Use --all to test all known components.",
|
||||
)
|
||||
parser.add_argument("--all", action="store_true", help="Test all known components.")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.all:
|
||||
components: list[str] = get_all_components()
|
||||
else:
|
||||
components: list[str] = args.components
|
||||
|
||||
sys.exit(run_tests(components))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -52,13 +52,16 @@ from helpers import (
|
||||
CPP_FILE_EXTENSIONS,
|
||||
PYTHON_FILE_EXTENSIONS,
|
||||
changed_files,
|
||||
filter_component_files,
|
||||
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,
|
||||
git_ls_files,
|
||||
parse_test_filename,
|
||||
root_path,
|
||||
@@ -143,10 +146,9 @@ def should_run_integration_tests(branch: str | None = None) -> bool:
|
||||
"""
|
||||
files = changed_files(branch)
|
||||
|
||||
# Check if any core files changed (esphome/core/*)
|
||||
for file in files:
|
||||
if file.startswith("esphome/core/"):
|
||||
return True
|
||||
if core_changed(files):
|
||||
# If any core files changed, run integration tests
|
||||
return True
|
||||
|
||||
# Check if any integration test files changed
|
||||
if any("tests/integration" in file for file in files):
|
||||
@@ -283,6 +285,40 @@ def should_run_python_linters(branch: str | None = None) -> bool:
|
||||
return _any_changed_file_endswith(branch, PYTHON_FILE_EXTENSIONS)
|
||||
|
||||
|
||||
def determine_cpp_unit_tests(
|
||||
branch: str | None = None,
|
||||
) -> tuple[bool, list[str]]:
|
||||
"""Determine if C++ unit tests should run based on changed files.
|
||||
|
||||
This function is used by the CI workflow to skip C++ unit tests when
|
||||
no relevant files have changed, saving CI time and resources.
|
||||
|
||||
C++ unit tests will run when any of the following conditions are met:
|
||||
|
||||
1. Any C++ core source files changed (esphome/core/*), in which case
|
||||
all cpp unit tests run.
|
||||
2. A test file for a component changed, which triggers tests for that
|
||||
component.
|
||||
3. The code for a component changed, which triggers tests for that
|
||||
component and all components that depend on it.
|
||||
|
||||
Args:
|
||||
branch: Branch to compare against. If None, uses default.
|
||||
|
||||
Returns:
|
||||
Tuple of (run_all, components) where:
|
||||
- run_all: True if all tests should run, False otherwise
|
||||
- components: List of specific components to test (empty if run_all)
|
||||
"""
|
||||
files = changed_files(branch)
|
||||
if core_changed(files):
|
||||
return (True, [])
|
||||
|
||||
# Filter to only C++ files
|
||||
cpp_files = list(filter(filter_component_and_test_cpp_files, files))
|
||||
return (False, get_cpp_changed_components(cpp_files))
|
||||
|
||||
|
||||
def _any_changed_file_endswith(branch: str | None, extensions: tuple[str, ...]) -> bool:
|
||||
"""Check if a changed file ends with any of the specified extensions."""
|
||||
return any(file.endswith(extensions) for file in changed_files(branch))
|
||||
@@ -579,7 +615,7 @@ def main() -> None:
|
||||
else:
|
||||
# Get both directly changed and all changed (with dependencies)
|
||||
changed = changed_files(args.branch)
|
||||
component_files = [f for f in changed if filter_component_files(f)]
|
||||
component_files = [f for f in changed if filter_component_and_test_files(f)]
|
||||
|
||||
directly_changed_components = get_components_with_dependencies(
|
||||
component_files, False
|
||||
@@ -646,6 +682,9 @@ def main() -> None:
|
||||
files_to_check_count = 0
|
||||
|
||||
# Build output
|
||||
# Determine which C++ unit tests to run
|
||||
cpp_run_all, cpp_components = determine_cpp_unit_tests(args.branch)
|
||||
|
||||
output: dict[str, Any] = {
|
||||
"integration_tests": run_integration,
|
||||
"clang_tidy": run_clang_tidy,
|
||||
@@ -661,6 +700,8 @@ def main() -> None:
|
||||
"dependency_only_count": len(dependency_only_components),
|
||||
"changed_cpp_file_count": changed_cpp_file_count,
|
||||
"memory_impact": memory_impact,
|
||||
"cpp_unit_tests_run_all": cpp_run_all,
|
||||
"cpp_unit_tests_components": cpp_components,
|
||||
}
|
||||
|
||||
# Output as JSON
|
||||
|
||||
@@ -2,19 +2,14 @@
|
||||
|
||||
import json
|
||||
|
||||
from helpers import git_ls_files
|
||||
from helpers import get_all_component_files, get_components_with_dependencies
|
||||
|
||||
from esphome.automation import ACTION_REGISTRY, CONDITION_REGISTRY
|
||||
from esphome.pins import PIN_SCHEMA_REGISTRY
|
||||
|
||||
list_components = __import__("list-components")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
files = git_ls_files()
|
||||
files = filter(list_components.filter_component_files, files)
|
||||
|
||||
components = list_components.get_components(files, True)
|
||||
files = get_all_component_files()
|
||||
components = get_components_with_dependencies(files, True)
|
||||
|
||||
dump = {
|
||||
"actions": sorted(list(ACTION_REGISTRY.keys())),
|
||||
|
||||
@@ -25,12 +25,21 @@ CPP_FILE_EXTENSIONS = (".cpp", ".h", ".hpp", ".cc", ".cxx", ".c", ".tcc")
|
||||
# Python file extensions
|
||||
PYTHON_FILE_EXTENSIONS = (".py", ".pyi")
|
||||
|
||||
# Combined C++ and Python file extensions for convenience
|
||||
CPP_AND_PYTHON_FILE_EXTENSIONS = (*CPP_FILE_EXTENSIONS, *PYTHON_FILE_EXTENSIONS)
|
||||
|
||||
# YAML file extensions
|
||||
YAML_FILE_EXTENSIONS = (".yaml", ".yml")
|
||||
|
||||
# Component path prefix
|
||||
ESPHOME_COMPONENTS_PATH = "esphome/components/"
|
||||
|
||||
# Test components path prefix
|
||||
ESPHOME_TESTS_COMPONENTS_PATH = "tests/components/"
|
||||
|
||||
# Tuple of component and test paths for efficient startswith checks
|
||||
COMPONENT_AND_TESTS_PATHS = (ESPHOME_COMPONENTS_PATH, ESPHOME_TESTS_COMPONENTS_PATH)
|
||||
|
||||
# Base bus components - these ARE the bus implementations and should not
|
||||
# be flagged as needing migration since they are the platform/base components
|
||||
BASE_BUS_COMPONENTS = {
|
||||
@@ -658,17 +667,32 @@ def get_components_from_integration_fixtures() -> set[str]:
|
||||
return components
|
||||
|
||||
|
||||
def filter_component_files(file_path: str) -> bool:
|
||||
"""Check if a file path is a component file.
|
||||
def filter_component_and_test_files(file_path: str) -> bool:
|
||||
"""Check if a file path is a component or test file.
|
||||
|
||||
Args:
|
||||
file_path: Path to check
|
||||
|
||||
Returns:
|
||||
True if the file is in a component directory
|
||||
True if the file is in a component or test directory
|
||||
"""
|
||||
return file_path.startswith("esphome/components/") or file_path.startswith(
|
||||
"tests/components/"
|
||||
return file_path.startswith(COMPONENT_AND_TESTS_PATHS) or (
|
||||
file_path.startswith(ESPHOME_TESTS_COMPONENTS_PATH)
|
||||
and file_path.endswith(YAML_FILE_EXTENSIONS)
|
||||
)
|
||||
|
||||
|
||||
def filter_component_and_test_cpp_files(file_path: str) -> bool:
|
||||
"""Check if a file is a C++ source file in component or test directories.
|
||||
|
||||
Args:
|
||||
file_path: Path to check
|
||||
|
||||
Returns:
|
||||
True if the file is a C++ source/header file in component or test directories
|
||||
"""
|
||||
return file_path.endswith(CPP_FILE_EXTENSIONS) and file_path.startswith(
|
||||
COMPONENT_AND_TESTS_PATHS
|
||||
)
|
||||
|
||||
|
||||
@@ -740,7 +764,7 @@ def create_components_graph() -> dict[str, list[str]]:
|
||||
|
||||
# The root directory of the repo
|
||||
root = Path(__file__).parent.parent
|
||||
components_dir = root / "esphome" / "components"
|
||||
components_dir = root / ESPHOME_COMPONENTS_PATH
|
||||
# Fake some directory so that get_component works
|
||||
CORE.config_path = root
|
||||
# Various configuration to capture different outcomes used by `AUTO_LOAD` function.
|
||||
@@ -873,3 +897,81 @@ def get_components_with_dependencies(
|
||||
return sorted(all_changed_components)
|
||||
|
||||
return sorted(components)
|
||||
|
||||
|
||||
def get_all_component_files() -> list[str]:
|
||||
"""Get all component and test files from git.
|
||||
|
||||
Returns:
|
||||
List of all component and test file paths
|
||||
"""
|
||||
files = git_ls_files()
|
||||
return list(filter(filter_component_and_test_files, files))
|
||||
|
||||
|
||||
def get_all_components() -> list[str]:
|
||||
"""Get all component names.
|
||||
|
||||
This function uses git to find all component files and extracts the component names.
|
||||
It returns the same list as calling list-components.py without arguments.
|
||||
|
||||
Returns:
|
||||
List of all component names
|
||||
"""
|
||||
return get_components_with_dependencies(get_all_component_files(), False)
|
||||
|
||||
|
||||
def core_changed(files: list[str]) -> bool:
|
||||
"""Check if any core C++ or Python files have changed.
|
||||
|
||||
Args:
|
||||
files: List of file paths to check
|
||||
|
||||
Returns:
|
||||
True if any core C++ or Python files have changed
|
||||
"""
|
||||
return any(
|
||||
f.startswith("esphome/core/") and f.endswith(CPP_AND_PYTHON_FILE_EXTENSIONS)
|
||||
for f in files
|
||||
)
|
||||
|
||||
|
||||
def get_cpp_changed_components(files: list[str]) -> list[str]:
|
||||
"""Get components that have changed C++ files or tests.
|
||||
|
||||
This function analyzes a list of changed files and determines which components
|
||||
are affected. It handles two scenarios:
|
||||
|
||||
1. Test files changed (tests/components/<component>/*.cpp):
|
||||
- Adds the component to the affected list
|
||||
- Only that component needs to be tested
|
||||
|
||||
2. Component C++ files changed (esphome/components/<component>/*):
|
||||
- Adds the component to the affected list
|
||||
- Also adds all components that depend on this component (recursively)
|
||||
- This ensures that changes propagate to dependent components
|
||||
|
||||
Args:
|
||||
files: List of file paths to analyze (should be C++ files)
|
||||
|
||||
Returns:
|
||||
Sorted list of component names that need C++ unit tests run
|
||||
"""
|
||||
components_graph = create_components_graph()
|
||||
affected: set[str] = set()
|
||||
for file in files:
|
||||
if not file.endswith(CPP_FILE_EXTENSIONS):
|
||||
continue
|
||||
if file.startswith(ESPHOME_TESTS_COMPONENTS_PATH):
|
||||
parts = file.split("/")
|
||||
if len(parts) >= 4:
|
||||
component_dir = Path(ESPHOME_TESTS_COMPONENTS_PATH) / parts[2]
|
||||
if component_dir.is_dir():
|
||||
affected.add(parts[2])
|
||||
elif file.startswith(ESPHOME_COMPONENTS_PATH):
|
||||
parts = file.split("/")
|
||||
if len(parts) >= 4:
|
||||
component = parts[2]
|
||||
affected.update(find_children_of_component(components_graph, component))
|
||||
affected.add(component)
|
||||
return sorted(affected)
|
||||
|
||||
@@ -3,18 +3,14 @@ import argparse
|
||||
|
||||
from helpers import (
|
||||
changed_files,
|
||||
filter_component_files,
|
||||
filter_component_and_test_cpp_files,
|
||||
filter_component_and_test_files,
|
||||
get_all_component_files,
|
||||
get_components_with_dependencies,
|
||||
git_ls_files,
|
||||
get_cpp_changed_components,
|
||||
)
|
||||
|
||||
|
||||
def get_all_component_files() -> list[str]:
|
||||
"""Get all component files from git."""
|
||||
files = git_ls_files()
|
||||
return list(filter(filter_component_files, files))
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
@@ -39,16 +35,29 @@ def main():
|
||||
parser.add_argument(
|
||||
"-b", "--branch", help="Branch to compare changed files against"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--cpp-changed",
|
||||
action="store_true",
|
||||
help="List components with changed C++ files",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.branch and not (
|
||||
args.changed or args.changed_direct or args.changed_with_deps
|
||||
args.changed
|
||||
or args.changed_direct
|
||||
or args.changed_with_deps
|
||||
or args.cpp_changed
|
||||
):
|
||||
parser.error(
|
||||
"--branch requires --changed, --changed-direct, or --changed-with-deps"
|
||||
"--branch requires --changed, --changed-direct, --changed-with-deps, or --cpp-changed"
|
||||
)
|
||||
|
||||
if args.changed or args.changed_direct or args.changed_with_deps:
|
||||
if (
|
||||
args.changed
|
||||
or args.changed_direct
|
||||
or args.changed_with_deps
|
||||
or args.cpp_changed
|
||||
):
|
||||
# When --changed* is passed, only get the changed files
|
||||
changed = changed_files(args.branch)
|
||||
|
||||
@@ -68,6 +77,11 @@ def main():
|
||||
# - --changed-with-deps: Used by CI test determination (script/determine-jobs.py)
|
||||
# Returns: Components with code changes + their dependencies (not infrastructure)
|
||||
# Reason: CI needs to test changed components and their dependents
|
||||
#
|
||||
# - --cpp-changed: Used by CI to determine if any C++ files changed (script/determine-jobs.py)
|
||||
# Returns: Only components with changed C++ files
|
||||
# Reason: Only components with C++ changes need C++ testing
|
||||
|
||||
base_test_changed = any(
|
||||
"tests/test_build_components" in file for file in changed
|
||||
)
|
||||
@@ -80,7 +94,7 @@ def main():
|
||||
# Only look at changed component files (ignore infrastructure changes)
|
||||
# For --changed-direct: only actual component code changes matter (for isolation)
|
||||
# For --changed-with-deps: only actual component code changes matter (for testing)
|
||||
files = [f for f in changed if filter_component_files(f)]
|
||||
files = [f for f in changed if filter_component_and_test_files(f)]
|
||||
else:
|
||||
# Get all component files
|
||||
files = get_all_component_files()
|
||||
@@ -100,6 +114,11 @@ def main():
|
||||
# Return only directly changed components (without dependencies)
|
||||
for c in get_components_with_dependencies(files, False):
|
||||
print(c)
|
||||
elif args.cpp_changed:
|
||||
# Only look at changed cpp files
|
||||
files = list(filter(filter_component_and_test_cpp_files, changed))
|
||||
for c in get_cpp_changed_components(files):
|
||||
print(c)
|
||||
else:
|
||||
# Return all changed components (with dependencies) - default behavior
|
||||
for c in get_components_with_dependencies(files, args.changed):
|
||||
|
||||
Reference in New Issue
Block a user