Skip to content

Commit 6d0ece3

Browse files
committed
refactor(example): fixture-driven unit testing and Nagios range defaults
Fixture files in unit-test/stdout/ are now named after the scenario (e.g. humidity-42-percent), not after an expected plugin state. The expected state is encoded in the testcase `id` instead, so a single fixture can drive multiple testcases that vary --warning / --critical (or any other parameter) to reach OK, WARN and CRIT. example plugin: - Parse the threshold value from the fetched data (first integer in stdout, populated from a hwmon humidity sensor reading) so unit tests can drive the state machine via fixtures. - Skip SQLite delta calculation in --test mode to avoid persistent state between runs. - Default --warning / --critical are now Nagios range strings ('80', '90') to match the type=str parser contract. example unit tests: - Rename stdout/ok-basic to stdout/humidity-42-percent. - Expand TESTS to four cases that reuse the same fixture: ok-below-warn, warn-above-warn, crit-above-crit, ok-always-ok-masks-crit. CONTRIBUTING.md: rewrite the unit test naming convention accordingly.
1 parent 148ddde commit 6d0ece3

File tree

6 files changed

+158
-88
lines changed

6 files changed

+158
-88
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3131
* CONTRIBUTING: add PEP 8 string quoting convention (single quotes, `"""` for triple-quoted)
3232
* CONTRIBUTING: add README structure guidelines with fixed section order, Fact Sheet template, and Nagios/Icinga check name for SEO
3333
* CONTRIBUTING: rewrite unit-test section with declarative test pattern, naming conventions, and tox usage
34+
* CONTRIBUTING: rewrite unit test fixture naming convention - fixtures in `stdout/` are named by scenario (e.g. `cpu-80-percent`), the expected state is encoded in the testcase `id` instead, so a single fixture can be reused by multiple testcases that vary plugin parameters to reach different states
3435
* CONTRIBUTING: run pylint without `--disable` flags
3536
* CONTRIBUTING: remove inline `pylint: disable` comments from code examples
3637
* All plugins: improve and expand DESCRIPTION to clearly explain what each plugin does for the admin deploying it
3738
* All plugins: rewrite all READMEs to follow consistent structure (Overview, Fact Sheet, Help, Usage Examples, States, Perfdata, Troubleshooting, Credits)
39+
* example: default `--warning` and `--critical` are now Nagios range strings (`'80'`, `'90'`) to match the `type=str` parser contract
40+
* example: parse the threshold value from the fetched data (humidity sensor reading from the Linux hwmon interface) so unit tests can drive OK/WARN/CRIT transitions via fixtures; skip SQLite delta calculation in `--test` mode to avoid persistent state between runs
41+
* example: rename unit test fixture `stdout/ok-basic` to `stdout/humidity-42-percent` (scenario-based) and expand tests to demonstrate fixture reuse across threshold combinations
3842
* example: rewrite as comprehensive skeleton covering all standard patterns (argparse, SQLite delta calculations, regex filtering, `--lengthy` table output, human-readable formatting, get_state/get_worst, Grafana-compatible perfdata)
3943
* example: rewrite README as skeleton template with Overview, Fact Sheet, States, Perfdata, and Troubleshooting sections
4044
* Update and extend pre-commit hooks (add `check-added-large-files`, `check-merge-conflict`, `check-yaml`; update all hook versions)

CONTRIBUTING.md

Lines changed: 44 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -699,28 +699,33 @@ Unit tests are implemented using the `unittest` framework (<https://docs.python.
699699
```text
700700
check-plugins/my-check/unit-test/
701701
├── run # the test file
702-
└── stdout/ # test data files
703-
├── ok-basic # descriptive names, not EXAMPLE01
704-
├── warn-threshold
705-
└── crit-service-down
702+
└── stdout/ # test data files (fixtures)
703+
├── empty-response # scenario-based names, not EXAMPLE01
704+
├── three-nodes-healthy
705+
└── three-nodes-one-down
706706
```
707707

708708
Only create `stderr/` if a test actually needs to inject stderr data. Do not create empty `retc/` or `stderr/` directories.
709709

710710

711711
#### Test data file naming
712712

713-
Use descriptive, lowercase, hyphenated names that indicate the expected state and scenario:
713+
Fixture files in `stdout/` are named after the **scenario** they represent, not after an expected plugin state. The expected state depends on the combination of fixture content and plugin parameters (thresholds, filters, switches) and therefore cannot be encoded in the fixture filename alone.
714714

715-
* `ok-basic`, `ok-empty-result`, `ok-all-healthy`
716-
* `warn-threshold-exceeded`, `warn-high-load`
717-
* `crit-service-down`, `crit-disk-full`
718-
* `unknown-missing-dependency`
715+
Use descriptive, lowercase, hyphenated names that describe the shape of the data:
716+
717+
* `empty-response`, `single-node`, `three-nodes-healthy`
718+
* `cpu-80-percent`, `disk-nearly-full`, `memory-400mb-used`
719+
* `three-nodes-one-down`, `malformed-json`, `service-unreachable`
720+
721+
The same fixture may (and should) be reused by multiple testcases that vary the plugin parameters to reach different states. For example, a single `cpu-80-percent` fixture can drive an `ok-below-warn` test with `--warning 90 --critical 95`, a `warn-above-warn` test with `--warning 70 --critical 95`, and a `crit-above-crit` test with `--warning 50 --critical 75`.
722+
723+
The expected state is encoded in the testcase `id` instead (see below).
719724

720725

721726
#### Writing tests
722727

723-
Define a `TESTS` list and use `lib.lftest.run()` to execute each testcase:
728+
Define a `TESTS` list and use `lib.lftest.run()` to execute each testcase. The testcase `id` should lead with the expected state (`ok-`, `warn-`, `crit-`, `unknown-`), followed by a short description of what is being verified. This is what appears in the subtest output and documents the intent of each case.
724729

725730
```python
726731
#!/usr/bin/env python3
@@ -734,19 +739,34 @@ import lib.lftest
734739

735740

736741
TESTS = [
742+
# Same fixture, three different threshold combinations.
737743
{
738-
'id': 'ok-basic',
739-
'test': 'stdout/ok-basic,,0',
740-
'params': '--warning 80 --critical 90',
744+
'id': 'ok-below-warn',
745+
'test': 'stdout/cpu-80-percent,,0',
746+
'params': '--warning 90 --critical 95',
741747
'assert-retc': STATE_OK,
742-
'assert-in': ['Everything is ok.'],
748+
'assert-in': ['80%'],
749+
},
750+
{
751+
'id': 'warn-above-warn',
752+
'test': 'stdout/cpu-80-percent,,0',
753+
'params': '--warning 70 --critical 95',
754+
'assert-retc': STATE_WARN,
755+
'assert-regex': r'80%.*\[WARNING\]',
743756
},
744757
{
745-
'id': 'crit-threshold-exceeded',
746-
'test': 'stdout/crit-threshold-exceeded,,0',
747-
'params': '--critical 50',
758+
'id': 'crit-above-crit',
759+
'test': 'stdout/cpu-80-percent,,0',
760+
'params': '--warning 50 --critical 75',
748761
'assert-retc': STATE_CRIT,
749-
'assert-regex': r'95.0%.*\[CRITICAL\]',
762+
'assert-regex': r'80%.*\[CRITICAL\]',
763+
},
764+
# Different fixture, different scenario.
765+
{
766+
'id': 'unknown-malformed-json',
767+
'test': 'stdout/malformed-json,,0',
768+
'params': '--warning 80 --critical 90',
769+
'assert-retc': STATE_UNKNOWN,
750770
},
751771
]
752772

@@ -765,6 +785,12 @@ if __name__ == '__main__':
765785
unittest.main()
766786
```
767787

788+
Naming rules for the testcase `id`:
789+
790+
* Lead with the expected state: `ok-`, `warn-`, `crit-`, `unknown-`.
791+
* Follow with a short description of what the test verifies (not the fixture name): `ok-below-warn`, `warn-above-warn`, `crit-disk-full`, `unknown-missing-dependency`.
792+
* `id` must be unique within the `TESTS` list.
793+
768794
Available assertion keys in each testcase dict:
769795

770796
* `assert-retc` (`int`, required): Expected return code (`STATE_OK`, `STATE_WARN`, `STATE_CRIT`, `STATE_UNKNOWN`).

check-plugins/example/example

Lines changed: 73 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -31,19 +31,19 @@ except ImportError:
3131

3232

3333
__author__ = 'Linuxfabrik GmbH, Zurich/Switzerland'
34-
__version__ = '2026040903'
34+
__version__ = '2026041202'
3535

3636
DESCRIPTION = """Skeleton plugin demonstrating all standard patterns and library functions:
3737
argparse with append/deprecated/suppress parameters, (success, result) error handling,
3838
SQLite delta calculations (no continuous counters), regex filtering, --lengthy table output,
3939
human-readable formatting (bytes, seconds, numbers), perfdata, get_state/get_worst, and
4040
Grafana-compatible panel design."""
4141

42-
DEFAULT_CRIT = 90
42+
DEFAULT_CRIT = '90'
4343
DEFAULT_LENGTHY = False
4444
DEFAULT_MODULE = ['calendar', 'Core', 'ctype', 'date']
4545
DEFAULT_TIMEOUT = 8
46-
DEFAULT_WARN = 80
46+
DEFAULT_WARN = '80'
4747

4848

4949
def parse_args():
@@ -228,23 +228,9 @@ def main():
228228
args.MODULE = DEFAULT_MODULE
229229
# args.NAME is not set here # case 3: None means "check all"
230230

231-
# create the SQLite database and table for delta calculations
232-
conn = lib.base.coe(
233-
lib.db_sqlite.connect(
234-
filename='linuxfabrik-monitoring-plugins-example.db',
235-
),
236-
)
237-
definition = """
238-
name TEXT NOT NULL,
239-
rx_bytes INT NOT NULL,
240-
timestamp INT NOT NULL
241-
"""
242-
lib.base.coe(lib.db_sqlite.create_table(conn, definition, drop_table_first=False))
243-
lib.base.coe(lib.db_sqlite.create_index(conn, 'name'))
244-
245231
# fetch data
246232
if args.TEST is None:
247-
cmd = 'cat /etc/os-release'
233+
cmd = 'cat /sys/class/hwmon/hwmon0/humidity1_input'
248234
stdout = lib.base.coe(get_data(cmd, timeout=args.TIMEOUT))
249235
else:
250236
# do not call the command, put in test data
@@ -263,14 +249,20 @@ def main():
263249
lib.base.cu('Unable to compile regex.')
264250

265251
# analyze data
266-
title = 'Lorem ipsum'
252+
title = 'humidity1'
267253
if args.NAME is not None:
268254
# case 3: user specified --name, so filter
269255
if title not in args.NAME:
270256
pass # in loops: continue
271257
if any(item.search(title) for item in compiled_ignore_regex):
272258
pass # in loops: continue
273-
value = str(lib.time.now())[-2:]
259+
# Parse the value to evaluate from the fetched data. Real plugins parse
260+
# their own data formats (JSON, regex, SNMP walks, etc.). This skeleton
261+
# reads a humidity sensor from the Linux `hwmon` interface and picks
262+
# the first integer from stdout, so unit tests can drive the state
263+
# machine via fixtures.
264+
match = re.search(r'(\d+)', stdout)
265+
value = match.group(1) if match else '0'
274266
uptime = 123456
275267
rx_bytes = 1073741824
276268
# multiline f-string: use parentheses for implicit concatenation
@@ -282,61 +274,80 @@ def main():
282274
item_state = lib.base.get_state(value, args.WARN, args.CRIT, _operator='range')
283275
state = lib.base.get_worst(state, item_state)
284276

285-
# store current measurement and calculate delta from previous run
286-
now = lib.time.now()
287-
lib.base.coe(
288-
lib.db_sqlite.insert(
289-
conn,
290-
{'name': title, 'rx_bytes': rx_bytes, 'timestamp': now},
277+
# calculate delta from the previous run using a local SQLite database.
278+
# In test mode (--test), skip persistent state entirely and use a
279+
# fixed stand-in so unit tests can exercise the full state machine
280+
# without depending on run order or leftover temp files.
281+
if args.TEST is None:
282+
conn = lib.base.coe(
283+
lib.db_sqlite.connect(
284+
filename='linuxfabrik-monitoring-plugins-example.db',
285+
),
291286
)
292-
)
293-
lib.base.coe(lib.db_sqlite.cut(conn, _max=2))
294-
lib.base.coe(lib.db_sqlite.commit(conn))
295-
296-
rows = lib.base.coe(
297-
lib.db_sqlite.select(
298-
conn,
299-
"""
300-
SELECT *
301-
FROM perfdata
302-
WHERE name = :name
303-
ORDER BY timestamp DESC
304-
""",
305-
{'name': title},
287+
definition = """
288+
name TEXT NOT NULL,
289+
rx_bytes INT NOT NULL,
290+
timestamp INT NOT NULL
291+
"""
292+
lib.base.coe(lib.db_sqlite.create_table(conn, definition, drop_table_first=False))
293+
lib.base.coe(lib.db_sqlite.create_index(conn, 'name'))
294+
295+
now = lib.time.now()
296+
lib.base.coe(
297+
lib.db_sqlite.insert(
298+
conn,
299+
{'name': title, 'rx_bytes': rx_bytes, 'timestamp': now},
300+
)
306301
)
307-
)
308-
if len(rows) < 2:
309-
lib.db_sqlite.close(conn)
310-
lib.base.oao('Waiting for more data.', STATE_OK)
302+
lib.base.coe(lib.db_sqlite.cut(conn, _max=2))
303+
lib.base.coe(lib.db_sqlite.commit(conn))
304+
305+
rows = lib.base.coe(
306+
lib.db_sqlite.select(
307+
conn,
308+
"""
309+
SELECT *
310+
FROM perfdata
311+
WHERE name = :name
312+
ORDER BY timestamp DESC
313+
""",
314+
{'name': title},
315+
)
316+
)
317+
if len(rows) < 2:
318+
lib.db_sqlite.close(conn)
319+
lib.base.oao('Waiting for more data.', STATE_OK)
320+
321+
timestamp_diff = rows[0]['timestamp'] - rows[1]['timestamp']
322+
if timestamp_diff == 0:
323+
timestamp_diff = 1
324+
rx_bytes_per_second = int(
325+
float(rows[0]['rx_bytes'] - rows[1]['rx_bytes']) / timestamp_diff,
326+
)
327+
if any(
328+
[
329+
timestamp_diff < 0,
330+
rx_bytes_per_second < 0,
331+
]
332+
):
333+
# happens after a reboot
334+
lib.db_sqlite.close(conn)
335+
lib.base.oao('Waiting for more data.', STATE_OK)
311336

312-
timestamp_diff = rows[0]['timestamp'] - rows[1]['timestamp']
313-
if timestamp_diff == 0:
314-
timestamp_diff = 1
315-
rx_bytes_per_second = int(
316-
float(rows[0]['rx_bytes'] - rows[1]['rx_bytes']) / timestamp_diff,
317-
)
318-
if any(
319-
[
320-
timestamp_diff < 0,
321-
rx_bytes_per_second < 0,
322-
]
323-
):
324-
# happens after a reboot
325337
lib.db_sqlite.close(conn)
326-
lib.base.oao('Waiting for more data.', STATE_OK)
327-
328-
lib.db_sqlite.close(conn)
338+
else:
339+
rx_bytes_per_second = 1234567
329340

330341
# build the message
331342
msg += (
332-
f'{value}% used{lib.base.state2str(item_state, prefix=" ")}'
343+
f'{value}% humidity{lib.base.state2str(item_state, prefix=" ")}'
333344
f', up {lib.human.seconds2human(uptime)}'
334345
f' since {lib.time.epoch2iso(lib.time.now() - uptime)}'
335346
f', {lib.human.bytes2human(rx_bytes_per_second)}/s'
336347
f', {lib.human.number2human(42000)} items'
337348
)
338349
perfdata += lib.base.get_perfdata(
339-
'cpu-usage',
350+
'humidity',
340351
value,
341352
uom='%',
342353
warn=args.WARN,

check-plugins/example/unit-test/run

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,45 @@ from lib.globals import STATE_CRIT, STATE_OK, STATE_UNKNOWN, STATE_WARN
1717
import lib.lftest
1818

1919

20+
# Fixture files in stdout/ are named after the scenario (the shape of the
21+
# data), not after the expected plugin state. The expected state lives in
22+
# the testcase `id` and depends on the combination of fixture content and
23+
# plugin parameters. The same fixture can therefore drive multiple
24+
# testcases that vary --warning/--critical (or any other parameter) to
25+
# reach different states. See CONTRIBUTING.md for the full convention.
26+
#
27+
# The humidity-42-percent fixture contains the raw reading from the
28+
# Linux hwmon humidity sensor. The skeleton parses the first integer
29+
# from stdout as its value. The four testcases below reuse that one
30+
# fixture and only differ in thresholds and switches.
2031
TESTS = [
2132
{
22-
'id': 'ok-basic',
23-
'test': 'stdout/ok-basic,,0',
24-
'params': '--token=dummy',
33+
'id': 'ok-below-warn',
34+
'test': 'stdout/humidity-42-percent,,0',
35+
'params': '--token=dummy --warning 80 --critical 90',
36+
'assert-retc': STATE_OK,
37+
'assert-in': ['42%'],
38+
},
39+
{
40+
'id': 'warn-above-warn',
41+
'test': 'stdout/humidity-42-percent,,0',
42+
'params': '--token=dummy --warning 40 --critical 90',
43+
'assert-retc': STATE_WARN,
44+
'assert-regex': r'42%.*\[WARNING\]',
45+
},
46+
{
47+
'id': 'crit-above-crit',
48+
'test': 'stdout/humidity-42-percent,,0',
49+
'params': '--token=dummy --warning 30 --critical 40',
50+
'assert-retc': STATE_CRIT,
51+
'assert-regex': r'42%.*\[CRITICAL\]',
52+
},
53+
{
54+
'id': 'ok-always-ok-masks-crit',
55+
'test': 'stdout/humidity-42-percent,,0',
56+
'params': '--token=dummy --warning 30 --critical 40 --always-ok',
2557
'assert-retc': STATE_OK,
26-
'assert-in': ['Waiting for more data.'],
58+
'assert-regex': r'42%.*\[CRITICAL\]',
2759
},
2860
]
2961

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
42

check-plugins/example/unit-test/stdout/ok-basic

Lines changed: 0 additions & 4 deletions
This file was deleted.

0 commit comments

Comments
 (0)