Skip to content

Commit 17dee5a

Browse files
committed
test: iterate platform lists via lib.lftest.attach_each for accurate test counts
Convert the last 7 run files that still used a plain for/subTest loop (apache-httpd-status, cpu-usage, keycloak-memory-usage, keycloak-stats, keycloak-version, logfile, mysql-connections) to lib.lftest.attach_each so each platform, image or scenario shows up as its own unittest test method. Plain subTest loops collapse into a single method and make ./run report "Ran 1 test" regardless of actual coverage. Document both the TESTS-list pattern (attach_tests) and the platform-list pattern (attach_each) in CONTRIBUTING.md, and update the container-based test example to match.
1 parent 0a2c76d commit 17dee5a

File tree

8 files changed

+352
-326
lines changed
  • check-plugins
    • apache-httpd-status/unit-test
    • cpu-usage/unit-test
    • keycloak-memory-usage/unit-test
    • keycloak-stats/unit-test
    • keycloak-version/unit-test
    • logfile/unit-test
    • mysql-connections/unit-test

8 files changed

+352
-326
lines changed

CONTRIBUTING.md

Lines changed: 49 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -693,7 +693,7 @@ pylint check-plugins/my-check/my-check
693693

694694
### Unit Tests
695695

696-
Unit tests are implemented using the `unittest` framework (<https://docs.python.org/3/library/unittest.html>) with a declarative, data-driven approach. Test definitions are a list of dicts, executed via `lib.lftest.run()` and `unittest.subTest()`. See the [example](check-plugins/example/unit-test/run) plugin for the reference implementation.
696+
Unit tests are implemented using the `unittest` framework (<https://docs.python.org/3/library/unittest.html>) with a declarative, data-driven approach. Test definitions are a list of dicts (or a list of platform/image items for container tests), materialised into one real `unittest` test method per item via `lib.lftest.attach_tests()` or `lib.lftest.attach_each()`. See the [example](check-plugins/example/unit-test/run) plugin for the reference implementation.
697697

698698

699699
#### Test directory structure
@@ -802,6 +802,20 @@ Available assertion keys in each testcase dict:
802802
* `assert-stderr` (`str`, optional): Expected stderr content. Default: `''`.
803803

804804

805+
#### Iterating over TESTS vs. platforms
806+
807+
Two iteration shapes show up in the test files, and each has its own helper. Both materialise one real `unittest` test method per item so `./run` reports an accurate count and `./run -v` names every case.
808+
809+
* **TESTS list** (the default): a list of testcase dicts executed by `lib.lftest.run()`. Use `lib.lftest.attach_tests(TestCheck, TESTS)`. This is the right shape for everything that injects fixture data via `--test=stdout/...`.
810+
* **Platform list** (container-based tests): a list of images, Containerfiles, or scenario dicts where each item needs its own setup (spin up a container, build an image, reset a cache DB). Use `lib.lftest.attach_each(TestCheck, ITEMS, action, id_func=...)` with an `action(test, item)` callable that does the per-item work. The `id_func` turns one item into the test method name. Examples in-tree:
811+
812+
* `check-plugins/mysql-connections/unit-test/run` iterates over an `IMAGES` list of `(image, label)` tuples, with `id_func=lambda it: it[1]`.
813+
* `check-plugins/cpu-usage/unit-test/run` iterates over a `CONTAINERFILES` list of strings, with the default `id_func=str`.
814+
* `check-plugins/apache-httpd-status/unit-test/run` iterates over a `SCENARIOS` list of dicts where the action resets a cache DB and then replays a multi-step sequence.
815+
816+
Do not fall back to a plain `for ... subTest()` loop. It still executes every case and failures still surface, but unittest collapses the whole loop into a single method and reports `Ran 1 test`, which hides the real coverage count from `./run` and from the `tox` summary.
817+
818+
805819
#### Running tests
806820

807821
Unit tests come in two flavors:
@@ -877,42 +891,47 @@ IMAGES = [
877891

878892

879893
class TestCheck(unittest.TestCase):
880-
def test(self):
881-
for image, version_tag in IMAGES:
882-
with self.subTest(image=image):
883-
with lib.lftest.run_container(
884-
image,
885-
env={
886-
'KEYCLOAK_ADMIN': 'admin',
887-
'KEYCLOAK_ADMIN_PASSWORD': 'admin',
888-
},
889-
ports=[8080],
890-
command='start-dev',
891-
wait_log='Listening on:',
892-
) as container:
893-
url = f'http://{container.get_container_host_ip()}:{container.get_exposed_port(8080)}'
894-
result = subprocess.run(
895-
['python3', '../keycloak-version',
896-
f'--url={url}', '--username=admin', '--password=admin',
897-
'--path=/nonexistent'],
898-
capture_output=True, text=True,
899-
)
900-
self.assertRegex(
901-
result.stdout + result.stderr,
902-
rf'Keycloak\s+{version_tag}',
903-
)
904-
self.assertIn(
905-
result.returncode, (STATE_OK, STATE_WARN, STATE_CRIT),
906-
)
894+
pass
895+
896+
897+
def _check_image(test, image_pair):
898+
image, version_tag = image_pair
899+
with lib.lftest.run_container(
900+
image,
901+
env={
902+
'KEYCLOAK_ADMIN': 'admin',
903+
'KEYCLOAK_ADMIN_PASSWORD': 'admin',
904+
},
905+
ports=[8080],
906+
command='start-dev',
907+
wait_log='Listening on:',
908+
) as container:
909+
url = f'http://{container.get_container_host_ip()}:{container.get_exposed_port(8080)}'
910+
result = subprocess.run(
911+
['python3', '../keycloak-version',
912+
f'--url={url}', '--username=admin', '--password=admin',
913+
'--path=/nonexistent'],
914+
capture_output=True, text=True,
915+
)
916+
test.assertRegex(
917+
result.stdout + result.stderr,
918+
rf'Keycloak\s+{version_tag}',
919+
)
920+
test.assertIn(
921+
result.returncode, (STATE_OK, STATE_WARN, STATE_CRIT),
922+
)
923+
924+
925+
lib.lftest.attach_each(TestCheck, IMAGES, _check_image, id_func=lambda it: it[1])
907926
```
908927

909928
Rules and tips:
910929

911930
* **Pull upstream images whenever possible.** You do not need a custom `Containerfile` that injects Python into the service image, because the plugin runs from the host and connects to the container via the exposed port. That is the common case for API-driven checks.
912931
* **Wait on a log marker, not a sleep.** The `wait_log` argument takes a substring that the service writes to stdout/stderr when it is ready (e.g. `Listening on:` for Keycloak, `ready for connections.` for MariaDB). Use `wait_log_timeout` for services that take longer than 2 minutes to start.
913932
* **Do not hardcode state-shifting assertions.** If the plugin reports something that depends on today's date (EOL windows, "last seen N days ago", "expires in X days"), assert only that the plugin returned a valid state (any of `STATE_OK`, `STATE_WARN`, `STATE_CRIT`) and that the output contains the expected version / service identifier. Locking in a specific state will break the test every time the calendar moves past a boundary.
914-
* **Multi-version matrix** goes in an `IMAGES` list at the top of the test file, iterated via `self.subTest(image=...)`. Add a new major release at the bottom of the list when it becomes available upstream.
915-
* **Rootless podman**: testcontainers-python works, but the Ryuk cleanup container needs to be disabled. Set `TESTCONTAINERS_RYUK_DISABLED=true` and `DOCKER_HOST=unix:///run/user/$UID/podman/podman.sock` before running the tests. A lightweight wrapper can live in `tools/run-container-tests` to set these for you.
933+
* **Multi-version matrix** goes in an `IMAGES` list (or `CONTAINERFILES`, `SCENARIOS`, ...) at the top of the test file, materialised into one real test method per item via `lib.lftest.attach_each()`. Add a new major release at the bottom of the list when it becomes available upstream. See the "Iterating over TESTS vs. platforms" subsection below for the rationale.
934+
* **Rootless podman**: testcontainers-python works, but the Ryuk cleanup container needs to be disabled. Set `TESTCONTAINERS_RYUK_DISABLED=true` and `CONTAINER_HOST=unix:///run/user/$UID/podman/podman.sock` before running the tests. `tools/run-unit-tests` sets both automatically when it detects a container-based test.
916935
* **Do not run container tests via `tox`.** They are integration tests and belong in `tools/run-container-tests`, not in the multi-Python matrix. `tools/run-unit-tests` detects them automatically by inspecting the `run` file for `podman` or `testcontainers` references.
917936
* **Keep hand-rolled podman orchestration out of new tests.** If you find a plugin that still builds containers via `subprocess.run(['podman', 'build', ...])`, migrate it to `lib.lftest.run_container()`; the old pattern is being retired.
918937

check-plugins/apache-httpd-status/unit-test/run

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -415,19 +415,20 @@ SCENARIOS = [
415415

416416

417417
class TestCheck(unittest.TestCase):
418-
419418
check = '../apache-httpd-status'
420419

421-
def test(self):
422-
for scenario in SCENARIOS:
423-
with self.subTest(scenario=scenario['id']):
424-
# Reset the sqlite cache so previous-value lookups
425-
# start from a clean slate for this scenario.
426-
if os.path.exists(CACHE_DB):
427-
os.remove(CACHE_DB)
428-
for i, step in enumerate(scenario['steps'], start=1):
429-
with self.subTest(scenario=scenario['id'], step=i):
430-
lib.lftest.run(self, self.check, step)
420+
421+
def _run_scenario(test, scenario):
422+
# Reset the sqlite cache so previous-value lookups start from a
423+
# clean slate for this scenario.
424+
if os.path.exists(CACHE_DB):
425+
os.remove(CACHE_DB)
426+
for i, step in enumerate(scenario['steps'], start=1):
427+
with test.subTest(step=i):
428+
lib.lftest.run(test, test.check, step)
429+
430+
431+
lib.lftest.attach_each(TestCheck, SCENARIOS, _run_scenario, id_func=lambda s: s['id'])
431432

432433

433434
if __name__ == '__main__':

check-plugins/cpu-usage/unit-test/run

Lines changed: 51 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import unittest
4141

4242
sys.path.insert(0, '..')
4343

44+
import lib.lftest
4445
from lib.globals import STATE_OK
4546

4647
try:
@@ -78,53 +79,56 @@ CONTAINERFILES = [
7879

7980

8081
class TestCheck(unittest.TestCase):
81-
def test(self):
82-
for containerfile in CONTAINERFILES:
83-
with self.subTest(containerfile=containerfile):
84-
print(f'\n=== Testing {containerfile} ===', flush=True)
85-
image_tag = f'lfmp-cpu-usage-{containerfile}'.lower()
86-
with DockerImage(
87-
path=HERE,
88-
dockerfile_path=f'containerfiles/{containerfile}',
89-
tag=image_tag,
90-
) as image:
91-
# `,Z` relabels the mount for SELinux on podman/rhel hosts
92-
# so the container can actually read the bind-mounted files.
93-
container = (
94-
DockerContainer(str(image.tag))
95-
.with_volume_mapping(LIB_DIR, '/tmp/lib', mode='ro,Z')
96-
.with_volume_mapping(PLUGIN, '/tmp/cpu-usage', mode='ro,Z')
97-
)
98-
with container:
99-
# Some containerfiles install dependencies into a venv
100-
# at /tmp/venv (debian, ubuntu, archlinux), others
101-
# install them system-wide (fedora, rhel). Detect and
102-
# use the right python invocation.
103-
probe = container.exec(
104-
['sh', '-c', 'test -d /tmp/venv && echo venv || echo system']
105-
)
106-
use_venv = b'venv' in probe.output
107-
if use_venv:
108-
cmd = [
109-
'sh',
110-
'-c',
111-
'. /tmp/venv/bin/activate && cd /tmp && python3 ./cpu-usage',
112-
]
113-
else:
114-
cmd = ['sh', '-c', 'cd /tmp && python3 ./cpu-usage']
115-
print(f'Run plugin: {" ".join(cmd)}', flush=True)
116-
result = container.exec(cmd)
117-
output = (result.output or b'').decode('utf-8', errors='replace')
118-
print(f'Script output:\n{output.strip()}', flush=True)
119-
120-
# Plugin must exit cleanly and produce its normal
121-
# "<percent>% - ..., user: X%, ..." line. Use a
122-
# multi-line-tolerant regex because the "user:"
123-
# segment can land on the first or the second line
124-
# depending on psutil's by-value sort when several
125-
# counters read 0%.
126-
self.assertEqual(result.exit_code, STATE_OK)
127-
self.assertRegex(output, r'(?s)\d+\.\d+%.*user:')
82+
pass
83+
84+
85+
def _check_containerfile(test, containerfile):
86+
print(f'\n=== Testing {containerfile} ===', flush=True)
87+
image_tag = f'lfmp-cpu-usage-{containerfile}'.lower()
88+
with DockerImage(
89+
path=HERE,
90+
dockerfile_path=f'containerfiles/{containerfile}',
91+
tag=image_tag,
92+
) as image:
93+
# `,Z` relabels the mount for SELinux on podman/rhel hosts so
94+
# the container can actually read the bind-mounted files.
95+
container = (
96+
DockerContainer(str(image.tag))
97+
.with_volume_mapping(LIB_DIR, '/tmp/lib', mode='ro,Z')
98+
.with_volume_mapping(PLUGIN, '/tmp/cpu-usage', mode='ro,Z')
99+
)
100+
with container:
101+
# Some containerfiles install dependencies into a venv at
102+
# /tmp/venv (debian, ubuntu, archlinux), others install
103+
# them system-wide (fedora, rhel). Detect and use the
104+
# right python invocation.
105+
probe = container.exec(
106+
['sh', '-c', 'test -d /tmp/venv && echo venv || echo system']
107+
)
108+
use_venv = b'venv' in probe.output
109+
if use_venv:
110+
cmd = [
111+
'sh',
112+
'-c',
113+
'. /tmp/venv/bin/activate && cd /tmp && python3 ./cpu-usage',
114+
]
115+
else:
116+
cmd = ['sh', '-c', 'cd /tmp && python3 ./cpu-usage']
117+
print(f'Run plugin: {" ".join(cmd)}', flush=True)
118+
result = container.exec(cmd)
119+
output = (result.output or b'').decode('utf-8', errors='replace')
120+
print(f'Script output:\n{output.strip()}', flush=True)
121+
122+
# Plugin must exit cleanly and produce its normal
123+
# "<percent>% - ..., user: X%, ..." line. Use a
124+
# multi-line-tolerant regex because the "user:" segment
125+
# can land on the first or the second line depending on
126+
# psutil's by-value sort when several counters read 0%.
127+
test.assertEqual(result.exit_code, STATE_OK)
128+
test.assertRegex(output, r'(?s)\d+\.\d+%.*user:')
129+
130+
131+
lib.lftest.attach_each(TestCheck, CONTAINERFILES, _check_containerfile)
128132

129133

130134
if __name__ == '__main__':

check-plugins/keycloak-memory-usage/unit-test/run

Lines changed: 56 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -39,59 +39,62 @@ IMAGES = [
3939

4040

4141
class TestCheck(unittest.TestCase):
42-
def test(self):
43-
"""Spin up each Keycloak image and run the plugin against the API."""
44-
for image, version_tag in IMAGES:
45-
with self.subTest(image=image):
46-
print(f'\n=== Testing {image} ===', flush=True)
47-
with lib.lftest.run_container(
48-
image,
49-
env={
50-
'KEYCLOAK_ADMIN': 'admin',
51-
'KEYCLOAK_ADMIN_PASSWORD': 'admin',
52-
},
53-
ports=[8080],
54-
command='start-dev',
55-
wait_log='Listening on:',
56-
wait_log_timeout=180,
57-
) as container:
58-
url = 'http://{}:{}'.format(
59-
container.get_container_host_ip(),
60-
container.get_exposed_port(8080),
61-
)
62-
cmd = [
63-
'python3',
64-
'../keycloak-memory-usage',
65-
f'--url={url}',
66-
'--username=admin',
67-
'--password=admin',
68-
]
69-
print(f'Run plugin: {" ".join(cmd)}', flush=True)
70-
result = subprocess.run(
71-
cmd,
72-
cwd=os.path.dirname(os.path.abspath(__file__)),
73-
capture_output=True,
74-
text=True,
75-
)
76-
combined = result.stdout + result.stderr
77-
print(f'Script output:\n{combined.strip()}', flush=True)
78-
79-
# The plugin reports "<percent>% - total: <size>, used: <size>,
80-
# free: <size>" plus perfdata. Check the message shape instead
81-
# of specific numbers (which depend on the container host's
82-
# current memory pressure).
83-
self.assertRegex(
84-
combined,
85-
r'\d+(?:\.\d+)?% - total:\s*\d+\.\d+(?:M|G)iB,'
86-
r'\s*used:\s*\d+\.\d+(?:M|G)iB,'
87-
r'\s*free:\s*\d+\.\d+(?:M|G)iB',
88-
)
89-
# Any valid monitoring state. Threshold-based alerts on heap
90-
# depend on how much memory the test host allocates to
91-
# Keycloak; not something we want to pin in the test.
92-
self.assertIn(
93-
result.returncode, (STATE_OK, STATE_WARN, STATE_CRIT)
94-
)
42+
pass
43+
44+
45+
def _check_image(test, image_pair):
46+
"""Spin up one Keycloak image and run the plugin against the API."""
47+
image, version_tag = image_pair
48+
print(f'\n=== Testing {image} ===', flush=True)
49+
with lib.lftest.run_container(
50+
image,
51+
env={
52+
'KEYCLOAK_ADMIN': 'admin',
53+
'KEYCLOAK_ADMIN_PASSWORD': 'admin',
54+
},
55+
ports=[8080],
56+
command='start-dev',
57+
wait_log='Listening on:',
58+
wait_log_timeout=180,
59+
) as container:
60+
url = 'http://{}:{}'.format(
61+
container.get_container_host_ip(),
62+
container.get_exposed_port(8080),
63+
)
64+
cmd = [
65+
'python3',
66+
'../keycloak-memory-usage',
67+
f'--url={url}',
68+
'--username=admin',
69+
'--password=admin',
70+
]
71+
print(f'Run plugin: {" ".join(cmd)}', flush=True)
72+
result = subprocess.run(
73+
cmd,
74+
cwd=os.path.dirname(os.path.abspath(__file__)),
75+
capture_output=True,
76+
text=True,
77+
)
78+
combined = result.stdout + result.stderr
79+
print(f'Script output:\n{combined.strip()}', flush=True)
80+
81+
# The plugin reports "<percent>% - total: <size>, used: <size>,
82+
# free: <size>" plus perfdata. Check the message shape instead
83+
# of specific numbers (which depend on the container host's
84+
# current memory pressure).
85+
test.assertRegex(
86+
combined,
87+
r'\d+(?:\.\d+)?% - total:\s*\d+\.\d+(?:M|G)iB,'
88+
r'\s*used:\s*\d+\.\d+(?:M|G)iB,'
89+
r'\s*free:\s*\d+\.\d+(?:M|G)iB',
90+
)
91+
# Any valid monitoring state. Threshold-based alerts on heap
92+
# depend on how much memory the test host allocates to
93+
# Keycloak; not something we want to pin in the test.
94+
test.assertIn(result.returncode, (STATE_OK, STATE_WARN, STATE_CRIT))
95+
96+
97+
lib.lftest.attach_each(TestCheck, IMAGES, _check_image, id_func=lambda it: it[1])
9598

9699

97100
if __name__ == '__main__':

0 commit comments

Comments
 (0)