From 4617471920f3d621901f481dd23c713a84c16467 Mon Sep 17 00:00:00 2001 From: Chris Langhans Date: Mon, 27 Apr 2026 14:46:38 +0200 Subject: [PATCH 1/4] feat(deps): add pytest-mock for unit test mocking Co-authored-by: $(git config user.name) <$(git config user.email)> --- requirements.in | 1 + requirements_lock_3_10.txt | 6 ++++++ requirements_lock_3_12.txt | 6 ++++++ 3 files changed, 13 insertions(+) diff --git a/requirements.in b/requirements.in index 011cbb0..37dd715 100644 --- a/requirements.in +++ b/requirements.in @@ -5,3 +5,4 @@ pytest==9.0.3 paramiko==4.0.0 typing-extensions==4.15.0 pydantic==2.10.6 +pytest-mock==3.14.0 diff --git a/requirements_lock_3_10.txt b/requirements_lock_3_10.txt index fd883f9..9c89864 100644 --- a/requirements_lock_3_10.txt +++ b/requirements_lock_3_10.txt @@ -505,6 +505,12 @@ pynacl==1.6.2 \ pytest==9.0.3 \ --hash=sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9 \ --hash=sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c + # via + # -r requirements.in + # pytest-mock +pytest-mock==3.14.0 \ + --hash=sha256:0b72c38033392a5f4621342fe11e9219ac11ec9d375f8e2a0c164539e0d70f6f \ + --hash=sha256:2719255a1efeceadbc056d6bf3df3d1c5015530fb40cf347c0f9afac88410bd0 # via -r requirements.in requests==2.33.0 \ --hash=sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b \ diff --git a/requirements_lock_3_12.txt b/requirements_lock_3_12.txt index 6762c0a..44692ac 100644 --- a/requirements_lock_3_12.txt +++ b/requirements_lock_3_12.txt @@ -505,6 +505,12 @@ pynacl==1.6.2 \ pytest==9.0.3 \ --hash=sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9 \ --hash=sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c + # via + # -r requirements.in + # pytest-mock +pytest-mock==3.14.0 \ + --hash=sha256:0b72c38033392a5f4621342fe11e9219ac11ec9d375f8e2a0c164539e0d70f6f \ + --hash=sha256:2719255a1efeceadbc056d6bf3df3d1c5015530fb40cf347c0f9afac88410bd0 # via -r requirements.in requests==2.33.0 \ --hash=sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b \ From 57728b145bbe40945584cb152c5389979edc9af3 Mon Sep 17 00:00:00 2001 From: Chris Langhans Date: Mon, 27 Apr 2026 14:47:32 +0200 Subject: [PATCH 2/4] feat(coverage): enable Bazel native coverage for unit tests --- MODULE.bazel | 1 + 1 file changed, 1 insertion(+) diff --git a/MODULE.bazel b/MODULE.bazel index 472eae8..5c23101 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -34,6 +34,7 @@ python = use_extension("@rules_python//python/extensions:python.bzl", "python") python.defaults(python_version = DEFAULT_PYTHON_VERSION) [python.toolchain( + configure_coverage_tool = True, is_default = version == DEFAULT_PYTHON_VERSION, python_version = version, ) for version in PYTHON_VERSIONS] From d68510e65d2d8198e8912b0ecc2f0dfab380e107 Mon Sep 17 00:00:00 2001 From: Chris Langhans Date: Mon, 27 Apr 2026 14:48:28 +0200 Subject: [PATCH 3/4] feat(test): add py_itf_unittest rule and restructure test directory - Introduce py_itf_unittest macro: lightweight py_test wrapper with no ITF plugin infrastructure, pytest-mock included by default - Export py_itf_unittest via defs.bzl alongside py_itf_test - Move existing tests to test/integration/ - Add test/unit/ with test_ping (mocker) and test_qemu_config_schema (pure Pydantic model validation, no file I/O) - Add integration-level test_qemu_config_schema (load_configuration with real JSON files) and test_attribute_plugin - Set coverage instrumentation filter to //score/itf[/:] --- .bazelrc | 3 + bazel/py_itf_unittest.bzl | 51 ++++++++++++ defs.bzl | 2 + examples/examples/itf/test_dlt.py | 2 +- examples/examples/itf/test_docker.py | 2 +- examples/examples/itf/test_qemu.py | 2 +- test/{ => integration}/BUILD | 51 +++++------- test/{ => integration}/conftest.py | 0 test/{ => integration}/test_async_exec.py | 0 .../test_attribute_plugin.py | 0 test/{ => integration}/test_dlt.py | 0 test/{ => integration}/test_docker.py | 0 test/{ => integration}/test_qemu.py | 0 .../test_qemu_config_schema.py | 0 .../test_rules_are_working_correctly.py | 0 test/{ => integration}/test_ssh.py | 0 test/test_ping.py | 35 -------- test/unit/BUILD | 32 ++++++++ test/unit/test_ping.py | 34 ++++++++ test/unit/test_qemu_config_schema.py | 80 +++++++++++++++++++ 20 files changed, 224 insertions(+), 70 deletions(-) create mode 100644 bazel/py_itf_unittest.bzl rename test/{ => integration}/BUILD (94%) rename test/{ => integration}/conftest.py (100%) rename test/{ => integration}/test_async_exec.py (100%) rename test/{ => integration}/test_attribute_plugin.py (100%) rename test/{ => integration}/test_dlt.py (100%) rename test/{ => integration}/test_docker.py (100%) rename test/{ => integration}/test_qemu.py (100%) rename test/{ => integration}/test_qemu_config_schema.py (100%) rename test/{ => integration}/test_rules_are_working_correctly.py (100%) rename test/{ => integration}/test_ssh.py (100%) delete mode 100644 test/test_ping.py create mode 100644 test/unit/BUILD create mode 100644 test/unit/test_ping.py create mode 100644 test/unit/test_qemu_config_schema.py diff --git a/.bazelrc b/.bazelrc index 24b7fdc..3af920b 100644 --- a/.bazelrc +++ b/.bazelrc @@ -3,6 +3,9 @@ common --registry=https://bcr.bazel.build test --test_output=errors +coverage --combined_report=lcov +coverage --instrumentation_filter="//score/itf[/:]" + build:x86_64-qnx --incompatible_strict_action_env build:x86_64-qnx --platforms=@score_bazel_platforms//:x86_64-qnx-sdp_8.0.0-posix build:x86_64-qnx --sandbox_writable_path=/var/tmp diff --git a/bazel/py_itf_unittest.bzl b/bazel/py_itf_unittest.bzl new file mode 100644 index 0000000..5542480 --- /dev/null +++ b/bazel/py_itf_unittest.bzl @@ -0,0 +1,51 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +"""Lightweight macro for running unit tests via pytest without ITF plugin infrastructure.""" + +load("@rules_python//python:defs.bzl", "py_test") + +def py_itf_unittest(name, srcs, deps = [], data = [], env = {}, pytest_config = None, **kwargs): + """Thin py_test wrapper for unit tests that do not need ITF plugin machinery. + + Unlike py_itf_test, this macro creates a direct py_test with no launcher + script or plugin infrastructure, so Bazel coverage works out of the box. + + Args: + name: Target name. + srcs: Python test source files. + deps: Additional Python dependencies. + data: Data files available at runtime. + env: Environment variables for the test. + pytest_config: Optional pytest config file. Defaults to @score_itf//:pytest.ini. + **kwargs: Forwarded to py_test (e.g. size, timeout, tags). + """ + pytest_bootstrap = Label("@score_itf//:main.py") + if not pytest_config: + pytest_config = Label("@score_itf//:pytest.ini") + + py_test( + name = name, + srcs = [pytest_bootstrap] + srcs, + main = pytest_bootstrap, + args = [ + "-c $(location %s)" % pytest_config, + "-p no:cacheprovider", + "--show-capture=no", + "--junitxml=$$XML_OUTPUT_FILE", + ] + ["$(location %s)" % x for x in srcs], + deps = ["@score_itf//:itf", "@itf_pip//pytest_mock"] + deps, + data = [pytest_config] + data, + env = {"PYTHONDONOTWRITEBYTECODE": "1"} | env, + **kwargs + ) diff --git a/defs.bzl b/defs.bzl index c319dc4..6a4d164 100644 --- a/defs.bzl +++ b/defs.bzl @@ -13,5 +13,7 @@ """ITF public Bazel interface""" load("@score_itf//bazel:py_itf_test.bzl", local_py_itf_test = "py_itf_test") +load("@score_itf//bazel:py_itf_unittest.bzl", local_py_itf_unittest = "py_itf_unittest") py_itf_test = local_py_itf_test +py_itf_unittest = local_py_itf_unittest diff --git a/examples/examples/itf/test_dlt.py b/examples/examples/itf/test_dlt.py index 0000191..20ed5dc 120000 --- a/examples/examples/itf/test_dlt.py +++ b/examples/examples/itf/test_dlt.py @@ -1 +1 @@ -../../../test/test_dlt.py \ No newline at end of file +../../../test/integration/test_dlt.py \ No newline at end of file diff --git a/examples/examples/itf/test_docker.py b/examples/examples/itf/test_docker.py index aeea5f3..a4a3947 120000 --- a/examples/examples/itf/test_docker.py +++ b/examples/examples/itf/test_docker.py @@ -1 +1 @@ -../../../test/test_docker.py \ No newline at end of file +../../../test/integration/test_docker.py \ No newline at end of file diff --git a/examples/examples/itf/test_qemu.py b/examples/examples/itf/test_qemu.py index e7d1b99..b503340 120000 --- a/examples/examples/itf/test_qemu.py +++ b/examples/examples/itf/test_qemu.py @@ -1 +1 @@ -../../../test/test_qemu.py \ No newline at end of file +../../../test/integration/test_qemu.py \ No newline at end of file diff --git a/test/BUILD b/test/integration/BUILD similarity index 94% rename from test/BUILD rename to test/integration/BUILD index 5b3c40f..fcd7559 100644 --- a/test/BUILD +++ b/test/integration/BUILD @@ -12,6 +12,25 @@ # ******************************************************************************* load("//:defs.bzl", "py_itf_test") +py_itf_test( + name = "test_attribute_plugin", + srcs = ["test_attribute_plugin.py"], + deps = ["//score/itf/plugins:attribute_plugin"], +) + +py_itf_test( + name = "test_qemu_config_schema", + srcs = ["test_qemu_config_schema.py"], + data = [ + "//test/resources:qemu_bridge_config", + "//test/resources:qemu_port_forwarding_config", + ], + deps = [ + "//score/itf/plugins/qemu", + "@rules_python//python/runfiles", + ], +) + py_itf_test( name = "test_rules_are_working_correctly", srcs = [ @@ -183,35 +202,3 @@ test_suite( ":test_ssh_configurable", ], ) - -py_itf_test( - name = "test_ping", - srcs = [ - "test_ping.py", - ], -) - -py_itf_test( - name = "test_qemu_config_schema", - srcs = [ - "test_qemu_config_schema.py", - ], - data = [ - "//test/resources:qemu_bridge_config", - "//test/resources:qemu_port_forwarding_config", - ], - deps = [ - "//score/itf/plugins/qemu", - "@rules_python//python/runfiles", - ], -) - -py_itf_test( - name = "test_attribute_plugin", - srcs = [ - "test_attribute_plugin.py", - ], - plugins = [ - "//score/itf/plugins:attribute_plugin", - ], -) diff --git a/test/conftest.py b/test/integration/conftest.py similarity index 100% rename from test/conftest.py rename to test/integration/conftest.py diff --git a/test/test_async_exec.py b/test/integration/test_async_exec.py similarity index 100% rename from test/test_async_exec.py rename to test/integration/test_async_exec.py diff --git a/test/test_attribute_plugin.py b/test/integration/test_attribute_plugin.py similarity index 100% rename from test/test_attribute_plugin.py rename to test/integration/test_attribute_plugin.py diff --git a/test/test_dlt.py b/test/integration/test_dlt.py similarity index 100% rename from test/test_dlt.py rename to test/integration/test_dlt.py diff --git a/test/test_docker.py b/test/integration/test_docker.py similarity index 100% rename from test/test_docker.py rename to test/integration/test_docker.py diff --git a/test/test_qemu.py b/test/integration/test_qemu.py similarity index 100% rename from test/test_qemu.py rename to test/integration/test_qemu.py diff --git a/test/test_qemu_config_schema.py b/test/integration/test_qemu_config_schema.py similarity index 100% rename from test/test_qemu_config_schema.py rename to test/integration/test_qemu_config_schema.py diff --git a/test/test_rules_are_working_correctly.py b/test/integration/test_rules_are_working_correctly.py similarity index 100% rename from test/test_rules_are_working_correctly.py rename to test/integration/test_rules_are_working_correctly.py diff --git a/test/test_ssh.py b/test/integration/test_ssh.py similarity index 100% rename from test/test_ssh.py rename to test/integration/test_ssh.py diff --git a/test/test_ping.py b/test/test_ping.py deleted file mode 100644 index 598f997..0000000 --- a/test/test_ping.py +++ /dev/null @@ -1,35 +0,0 @@ -# ******************************************************************************* -# Copyright (c) 2026 Contributors to the Eclipse Foundation -# -# See the NOTICE file(s) distributed with this work for additional -# information regarding copyright ownership. -# -# This program and the accompanying materials are made available under the -# terms of the Apache License Version 2.0 which is available at -# https://www.apache.org/licenses/LICENSE-2.0 -# -# SPDX-License-Identifier: Apache-2.0 -# ******************************************************************************* - -import pytest -from unittest.mock import patch - -from score.itf.core.com.ping import ping - - -def test_ping_raises_when_ping_utility_is_missing(): - with patch("score.itf.core.com.ping.shutil.which", return_value=None): - with pytest.raises(RuntimeError, match="'ping' utility is not installed"): - ping("127.0.0.1") - - -def test_ping_returns_true_when_host_is_reachable(): - with patch("score.itf.core.com.ping.shutil.which", return_value="/usr/bin/ping"): - with patch("score.itf.core.com.ping.os.system", return_value=0): - assert ping("127.0.0.1") is True - - -def test_ping_returns_false_when_host_is_unreachable(): - with patch("score.itf.core.com.ping.shutil.which", return_value="/usr/bin/ping"): - with patch("score.itf.core.com.ping.os.system", return_value=1): - assert ping("192.0.2.1") is False diff --git a/test/unit/BUILD b/test/unit/BUILD new file mode 100644 index 0000000..68c32da --- /dev/null +++ b/test/unit/BUILD @@ -0,0 +1,32 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +load("//:defs.bzl", "py_itf_unittest") + +py_itf_unittest( + name = "test_ping", + srcs = ["test_ping.py"], +) + +py_itf_unittest( + name = "test_qemu_config_schema", + srcs = ["test_qemu_config_schema.py"], + deps = ["//score/itf/plugins/qemu:config"], +) + +test_suite( + name = "unit", + tests = [ + ":test_ping", + ":test_qemu_config_schema", + ], +) diff --git a/test/unit/test_ping.py b/test/unit/test_ping.py new file mode 100644 index 0000000..b5fc782 --- /dev/null +++ b/test/unit/test_ping.py @@ -0,0 +1,34 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +import pytest + +from score.itf.core.com.ping import ping + + +def test_ping_raises_when_ping_utility_is_missing(mocker): + mocker.patch("score.itf.core.com.ping.shutil.which", return_value=None) + with pytest.raises(RuntimeError, match="'ping' utility is not installed"): + ping("127.0.0.1") + + +def test_ping_returns_true_when_host_is_reachable(mocker): + mocker.patch("score.itf.core.com.ping.shutil.which", return_value="/usr/bin/ping") + mocker.patch("score.itf.core.com.ping.os.system", return_value=0) + assert ping("127.0.0.1") is True + + +def test_ping_returns_false_when_host_is_unreachable(mocker): + mocker.patch("score.itf.core.com.ping.shutil.which", return_value="/usr/bin/ping") + mocker.patch("score.itf.core.com.ping.os.system", return_value=1) + assert ping("192.0.2.1") is False diff --git a/test/unit/test_qemu_config_schema.py b/test/unit/test_qemu_config_schema.py new file mode 100644 index 0000000..772a6ed --- /dev/null +++ b/test/unit/test_qemu_config_schema.py @@ -0,0 +1,80 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +import pytest + +from score.itf.plugins.qemu.config import QemuConfigModel + + +_VALID_BRIDGE_CONFIG = { + "networks": [{"name": "tap0", "ip_address": "169.254.158.190", "gateway": "169.254.21.88"}], + "ssh_port": 22, + "qemu_num_cores": 2, + "qemu_ram_size": "1G", +} + +_VALID_PORT_FORWARDING_CONFIG = { + "networks": [{"name": "lo", "ip_address": "127.0.0.1", "gateway": "127.0.0.1"}], + "ssh_port": 2222, + "qemu_num_cores": 2, + "qemu_ram_size": "1G", + "port_forwarding": [{"host_port": 2222, "guest_port": 22}], +} + + +def test_valid_bridge_config(): + QemuConfigModel.model_validate(_VALID_BRIDGE_CONFIG) + + +def test_valid_port_forwarding_config(): + QemuConfigModel.model_validate(_VALID_PORT_FORWARDING_CONFIG) + + +def test_missing_networks_is_rejected(): + config = {**_VALID_BRIDGE_CONFIG} + del config["networks"] + with pytest.raises(Exception): + QemuConfigModel.model_validate(config) + + +def test_empty_networks_is_rejected(): + config = {**_VALID_BRIDGE_CONFIG, "networks": []} + with pytest.raises(Exception): + QemuConfigModel.model_validate(config) + + +def test_invalid_ip_address_is_rejected(): + config = { + **_VALID_BRIDGE_CONFIG, + "networks": [{"name": "tap0", "ip_address": "not-an-ip", "gateway": "169.254.21.88"}], + } + with pytest.raises(Exception): + QemuConfigModel.model_validate(config) + + +def test_invalid_ssh_port_is_rejected(): + config = {**_VALID_BRIDGE_CONFIG, "ssh_port": 0} + with pytest.raises(Exception): + QemuConfigModel.model_validate(config) + + +def test_invalid_ram_size_is_rejected(): + config = {**_VALID_BRIDGE_CONFIG, "qemu_ram_size": "1GB"} + with pytest.raises(Exception): + QemuConfigModel.model_validate(config) + + +def test_unknown_keys_are_rejected(): + config = {**_VALID_BRIDGE_CONFIG, "unknown_key": "value"} + with pytest.raises(Exception): + QemuConfigModel.model_validate(config) From 238efe0aa2fa076bb768f6f2c4e591f45e98abb1 Mon Sep 17 00:00:00 2001 From: Chris Langhans Date: Mon, 27 Apr 2026 14:49:02 +0200 Subject: [PATCH 4/4] refactor(qemu): extract :config as separate Bazel target Split //score/itf/plugins/qemu into :config (config.py + pydantic only) and :qemu (rest of plugin, depends on :config). Unit tests depend on :config to avoid pulling in qemu infrastructure as coverage denominator. --- score/itf/plugins/qemu/BUILD | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/score/itf/plugins/qemu/BUILD b/score/itf/plugins/qemu/BUILD index 2f4df1c..52b42f0 100644 --- a/score/itf/plugins/qemu/BUILD +++ b/score/itf/plugins/qemu/BUILD @@ -14,12 +14,19 @@ load("@itf_pip//:requirements.bzl", "requirement") load("@rules_python//python:defs.bzl", "py_library") +py_library( + name = "config", + srcs = ["config.py"], + imports = ["."], + visibility = ["//visibility:public"], + deps = [requirement("pydantic")], +) + py_library( name = "qemu", srcs = [ "__init__.py", "checks.py", - "config.py", "qemu.py", "qemu_process.py", "qemu_target.py", @@ -27,8 +34,8 @@ py_library( imports = ["."], visibility = ["//visibility:public"], deps = [ + ":config", "//score/itf/core/process", "//score/itf/core/utils", - requirement("pydantic"), ], )