|
| 1 | +<!-- |
| 2 | +******************************************************************************* |
| 3 | +Copyright (c) 2026 Contributors to the Eclipse Foundation |
| 4 | +
|
| 5 | +See the NOTICE file(s) distributed with this work for additional |
| 6 | +information regarding copyright ownership. |
| 7 | +
|
| 8 | +This program and the accompanying materials are made available under the |
| 9 | +terms of the Apache License Version 2.0 which is available at |
| 10 | +https://www.apache.org/licenses/LICENSE-2.0 |
| 11 | +
|
| 12 | +SPDX-License-Identifier: Apache-2.0 |
| 13 | +******************************************************************************* |
| 14 | +--> |
| 15 | + |
| 16 | +# DR-001-Infra: Unit Test Infrastructure Design |
| 17 | + |
| 18 | +**Date:** 2026-05-11 |
| 19 | +**Status:** Accepted |
| 20 | +**PR:** [eclipse-score/itf#94](https://github.com/eclipse-score/itf/pull/94) |
| 21 | +**Discussion:** [eclipse-score/discussions#2867](https://github.com/orgs/eclipse-score/discussions/2867) |
| 22 | + |
| 23 | +> This record follows the Decision Record convention established by the |
| 24 | +> Eclipse S-CORE project: |
| 25 | +> [eclipse-score/score — docs/design_decisions](https://github.com/eclipse-score/score/tree/main/docs/design_decisions). |
| 26 | +
|
| 27 | +## Overview |
| 28 | + |
| 29 | +This decision record documents the infrastructure design for unit testing in |
| 30 | +ITF. It covers the Bazel macro, dependency scoping strategy, pytest bootstrap |
| 31 | +pattern, and mocking library choice, all accepted as part of PR #94. |
| 32 | + |
| 33 | +## Problem Statement |
| 34 | + |
| 35 | +ITF previously had only integration tests: tests that start a real target |
| 36 | +(Docker or QEMU) and exercise the system end-to-end. Adding unit tests raised |
| 37 | +four concrete questions that each had multiple viable answers: |
| 38 | + |
| 39 | +1. Should unit tests reuse `py_itf_test` or have a dedicated macro? |
| 40 | +2. How should Bazel dependencies be scoped to keep tests atomic? |
| 41 | +3. How does pytest run inside Bazel, and what does that mean for test |
| 42 | + structure? |
| 43 | +4. Which mocking library should be used? |
| 44 | + |
| 45 | +## Options Evaluated |
| 46 | + |
| 47 | +### Macro design |
| 48 | + |
| 49 | +**Option A — Reuse `py_itf_test` with empty `plugins`.** |
| 50 | +The macro would not crash with an empty plugin list, but it would still |
| 51 | +generate the launcher script and resolve `PyItfPluginInfo` providers. The |
| 52 | +BUILD file would not communicate that no target is involved. |
| 53 | + |
| 54 | +**Option B — Dedicated `py_itf_unittest` macro (chosen).** |
| 55 | +A thin wrapper around `py_test` with no plugin machinery. The name makes |
| 56 | +intent explicit. `pytest-mock` is included as a default dep. JUnit XML |
| 57 | +reporting is baked in via `$XML_OUTPUT_FILE`. |
| 58 | + |
| 59 | +### Dependency scoping |
| 60 | + |
| 61 | +**Option A — One large Bazel target per package.** |
| 62 | +Simple to maintain, but pulls in all transitive dependencies as runfiles. |
| 63 | +Bazel measures coverage over all files in the runfiles tree, so the coverage |
| 64 | +denominator grows with every transitive dep, even ones not under test. |
| 65 | + |
| 66 | +**Option B — Surgical target splitting (chosen).** |
| 67 | +Split Bazel targets along cohesion boundaries so each unit test can declare |
| 68 | +only the module it actually exercises. Example: `score/itf/plugins/qemu/BUILD` |
| 69 | +was split into `:config` (Pydantic schema only) and `:qemu` (full plugin). The |
| 70 | +unit test for schema validation depends only on `:config`, excluding process |
| 71 | +management, SSH, and QEMU binary wrappers from its runfiles tree. |
| 72 | + |
| 73 | +### Pytest bootstrap |
| 74 | + |
| 75 | +**Option A — `score_py_pytest` from `@score_tooling`.** |
| 76 | +The tooling repository provides a `score_py_pytest` rule, but it bundles a |
| 77 | +full Python development environment including `basedpyright` and |
| 78 | +`nodejs-wheel-binaries`. These are unrelated to the code under test and expand |
| 79 | +the runfiles tree significantly, inflating the coverage denominator and |
| 80 | +increasing build time. |
| 81 | + |
| 82 | +**Option B — Shared `main.py` entry point (chosen).** |
| 83 | +`py_test` requires an executable Python module. A minimal `main.py` that calls |
| 84 | +`pytest.main(sys.argv[1:])` is the de facto standard for Bazel + pytest. The |
| 85 | +same bootstrap file is shared across integration and unit test rules, keeping |
| 86 | +the approach consistent. This was confirmed as the community standard in the |
| 87 | +GitHub discussion linked above. |
| 88 | + |
| 89 | +### Mocking library |
| 90 | + |
| 91 | +**Option A — `unittest.mock.patch` via context managers.** |
| 92 | +Part of the standard library, no extra dep. Context manager nesting becomes |
| 93 | +verbose when multiple objects need patching. |
| 94 | + |
| 95 | +**Option B — `pytest-mock` via the `mocker` fixture (chosen).** |
| 96 | +Patches are registered and torn down automatically through the pytest fixture |
| 97 | +lifecycle, removing context manager nesting. Cleaner for tests that mock |
| 98 | +several collaborators: |
| 99 | + |
| 100 | +```python |
| 101 | +def test_ping_reachable(mocker): |
| 102 | + mocker.patch("score.itf.core.com.ping.shutil.which", return_value="/usr/bin/ping") |
| 103 | + mocker.patch("score.itf.core.com.ping.os.system", return_value=0) |
| 104 | + assert ping("127.0.0.1") is True |
| 105 | +``` |
| 106 | + |
| 107 | +## Decision & Rationale |
| 108 | + |
| 109 | +All four decisions favour the option that minimises coupling and maximises |
| 110 | +clarity in the BUILD file: |
| 111 | + |
| 112 | +- **Dedicated `py_itf_unittest` macro** — the name signals "no target" and |
| 113 | + the macro carries no plugin machinery. |
| 114 | +- **Surgical Bazel target splitting** — dep declarations in BUILD files become |
| 115 | + a lightweight design signal: a test that can only list `:config` as a dep |
| 116 | + proves that the schema module is cohesive and has no hidden coupling. |
| 117 | +- **Shared `main.py` bootstrap** — consistent with integration tests and |
| 118 | + aligned with community practice. |
| 119 | +- **`pytest-mock`** — included as a default dep in `py_itf_unittest`; test |
| 120 | + authors get `mocker` without an explicit declaration. |
| 121 | + |
| 122 | +Coverage uses Bazel-native LCOV (`configure_coverage_tool = True` in |
| 123 | +`MODULE.bazel`) rather than `pytest-cov`, for consistency across all test |
| 124 | +types and compatibility with Bazel's `--combined_report`. |
| 125 | + |
| 126 | +## Key Implications |
| 127 | + |
| 128 | +- Unit tests live in `test/unit/` and integration tests in `test/integration/`. |
| 129 | + The split is enforced by directory layout and BUILD files, not just naming. |
| 130 | +- Adding unit tests for a new module may require splitting its Bazel target if |
| 131 | + the current target has a large transitive dep set. This is intentional: |
| 132 | + splitting is a design signal that the module has a cohesion opportunity. |
| 133 | +- `py_itf_unittest` does not support the `plugins` attribute. A test that |
| 134 | + needs a real target belongs in `test/integration/` and uses `py_itf_test`. |
| 135 | +- The `mocker` fixture preference applies project-wide; `unittest.mock` context |
| 136 | + managers should not be introduced in new tests. |
0 commit comments