Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@

ruff==0.11.6
pytest==7.4.0
pyyaml==6.0.2
30 changes: 30 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
name: "🧪 Mock Tests"

on:
push:
branches: ["main"]
pull_request:
branches: ["main"]

jobs:
mock-tests:
runs-on: ubuntu-latest
strategy:
fail-fast: false

steps:
- name: "⏳ Checkout repository"
uses: actions/checkout@v3

- name: "🐍 Set up Python"
uses: actions/setup-python@v4
with:
cache: "pip"
python-version: "3.10"

- name: "🛠 Install dependencies"
run: |
pip install -r .github/workflows/requirements.txt

- name: "🧪 Run mock tests"
run: python -m pytest tests/ -v -k mock
150 changes: 145 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -360,15 +360,155 @@ mic.stop()
level = mic.sound_level(samples) # Sound level in dB
```

## Testing

The project includes a test framework that supports both mock tests (without hardware) and hardware tests (with a STeaMi board connected).

### Install test dependencies

```bash
pip install pytest pyyaml
```

### Run mock tests (no hardware needed)

```bash
python -m pytest tests/ -v -k mock
```

### Run hardware tests (STeaMi board connected)

```bash
python -m pytest tests/ -v --port /dev/ttyACM0
```

### Run tests for a specific driver

```bash
python -m pytest tests/ -v --driver hts221 --port /dev/ttyACM0
```

### Run interactive tests (with manual validation)

```bash
python -m pytest tests/ -v --port /dev/ttyACM0 -s
```

### Generate a test report

Reports are saved as Markdown files in the `reports/` directory, with a summary and a detailed sub-report per driver.

```bash
# Timestamped report
python -m pytest tests/ -v --port /dev/ttyACM0 --report auto

# Named report (e.g. before a release)
python -m pytest tests/ -v --port /dev/ttyACM0 --report v1.0-validation
```

### Add tests for a new driver

Create a YAML scenario file in `tests/scenarios/<driver>.yaml`:

```yaml
driver: hts221
driver_class: HTS221
i2c_address: 0x5F

i2c:
id: 1

mock_registers:
0x0F: 0xBC

tests:
- name: "Verify device ID"
action: read_register
register: 0x0F
expect: 0xBC
mode: [mock, hardware]

- name: "Temperature in plausible range"
action: call
method: temperature
expect_range: [10.0, 45.0]
mode: [hardware]
```

The test runner automatically discovers new YAML files.

## Contributing

Contributions are welcome! Here's how you can contribute:
Contributions are welcome! Please follow the guidelines below.

### Driver structure

Each driver must follow this structure:

```
lib/<component>/
├── README.md
├── manifest.py # metadata() + package("<module_name>")
├── <module_name>/
│ ├── __init__.py # exports main class
│ ├── const.py # register constants using micropython.const()
│ └── device.py # main driver class
└── examples/
└── *.py
```

### Coding conventions

- **Constants**: use `from micropython import const` in `const.py` files.
- **Naming**: `snake_case` for new methods. Legacy `camelCase` is acceptable for I2C helpers to stay consistent with existing drivers.
- **Class inheritance**: `class Foo(object):` is the existing convention.
- **Time**: use `from time import sleep_ms` (not `utime`).
- **No debug `print()`** in production driver code.

### Linting

The project uses [ruff](https://docs.astral.sh/ruff/) (config in `pyproject.toml`).

```bash
# Check for linting errors
ruff check

# Auto-format code
ruff format
```

### Commit messages

Commit messages are validated by CI with the following pattern:

```
<scope>: <Description starting with a capital letter ending with a period.>
```

- Max 78 characters.
- Examples:
- `hts221: Fix missing self parameter in getAv method.`
- `docs: Fix typos in README files.`
- `bq27441: Remove debug print statements from driver.`

### CI checks

All pull requests must pass these checks:

| Check | Workflow | Description |
|-------|----------|-------------|
| Commit messages | `check-commits.yml` | Validates commit message format |
| Linting | `python-linter.yml` | Runs `ruff check` |
| Mock tests | `tests.yml` | Runs mock driver tests |

### Workflow

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

## License

Expand Down
6 changes: 6 additions & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
[pytest]
testpaths = tests
markers =
mock: tests using FakeI2C (no hardware needed)
hardware: tests requiring a real board (use --port)
manual: tests requiring human validation
Empty file added reports/.gitkeep
Empty file.
Empty file added tests/__init__.py
Empty file.
71 changes: 71 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"""Pytest configuration and fixtures for driver testing."""

import yaml
from pathlib import Path

import pytest

pytest_plugins = ["tests.report_plugin"]


def pytest_addoption(parser):
parser.addoption(
"--port",
action="store",
default=None,
help="Serial port for hardware tests (e.g. /dev/ttyACM0)",
)
parser.addoption(
"--driver",
action="store",
default=None,
help="Run tests for a specific driver only (e.g. hts221)",
)


def pytest_configure(config):
config.addinivalue_line("markers", "mock: tests that run with FakeI2C (no hardware)")
config.addinivalue_line("markers", "hardware: tests that require a real board")
config.addinivalue_line("markers", "manual: tests that require human validation")


def pytest_collection_modifyitems(config, items):
port = config.getoption("--port")
driver = config.getoption("--driver")
skip_hardware = pytest.mark.skip(reason="needs --port to run hardware tests")

selected = []
for item in items:
# Filter by driver if --driver is specified
if driver and f"[{driver}/" not in item.nodeid:
continue
if "hardware" in item.keywords and not port:
item.add_marker(skip_hardware)
selected.append(item)

items[:] = selected


@pytest.fixture
def port(request):
return request.config.getoption("--port")


def load_scenarios():
"""Load all YAML scenario files from tests/scenarios/."""
scenarios_dir = Path(__file__).parent / "scenarios"
scenarios = []
for yaml_file in sorted(scenarios_dir.glob("*.yaml")):
with open(yaml_file, encoding="utf-8") as f:
scenario = yaml.safe_load(f)
scenario["_file"] = yaml_file.name
scenarios.append(scenario)
return scenarios


@pytest.fixture
def mpremote_bridge(port):
"""Create a MpremoteBridge connected to the board."""
from tests.runner.mpremote_bridge import MpremoteBridge

return MpremoteBridge(port=port)
6 changes: 6 additions & 0 deletions tests/fake_machine/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""Fake machine module for testing MicroPython drivers on CPython."""

from tests.fake_machine.i2c import FakeI2C
from tests.fake_machine.pin import FakePin

__all__ = ["FakeI2C", "FakePin"]
55 changes: 55 additions & 0 deletions tests/fake_machine/i2c.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""Fake I2C bus for testing drivers without hardware."""


class FakeI2C:
"""Simulates a MicroPython I2C bus with pre-loaded register values.

Args:
registers: dict mapping register addresses to bytes values.
Single-byte registers can use int values.
address: expected I2C device address for validation.
"""

def __init__(self, registers=None, address=None):
self._registers = {}
self._address = address
self._write_log = []

if registers:
for reg, value in registers.items():
if isinstance(value, int):
self._registers[reg] = bytes([value])
else:
self._registers[reg] = bytes(value)

def readfrom_mem(self, addr, reg, nbytes):
self._check_address(addr)
data = self._registers.get(reg, b"\x00" * nbytes)
return data[:nbytes]

def readfrom_mem_into(self, addr, reg, buf):
self._check_address(addr)
data = self._registers.get(reg, b"\x00" * len(buf))
for i in range(len(buf)):
buf[i] = data[i] if i < len(data) else 0

def writeto_mem(self, addr, reg, buf):
self._check_address(addr)
self._registers[reg] = bytes(buf)
self._write_log.append((reg, bytes(buf)))

def scan(self):
if self._address is not None:
return [self._address]
return []

def get_write_log(self):
"""Return list of (register, data) tuples written."""
return list(self._write_log)

def clear_write_log(self):
self._write_log.clear()

def _check_address(self, addr):
if self._address is not None and addr != self._address:
raise OSError("I2C device not found at 0x{:02X}".format(addr))
5 changes: 5 additions & 0 deletions tests/fake_machine/micropython_stub.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Stub for the micropython module, so const() works on CPython."""


def const(x):
return x
25 changes: 25 additions & 0 deletions tests/fake_machine/pin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"""Fake Pin for testing drivers without hardware."""


class FakePin:
"""Simulates a MicroPython Pin."""

IN = 0
OUT = 1
PULL_UP = 1
PULL_DOWN = 2

def __init__(self, pin_id, mode=IN, pull=None):
self._id = pin_id
self._mode = mode
self._pull = pull
self._value = 0

def value(self, val=None):
if val is None:
return self._value
self._value = val

def init(self, mode=IN, pull=None):
self._mode = mode
self._pull = pull
Loading
Loading