1
0
mirror of https://github.com/esphome/esphome.git synced 2025-10-26 12:43:48 +00: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:
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

View File

@@ -1 +1 @@
d7693a1e996cacd4a3d1c9a16336799c2a8cc3db02e4e74084151ce964581248
3d46b63015d761c85ca9cb77ab79a389509e5776701fb22aed16e7b79d432c0c

View File

@@ -178,6 +178,8 @@ jobs:
component-test-count: ${{ steps.determine.outputs.component-test-count }}
changed-cpp-file-count: ${{ steps.determine.outputs.changed-cpp-file-count }}
memory_impact: ${{ steps.determine.outputs.memory-impact }}
cpp-unit-tests-run-all: ${{ steps.determine.outputs.cpp-unit-tests-run-all }}
cpp-unit-tests-components: ${{ steps.determine.outputs.cpp-unit-tests-components }}
steps:
- name: Check out code from GitHub
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
@@ -210,6 +212,8 @@ jobs:
echo "component-test-count=$(echo "$output" | jq -r '.component_test_count')" >> $GITHUB_OUTPUT
echo "changed-cpp-file-count=$(echo "$output" | jq -r '.changed_cpp_file_count')" >> $GITHUB_OUTPUT
echo "memory-impact=$(echo "$output" | jq -c '.memory_impact')" >> $GITHUB_OUTPUT
echo "cpp-unit-tests-run-all=$(echo "$output" | jq -r '.cpp_unit_tests_run_all')" >> $GITHUB_OUTPUT
echo "cpp-unit-tests-components=$(echo "$output" | jq -c '.cpp_unit_tests_components')" >> $GITHUB_OUTPUT
integration-tests:
name: Run integration tests
@@ -247,6 +251,33 @@ jobs:
. venv/bin/activate
pytest -vv --no-cov --tb=native -n auto tests/integration/
cpp-unit-tests:
name: Run C++ unit tests
runs-on: ubuntu-24.04
needs:
- common
- determine-jobs
if: github.event_name == 'pull_request' && (needs.determine-jobs.outputs.cpp-unit-tests-run-all == 'true' || needs.determine-jobs.outputs.cpp-unit-tests-components != '[]')
steps:
- name: Check out code from GitHub
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Restore Python
uses: ./.github/actions/restore-python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- name: Run cpp_unit_test.py
run: |
. venv/bin/activate
if [ "${{ needs.determine-jobs.outputs.cpp-unit-tests-run-all }}" = "true" ]; then
script/cpp_unit_test.py --all
else
ARGS=$(echo '${{ needs.determine-jobs.outputs.cpp-unit-tests-components }}' | jq -r '.[] | @sh' | xargs)
script/cpp_unit_test.py $ARGS
fi
clang-tidy-single:
name: ${{ matrix.name }}
runs-on: ubuntu-24.04

View File

@@ -46,6 +46,10 @@ lib_deps =
; This is using the repository until a new release is published to PlatformIO
https://github.com/Sensirion/arduino-gas-index-algorithm.git#3.2.1 ; Sensirion Gas Index Algorithm Arduino Library
lvgl/lvgl@8.4.0 ; lvgl
; This dependency is used only in unit tests.
; Must coincide with PLATFORMIO_GOOGLE_TEST_LIB in scripts/cpp_unit_test.py
; See scripts/cpp_unit_test.py and tests/components/README.md
google/googletest@^1.15.2
build_flags =
-DESPHOME_LOG_LEVEL=ESPHOME_LOG_LEVEL_VERY_VERBOSE
-std=gnu++20

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,9 +146,8 @@ 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/"):
if core_changed(files):
# If any core files changed, run integration tests
return True
# Check if any integration test files changed
@@ -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):

5
tests/components/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
# Gitignore settings for ESPHome
# This is an example and may include too much for your use-case.
# You can modify this file to suit your needs.
/.esphome/
/secrets.yaml

View File

@@ -0,0 +1,32 @@
# How to write C++ ESPHome unit tests
1. Locate the folder with your component or create a new one with the same name as the component.
2. Write the tests. You can add as many `.cpp` and `.h` files as you need to organize your tests.
**IMPORTANT**: wrap all your testing code in a unique namespace to avoid linker collisions when compiling
testing binaries that combine many components. By convention, this unique namespace is `esphome::component::testing`
(where "component" is the component under test), for example: `esphome::uart::testing`.
## Running component unit tests
(from the repository root)
```bash
./script/cpp_unit_test.py component1 component2 ...
```
The above will compile and run the provided components and their tests.
To run all tests, you can invoke `cpp_unit_test.py` with the special `--all` flag:
```bash
./script/cpp_unit_test.py --all
```
To run a specific test suite, you can provide a Google Test filter:
```bash
GTEST_FILTER='UART*' ./script/cpp_unit_test.py uart modbus
```
The process will return `0` for success or nonzero for failure. In case of failure, the errors will be printed out to the console.

26
tests/components/main.cpp Normal file
View File

@@ -0,0 +1,26 @@
#include <gtest/gtest.h>
/*
This special main.cpp replaces the default one.
It will run all the Google Tests found in all compiled cpp files and then exit with the result
See README.md for more information
*/
// Auto generated code by esphome
// ========== AUTO GENERATED INCLUDE BLOCK BEGIN ===========
// ========== AUTO GENERATED INCLUDE BLOCK END ==========="
void original_setup() {
// This function won't be run.
// ========== AUTO GENERATED CODE BEGIN ===========
// =========== AUTO GENERATED CODE END ============
}
void setup() {
::testing::InitGoogleTest();
int exit_code = RUN_ALL_TESTS();
exit(exit_code);
}
void loop() {}

View File

@@ -0,0 +1,37 @@
#pragma once
#include <vector>
#include <cstdint>
#include <cstring>
#include <gmock/gmock.h>
#include <gtest/gtest.h>
#include "esphome/components/uart/uart_component.h"
namespace esphome::uart::testing {
using ::testing::_;
using ::testing::Return;
using ::testing::SaveArg;
using ::testing::DoAll;
using ::testing::Invoke;
using ::testing::SetArgPointee;
// Derive a mock from UARTComponent to test the wrapper implementations.
class MockUARTComponent : public UARTComponent {
public:
using UARTComponent::write_array;
using UARTComponent::write_byte;
// NOTE: std::vector is used here for test convenience. For production code,
// consider using StaticVector or FixedVector from esphome/core/helpers.h instead.
std::vector<uint8_t> written_data;
void write_array(const uint8_t *data, size_t len) override { written_data.assign(data, data + len); }
MOCK_METHOD(bool, read_array, (uint8_t * data, size_t len), (override));
MOCK_METHOD(bool, peek_byte, (uint8_t * data), (override));
MOCK_METHOD(int, available, (), (override));
MOCK_METHOD(void, flush, (), (override));
MOCK_METHOD(void, check_logger_conflict, (), (override));
};
} // namespace esphome::uart::testing

View File

@@ -0,0 +1,73 @@
#include "common.h"
namespace esphome::uart::testing {
TEST(UARTComponentTest, SetGetBaudRate) {
MockUARTComponent mock;
mock.set_baud_rate(38400);
EXPECT_EQ(mock.get_baud_rate(), 38400);
}
TEST(UARTComponentTest, SetGetStopBits) {
MockUARTComponent mock;
mock.set_stop_bits(2);
EXPECT_EQ(mock.get_stop_bits(), 2);
}
TEST(UARTComponentTest, SetGetDataBits) {
MockUARTComponent mock;
mock.set_data_bits(7);
EXPECT_EQ(mock.get_data_bits(), 7);
}
TEST(UARTComponentTest, SetGetParity) {
MockUARTComponent mock;
mock.set_parity(UARTParityOptions::UART_CONFIG_PARITY_EVEN);
EXPECT_EQ(mock.get_parity(), UARTParityOptions::UART_CONFIG_PARITY_EVEN);
}
TEST(UARTComponentTest, SetGetRxBufferSize) {
MockUARTComponent mock;
mock.set_rx_buffer_size(128);
EXPECT_EQ(mock.get_rx_buffer_size(), 128);
}
TEST(UARTComponentTest, WriteArrayVector) {
MockUARTComponent mock;
std::vector<uint8_t> data = {10, 20, 30};
mock.write_array(data);
EXPECT_EQ(mock.written_data, data);
}
TEST(UARTComponentTest, WriteByte) {
MockUARTComponent mock;
uint8_t byte = 0x79;
mock.write_byte(byte);
EXPECT_EQ(mock.written_data.size(), 1);
EXPECT_EQ(mock.written_data[0], byte);
}
TEST(UARTComponentTest, WriteStr) {
MockUARTComponent mock;
const char *str = "Hello";
std::vector<uint8_t> captured;
mock.write_str(str);
EXPECT_EQ(mock.written_data.size(), strlen(str));
EXPECT_EQ(0, strncmp(str, (const char *) mock.written_data.data(), mock.written_data.size()));
}
// Tests for wrapper methods forwarding to pure virtual read_array
TEST(UARTComponentTest, ReadByteSuccess) {
MockUARTComponent mock;
uint8_t value = 0;
EXPECT_CALL(mock, read_array(&value, 1)).WillOnce(Return(true));
EXPECT_TRUE(mock.read_byte(&value));
}
TEST(UARTComponentTest, ReadByteFailure) {
MockUARTComponent mock;
uint8_t value = 0xFF;
EXPECT_CALL(mock, read_array(&value, 1)).WillOnce(Return(false));
EXPECT_FALSE(mock.read_byte(&value));
}
} // namespace esphome::uart::testing

View File

@@ -0,0 +1,108 @@
#include "common.h"
#include "esphome/components/uart/uart.h"
namespace esphome::uart::testing {
TEST(UARTDeviceTest, ReadByteSuccess) {
MockUARTComponent mock;
UARTDevice dev(&mock);
uint8_t value = 0;
EXPECT_CALL(mock, read_array(_, 1)).WillOnce(DoAll(SetArgPointee<0>(0x5A), Return(true)));
bool result = dev.read_byte(&value);
EXPECT_TRUE(result);
EXPECT_EQ(value, 0x5A);
}
TEST(UARTDeviceTest, ReadByteFailure) {
MockUARTComponent mock;
UARTDevice dev(&mock);
uint8_t value = 0xFF;
EXPECT_CALL(mock, read_array(_, 1)).WillOnce(Return(false));
bool result = dev.read_byte(&value);
EXPECT_FALSE(result);
}
TEST(UARTDeviceTest, PeekByteSuccess) {
MockUARTComponent mock;
UARTDevice dev(&mock);
uint8_t value = 0;
EXPECT_CALL(mock, peek_byte(_)).WillOnce(DoAll(SetArgPointee<0>(0xA5), Return(true)));
bool result = dev.peek_byte(&value);
EXPECT_TRUE(result);
EXPECT_EQ(value, 0xA5);
}
TEST(UARTDeviceTest, PeekByteFailure) {
MockUARTComponent mock;
UARTDevice dev(&mock);
uint8_t value = 0;
EXPECT_CALL(mock, peek_byte(_)).WillOnce(Return(false));
bool result = dev.peek_byte(&value);
EXPECT_FALSE(result);
}
TEST(UARTDeviceTest, Available) {
MockUARTComponent mock;
UARTDevice dev(&mock);
EXPECT_CALL(mock, available()).WillOnce(Return(5));
EXPECT_EQ(dev.available(), 5);
}
TEST(UARTDeviceTest, FlushCallsParent) {
MockUARTComponent mock;
UARTDevice dev(&mock);
EXPECT_CALL(mock, flush()).Times(1);
dev.flush();
}
TEST(UARTDeviceTest, WriteByteForwardsToWriteArray) {
MockUARTComponent mock;
UARTDevice dev(&mock);
dev.write_byte(0xAB);
EXPECT_EQ(mock.written_data.size(), 1);
EXPECT_EQ(mock.written_data[0], 0xAB);
}
TEST(UARTDeviceTest, WriteArrayPointer) {
MockUARTComponent mock;
UARTDevice dev(&mock);
uint8_t data[3] = {1, 2, 3};
dev.write_array(data, 3);
EXPECT_EQ(mock.written_data.size(), 3);
EXPECT_EQ(mock.written_data, std::vector(data, data + 3));
}
TEST(UARTDeviceTest, WriteArrayVector) {
MockUARTComponent mock;
UARTDevice dev(&mock);
std::vector<uint8_t> data = {4, 5, 6};
dev.write_array(data);
EXPECT_EQ(mock.written_data, data);
}
TEST(UARTDeviceTest, WriteArrayStdArray) {
MockUARTComponent mock;
UARTDevice dev(&mock);
std::array<uint8_t, 4> data = {7, 8, 9, 10};
dev.write_array(data);
EXPECT_EQ(mock.written_data.size(), data.size());
EXPECT_EQ(mock.written_data, std::vector(data.begin(), data.end()));
}
TEST(UARTDeviceTest, WriteStrForwardsToWriteArray) {
MockUARTComponent mock;
UARTDevice dev(&mock);
const char *str = "ESPHome";
dev.write_str(str);
EXPECT_EQ(mock.written_data.size(), strlen(str));
EXPECT_EQ(0, strncmp(str, (const char *) mock.written_data.data(), mock.written_data.size()));
}
TEST(UARTDeviceTest, WriteStrEmptyString) {
MockUARTComponent mock;
UARTDevice dev(&mock);
const char *str = "";
dev.write_str(str);
EXPECT_EQ(mock.written_data.size(), 0);
}
} // namespace esphome::uart::testing

View File

@@ -5,7 +5,6 @@ import importlib.util
import json
import os
from pathlib import Path
import subprocess
import sys
from unittest.mock import Mock, call, patch
@@ -56,9 +55,9 @@ def mock_should_run_python_linters() -> Generator[Mock, None, None]:
@pytest.fixture
def mock_subprocess_run() -> Generator[Mock, None, None]:
"""Mock subprocess.run for list-components.py calls."""
with patch.object(determine_jobs.subprocess, "run") as mock:
def mock_determine_cpp_unit_tests() -> Generator[Mock, None, None]:
"""Mock determine_cpp_unit_tests from helpers."""
with patch.object(determine_jobs, "determine_cpp_unit_tests") as mock:
yield mock
@@ -82,8 +81,8 @@ def test_main_all_tests_should_run(
mock_should_run_clang_tidy: Mock,
mock_should_run_clang_format: Mock,
mock_should_run_python_linters: Mock,
mock_subprocess_run: Mock,
mock_changed_files: Mock,
mock_determine_cpp_unit_tests: Mock,
capsys: pytest.CaptureFixture[str],
monkeypatch: pytest.MonkeyPatch,
) -> None:
@@ -95,6 +94,7 @@ def test_main_all_tests_should_run(
mock_should_run_clang_tidy.return_value = True
mock_should_run_clang_format.return_value = True
mock_should_run_python_linters.return_value = True
mock_determine_cpp_unit_tests.return_value = (False, ["wifi", "api", "sensor"])
# Mock changed_files to return non-component files (to avoid memory impact)
# Memory impact only runs when component C++ files change
@@ -114,15 +114,15 @@ def test_main_all_tests_should_run(
),
patch.object(
determine_jobs,
"filter_component_files",
"filter_component_and_test_files",
side_effect=lambda f: f.startswith("esphome/components/"),
),
patch.object(
determine_jobs,
"get_components_with_dependencies",
side_effect=lambda files, deps: ["wifi", "api"]
if not deps
else ["wifi", "api", "sensor"],
side_effect=lambda files, deps: (
["wifi", "api"] if not deps else ["wifi", "api", "sensor"]
),
),
):
determine_jobs.main()
@@ -150,6 +150,8 @@ def test_main_all_tests_should_run(
# memory_impact should be false (no component C++ files changed)
assert "memory_impact" in output
assert output["memory_impact"]["should_run"] == "false"
assert output["cpp_unit_tests_run_all"] is False
assert output["cpp_unit_tests_components"] == ["wifi", "api", "sensor"]
def test_main_no_tests_should_run(
@@ -157,8 +159,8 @@ def test_main_no_tests_should_run(
mock_should_run_clang_tidy: Mock,
mock_should_run_clang_format: Mock,
mock_should_run_python_linters: Mock,
mock_subprocess_run: Mock,
mock_changed_files: Mock,
mock_determine_cpp_unit_tests: Mock,
capsys: pytest.CaptureFixture[str],
monkeypatch: pytest.MonkeyPatch,
) -> None:
@@ -170,6 +172,7 @@ def test_main_no_tests_should_run(
mock_should_run_clang_tidy.return_value = False
mock_should_run_clang_format.return_value = False
mock_should_run_python_linters.return_value = False
mock_determine_cpp_unit_tests.return_value = (False, [])
# Mock changed_files to return no component files
mock_changed_files.return_value = []
@@ -178,7 +181,9 @@ def test_main_no_tests_should_run(
with (
patch("sys.argv", ["determine-jobs.py"]),
patch.object(determine_jobs, "get_changed_components", return_value=[]),
patch.object(determine_jobs, "filter_component_files", return_value=False),
patch.object(
determine_jobs, "filter_component_and_test_files", return_value=False
),
patch.object(
determine_jobs, "get_components_with_dependencies", return_value=[]
),
@@ -202,31 +207,8 @@ def test_main_no_tests_should_run(
# memory_impact should be present
assert "memory_impact" in output
assert output["memory_impact"]["should_run"] == "false"
def test_main_list_components_fails(
mock_should_run_integration_tests: Mock,
mock_should_run_clang_tidy: Mock,
mock_should_run_clang_format: Mock,
mock_should_run_python_linters: Mock,
mock_subprocess_run: Mock,
capsys: pytest.CaptureFixture[str],
) -> None:
"""Test when list-components.py fails."""
mock_should_run_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
# Mock list-components.py failure
mock_subprocess_run.side_effect = subprocess.CalledProcessError(1, "cmd")
# Run main function with mocked argv - should raise
with (
patch("sys.argv", ["determine-jobs.py"]),
pytest.raises(subprocess.CalledProcessError),
):
determine_jobs.main()
assert output["cpp_unit_tests_run_all"] is False
assert output["cpp_unit_tests_components"] == []
def test_main_with_branch_argument(
@@ -234,8 +216,8 @@ def test_main_with_branch_argument(
mock_should_run_clang_tidy: Mock,
mock_should_run_clang_format: Mock,
mock_should_run_python_linters: Mock,
mock_subprocess_run: Mock,
mock_changed_files: Mock,
mock_determine_cpp_unit_tests: Mock,
capsys: pytest.CaptureFixture[str],
monkeypatch: pytest.MonkeyPatch,
) -> None:
@@ -247,6 +229,7 @@ def test_main_with_branch_argument(
mock_should_run_clang_tidy.return_value = True
mock_should_run_clang_format.return_value = False
mock_should_run_python_linters.return_value = True
mock_determine_cpp_unit_tests.return_value = (False, ["mqtt"])
# Mock changed_files to return non-component files (to avoid memory impact)
# Memory impact only runs when component C++ files change
@@ -258,7 +241,7 @@ def test_main_with_branch_argument(
patch.object(determine_jobs, "get_changed_components", return_value=["mqtt"]),
patch.object(
determine_jobs,
"filter_component_files",
"filter_component_and_test_files",
side_effect=lambda f: f.startswith("esphome/components/"),
),
patch.object(
@@ -296,6 +279,8 @@ def test_main_with_branch_argument(
# memory_impact should be false (no component C++ files changed)
assert "memory_impact" in output
assert output["memory_impact"]["should_run"] == "false"
assert output["cpp_unit_tests_run_all"] is False
assert output["cpp_unit_tests_components"] == ["mqtt"]
def test_should_run_integration_tests(
@@ -506,7 +491,6 @@ def test_main_filters_components_without_tests(
mock_should_run_clang_tidy: Mock,
mock_should_run_clang_format: Mock,
mock_should_run_python_linters: Mock,
mock_subprocess_run: Mock,
mock_changed_files: Mock,
capsys: pytest.CaptureFixture[str],
tmp_path: Path,
@@ -556,16 +540,17 @@ def test_main_filters_components_without_tests(
),
patch.object(
determine_jobs,
"filter_component_files",
"filter_component_and_test_files",
side_effect=lambda f: f.startswith("esphome/components/"),
),
patch.object(
determine_jobs,
"get_components_with_dependencies",
side_effect=lambda files, deps: ["wifi", "sensor"]
if not deps
else ["wifi", "sensor", "airthings_ble"],
side_effect=lambda files, deps: (
["wifi", "sensor"] if not deps else ["wifi", "sensor", "airthings_ble"]
),
),
patch.object(determine_jobs, "changed_files", return_value=[]),
):
# Clear the cache since we're mocking root_path
determine_jobs._component_has_tests.cache_clear()
@@ -808,7 +793,6 @@ def test_clang_tidy_mode_full_scan(
mock_should_run_clang_tidy: Mock,
mock_should_run_clang_format: Mock,
mock_should_run_python_linters: Mock,
mock_subprocess_run: Mock,
mock_changed_files: Mock,
capsys: pytest.CaptureFixture[str],
monkeypatch: pytest.MonkeyPatch,
@@ -829,7 +813,9 @@ def test_clang_tidy_mode_full_scan(
patch("sys.argv", ["determine-jobs.py"]),
patch.object(determine_jobs, "_is_clang_tidy_full_scan", return_value=True),
patch.object(determine_jobs, "get_changed_components", return_value=[]),
patch.object(determine_jobs, "filter_component_files", return_value=False),
patch.object(
determine_jobs, "filter_component_and_test_files", return_value=False
),
patch.object(
determine_jobs, "get_components_with_dependencies", return_value=[]
),
@@ -873,7 +859,6 @@ def test_clang_tidy_mode_targeted_scan(
mock_should_run_clang_tidy: Mock,
mock_should_run_clang_format: Mock,
mock_should_run_python_linters: Mock,
mock_subprocess_run: Mock,
mock_changed_files: Mock,
capsys: pytest.CaptureFixture[str],
monkeypatch: pytest.MonkeyPatch,
@@ -912,7 +897,7 @@ def test_clang_tidy_mode_targeted_scan(
patch.object(determine_jobs, "get_changed_components", return_value=components),
patch.object(
determine_jobs,
"filter_component_files",
"filter_component_and_test_files",
side_effect=lambda f: f.startswith("esphome/components/"),
),
patch.object(