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:
Javier Peletier
2025-10-22 00:21:22 +02:00
committed by GitHub
parent 1ea80594c6
commit ae50a09b4e
15 changed files with 710 additions and 80 deletions

172
script/cpp_unit_test.py Executable file
View 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()

View File

@@ -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

View File

@@ -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())),

View File

@@ -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)

View File

@@ -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):