Skip to content

Commit 9d8ab0e

Browse files
committed
tests: Add test framework with mock and hardware support.
1 parent ca00fb4 commit 9d8ab0e

18 files changed

Lines changed: 1099 additions & 5 deletions

.github/workflows/requirements.txt

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

44
ruff==0.11.6
55
pytest==7.4.0
6+
pyyaml==6.0.2

.github/workflows/tests.yml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
name: "🧪 Mock Tests"
2+
3+
on:
4+
push:
5+
branches: ["main"]
6+
pull_request:
7+
branches: ["main"]
8+
9+
jobs:
10+
mock-tests:
11+
runs-on: ubuntu-latest
12+
strategy:
13+
fail-fast: false
14+
15+
steps:
16+
- name: "⏳ Checkout repository"
17+
uses: actions/checkout@v3
18+
19+
- name: "🐍 Set up Python"
20+
uses: actions/setup-python@v4
21+
with:
22+
cache: "pip"
23+
python-version: "3.10"
24+
25+
- name: "🛠 Install dependencies"
26+
run: |
27+
pip install -r .github/workflows/requirements.txt
28+
29+
- name: "🧪 Run mock tests"
30+
run: python -m pytest tests/ -v -k mock

README.md

Lines changed: 145 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -360,15 +360,155 @@ mic.stop()
360360
level = mic.sound_level(samples) # Sound level in dB
361361
```
362362

363+
## Testing
364+
365+
The project includes a test framework that supports both mock tests (without hardware) and hardware tests (with a STeaMi board connected).
366+
367+
### Install test dependencies
368+
369+
```bash
370+
pip install pytest pyyaml
371+
```
372+
373+
### Run mock tests (no hardware needed)
374+
375+
```bash
376+
python -m pytest tests/ -v -k mock
377+
```
378+
379+
### Run hardware tests (STeaMi board connected)
380+
381+
```bash
382+
python -m pytest tests/ -v --port /dev/ttyACM0
383+
```
384+
385+
### Run tests for a specific driver
386+
387+
```bash
388+
python -m pytest tests/ -v --driver hts221 --port /dev/ttyACM0
389+
```
390+
391+
### Run interactive tests (with manual validation)
392+
393+
```bash
394+
python -m pytest tests/ -v --port /dev/ttyACM0 -s
395+
```
396+
397+
### Generate a test report
398+
399+
Reports are saved as Markdown files in the `reports/` directory, with a summary and a detailed sub-report per driver.
400+
401+
```bash
402+
# Timestamped report
403+
python -m pytest tests/ -v --port /dev/ttyACM0 --report auto
404+
405+
# Named report (e.g. before a release)
406+
python -m pytest tests/ -v --port /dev/ttyACM0 --report v1.0-validation
407+
```
408+
409+
### Add tests for a new driver
410+
411+
Create a YAML scenario file in `tests/scenarios/<driver>.yaml`:
412+
413+
```yaml
414+
driver: hts221
415+
driver_class: HTS221
416+
i2c_address: 0x5F
417+
418+
i2c:
419+
id: 1
420+
421+
mock_registers:
422+
0x0F: 0xBC
423+
424+
tests:
425+
- name: "Verify device ID"
426+
action: read_register
427+
register: 0x0F
428+
expect: 0xBC
429+
mode: [mock, hardware]
430+
431+
- name: "Temperature in plausible range"
432+
action: call
433+
method: temperature
434+
expect_range: [10.0, 45.0]
435+
mode: [hardware]
436+
```
437+
438+
The test runner automatically discovers new YAML files.
439+
363440
## Contributing
364441
365-
Contributions are welcome! Here's how you can contribute:
442+
Contributions are welcome! Please follow the guidelines below.
443+
444+
### Driver structure
445+
446+
Each driver must follow this structure:
447+
448+
```
449+
lib/<component>/
450+
├── README.md
451+
├── manifest.py # metadata() + package("<module_name>")
452+
├── <module_name>/
453+
│ ├── __init__.py # exports main class
454+
│ ├── const.py # register constants using micropython.const()
455+
│ └── device.py # main driver class
456+
└── examples/
457+
└── *.py
458+
```
459+
460+
### Coding conventions
461+
462+
- **Constants**: use `from micropython import const` in `const.py` files.
463+
- **Naming**: `snake_case` for new methods. Legacy `camelCase` is acceptable for I2C helpers to stay consistent with existing drivers.
464+
- **Class inheritance**: `class Foo(object):` is the existing convention.
465+
- **Time**: use `from time import sleep_ms` (not `utime`).
466+
- **No debug `print()`** in production driver code.
467+
468+
### Linting
469+
470+
The project uses [ruff](https://docs.astral.sh/ruff/) (config in `pyproject.toml`).
471+
472+
```bash
473+
# Check for linting errors
474+
ruff check
475+
476+
# Auto-format code
477+
ruff format
478+
```
479+
480+
### Commit messages
481+
482+
Commit messages are validated by CI with the following pattern:
483+
484+
```
485+
<scope>: <Description starting with a capital letter ending with a period.>
486+
```
487+
488+
- Max 78 characters.
489+
- Examples:
490+
- `hts221: Fix missing self parameter in getAv method.`
491+
- `docs: Fix typos in README files.`
492+
- `bq27441: Remove debug print statements from driver.`
493+
494+
### CI checks
495+
496+
All pull requests must pass these checks:
497+
498+
| Check | Workflow | Description |
499+
|-------|----------|-------------|
500+
| Commit messages | `check-commits.yml` | Validates commit message format |
501+
| Linting | `python-linter.yml` | Runs `ruff check` |
502+
| Mock tests | `tests.yml` | Runs mock driver tests |
503+
504+
### Workflow
366505

367506
1. Fork the repository
368-
2. Create a branch for your feature (`git checkout -b my-new-feature`)
369-
3. Commit your changes (`git commit -am 'Add a new feature'`)
370-
4. Push to the branch (`git push origin my-new-feature`)
371-
5. Create a new Pull Request
507+
2. Create a branch for your feature (`git checkout -b feat/my-new-feature`)
508+
3. Write your code and add tests in `tests/scenarios/<driver>.yaml`
509+
4. Run `ruff check` and `python -m pytest tests/ -v -k mock` locally
510+
5. Commit your changes following the commit message format
511+
6. Push to the branch and create a Pull Request
372512

373513
## License
374514

pytest.ini

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
[pytest]
2+
testpaths = tests
3+
markers =
4+
mock: tests using FakeI2C (no hardware needed)
5+
hardware: tests requiring a real board (use --port)
6+
manual: tests requiring human validation

reports/.gitkeep

Whitespace-only changes.

tests/__init__.py

Whitespace-only changes.

tests/conftest.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
"""Pytest configuration and fixtures for driver testing."""
2+
3+
import yaml
4+
from pathlib import Path
5+
6+
import pytest
7+
8+
pytest_plugins = ["tests.report_plugin"]
9+
10+
11+
def pytest_addoption(parser):
12+
parser.addoption(
13+
"--port",
14+
action="store",
15+
default=None,
16+
help="Serial port for hardware tests (e.g. /dev/ttyACM0)",
17+
)
18+
parser.addoption(
19+
"--driver",
20+
action="store",
21+
default=None,
22+
help="Run tests for a specific driver only (e.g. hts221)",
23+
)
24+
25+
26+
def pytest_configure(config):
27+
config.addinivalue_line("markers", "mock: tests that run with FakeI2C (no hardware)")
28+
config.addinivalue_line("markers", "hardware: tests that require a real board")
29+
config.addinivalue_line("markers", "manual: tests that require human validation")
30+
31+
32+
def pytest_collection_modifyitems(config, items):
33+
port = config.getoption("--port")
34+
driver = config.getoption("--driver")
35+
skip_hardware = pytest.mark.skip(reason="needs --port to run hardware tests")
36+
37+
selected = []
38+
for item in items:
39+
# Filter by driver if --driver is specified
40+
if driver and f"[{driver}/" not in item.nodeid:
41+
continue
42+
if "hardware" in item.keywords and not port:
43+
item.add_marker(skip_hardware)
44+
selected.append(item)
45+
46+
items[:] = selected
47+
48+
49+
@pytest.fixture
50+
def port(request):
51+
return request.config.getoption("--port")
52+
53+
54+
def load_scenarios():
55+
"""Load all YAML scenario files from tests/scenarios/."""
56+
scenarios_dir = Path(__file__).parent / "scenarios"
57+
scenarios = []
58+
for yaml_file in sorted(scenarios_dir.glob("*.yaml")):
59+
with open(yaml_file, encoding="utf-8") as f:
60+
scenario = yaml.safe_load(f)
61+
scenario["_file"] = yaml_file.name
62+
scenarios.append(scenario)
63+
return scenarios
64+
65+
66+
@pytest.fixture
67+
def mpremote_bridge(port):
68+
"""Create a MpremoteBridge connected to the board."""
69+
from tests.runner.mpremote_bridge import MpremoteBridge
70+
71+
return MpremoteBridge(port=port)

tests/fake_machine/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""Fake machine module for testing MicroPython drivers on CPython."""
2+
3+
from tests.fake_machine.i2c import FakeI2C
4+
from tests.fake_machine.pin import FakePin
5+
6+
__all__ = ["FakeI2C", "FakePin"]

tests/fake_machine/i2c.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
"""Fake I2C bus for testing drivers without hardware."""
2+
3+
4+
class FakeI2C:
5+
"""Simulates a MicroPython I2C bus with pre-loaded register values.
6+
7+
Args:
8+
registers: dict mapping register addresses to bytes values.
9+
Single-byte registers can use int values.
10+
address: expected I2C device address for validation.
11+
"""
12+
13+
def __init__(self, registers=None, address=None):
14+
self._registers = {}
15+
self._address = address
16+
self._write_log = []
17+
18+
if registers:
19+
for reg, value in registers.items():
20+
if isinstance(value, int):
21+
self._registers[reg] = bytes([value])
22+
else:
23+
self._registers[reg] = bytes(value)
24+
25+
def readfrom_mem(self, addr, reg, nbytes):
26+
self._check_address(addr)
27+
data = self._registers.get(reg, b"\x00" * nbytes)
28+
return data[:nbytes]
29+
30+
def readfrom_mem_into(self, addr, reg, buf):
31+
self._check_address(addr)
32+
data = self._registers.get(reg, b"\x00" * len(buf))
33+
for i in range(len(buf)):
34+
buf[i] = data[i] if i < len(data) else 0
35+
36+
def writeto_mem(self, addr, reg, buf):
37+
self._check_address(addr)
38+
self._registers[reg] = bytes(buf)
39+
self._write_log.append((reg, bytes(buf)))
40+
41+
def scan(self):
42+
if self._address is not None:
43+
return [self._address]
44+
return []
45+
46+
def get_write_log(self):
47+
"""Return list of (register, data) tuples written."""
48+
return list(self._write_log)
49+
50+
def clear_write_log(self):
51+
self._write_log.clear()
52+
53+
def _check_address(self, addr):
54+
if self._address is not None and addr != self._address:
55+
raise OSError("I2C device not found at 0x{:02X}".format(addr))
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""Stub for the micropython module, so const() works on CPython."""
2+
3+
4+
def const(x):
5+
return x

0 commit comments

Comments
 (0)