From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from mails.dpdk.org (mails.dpdk.org [217.70.189.124]) by inbox.dpdk.org (Postfix) with ESMTP id 98180A00C4; Mon, 14 Nov 2022 17:55:48 +0100 (CET) Received: from mails.dpdk.org (localhost [127.0.0.1]) by mails.dpdk.org (Postfix) with ESMTP id 2EDB042D4A; Mon, 14 Nov 2022 17:54:55 +0100 (CET) Received: from lb.pantheon.sk (lb.pantheon.sk [46.229.239.20]) by mails.dpdk.org (Postfix) with ESMTP id 4AF5F42D34 for ; Mon, 14 Nov 2022 17:54:52 +0100 (CET) Received: from localhost (localhost [127.0.0.1]) by lb.pantheon.sk (Postfix) with ESMTP id A279D165617; Mon, 14 Nov 2022 17:54:51 +0100 (CET) X-Virus-Scanned: amavisd-new at siecit.sk Received: from lb.pantheon.sk ([127.0.0.1]) by localhost (lb.pantheon.sk [127.0.0.1]) (amavisd-new, port 10024) with ESMTP id 9WLyfAV_t_aU; Mon, 14 Nov 2022 17:54:49 +0100 (CET) Received: from entguard.lab.pantheon.local (unknown [46.229.239.141]) by lb.pantheon.sk (Postfix) with ESMTP id 87F03243CF8; Mon, 14 Nov 2022 17:54:43 +0100 (CET) From: =?UTF-8?q?Juraj=20Linke=C5=A1?= To: thomas@monjalon.net, Honnappa.Nagarahalli@arm.com, ohilyard@iol.unh.edu, lijuan.tu@intel.com, bruce.richardson@intel.com Cc: dev@dpdk.org, =?UTF-8?q?Juraj=20Linke=C5=A1?= Subject: [RFC PATCH v2 08/10] dts: add testsuite class Date: Mon, 14 Nov 2022 16:54:36 +0000 Message-Id: <20221114165438.1133783-9-juraj.linkes@pantheon.tech> X-Mailer: git-send-email 2.25.1 In-Reply-To: <20221114165438.1133783-1-juraj.linkes@pantheon.tech> References: <20220824162454.394285-1-juraj.linkes@pantheon.tech> <20221114165438.1133783-1-juraj.linkes@pantheon.tech> MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit X-BeenThere: dev@dpdk.org X-Mailman-Version: 2.1.29 Precedence: list List-Id: DPDK patches and discussions List-Unsubscribe: , List-Archive: List-Post: List-Help: List-Subscribe: , Errors-To: dev-bounces@dpdk.org This is the base class that all test suites inherit from. The base class implements methods common to all test suites. The derived test suites implement tests and any particular setup needed for the suite or tests. Signed-off-by: Juraj Linkeš --- dts/conf.yaml | 4 + dts/framework/config/__init__.py | 33 ++- dts/framework/config/conf_yaml_schema.json | 49 ++++ dts/framework/dts.py | 29 +++ dts/framework/exception.py | 65 ++++++ dts/framework/settings.py | 25 +++ dts/framework/test_case.py | 246 +++++++++++++++++++++ 7 files changed, 450 insertions(+), 1 deletion(-) create mode 100644 dts/framework/test_case.py diff --git a/dts/conf.yaml b/dts/conf.yaml index 976888a88e..0b0f2c59b0 100644 --- a/dts/conf.yaml +++ b/dts/conf.yaml @@ -7,6 +7,10 @@ executions: os: linux cpu: native compiler: gcc + perf: false + func: true + test_suites: + - hello_world system_under_test: "SUT 1" nodes: - name: "SUT 1" diff --git a/dts/framework/config/__init__.py b/dts/framework/config/__init__.py index 344d697a69..8874b10030 100644 --- a/dts/framework/config/__init__.py +++ b/dts/framework/config/__init__.py @@ -11,7 +11,7 @@ import pathlib from dataclasses import dataclass from enum import Enum, auto, unique -from typing import Any, Iterable +from typing import Any, Iterable, TypedDict import warlock # type: ignore import yaml @@ -186,9 +186,34 @@ def from_dict(d: dict) -> "BuildTargetConfiguration": ) +class TestSuiteConfigDict(TypedDict): + suite: str + cases: list[str] + + +@dataclass(slots=True, frozen=True) +class TestSuiteConfig: + test_suite: str + test_cases: list[str] + + @staticmethod + def from_dict( + entry: str | TestSuiteConfigDict, + ) -> "TestSuiteConfig": + if isinstance(entry, str): + return TestSuiteConfig(test_suite=entry, test_cases=[]) + elif isinstance(entry, dict): + return TestSuiteConfig(test_suite=entry["suite"], test_cases=entry["cases"]) + else: + raise TypeError(f"{type(entry)} is not valid for a test suite config.") + + @dataclass(slots=True, frozen=True) class ExecutionConfiguration: build_targets: list[BuildTargetConfiguration] + perf: bool + func: bool + test_suites: list[TestSuiteConfig] system_under_test: NodeConfiguration @staticmethod @@ -196,11 +221,17 @@ def from_dict(d: dict, node_map: dict) -> "ExecutionConfiguration": build_targets: list[BuildTargetConfiguration] = list( map(BuildTargetConfiguration.from_dict, d["build_targets"]) ) + test_suites: list[TestSuiteConfig] = list( + map(TestSuiteConfig.from_dict, d["test_suites"]) + ) sut_name = d["system_under_test"] assert sut_name in node_map, f"Unknown SUT {sut_name} in execution {d}" return ExecutionConfiguration( build_targets=build_targets, + perf=d["perf"], + func=d["func"], + test_suites=test_suites, system_under_test=node_map[sut_name], ) diff --git a/dts/framework/config/conf_yaml_schema.json b/dts/framework/config/conf_yaml_schema.json index c59d3e30e6..e37ced65fe 100644 --- a/dts/framework/config/conf_yaml_schema.json +++ b/dts/framework/config/conf_yaml_schema.json @@ -63,6 +63,31 @@ } }, "additionalProperties": false + }, + "test_suite": { + "type": "string", + "enum": [ + "hello_world" + ] + }, + "test_target": { + "type": "object", + "properties": { + "suite": { + "$ref": "#/definitions/test_suite" + }, + "cases": { + "type": "array", + "items": { + "type": "string" + }, + "minimum": 1 + } + }, + "required": [ + "suite" + ], + "additionalProperties": false } }, "type": "object", @@ -130,6 +155,27 @@ }, "minimum": 1 }, + "perf": { + "type": "boolean", + "description": "Enable performance testing" + }, + "func": { + "type": "boolean", + "description": "Enable functional testing" + }, + "test_suites": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/definitions/test_suite" + }, + { + "$ref": "#/definitions/test_target" + } + ] + } + }, "system_under_test": { "$ref": "#/definitions/node_name" } @@ -137,6 +183,9 @@ "additionalProperties": false, "required": [ "build_targets", + "perf", + "func", + "test_suites", "system_under_test" ] }, diff --git a/dts/framework/dts.py b/dts/framework/dts.py index a7c243a5c3..ba3f4b4168 100644 --- a/dts/framework/dts.py +++ b/dts/framework/dts.py @@ -15,6 +15,7 @@ from .logger import DTSLOG, getLogger from .settings import SETTINGS from .stats_reporter import TestStats +from .test_case import TestCase from .test_result import Result from .utils import check_dts_python_version @@ -129,6 +130,34 @@ def run_suite( Use the given build_target to run the test suite with possibly only a subset of tests. If no subset is specified, run all tests. """ + for test_suite_config in execution.test_suites: + result.test_suite = test_suite_config.test_suite + full_suite_path = f"tests.TestSuite_{test_suite_config.test_suite}" + testcase_classes = TestCase.get_testcases(full_suite_path) + dts_logger.debug( + f"Found testcase classes '{testcase_classes}' in '{full_suite_path}'" + ) + for testcase_class in testcase_classes: + testcase = testcase_class( + sut_node, test_suite_config.test_suite, build_target, execution + ) + + testcase.init_log() + testcase.set_requested_cases(SETTINGS.test_cases) + testcase.set_requested_cases(test_suite_config.test_cases) + + dts_logger.info(f"Running test suite '{testcase_class.__name__}'") + try: + testcase.execute_setup_all() + testcase.execute_test_cases() + dts_logger.info( + f"Finished running test suite '{testcase_class.__name__}'" + ) + result.copy_suite(testcase.get_result()) + test_stats.save(result) # this was originally after teardown + + finally: + testcase.execute_tear_downall() def quit_execution(nodes: Iterable[Node], return_code: ReturnCode) -> None: diff --git a/dts/framework/exception.py b/dts/framework/exception.py index 93d99432ae..a35eeff640 100644 --- a/dts/framework/exception.py +++ b/dts/framework/exception.py @@ -29,6 +29,10 @@ class ReturnCode(IntEnum): DPDK_BUILD_ERR = 10 NODE_SETUP_ERR = 20 NODE_CLEANUP_ERR = 21 + SUITE_SETUP_ERR = 30 + SUITE_EXECUTION_ERR = 31 + TESTCASE_VERIFY_ERR = 32 + SUITE_CLEANUP_ERR = 33 class DTSError(Exception): @@ -153,6 +157,67 @@ def __init__(self): ) +class TestSuiteNotFound(DTSError): + """ + Raised when a configured test suite cannot be imported. + """ + + return_code: ClassVar[ReturnCode] = ReturnCode.SUITE_SETUP_ERR + + +class SuiteSetupError(DTSError): + """ + Raised when an error occurs during suite setup. + """ + + return_code: ClassVar[ReturnCode] = ReturnCode.SUITE_SETUP_ERR + + def __init__(self): + super(SuiteSetupError, self).__init__("An error occurred during suite setup.") + + +class SuiteExecutionError(DTSError): + """ + Raised when an error occurs during suite execution. + """ + + return_code: ClassVar[ReturnCode] = ReturnCode.SUITE_EXECUTION_ERR + + def __init__(self): + super(SuiteExecutionError, self).__init__( + "An error occurred during suite execution." + ) + + +class VerifyError(DTSError): + """ + To be used within the test cases to verify if a command output + is as it was expected. + """ + + value: str + return_code: ClassVar[ReturnCode] = ReturnCode.TESTCASE_VERIFY_ERR + + def __init__(self, value: str): + self.value = value + + def __str__(self) -> str: + return repr(self.value) + + +class SuiteCleanupError(DTSError): + """ + Raised when an error occurs during suite cleanup. + """ + + return_code: ClassVar[ReturnCode] = ReturnCode.SUITE_CLEANUP_ERR + + def __init__(self): + super(SuiteCleanupError, self).__init__( + "An error occurred during suite cleanup." + ) + + def convert_exception(exception: type[DTSError]) -> Callable[..., Callable[..., None]]: """ When a non-DTS exception is raised while executing the decorated function, diff --git a/dts/framework/settings.py b/dts/framework/settings.py index e2bf3d2ce4..069f28ce81 100644 --- a/dts/framework/settings.py +++ b/dts/framework/settings.py @@ -64,6 +64,8 @@ class _Settings: skip_setup: bool dpdk_ref: Path compile_timeout: float + test_cases: list + re_run: int def _get_parser() -> argparse.ArgumentParser: @@ -138,6 +140,25 @@ def _get_parser() -> argparse.ArgumentParser: help="[DTS_COMPILE_TIMEOUT] The timeout for compiling DPDK.", ) + parser.add_argument( + "--test-cases", + action=_env_arg("DTS_TESTCASES"), + default="", + required=False, + help="[DTS_TESTCASES] Comma-separated list of testcases to execute", + ) + + parser.add_argument( + "--re-run", + "--re_run", + action=_env_arg("DTS_RERUN"), + default=0, + type=int, + required=False, + help="[DTS_RERUN] Re-run tests the specified amount of times if a test failure " + "occurs", + ) + return parser @@ -151,6 +172,10 @@ def _get_settings() -> _Settings: skip_setup=(parsed_args.skip_setup == "Y"), dpdk_ref=parsed_args.dpdk_ref, compile_timeout=parsed_args.compile_timeout, + test_cases=parsed_args.test_cases.split(",") + if parsed_args.test_cases != "" + else [], + re_run=parsed_args.re_run, ) diff --git a/dts/framework/test_case.py b/dts/framework/test_case.py new file mode 100644 index 0000000000..0479f795bb --- /dev/null +++ b/dts/framework/test_case.py @@ -0,0 +1,246 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright(c) 2010-2014 Intel Corporation +# Copyright(c) 2022 PANTHEON.tech s.r.o. + +""" +A base class for creating DTS test cases. +""" + +import importlib +import inspect +import re +import time +import traceback + +from .exception import ( + SSHTimeoutError, + SuiteCleanupError, + SuiteExecutionError, + SuiteSetupError, + TestSuiteNotFound, + VerifyError, + convert_exception, +) +from .logger import getLogger +from .settings import SETTINGS +from .test_result import Result + + +class TestCase(object): + def __init__(self, sut_node, suitename, target, execution): + self.sut_node = sut_node + self.suite_name = suitename + self.target = target + + # local variable + self._requested_tests = [] + self._subtitle = None + + # result object for save suite result + self._suite_result = Result() + self._suite_result.sut = self.sut_node.config.hostname + self._suite_result.target = target + self._suite_result.test_suite = self.suite_name + if self._suite_result is None: + raise ValueError("Result object should not None") + + self._enable_func = execution.func + + # command history + self.setup_history = list() + self.test_history = list() + + def init_log(self): + # get log handler + class_name = self.__class__.__name__ + self.logger = getLogger(class_name) + + def set_up_all(self): + pass + + def set_up(self): + pass + + def tear_down(self): + pass + + def tear_down_all(self): + pass + + def verify(self, passed, description): + if not passed: + raise VerifyError(description) + + def _get_functional_cases(self): + """ + Get all functional test cases. + """ + return self._get_test_cases(r"test_(?!perf_)") + + def _has_it_been_requested(self, test_case, test_name_regex): + """ + Check whether test case has been requested for validation. + """ + name_matches = re.match(test_name_regex, test_case.__name__) + if self._requested_tests: + return name_matches and test_case.__name__ in self._requested_tests + + return name_matches + + def set_requested_cases(self, case_list): + """ + Pass down input cases list for check + """ + self._requested_tests += case_list + + def _get_test_cases(self, test_name_regex): + """ + Return case list which name matched regex. + """ + self.logger.debug(f"Searching for testcases in {self.__class__}") + for test_case_name in dir(self): + test_case = getattr(self, test_case_name) + if callable(test_case) and self._has_it_been_requested( + test_case, test_name_regex + ): + yield test_case + + @convert_exception(SuiteSetupError) + def execute_setup_all(self): + """ + Execute suite setup_all function before cases. + """ + try: + self.set_up_all() + return True + except Exception as v: + self.logger.error("set_up_all failed:\n" + traceback.format_exc()) + # record all cases blocked + if self._enable_func: + for case_obj in self._get_functional_cases(): + self._suite_result.test_case = case_obj.__name__ + self._suite_result.test_case_blocked( + "set_up_all failed: {}".format(str(v)) + ) + return False + + def _execute_test_case(self, case_obj): + """ + Execute specified test case in specified suite. If any exception occurred in + validation process, save the result and tear down this case. + """ + case_name = case_obj.__name__ + self._suite_result.test_case = case_obj.__name__ + + case_result = True + try: + self.logger.info("Test Case %s Begin" % case_name) + + self.running_case = case_name + # run set_up function for each case + self.set_up() + # run test case + case_obj() + + self._suite_result.test_case_passed() + + self.logger.info("Test Case %s Result PASSED:" % case_name) + + except VerifyError as v: + case_result = False + self._suite_result.test_case_failed(str(v)) + self.logger.error("Test Case %s Result FAILED: " % (case_name) + str(v)) + except KeyboardInterrupt: + self._suite_result.test_case_blocked("Skipped") + self.logger.error("Test Case %s SKIPPED: " % (case_name)) + self.tear_down() + raise KeyboardInterrupt("Stop DTS") + except SSHTimeoutError as e: + case_result = False + self._suite_result.test_case_failed(str(e)) + self.logger.error("Test Case %s Result FAILED: " % (case_name) + str(e)) + self.logger.error("%s" % (e.get_output())) + except Exception: + case_result = False + trace = traceback.format_exc() + self._suite_result.test_case_failed(trace) + self.logger.error("Test Case %s Result ERROR: " % (case_name) + trace) + finally: + self.execute_tear_down() + return case_result + + @convert_exception(SuiteExecutionError) + def execute_test_cases(self): + """ + Execute all test cases in one suite. + """ + # prepare debugger rerun case environment + if self._enable_func: + for case_obj in self._get_functional_cases(): + for i in range(SETTINGS.re_run + 1): + ret = self.execute_test_case(case_obj) + + if ret is False and SETTINGS.re_run: + self.sut_node.get_session_output(timeout=0.5 * (i + 1)) + time.sleep(i + 1) + self.logger.info( + " Test case %s failed and re-run %d time" + % (case_obj.__name__, i + 1) + ) + else: + break + + def execute_test_case(self, case_obj): + """ + Execute test case or enter into debug mode. + """ + return self._execute_test_case(case_obj) + + def get_result(self): + """ + Return suite test result + """ + return self._suite_result + + @convert_exception(SuiteCleanupError) + def execute_tear_downall(self): + """ + execute suite tear_down_all function + """ + self.tear_down_all() + + self.sut_node.kill_cleanup_dpdk_apps() + + def execute_tear_down(self): + """ + execute suite tear_down function + """ + try: + self.tear_down() + except Exception: + self.logger.error("tear_down failed:\n" + traceback.format_exc()) + self.logger.warning( + "tear down %s failed, might iterfere next case's result!" + % self.running_case + ) + + @staticmethod + def get_testcases(testsuite_module_path: str) -> list[type["TestCase"]]: + def is_testcase(object) -> bool: + try: + if issubclass(object, TestCase) and object != TestCase: + return True + except TypeError: + return False + return False + + try: + testcase_module = importlib.import_module(testsuite_module_path) + except ModuleNotFoundError as e: + raise TestSuiteNotFound( + f"Testsuite '{testsuite_module_path}' not found." + ) from e + return [ + testcase_class + for _, testcase_class in inspect.getmembers(testcase_module, is_testcase) + ] -- 2.30.2