Writing Tests¶
Tests live in Python modules discovered by the hiltests runner. This guide
covers layout, lifecycle hooks, assertion helpers, signature expectations, and
peripheral-specific examples so authors can build reliable suites for both
hardware rigs and simulation hosts. For a function-by-function breakdown of the
helpers available to test authors, see the Test API Reference.
Folder layout and naming¶
- Default directory:
./hil_tests/. - Files must start with
test_and use the.pyextension. - Each file represents a test group and may define:
- Test functions named
test_*. - Optional
setup_group()andteardown_group()functions called once per file. - Use a different directory with
hiltests --test-dir ./custom_tests. Paths are resolved absolutely so you can run from any working directory.
A multi-suite structure might look like:
hil_tests/
├─ test_gpio_inputs.py
├─ test_pwm_outputs.py
└─ test_spi_flash.py
hil_tests_ext/
└─ test_field_rig.py
--test-dir ./hil_tests_ext.
Scaffolding a new group from the template¶
Use the CLI flag to create a starter file without running any tests:
hiltests --create-test-group <group_name> [target_dir]
- If
target_diris omitted, the generator uses the path from--test-dir(default:./hil_tests). - The file is named
test_<group_name>.pyand is populated fromexample/test_group_template.py. - The
{test_group_name}placeholder inside the template is rendered with the provided name so setup/teardown messages already include your group.
When the target folder does not exist or the destination file already exists, the command exits with an error and a log entry instead of overwriting anything.
Function signatures and context¶
The runner wraps each test and inspects its signature:
- No parameters → the function is called directly.
- Any parameters → the framework instance is passed as the first argument
(useful if you want to call framework.logger or inspect counts).
Group-level hooks take no arguments. Context is automatically injected so
assertions and TEST_INFO_MESSAGE know which test is running.
If setup_group() raises, the entire group is skipped and the failure is stored
as a synthetic test result for reporting. teardown_group() errors are also
captured as failures after the last test so cleanup issues stay visible in
reports.
Lifecycle hook example¶
shared_uart = None
def setup_group():
global shared_uart
shared_uart = get_RPiUART_peripheral()
TEST_INFO_MESSAGE("UART ready for echo tests")
def teardown_group():
TEST_INFO_MESSAGE("Tearing down UART group")
def test_uart_echo():
shared_uart.send(b"ping")
TEST_ASSERT_EQUAL(b"ping", shared_uart.receive(4))
Sharing peripherals across tests¶
Expensive peripheral discovery or board resets can be centralized at group
scope. Declare a module-level variable, assign it inside setup_group() using
the appropriate getter, and mark it global so each test uses the same
instance:
def setup_group():
global gpio
gpio = get_RPiGPIO_peripheral()
TEST_INFO_MESSAGE("Setting up LedOutputTestGroup")
def teardown_group():
TEST_INFO_MESSAGE("Tearing down LedOutputTestGroup")
def test_GivenDutResetWhenPushbuttonLongPushThenLedIsOn():
reset_dut()
gpio.write("PUSHBUTTON", "LOW")
time.sleep(1.02)
gpio.write("PUSHBUTTON", "HIGH")
TEST_ASSERT_EQUAL(1, gpio.read("LED"))
""" Helper routines that are *not* tests should avoid the `test_` prefix so the
runner does not collect them. This keeps setup utilities discoverable without
polluting results: """
def reset_dut():
gpio.write("RST", "LOW")
time.sleep(0.001)
gpio.write("RST", "HIGH")
Minimal template¶
from py_micro_hil.assertions import *
from py_micro_hil.framework_API import *
def setup_group():
TEST_INFO_MESSAGE("Preparing test group")
def teardown_group():
TEST_INFO_MESSAGE("Cleaning up test group")
def test_led_toggle():
gpio = get_RPiGPIO_peripheral()
gpio.write("LED_RED", "HIGH")
TEST_ASSERT_EQUAL(1, gpio.read("PUSHBUTTON"))
Assertion helpers¶
TEST_ASSERT_EQUAL(expected, actual)— equality with descriptive errors.TEST_ASSERT_TRUE(condition)— truthiness check.TEST_ASSERT_IN(item, collection)— membership check.TEST_INFO_MESSAGE(message)— informational log that flows to console, log, and HTML report.TEST_FAIL_MESSAGE(message)— explicit failure with context.
These helpers automatically report to the active framework; when run outside the runner (e.g., REPL), they return symbolic tuples to aid debugging.
Assertion usage examples¶
def test_assert_equal_and_true():
gpio = get_RPiGPIO_peripheral()
gpio.write("LED_RED", "high") # string values map to 1/0
TEST_ASSERT_EQUAL(1, gpio.read("LED_RED"))
TEST_ASSERT_TRUE(gpio.read("LED_RED"))
def test_assert_in():
peripherals = ["gpio", "spi", "uart"]
TEST_ASSERT_IN("spi", peripherals)
def test_info_and_fail_message():
TEST_INFO_MESSAGE("About to force a failure for demonstration")
TEST_FAIL_MESSAGE("Deliberate failure to showcase reporting")
Accessing configured peripherals¶
The framework registers peripherals from the active YAML file (see Hardware
Configuration). Use getters from py_micro_hil.framework_API:
- get_RPiGPIO_peripheral() — software-accessible GPIO pins.
- get_RPiPWM_peripheral() / get_RPiHardwarePWM_peripheral() — PWM outputs.
- get_RPiUART_peripheral() — UART channel from the uart block.
- get_RPiI2C_peripheral() — I²C controller declared under i2c.
- get_RPiSPI_peripheral() — SPI controller declared under spi.
- get_RPiOneWire_peripheral() — 1-Wire bus for DS18B20 sensors.
- get_ModBus_peripheral() — Modbus RTU client configured under
protocols.modbus.
Dummy implementations are returned on non-Raspberry Pi hosts so you can execute and debug tests without hardware (assertions still run).
End-to-end examples by peripheral¶
GPIO¶
You can address configured pins either by their numeric BCM index or by the
name token declared in peripherals.gpio of peripherals_config.yaml. GPIO
writes also accept string literals "high"/"HIGH" and "low"/"LOW" in
addition to 1/0.
After the YAML is loaded, every name token is also exposed as a Python
constant alongside HIGH/LOW helpers so you can skip quotes entirely when
you from py_micro_hil.peripherals.RPiPeripherals import *:
gpio.write(PUSHBUTTON, LOW)
gpio.write(RST, HIGH)
def test_button_triggers_led():
gpio = get_RPiGPIO_peripheral()
TEST_INFO_MESSAGE("Driving LED high on pin 17")
gpio.write("LED_RED", 1)
TEST_ASSERT_TRUE(gpio.read("PUSHBUTTON")) # button input sensed on pin 18
Need lower-level access to the underlying GPIO module (for example, to register
edge-detection callbacks that are not wrapped by the helper methods)? Use the
static accessor on RPiGPIO—it also works when called on an instance:
from py_micro_hil.peripherals.RPiPeripherals import RPiGPIO
def test_gpio_edge_events():
gpio = get_RPiGPIO_peripheral()
# Both forms below return the same module imported by the framework
gpio_module = RPiGPIO.get_gpio_interface()
same_module = gpio.get_gpio_interface()
gpio_module.add_event_detect(18, gpio_module.RISING)
gpio.write(17, 1)
TEST_ASSERT_TRUE(gpio_module.event_detected(18))
Software PWM¶
def test_pwm_frequency_and_duty():
pwm = get_RPiPWM_peripheral()
pwm.set_frequency(1000)
pwm.set_duty_cycle(25)
TEST_ASSERT_TRUE(True) # configuration calls completed without error
Hardware PWM¶
def test_hardware_pwm_ramp():
hpwm = get_RPiHardwarePWM_peripheral()
for duty in (10, 50, 90):
hpwm.set_duty_cycle(duty)
TEST_ASSERT_TRUE(True)
UART¶
def test_uart_roundtrip():
uart = get_RPiUART_peripheral()
payload = b"ping\n"
uart.send(payload)
reply = uart.receive(len(payload))
TEST_ASSERT_EQUAL(payload, reply)
I²C¶
def test_i2c_probe_and_read():
i2c = get_RPiI2C_peripheral()
devices = i2c.scan()
TEST_ASSERT_TRUE(len(devices) > 0)
address = devices[0]
register = 0x00
data = i2c.read(address, register, 1)
TEST_ASSERT_TRUE(len(data) == 1)
SPI¶
def test_spi_transfer():
spi = get_RPiSPI_peripheral()
rx = spi.transfer([0x9F, 0x00, 0x00, 0x00]) # JEDEC ID read frame
TEST_ASSERT_TRUE(len(rx) == 4)
Modbus RTU¶
def test_modbus_register_flow():
modbus = get_ModBus_peripheral()
slave = 1
register = 0x000A
modbus.write_single_register(slave, register, 123)
values = modbus.read_holding_registers(slave, register, 1)
TEST_ASSERT_EQUAL(123, values[0])
1-Wire (DS18B20)¶
def test_onewire_temperature_read():
ow = get_RPiOneWire_peripheral()
devices = ow.scan_devices()
TEST_ASSERT_TRUE(len(devices) > 0)
temp_c = ow.read_temperature(devices[0])
TEST_ASSERT_TRUE(temp_c > -50) # sanity bound for DS18B20
Advanced authoring patterns¶
- Data-driven checks: wrap repeated patterns in helper functions within the same file to keep groups focused.
- Parallel suites: keep separate folders (e.g.,
hil_tests_lab/,hil_tests_prod/) and pair each with the appropriate YAML via--config. - Selective execution: create a temporary folder containing only the tests
you want, then pass it via
--test-dirinstead of adding complex selection logic in code. - Logging steps: add
TEST_INFO_MESSAGEinside loops or protocol exchanges to make HTML reports easier to debug after failures. - Deterministic checks: when timing-sensitive (PWM, SPI), assert on API outcomes rather than analog characteristics to keep runs stable across hardware and simulation modes.
Troubleshooting tips¶
- If a test fails before executing assertions, check YAML parsing and peripheral initialization logs at the top of the run output.
- Missing peripherals in YAML result in
Nonereturns from getters—double-check spelling and structure. - When running on a PC, remember that dummy peripherals do not interact with real hardware; adjust expectations accordingly or gate hardware-only cases via environment variables.