diff --git a/HISTORY.rst b/HISTORY.rst index 4c98db7b..d72dd852 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -46,8 +46,9 @@ Please check `Migration guide from 5.x to 6.0 `_ solution and its documentation - - New: `Test Hierarchies `_ (`issue #4135 `_) - - New: `doctest `__ testing framework (`issue #4240 `_) + - New: `Test Hierarchy `_ (`issue #4135 `_) + - New: `Doctest `__ testing framework (`issue #4240 `_) + - New: `GoogleTest `__ testing and mocking framework (`issue #3572 `_) - New: `Semihosting `__ (`issue #3516 `_) - New: Hardware `Simulators `__ for Unit Testing (QEMU, Renode, SimAVR, and custom solutions) - New: ``test`` `build configuration `__ diff --git a/docs b/docs index 3bbe54ac..db1960e2 160000 --- a/docs +++ b/docs @@ -1 +1 @@ -Subproject commit 3bbe54ac3bac1a3d8f429998f705ccca225b3a5d +Subproject commit db1960e263b4be666b0d35c8c53e37b66e47df3f diff --git a/examples b/examples index cb4befb5..042c58bb 160000 --- a/examples +++ b/examples @@ -1 +1 @@ -Subproject commit cb4befb56b07a785907ef058738e0a4f1382dffa +Subproject commit 042c58bb71fac5245e316231a7ecd2e5b8c665da diff --git a/platformio/project/options.py b/platformio/project/options.py index a573ad81..02a8e580 100644 --- a/platformio/project/options.py +++ b/platformio/project/options.py @@ -652,7 +652,7 @@ ProjectOptions = OrderedDict( group="test", name="test_framework", description="A unit testing framework", - type=click.Choice(["doctest", "unity", "custom"]), + type=click.Choice(["doctest", "googletest", "unity", "custom"]), default="unity", ), ConfigEnvOption( diff --git a/platformio/test/result.py b/platformio/test/result.py index b6e02803..5ca716df 100644 --- a/platformio/test/result.py +++ b/platformio/test/result.py @@ -30,11 +30,11 @@ class TestStatus(enum.Enum): @classmethod def from_string(cls, value: str): value = value.lower() - if value.startswith("fail"): + if value.startswith(("failed", "fail")): return cls.FAILED - if value.startswith(("pass", "success")): + if value.startswith(("passed", "pass", "success", "ok")): return cls.PASSED - if value.startswith(("ignore", "skip")): + if value.startswith(("skipped", "skip", "ignore", "ignored")): return cls.SKIPPED if value.startswith("WARNING"): return cls.WARNED diff --git a/platformio/test/runners/googletest.py b/platformio/test/runners/googletest.py new file mode 100644 index 00000000..deb81188 --- /dev/null +++ b/platformio/test/runners/googletest.py @@ -0,0 +1,115 @@ +# Copyright (c) 2014-present PlatformIO +# +# 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 +# +# http://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. + +import os +import re + +import click + +from platformio.test.result import TestCase, TestCaseSource, TestStatus +from platformio.test.runners.base import TestRunnerBase + + +class DoctestTestCaseParser: + + # Examples: + # [ RUN ] FooTest.Bar + # ... + # [ FAILED ] FooTest.Bar (0 ms) + STATUS__NAME_RE = r"^\[\s+(?P[A-Z]+)\s+\]\s+(?P[^\(\s]+)" + + # Examples: + # [ RUN ] FooTest.Bar + # test/test_gtest/test_main.cpp:26: Failure + # Y:\core\examples\unit-testing\googletest\test\test_gtest\test_main.cpp:26: Failure + SOURCE_MESSAGE_RE = r"^(?P.+):(?P\d+):(?P.*)$" + + def __init__(self): + self._tmp_tc = None + + def parse(self, line): + if self._tmp_tc: + self._tmp_tc.stdout += line + return self._parse_test_case(line) + + def _parse_test_case(self, line): + status, name = self._parse_status_and_name(line) + if status == "RUN": + self._tmp_tc = TestCase(name, TestStatus.PASSED, stdout=line) + return None + if not status or not self._tmp_tc: + return None + source, message = self._parse_source_and_message(self._tmp_tc.stdout) + test_case = TestCase( + name=self._tmp_tc.name, + status=TestStatus.from_string(status), + message=message, + source=source, + stdout=self._tmp_tc.stdout.strip(), + ) + self._tmp_tc = None + return test_case + + def _parse_status_and_name(self, line): + result = (None, None) + line = line.strip() + if not line.startswith("["): + return result + match = re.search(self.STATUS__NAME_RE, line) + if not match: + return result + return match.group("status"), match.group("name") + + def _parse_source_and_message(self, stdout): + for line in stdout.split("\n"): + line = line.strip() + if not line: + continue + match = re.search(self.SOURCE_MESSAGE_RE, line) + if not match: + continue + return ( + TestCaseSource( + match.group("source_file"), int(match.group("source_line")) + ), + (match.group("message") or "").strip() or None, + ) + return (None, None) + + +class GoogletestTestRunner(TestRunnerBase): + + EXTRA_LIB_DEPS = ["google/googletest@^1.11.0"] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._tc_parser = DoctestTestCaseParser() + os.environ["GTEST_COLOR"] = "no" # disable ANSI symbols + + def configure_build_env(self, env): + if self.platform.is_embedded(): + return + env.Append(CXXFLAGS=["-std=c++11"]) + + def on_testing_line_output(self, line): + if self.options.verbose: + click.echo(line, nl=False) + + test_case = self._tc_parser.parse(line) + if test_case: + click.echo(test_case.humanize()) + self.test_suite.add_case(test_case) + + if "Global test environment tear-down" in line: + self.test_suite.on_finish() diff --git a/tests/commands/test_test.py b/tests/commands/test_test.py index 362ae5a3..159fa427 100644 --- a/tests/commands/test_test.py +++ b/tests/commands/test_test.py @@ -436,6 +436,10 @@ void unittest_uart_end(){} validate_cliresult(result) +@pytest.mark.skipif( + sys.platform == "win32" and os.environ.get("GITHUB_ACTIONS") == "true", + reason="skip Github Actions on Windows (MinGW issue)", +) def test_doctest_framework(clirunner, tmp_path: Path): project_dir = tmp_path / "project" project_dir.mkdir() @@ -562,3 +566,57 @@ int main(int argc, char **argv) json_report = load_json(str(json_output_path)) assert json_report["testcase_nums"] == 1 assert json_report["failure_nums"] == 1 + + +def test_googletest_framework(clirunner, tmp_path: Path): + project_dir = os.path.join("examples", "unit-testing", "googletest") + junit_output_path = tmp_path / "junit.xml" + result = clirunner.invoke( + pio_test_cmd, + [ + "-d", + project_dir, + "-e", + "native", + "--output-format=junit", + "--output-path", + str(junit_output_path), + ], + ) + assert result.exit_code != 0 + # test JUnit output + junit_testsuites = ET.parse(junit_output_path).getroot() + assert int(junit_testsuites.get("tests")) == 4 + assert int(junit_testsuites.get("errors")) == 0 + assert int(junit_testsuites.get("failures")) == 1 + assert len(junit_testsuites.findall("testsuite")) == 4 + junit_failed_testcase = junit_testsuites.find(".//testcase[@name='FooTest.Bar']") + assert junit_failed_testcase.get("status") == "FAILED" + assert "test_main.cpp" in junit_failed_testcase.get("file") + assert junit_failed_testcase.get("line") == "26" + assert junit_failed_testcase.find("failure").get("message") == "Failure" + assert "Expected equality" in junit_failed_testcase.find("failure").text + + # test program arguments + json_output_path = tmp_path / "report.json" + result = clirunner.invoke( + pio_test_cmd, + [ + "-d", + project_dir, + "-e", + "native", + "--output-format=json", + "--output-path", + str(json_output_path), + "-a", + "--gtest_filter=-FooTest.Bar", + ], + ) + assert result.exit_code == 0 + # test JSON + json_report = load_json(str(json_output_path)) + assert json_report["testcase_nums"] == 3 + assert json_report["failure_nums"] == 0 + assert json_report["skipped_nums"] == 1 + assert len(json_report["test_suites"]) == 4