refactor(test): Run tests with pytest
Replaced the run-test.sh script with a Python script that runs our unit tests through pytest. Tests are now run in parallel to speed up running the entire test suite, and it allows for integration with other tools that support pytest, such as IDEs. Also removed a dependency on remarshal, because it depends on an old version of PyYAML that conflicts with other Python packages in our Docker image. Replaced it with yq.
This commit is contained in:
parent
41830ce19a
commit
e1a925ff83
8 changed files with 247 additions and 76 deletions
12
.github/workflows/build-user-config.yml
vendored
12
.github/workflows/build-user-config.yml
vendored
|
@ -29,18 +29,16 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
name: Fetch Build Keyboards
|
||||
outputs:
|
||||
build_matrix: ${{ env.build_matrix }}
|
||||
build_matrix: ${{ steps.fetch_build_matrix.outputs.result }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Install yaml2json
|
||||
run: python3 -m pip install remarshal
|
||||
|
||||
- name: Fetch Build Matrix
|
||||
run: |
|
||||
echo "build_matrix=$(yaml2json ${{ inputs.build_matrix_path }} | jq -c .)" >> $GITHUB_ENV
|
||||
yaml2json ${{ inputs.build_matrix_path }} | jq
|
||||
id: fetch_build_matrix
|
||||
uses: mikefarah/yq@v4.30.8
|
||||
with:
|
||||
cmd: yq -o json ${{ inputs.build_matrix_path }}
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
5
.github/workflows/test.yml
vendored
5
.github/workflows/test.yml
vendored
|
@ -33,10 +33,11 @@ jobs:
|
|||
test: ${{ fromJSON(needs.collect-tests.outputs.test-dirs) }}
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: docker.io/zmkfirmware/zmk-build-arm:3.0
|
||||
image: docker.io/zmkfirmware/zmk-build-arm:stable
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Cache west modules
|
||||
uses: actions/cache@v3.0.2
|
||||
env:
|
||||
|
@ -62,7 +63,7 @@ jobs:
|
|||
run: west zephyr-export
|
||||
- name: Test ${{ matrix.test }}
|
||||
working-directory: app
|
||||
run: west test tests/${{ matrix.test }}
|
||||
run: west test ${{ matrix.test }}
|
||||
- name: Archive artifacts
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v2
|
||||
|
|
6
.vscode/settings.json
vendored
6
.vscode/settings.json
vendored
|
@ -3,5 +3,9 @@
|
|||
"*.overlay": "dts",
|
||||
"*.keymap": "dts"
|
||||
},
|
||||
"python.formatting.provider": "black"
|
||||
"python.formatting.provider": "black",
|
||||
"python.testing.pytestEnabled": true,
|
||||
"python.testing.unittestEnabled": false,
|
||||
"python.testing.cwd": "${workspaceFolder}/app",
|
||||
"python.testing.pytestArgs": ["test_zmk.py", "-v", "--numprocesses=auto"]
|
||||
}
|
|
@ -1,47 +0,0 @@
|
|||
#!/bin/sh
|
||||
|
||||
# Copyright (c) 2020 The ZMK Contributors
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
if [ -z "$1" ]; then
|
||||
echo "Usage: ./run-test.sh <path to testcase>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
path="$1"
|
||||
if [ $path = "all" ]; then
|
||||
path="tests"
|
||||
fi
|
||||
|
||||
testcases=$(find $path -name native_posix_64.keymap -exec dirname \{\} \;)
|
||||
num_cases=$(echo "$testcases" | wc -l)
|
||||
if [ $num_cases -gt 1 ] || [ "$testcases" != "$path" ]; then
|
||||
echo "" > ./build/tests/pass-fail.log
|
||||
echo "$testcases" | xargs -L 1 -P ${J:-4} ./run-test.sh
|
||||
err=$?
|
||||
sort -k2 ./build/tests/pass-fail.log
|
||||
exit $err
|
||||
fi
|
||||
|
||||
testcase="$path"
|
||||
echo "Running $testcase:"
|
||||
|
||||
west build -d build/$testcase -b native_posix_64 -- -DZMK_CONFIG="$(pwd)/$testcase" > /dev/null 2>&1
|
||||
if [ $? -gt 0 ]; then
|
||||
echo "FAILED: $testcase did not build" | tee -a ./build/tests/pass-fail.log
|
||||
exit 1
|
||||
fi
|
||||
|
||||
./build/$testcase/zephyr/zmk.exe | sed -e "s/.*> //" | tee build/$testcase/keycode_events_full.log | sed -n -f $testcase/events.patterns > build/$testcase/keycode_events.log
|
||||
diff -au $testcase/keycode_events.snapshot build/$testcase/keycode_events.log
|
||||
if [ $? -gt 0 ]; then
|
||||
if [ -f $testcase/pending ]; then
|
||||
echo "PENDING: $testcase" | tee -a ./build/tests/pass-fail.log
|
||||
exit 0
|
||||
fi
|
||||
echo "FAILED: $testcase" | tee -a ./build/tests/pass-fail.log
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "PASS: $testcase" | tee -a ./build/tests/pass-fail.log
|
||||
exit 0
|
|
@ -1,8 +1,10 @@
|
|||
# Copyright (c) 2021 The ZMK Contributors
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
# Convert YAML to JSON for validation
|
||||
remarshal>=0.14.0
|
||||
|
||||
# Perform our hardware metadata validation
|
||||
jsonschema>=3.2.0
|
||||
jsonschema>=3.2.0
|
||||
|
||||
# Unit testing
|
||||
pythonsed>=2.1
|
||||
pytest>=7.0.0
|
||||
pytest-xdist>=3.0.0
|
||||
|
|
|
@ -2,9 +2,7 @@
|
|||
# SPDX-License-Identifier: MIT
|
||||
"""Test runner for ZMK."""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
from west.commands import WestCommand
|
||||
from west import log # use this for user output
|
||||
|
||||
|
@ -14,7 +12,8 @@ class Test(WestCommand):
|
|||
super().__init__(
|
||||
name="test",
|
||||
help="run ZMK testsuite",
|
||||
description="Run the ZMK testsuite.",
|
||||
description="Run the ZMK testsuite. Arguments are passed through to pytest.",
|
||||
accepts_unknown_args=True,
|
||||
)
|
||||
|
||||
def do_add_parser(self, parser_adder):
|
||||
|
@ -25,17 +24,18 @@ class Test(WestCommand):
|
|||
)
|
||||
|
||||
parser.add_argument(
|
||||
"test_path",
|
||||
default="all",
|
||||
help='The path to the test. Defaults to "all".',
|
||||
"test",
|
||||
help="The path to the test to run. Runs all tests if not specified.",
|
||||
nargs="?",
|
||||
)
|
||||
return parser
|
||||
|
||||
def do_run(self, args, unknown_args):
|
||||
# the run-test script assumes the app directory is the current dir.
|
||||
os.chdir(f"{self.topdir}/app")
|
||||
completed_process = subprocess.run(
|
||||
[f"{self.topdir}/app/run-test.sh", args.test_path]
|
||||
)
|
||||
exit(completed_process.returncode)
|
||||
pytest_args = [f"{self.topdir}/app/test_zmk.py", "--numprocesses=auto"]
|
||||
pytest_args += unknown_args
|
||||
|
||||
if args.test:
|
||||
pytest_args += ["-k", args.test]
|
||||
|
||||
returncode = pytest.main(pytest_args)
|
||||
exit(returncode)
|
||||
|
|
187
app/test_zmk.py
Normal file
187
app/test_zmk.py
Normal file
|
@ -0,0 +1,187 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
# Copyright (c) 2023 The ZMK Contributors
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
|
||||
from io import StringIO
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
from subprocess import CalledProcessError, PIPE
|
||||
from typing import Sequence, Union
|
||||
|
||||
from PythonSed import Sed
|
||||
import pytest
|
||||
|
||||
|
||||
APP_PATH = Path(__file__).parent
|
||||
TESTS_PATH = APP_PATH / "tests"
|
||||
BUILD_PATH = APP_PATH / "build/tests"
|
||||
|
||||
# TODO: change all tests to build with a unit test shield
|
||||
TEST_KEYMAP_NAME = "native_posix_64.keymap"
|
||||
|
||||
|
||||
def check_call_teed(args: Sequence[Union[str, Path]], **kwargs):
|
||||
"""
|
||||
Call a subprocess, print its output, and also return its output.
|
||||
"""
|
||||
with subprocess.Popen(
|
||||
args, bufsize=1, stdout=PIPE, text=True, encoding="utf-8", **kwargs
|
||||
) as p:
|
||||
stdout = StringIO()
|
||||
|
||||
for line in p.stdout:
|
||||
stdout.write(line)
|
||||
print(line, end="")
|
||||
|
||||
returncode = p.wait()
|
||||
output = stdout.getvalue()
|
||||
|
||||
if returncode:
|
||||
raise CalledProcessError(returncode, p.args, output)
|
||||
|
||||
return output
|
||||
|
||||
|
||||
def strip_log_tags(log: str):
|
||||
def strip(line: str):
|
||||
left, _, right = line.partition("> ")
|
||||
return right or left
|
||||
|
||||
return "\n".join(strip(line) for line in log.splitlines())
|
||||
|
||||
|
||||
def filter_log(log: str, patterns_file: Path):
|
||||
sed = Sed(encoding="utf-8")
|
||||
sed.no_autoprint = True
|
||||
sed.load_script(str(patterns_file))
|
||||
|
||||
input = StringIO(log)
|
||||
output = StringIO()
|
||||
sed.apply(input, output)
|
||||
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
class Runner:
|
||||
def __init__(self, path: Path, build_path: Path):
|
||||
self.path = path
|
||||
self.build_path = build_path
|
||||
|
||||
@property
|
||||
def board(self) -> str:
|
||||
"""The Zephyr board to build"""
|
||||
raise NotImplementedError()
|
||||
|
||||
@classmethod
|
||||
def check(cls):
|
||||
"""Check that the environment is valid for the runner."""
|
||||
pass
|
||||
|
||||
def run(self) -> str:
|
||||
"""Run the unit test and return its output."""
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
class PosixRunner(Runner):
|
||||
@property
|
||||
def board(self):
|
||||
return "native_posix_64"
|
||||
|
||||
def run(self):
|
||||
return check_call_teed([self.build_path / "zephyr/zmk.exe"], cwd=APP_PATH)
|
||||
|
||||
|
||||
# TODO: Add QemuRunner for non-Posix platforms
|
||||
|
||||
|
||||
class ZmkTestCase:
|
||||
def __init__(self, path: Path) -> None:
|
||||
self.path = path
|
||||
self.runner = self._get_runner()
|
||||
self.runner.check()
|
||||
|
||||
self.patterns_file = self.path / "events.patterns"
|
||||
self.snapshot_file = self.path / "keycode_events.snapshot"
|
||||
|
||||
if not self.patterns_file.exists():
|
||||
pytest.fail(f"Missing patterns file: {self.patterns_file}")
|
||||
|
||||
if not self.snapshot_file.exists():
|
||||
pytest.fail(f"Missing snapshot file: {self.snapshot_file}")
|
||||
|
||||
def run(self):
|
||||
if reason := self.get_pending_reason():
|
||||
pytest.skip(reason)
|
||||
|
||||
self._build()
|
||||
self._test()
|
||||
|
||||
@property
|
||||
def build_path(self):
|
||||
return BUILD_PATH / self.path.relative_to(TESTS_PATH)
|
||||
|
||||
@property
|
||||
def relative_build_path(self):
|
||||
return self.build_path.relative_to(APP_PATH)
|
||||
|
||||
def get_pending_reason(self):
|
||||
for file in ["pending", f"pending-{self.runner.board}"]:
|
||||
try:
|
||||
return (self.path / file).read_text(encoding="utf-8")
|
||||
except:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
def _build(self):
|
||||
subprocess.check_call(
|
||||
[
|
||||
"west",
|
||||
"build",
|
||||
"-d",
|
||||
self.build_path.relative_to(APP_PATH).as_posix(),
|
||||
"-b",
|
||||
self.runner.board,
|
||||
"--",
|
||||
f"-DZMK_CONFIG={self.path.as_posix()}",
|
||||
],
|
||||
cwd=APP_PATH,
|
||||
)
|
||||
|
||||
def _test(self):
|
||||
output = self.runner.run()
|
||||
output = strip_log_tags(output)
|
||||
with self._open_log("keycode_events_full.log") as log:
|
||||
log.write(output)
|
||||
|
||||
output = filter_log(output, self.patterns_file)
|
||||
with self._open_log("keycode_events.log") as log:
|
||||
log.write(output)
|
||||
|
||||
assert output == self.snapshot_file.read_text(encoding="utf-8")
|
||||
|
||||
def _get_runner(self) -> type[Runner]:
|
||||
# TODO: return a QemuRunner for non-Posix platforms
|
||||
return PosixRunner(self.path, self.build_path)
|
||||
|
||||
def _open_log(self, name: str, mode="w"):
|
||||
log_path = self.build_path / name
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
return log_path.open(mode, encoding="utf-8")
|
||||
|
||||
|
||||
def get_tests():
|
||||
paths = sorted(keymap.parent for keymap in TESTS_PATH.rglob(TEST_KEYMAP_NAME))
|
||||
|
||||
return [
|
||||
pytest.param(path, id=str(path.relative_to(TESTS_PATH).as_posix()))
|
||||
for path in paths
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", get_tests())
|
||||
def test(name):
|
||||
ZmkTestCase(TESTS_PATH / name).run()
|
|
@ -5,8 +5,34 @@ sidebar_label: Tests
|
|||
|
||||
- Running tests requires [native posix support](posix-board.md).
|
||||
- Any folder under `/app/tests` containing `native_posix_64.keymap` will be selected when running `west test`.
|
||||
- Run tests from within the `/zmk/app` directory.
|
||||
- Run a single test with `west test <testname>`, like `west test tests/toggle-layer/normal`.
|
||||
|
||||
## Running Tests
|
||||
|
||||
All the following commands assume you have a terminal opened to the `zmk/app` directory.
|
||||
|
||||
First, make sure all Python dependencies are installed:
|
||||
|
||||
```sh
|
||||
python3 -m pip install -r scripts/requirements.txt
|
||||
```
|
||||
|
||||
Tests can then be run from Zephyr's [west](https://docs.zephyrproject.org/3.2.0/develop/west/index.html) tool with the `test` subcommand:
|
||||
|
||||
```sh
|
||||
west test
|
||||
```
|
||||
|
||||
Running it with no arguments will run the entire test suite. You can run a single test case or test set by providing the relative path from `zmk/app/tests` to the test directory, e.g.
|
||||
|
||||
```sh
|
||||
# Run all tests cases in the toggle-layer set
|
||||
west test toggle-layer
|
||||
|
||||
# Run the toggle-layer/normal test case
|
||||
west test toggle-layer/normal
|
||||
```
|
||||
|
||||
Any additional arguments are passed through to [PyTest](https://docs.pytest.org/).
|
||||
|
||||
## Creating a New Test Set
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue