Skip to content

Commit 49f334d

Browse files
committed
test: add --test hooks and fixture-based unit tests to 7 psutil plugins
Extend the sensors-* pattern to seven plugins that previously had no unit tests at all, because they call `psutil.*` (or similar host-local APIs) directly instead of parsing a shell command's output. Each plugin now has a `--test` argument that short-circuits the live data fetch and loads the return value from a JSON fixture mirroring psutil's own shape: - **load**: fakes `psutil.getloadavg()` + `psutil.cpu_count()` via `{loadavg, cpu_count}`. 4 fixtures, 6 testcases. - **uptime**: fakes `psutil.boot_time()` and `lib.time.now()` via `{now, boot_time}` and bypasses the host cache DB under `--test`. Asserts stay timezone-agnostic. 4 fixtures, 6 testcases. - **memory-usage**: mocks `psutil.virtual_memory()` and forces `--top=0` in the test path so the live `/proc/<pid>/status` walk is skipped. 3 fixtures, 5 testcases. - **swap-usage**: mocks `psutil.swap_memory()` and skips the live per-process swap scan under `--test`. 4 fixtures, 6 testcases. - **file-descriptors**: replaces the `/proc/sys/fs/file-nr` read with a 3-field JSON fixture and forces `--top=0`. 3 fixtures, 5 testcases. - **disk-usage**: mocks both `psutil.disk_partitions()` and the per-mountpoint `psutil.disk_usage()` via a combined `{partitions, usage}` fixture. 4 fixtures, 6 testcases. - **network-connections**: mocks `psutil.net_connections(kind='inet')` with a list of `sconn` namedtuples; the fixture uses a human-friendly `{proto, status}` form that the loader maps to the right `(family, type)` pair. 3 fixtures, 6 testcases. All seven plugins bump `__version__` and all 118 unit tests still pass under `tools/run-unit-tests --no-container`. Six plugins from the original Category A list were left for a follow-up because a thin `--test` hook is not enough to cover them: - **about-me**: 1500-line plugin with data from psutil, lib.net, lib.cpu, lib.distro, platform, /etc/os-release, /proc/cpuinfo. - **disk-io**, **network-io**: SQLite-backed trend state and a `lib.cache` max-tracker on top of psutil counters. A clean fixture branch needs cache mocking on top of the psutil branch. - **procs**: iterates `psutil.process_iter()` returning live `Process` objects with an `.info` dict; faking the generator is bigger than the rest of the plugin. - **mysql-memory**, **mysql-system**: need a live MySQL connection and are better tested with testcontainers-python.
1 parent ddcdc07 commit 49f334d

39 files changed

+1163
-63
lines changed

check-plugins/disk-usage/disk-usage

Lines changed: 93 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,15 @@
1111
"""See the check's README for more details."""
1212

1313
import argparse
14+
import json
1415
import re
1516
import sys
17+
from collections import namedtuple
1618

1719
import lib.args
1820
import lib.base
1921
import lib.human
22+
import lib.lftest
2023
from lib.globals import STATE_CRIT, STATE_OK, STATE_UNKNOWN, STATE_WARN
2124

2225
try:
@@ -25,9 +28,25 @@ except ImportError:
2528
print('Python module "psutil" is not installed.')
2629
sys.exit(STATE_UNKNOWN)
2730

31+
# psutil 5.x exposes sdiskpart/sdiskusage under psutil._common,
32+
# psutil 6+ under psutil._ntuples. Fall back to plain namedtuples
33+
# with the fields we touch if neither is available.
34+
try:
35+
from psutil._ntuples import sdiskpart, sdiskusage
36+
except ImportError:
37+
try:
38+
from psutil._common import sdiskpart, sdiskusage
39+
except ImportError:
40+
sdiskpart = namedtuple(
41+
'sdiskpart', ['device', 'mountpoint', 'fstype', 'opts']
42+
)
43+
sdiskusage = namedtuple(
44+
'sdiskusage', ['total', 'used', 'free', 'percent']
45+
)
46+
2847

2948
__author__ = 'Linuxfabrik GmbH, Zurich/Switzerland'
30-
__version__ = '2026040801'
49+
__version__ = '2026041301'
3150

3251
DESCRIPTION = """Checks used or free disk space for each mounted partition. By default, only physical
3352
devices are checked (hard disks, USB drives), ignoring pseudo and memory filesystems.
@@ -149,6 +168,13 @@ def parse_args():
149168
default=None,
150169
)
151170

171+
parser.add_argument(
172+
'--test',
173+
help=lib.args.help('--test'),
174+
dest='TEST',
175+
type=lib.args.csv,
176+
)
177+
152178
parser.add_argument(
153179
'-w',
154180
'--warning',
@@ -167,6 +193,48 @@ def parse_args():
167193
return args
168194

169195

196+
def _load_disk_usage_fixture(raw_json):
197+
"""Convert a test fixture into the two data structures the plugin
198+
expects from `psutil.disk_partitions()` and
199+
`psutil.disk_usage(mountpoint)`:
200+
201+
- `partitions`: a list of `sdiskpart(device, mountpoint, fstype, opts)`
202+
namedtuples
203+
- `usage`: a dict keyed by mountpoint, each value an
204+
`sdiskusage(total, used, free, percent)` namedtuple
205+
206+
Fixture shape:
207+
208+
{
209+
"partitions": [
210+
{"device": "/dev/vda1", "mountpoint": "/", "fstype": "ext4",
211+
"opts": "rw,relatime"},
212+
...
213+
],
214+
"usage": {
215+
"/": {"total": <bytes>, "used": <bytes>, "free": <bytes>,
216+
"percent": <0..100>},
217+
...
218+
}
219+
}
220+
"""
221+
data = json.loads(raw_json)
222+
partitions = [
223+
sdiskpart(
224+
p['device'],
225+
p['mountpoint'],
226+
p.get('fstype', ''),
227+
p.get('opts', ''),
228+
)
229+
for p in data.get('partitions', [])
230+
]
231+
usage = {
232+
mp: sdiskusage(u['total'], u['used'], u['free'], u['percent'])
233+
for mp, u in data.get('usage', {}).items()
234+
}
235+
return partitions, usage
236+
237+
170238
def compile_regex(regex, what):
171239
"""Return a compiled regex."""
172240
try:
@@ -255,19 +323,25 @@ def main():
255323
compiled_exclude_regex = compile_regex(args.EXCLUDE_REGEX, 'exclude-regex')
256324
compiled_perfdata_regex = compile_regex(args.PERFDATA_REGEX, 'perfdata-regex')
257325

258-
# analyze data
259-
if args.FSTYPE:
260-
# user wants to check file system types on his own
261-
parts = psutil.disk_partitions(all=True)
326+
# fetch data
327+
fixture_usage = None
328+
if args.TEST is None:
329+
if args.FSTYPE:
330+
# user wants to check file system types on his own
331+
parts = psutil.disk_partitions(all=True)
332+
else:
333+
# default behaviour - check physical devices only (e.g. hard disks, cd-rom drives,
334+
# USB keys) and ignore all others (e.g. pseudo, memory, duplicate, inaccessible
335+
# filesystems)
336+
try:
337+
parts = psutil.disk_partitions(all=False)
338+
except AttributeError:
339+
lib.base.cu(
340+
'Did not find physical devices (e.g. hard disks, cd-rom drives, USB keys).'
341+
)
262342
else:
263-
# default behaviour - check physical devices only (e.g. hard disks, cd-rom drives, USB keys)
264-
# and ignore all others (e.g. pseudo, memory, duplicate, inaccessible filesystems)
265-
try:
266-
parts = psutil.disk_partitions(all=False)
267-
except AttributeError:
268-
lib.base.cu(
269-
'Did not find physical devices (e.g. hard disks, cd-rom drives, USB keys).'
270-
)
343+
stdout, _, _ = lib.lftest.test(args.TEST)
344+
parts, fixture_usage = _load_disk_usage_fixture(stdout)
271345

272346
for part in parts:
273347
# sdiskpart(device='/dev/vda2', mountpoint='/', fstype='ext4', opts='rw,relatime')
@@ -307,7 +381,12 @@ def main():
307381
continue
308382

309383
try:
310-
usage = psutil.disk_usage(part.mountpoint)
384+
if fixture_usage is not None:
385+
if part.mountpoint not in fixture_usage:
386+
raise FileNotFoundError(part.mountpoint)
387+
usage = fixture_usage[part.mountpoint]
388+
else:
389+
usage = psutil.disk_usage(part.mountpoint)
311390
except (PermissionError, FileNotFoundError, OSError):
312391
table_data.append(
313392
{
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
#!/usr/bin/env python3
2+
# -*- coding: utf-8; py-indent-offset: 4 -*-
3+
#
4+
# Author: Linuxfabrik GmbH, Zurich, Switzerland
5+
# Contact: info (at) linuxfabrik (dot) ch
6+
# https://www.linuxfabrik.ch/
7+
# License: The Unlicense, see LICENSE file.
8+
9+
# https://github.com/Linuxfabrik/monitoring-plugins/blob/main/CONTRIBUTING.md
10+
11+
import sys
12+
sys.path.insert(0, '..')
13+
14+
import unittest
15+
16+
from lib.globals import STATE_CRIT, STATE_OK, STATE_WARN
17+
import lib.lftest
18+
19+
20+
# The disk-usage plugin normally calls `psutil.disk_partitions()` to
21+
# enumerate mountpoints and then `psutil.disk_usage(mountpoint)` for
22+
# each one. Its `--test` hook replaces both calls with a JSON
23+
# fixture:
24+
#
25+
# {
26+
# "partitions": [
27+
# {"device": "...", "mountpoint": "...", "fstype": "...", "opts": "..."},
28+
# ...
29+
# ],
30+
# "usage": {
31+
# "<mountpoint>": {"total": <bytes>, "used": <bytes>,
32+
# "free": <bytes>, "percent": <0..100>},
33+
# ...
34+
# }
35+
# }
36+
TESTS = [
37+
{
38+
'id': 'ok-single-root-nominal',
39+
'test': 'stdout/single-root-nominal,,0',
40+
'assert-retc': STATE_OK,
41+
'assert-in': ['/ 30.0%'],
42+
},
43+
{
44+
'id': 'ok-two-mounts-root-and-boot',
45+
'test': 'stdout/two-mounts-root-and-boot,,0',
46+
'assert-retc': STATE_OK,
47+
'assert-in': [
48+
'Everything is ok.',
49+
'/',
50+
'/boot',
51+
],
52+
},
53+
{
54+
'id': 'warn-single-root-above-warn',
55+
'test': 'stdout/single-root-warn,,0',
56+
'assert-retc': STATE_WARN,
57+
'assert-in': ['91.0%', '[WARNING]'],
58+
},
59+
{
60+
'id': 'crit-single-root-above-crit',
61+
'test': 'stdout/single-root-crit,,0',
62+
'assert-retc': STATE_CRIT,
63+
'assert-in': ['98.0%', '[CRITICAL]'],
64+
},
65+
{
66+
'id': 'ok-always-ok-masks-critical',
67+
'test': 'stdout/single-root-crit,,0',
68+
'params': '--always-ok',
69+
'assert-retc': STATE_OK,
70+
'assert-in': ['98.0%'],
71+
},
72+
{
73+
'id': 'ok-exclude-boot-leaves-only-root',
74+
'test': 'stdout/two-mounts-root-and-boot,,0',
75+
'params': '--exclude-pattern=boot',
76+
'assert-retc': STATE_OK,
77+
'assert-not-in': ['/boot'],
78+
},
79+
]
80+
81+
82+
class TestCheck(unittest.TestCase):
83+
84+
check = '../disk-usage'
85+
86+
def test(self):
87+
for t in TESTS:
88+
with self.subTest(id=t['id']):
89+
lib.lftest.run(self, self.check, t)
90+
91+
92+
if __name__ == '__main__':
93+
unittest.main()
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"partitions": [
3+
{"device": "/dev/vda1", "mountpoint": "/", "fstype": "ext4", "opts": "rw,relatime"}
4+
],
5+
"usage": {
6+
"/": {"total": 107374182400, "used": 104857600000, "free": 2516582400, "percent": 98.0}
7+
}
8+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"partitions": [
3+
{"device": "/dev/vda1", "mountpoint": "/", "fstype": "ext4", "opts": "rw,relatime"}
4+
],
5+
"usage": {
6+
"/": {"total": 107374182400, "used": 32212254720, "free": 75161927680, "percent": 30.0}
7+
}
8+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"partitions": [
3+
{"device": "/dev/vda1", "mountpoint": "/", "fstype": "ext4", "opts": "rw,relatime"}
4+
],
5+
"usage": {
6+
"/": {"total": 107374182400, "used": 96636764160, "free": 10737418240, "percent": 91.0}
7+
}
8+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"partitions": [
3+
{"device": "/dev/vda1", "mountpoint": "/", "fstype": "ext4", "opts": "rw,relatime"},
4+
{"device": "/dev/vda2", "mountpoint": "/boot", "fstype": "ext4", "opts": "rw,relatime"}
5+
],
6+
"usage": {
7+
"/": {"total": 107374182400, "used": 40000000000, "free": 67374182400, "percent": 37.0},
8+
"/boot": {"total": 1073741824, "used": 314572800, "free": 759169024, "percent": 29.0}
9+
}
10+
}

check-plugins/file-descriptors/file-descriptors

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,14 @@
1111
"""See the check's README for more details."""
1212

1313
import argparse
14+
import json
1415
import sys
1516
from collections import Counter
1617

1718
import lib.args
1819
import lib.base
1920
import lib.human
21+
import lib.lftest
2022
import lib.version
2123
from lib.globals import STATE_OK, STATE_UNKNOWN
2224

@@ -28,7 +30,7 @@ except ImportError:
2830

2931

3032
__author__ = 'Linuxfabrik GmbH, Zurich/Switzerland'
31-
__version__ = '2026040801'
33+
__version__ = '2026041301'
3234

3335
DESCRIPTION = """Checks the system-wide file descriptor usage as a percentage of the kernel maximum.
3436
Also lists the top processes consuming the most file descriptors to help identify
@@ -78,6 +80,13 @@ def parse_args():
7880
default=DEFAULT_TOP,
7981
)
8082

83+
parser.add_argument(
84+
'--test',
85+
help=lib.args.help('--test'),
86+
dest='TEST',
87+
type=lib.args.csv,
88+
)
89+
8190
parser.add_argument(
8291
'-w',
8392
'--warning',
@@ -92,6 +101,22 @@ def parse_args():
92101
return args
93102

94103

104+
def _load_file_descriptors_fixture(raw_json):
105+
"""Convert a test fixture into the list of floats the plugin
106+
expects from parsing `/proc/sys/fs/file-nr`. The kernel file has
107+
the form "<allocated>\\t<unused>\\t<max>\\n"; our fixture JSON
108+
supplies the three numbers directly:
109+
110+
{"allocated": <int>, "unused": <int>, "max": <int>}
111+
"""
112+
data = json.loads(raw_json)
113+
return [
114+
float(data['allocated']),
115+
float(data.get('unused', 0)),
116+
float(data['max']),
117+
]
118+
119+
95120
def top(count):
96121
"""Get top X processes opening file descriptors."""
97122
cnt = Counter()
@@ -132,8 +157,15 @@ def main():
132157
perfdata = ''
133158
state = STATE_OK
134159

135-
with open('/proc/sys/fs/file-nr') as file:
136-
fs = [float(item) for item in file.readline().split('\t')]
160+
# fetch data
161+
if args.TEST is None:
162+
with open('/proc/sys/fs/file-nr') as file:
163+
fs = [float(item) for item in file.readline().split('\t')]
164+
else:
165+
stdout, _, _ = lib.lftest.test(args.TEST)
166+
fs = _load_file_descriptors_fixture(stdout)
167+
# force --top=0 under --test so we don't walk real processes
168+
args.TOP = 0
137169

138170
files_allocated = fs[0] # The number of allocated file handles
139171
files_max = fs[2] # The number of system-wide maximum number of file handles

0 commit comments

Comments
 (0)