pw_unit_test: Python module for running tests over RPC
This adds Python code for running unit tests over RPC, logging the
results by default.
Change-Id: I2887619372f596f6e8fe4fd92a303a3ca72eb3b9
Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/pigweed/+/26960
Reviewed-by: Wyatt Hepler <hepler@google.com>
Commit-Queue: Armando Montanez <amontanez@google.com>
diff --git a/pw_unit_test/docs.rst b/pw_unit_test/docs.rst
index 6fb2fb4..ded3202 100644
--- a/pw_unit_test/docs.rst
+++ b/pw_unit_test/docs.rst
@@ -238,3 +238,19 @@
void RegisterServices() {
server.RegisterService(unit_test_services);
}
+
+All tests flashed to an attached device can be run via python by calling
+``pw_unit_test.rpc.run_tests()`` with a RPC client services object that has
+the unit testing RPC service enabled. By default, the results will output via
+logging.
+
+.. code:: python
+
+ from pw_hdlc_lite.rpc import HdlcRpcClient
+ from pw_unit_test.rpc import run_tests
+
+ PROTO = Path(os.environ['PW_ROOT'],
+ 'pw_unit_test/pw_unit_test_proto/unit_test.proto')
+
+ client = HdlcRpcClient(serial.Serial(device, baud), PROTO)
+ run_tests(client.rpcs())
diff --git a/pw_unit_test/py/BUILD.gn b/pw_unit_test/py/BUILD.gn
index 808a07a..468af15 100644
--- a/pw_unit_test/py/BUILD.gn
+++ b/pw_unit_test/py/BUILD.gn
@@ -20,8 +20,13 @@
setup = [ "setup.py" ]
sources = [
"pw_unit_test/__init__.py",
+ "pw_unit_test/rpc.py",
"pw_unit_test/test_runner.py",
]
- python_deps = [ "$dir_pw_cli/py" ]
+ python_deps = [
+ "$dir_pw_cli/py",
+ "$dir_pw_rpc/py",
+ "..:unit_test_proto.python",
+ ]
pylintrc = "$dir_pigweed/.pylintrc"
}
diff --git a/pw_unit_test/py/pw_unit_test/rpc.py b/pw_unit_test/py/pw_unit_test/rpc.py
new file mode 100644
index 0000000..c3298a6
--- /dev/null
+++ b/pw_unit_test/py/pw_unit_test/rpc.py
@@ -0,0 +1,155 @@
+# Copyright 2020 The Pigweed Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not
+# use this file except in compliance with the License. You may obtain a copy of
+# the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations under
+# the License.
+"""Utilities for running unit tests over Pigweed RPC."""
+
+import abc
+from dataclasses import dataclass
+import logging
+from typing import Iterable
+
+from pw_unit_test_proto import unit_test_pb2 # type: ignore
+
+import pw_rpc.client
+
+_LOG = logging.getLogger(__name__)
+
+
+@dataclass(frozen=True)
+class TestCase:
+ suite_name: str
+ test_name: str
+ file_name: str
+
+ def __str__(self) -> str:
+ return f'{self.suite_name}.{self.test_name}'
+
+ def __repr__(self) -> str:
+ return f'TestCase({str(self)})'
+
+
+@dataclass(frozen=True)
+class TestExpectation:
+ expression: str
+ evaluated_expression: str
+ line_number: int
+ success: bool
+
+ def __str__(self) -> str:
+ return self.expression
+
+ def __repr__(self) -> str:
+ return f'TestExpectation({str(self)})'
+
+
+class EventHandler(abc.ABC):
+ @abc.abstractmethod
+ def run_all_tests_start(self):
+ """Called before all tests are run."""
+
+ @abc.abstractmethod
+ def run_all_tests_end(self, passed_tests: int, failed_tests: int):
+ """Called after the test run is complete."""
+
+ @abc.abstractmethod
+ def test_case_start(self, test_case: TestCase):
+ """Called when a new test case is started."""
+
+ @abc.abstractmethod
+ def test_case_end(self, test_case: TestCase,
+ result: unit_test_pb2.TestCaseResult):
+ """Called when a test case completes with its overall result."""
+
+ @abc.abstractmethod
+ def test_case_disabled(self, test_case: TestCase):
+ """Called when a disabled test case is encountered."""
+
+ @abc.abstractmethod
+ def test_case_expect(self, test_case: TestCase,
+ expectation: TestExpectation):
+ """Called after each expect/assert statement within a test case."""
+
+
+class LoggingEventHandler(EventHandler):
+ """Event handler that logs test events using Google Test format."""
+ def run_all_tests_start(self):
+ _LOG.info('[==========] Running all tests.')
+
+ def run_all_tests_end(self, passed_tests: int, failed_tests: int):
+ _LOG.info('[==========] Done running all tests.')
+ _LOG.info('[ PASSED ] %d test(s).', passed_tests)
+ if failed_tests:
+ _LOG.info('[ FAILED ] %d test(s).', failed_tests)
+
+ def test_case_start(self, test_case: TestCase):
+ _LOG.info('[ RUN ] %s', test_case)
+
+ def test_case_end(self, test_case: TestCase,
+ result: unit_test_pb2.TestCaseResult):
+ if result == unit_test_pb2.TestCaseResult.SUCCESS:
+ _LOG.info('[ OK ] %s', test_case)
+ else:
+ _LOG.info('[ FAILED ] %s', test_case)
+
+ def test_case_disabled(self, test_case: TestCase):
+ _LOG.info('Skipping disabled test %s', test_case)
+
+ def test_case_expect(self, test_case: TestCase,
+ expectation: TestExpectation):
+ result = 'Success' if expectation.success else 'Failure'
+ log = _LOG.info if expectation.success else _LOG.error
+ log('%s:%d: %s', test_case.file_name, expectation.line_number, result)
+ log(' Expected: %s', expectation.expression)
+ log(' Actual: %s', expectation.evaluated_expression)
+
+
+def run_tests(
+ rpcs: pw_rpc.client.Services,
+ report_passed_expectations: bool = False,
+ event_handlers: Iterable[EventHandler] = (LoggingEventHandler(), )):
+ """Runs unit tests on a device over Pigweed RPC.
+
+ Calls each of the provided event handlers as test events occur.
+ """
+ unit_test_service = rpcs.pw.unit_test.UnitTest # type: ignore[attr-defined]
+
+ for response in unit_test_service.Run(
+ report_passed_expectations=report_passed_expectations):
+ if response.HasField('test_case_start'):
+ raw_test_case = response.test_case_start
+ current_test_case = TestCase(raw_test_case.suite_name,
+ raw_test_case.test_name,
+ raw_test_case.file_name)
+
+ for event_handler in event_handlers:
+ if response.HasField('test_run_start'):
+ event_handler.run_all_tests_start()
+ elif response.HasField('test_run_end'):
+ event_handler.run_all_tests_end(response.test_run_end.passed,
+ response.test_run_end.failed)
+ elif response.HasField('test_case_start'):
+ event_handler.test_case_start(current_test_case)
+ elif response.HasField('test_case_end'):
+ event_handler.test_case_end(current_test_case,
+ response.test_case_end)
+ elif response.HasField('test_case_disabled'):
+ event_handler.test_case_disabled(current_test_case)
+ elif response.HasField('test_case_expectation'):
+ raw_expectation = response.test_case_expectation
+ expectation = TestExpectation(
+ raw_expectation.expression,
+ raw_expectation.evaluated_expression,
+ raw_expectation.line_number,
+ raw_expectation.success,
+ )
+ event_handler.test_case_expect(current_test_case, expectation)