diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index c9518eef..e7a287da 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,20 @@ # Release History +## 3.0.9 (2020-06-07) + +**Fixed** + +- fix: miss formatting referenced testcase +- fix: handle cases when parent directory name includes dot/hyphen/space + +**Changed** + +- change: add `export` keyword in TStep to export session variables from referenced testcase +- change: rename TestCaseInOut field, config_vars and export_vars +- change: rename StepData field, export_vars +- change: add `--tb=short` for `hrun` command to use shorter traceback format by default +- change: search debugtalk.py upward recursively until system root dir + ## 3.0.8 (2020-06-04) **Added** diff --git a/examples/httpbin/basic_test.py b/examples/httpbin/basic_test.py index 24cd405e..bfa47499 100644 --- a/examples/httpbin/basic_test.py +++ b/examples/httpbin/basic_test.py @@ -1,4 +1,4 @@ -# NOTICE: Generated By HttpRunner v3.0.8 +# NOTE: Generated By HttpRunner v3.0.9 # FROM: examples/httpbin/basic.yml from httprunner import HttpRunner, Config, Step, RunRequest, RunTestCase diff --git a/examples/httpbin/hooks_test.py b/examples/httpbin/hooks_test.py index 0e2cd0b9..cec6ad42 100644 --- a/examples/httpbin/hooks_test.py +++ b/examples/httpbin/hooks_test.py @@ -1,4 +1,4 @@ -# NOTICE: Generated By HttpRunner v3.0.8 +# NOTE: Generated By HttpRunner v3.0.9 # FROM: examples/httpbin/hooks.yml from httprunner import HttpRunner, Config, Step, RunRequest, RunTestCase diff --git a/examples/httpbin/load_image_test.py b/examples/httpbin/load_image_test.py index 36453b6c..cca17a7c 100644 --- a/examples/httpbin/load_image_test.py +++ b/examples/httpbin/load_image_test.py @@ -1,4 +1,4 @@ -# NOTICE: Generated By HttpRunner v3.0.8 +# NOTE: Generated By HttpRunner v3.0.9 # FROM: examples/httpbin/load_image.yml from httprunner import HttpRunner, Config, Step, RunRequest, RunTestCase diff --git a/examples/httpbin/upload_test.py b/examples/httpbin/upload_test.py index 53ce4461..6ed390a8 100644 --- a/examples/httpbin/upload_test.py +++ b/examples/httpbin/upload_test.py @@ -1,4 +1,4 @@ -# NOTICE: Generated By HttpRunner v3.0.8 +# NOTE: Generated By HttpRunner v3.0.9 # FROM: examples/httpbin/upload.yml from httprunner import HttpRunner, Config, Step, RunRequest, RunTestCase diff --git a/examples/httpbin/validate_test.py b/examples/httpbin/validate_test.py index c9115e60..093d5075 100644 --- a/examples/httpbin/validate_test.py +++ b/examples/httpbin/validate_test.py @@ -1,4 +1,4 @@ -# NOTICE: Generated By HttpRunner v3.0.8 +# NOTE: Generated By HttpRunner v3.0.9 # FROM: examples/httpbin/validate.yml from httprunner import HttpRunner, Config, Step, RunRequest, RunTestCase diff --git a/examples/postman_echo/cookie_manipulation/hardcode_test.py b/examples/postman_echo/cookie_manipulation/hardcode_test.py deleted file mode 100644 index 000d0404..00000000 --- a/examples/postman_echo/cookie_manipulation/hardcode_test.py +++ /dev/null @@ -1,54 +0,0 @@ -# NOTICE: Generated By HttpRunner. DO NOT EDIT! -# FROM: examples/postman_echo/cookie_manipulation/hardcode.yml -from httprunner import HttpRunner, TConfig, TStep - - -class TestCaseHardcode(HttpRunner): - config = TConfig( - **{ - "name": "set & delete cookies.", - "base_url": "https://postman-echo.com", - "verify": False, - "export": ["cookie_foo1", "cookie_foo3"], - "path": "examples/postman_echo/cookie_manipulation/hardcode_test.py", - } - ) - - teststeps = [ - TStep( - **{ - "name": "set cookie foo1 & foo2 & foo3", - "request": { - "method": "GET", - "url": "/cookies/set", - "params": {"foo1": "bar1", "foo2": "bar2"}, - "headers": {"User-Agent": "HttpRunner/${get_httprunner_version()}"}, - }, - "extract": {"cookie_foo1": "$.cookies.foo1"}, - "validate": [ - {"eq": ["status_code", 200]}, - {"eq": ["cookies.foo1", "bar1"]}, - ], - } - ), - TStep( - **{ - "name": "delete cookie foo2", - "request": { - "method": "GET", - "url": "/cookies/delete?foo2", - "headers": {"User-Agent": "HttpRunner/${get_httprunner_version()}"}, - }, - "validate": [ - {"eq": ["status_code", 200]}, - {"ne": ["$.cookies.foo1", "$foo1"]}, - {"eq": ["$.cookies.foo1", "$cookie_foo1"]}, - {"eq": ["$.cookies.foo3", "$cookie_foo3"]}, - ], - } - ), - ] - - -if __name__ == "__main__": - TestCaseHardcode().test_start() diff --git a/examples/postman_echo/cookie_manipulation/set_delete_cookies.py b/examples/postman_echo/cookie_manipulation/set_delete_cookies.py deleted file mode 100644 index b2e3bf26..00000000 --- a/examples/postman_echo/cookie_manipulation/set_delete_cookies.py +++ /dev/null @@ -1,62 +0,0 @@ -import unittest -import requests - -from httprunner.runner import HttpRunner -from httprunner.schema import TConfig, TStep - - -class TestCaseSetDeleteCookies(unittest.TestCase): - config = TConfig( - **{ - "name": "set & delete cookies.", - "base_url": "https://postman-echo.com", - "variables": {"foo1": "bar1", "foo2": "bar2"}, - "verify": False, - "export": ["cookie_foo1", "cookie_foo3"], - } - ) - - teststeps = [ - TStep( - **{ - "name": "set cookie foo1 & foo2 & foo3", - "variables": {"foo3": "bar3"}, - "request": { - "method": "GET", - "url": "/cookies/set", - "params": {"foo1": "bar111", "foo2": "$foo2", "foo3": "$foo3"}, - "headers": {"User-Agent": "HttpRunner/${get_httprunner_version()}"}, - }, - "extract": { - "cookie_foo1": "$.cookies.foo1", - "cookie_foo3": "$.cookies.foo3", - }, - "validate": [ - {"eq": ["status_code", 200]}, - {"eq": ["$.cookies.foo3", "$foo3"]}, - ], - } - ), - TStep( - **{ - "name": "delete cookie foo2", - "request": { - "method": "GET", - "url": "/cookies/delete?foo2", - "headers": {"User-Agent": "HttpRunner/${get_httprunner_version()}"}, - }, - "validate": [ - {"eq": ["status_code", 200]}, - {"ne": ["$.cookies.foo1", "$foo1"]}, - {"eq": ["$.cookies.foo1", "$cookie_foo1"]}, - {"eq": ["$.cookies.foo3", "$cookie_foo3"]}, - ], - } - ), - ] - - def test_start(self): - s = requests.Session() - HttpRunner(self.config, self.teststeps, session=s).with_variables( - foo1="bar123", foo2="bar22" - ).run() diff --git a/examples/postman_echo/cookie_manipulation/set_delete_cookies_test.py b/examples/postman_echo/cookie_manipulation/set_delete_cookies_test.py deleted file mode 100644 index 540a2eb1..00000000 --- a/examples/postman_echo/cookie_manipulation/set_delete_cookies_test.py +++ /dev/null @@ -1,59 +0,0 @@ -# NOTICE: Generated By HttpRunner. DO NOT EDIT! -# FROM: examples/postman_echo/cookie_manipulation/set_delete_cookies.yml -from httprunner import HttpRunner, TConfig, TStep - - -class TestCaseSetDeleteCookies(HttpRunner): - config = TConfig( - **{ - "name": "set & delete cookies.", - "variables": {"foo1": "bar1", "foo2": "bar2"}, - "base_url": "https://postman-echo.com", - "verify": False, - "export": ["cookie_foo1", "cookie_foo3"], - "path": "examples/postman_echo/cookie_manipulation/set_delete_cookies_test.py", - } - ) - - teststeps = [ - TStep( - **{ - "name": "set cookie foo1 & foo2 & foo3", - "variables": {"foo3": "bar3"}, - "request": { - "method": "GET", - "url": "/cookies/set", - "params": {"foo1": "bar111", "foo2": "$foo2", "foo3": "$foo3"}, - "headers": {"User-Agent": "HttpRunner/${get_httprunner_version()}"}, - }, - "extract": { - "cookie_foo1": "$.cookies.foo1", - "cookie_foo3": "$.cookies.foo3", - }, - "validate": [ - {"eq": ["status_code", 200]}, - {"ne": ["$.cookies.foo3", "$foo3"]}, - ], - } - ), - TStep( - **{ - "name": "delete cookie foo2", - "request": { - "method": "GET", - "url": "/cookies/delete?foo2", - "headers": {"User-Agent": "HttpRunner/${get_httprunner_version()}"}, - }, - "validate": [ - {"eq": ["status_code", 200]}, - {"ne": ["$.cookies.foo1", "$foo1"]}, - {"eq": ["$.cookies.foo1", "$cookie_foo1"]}, - {"eq": ["$.cookies.foo3", "$cookie_foo3"]}, - ], - } - ), - ] - - -if __name__ == "__main__": - TestCaseSetDeleteCookies().test_start() diff --git a/examples/postman_echo/request_methods/__init__.py b/examples/postman_echo/request_methods/__init__.py index e69de29b..70cfba53 100644 --- a/examples/postman_echo/request_methods/__init__.py +++ b/examples/postman_echo/request_methods/__init__.py @@ -0,0 +1 @@ +# NOTICE: Generated By HttpRunner. DO NOT EDIT! diff --git a/examples/postman_echo/request_methods/demo_testsuite_yml/request_with_functions_test.py b/examples/postman_echo/request_methods/demo_testsuite_yml/request_with_functions_test.py index 345887c2..acb70cd0 100644 --- a/examples/postman_echo/request_methods/demo_testsuite_yml/request_with_functions_test.py +++ b/examples/postman_echo/request_methods/demo_testsuite_yml/request_with_functions_test.py @@ -1,4 +1,4 @@ -# NOTICE: Generated By HttpRunner v3.0.8 +# NOTE: Generated By HttpRunner v3.0.9 # FROM: examples/postman_echo/request_methods/request_with_functions.yml from httprunner import HttpRunner, Config, Step, RunRequest, RunTestCase diff --git a/examples/postman_echo/request_methods/demo_testsuite_yml/request_with_testcase_reference_test.py b/examples/postman_echo/request_methods/demo_testsuite_yml/request_with_testcase_reference_test.py index ca0232b9..294df37f 100644 --- a/examples/postman_echo/request_methods/demo_testsuite_yml/request_with_testcase_reference_test.py +++ b/examples/postman_echo/request_methods/demo_testsuite_yml/request_with_testcase_reference_test.py @@ -1,4 +1,4 @@ -# NOTICE: Generated By HttpRunner v3.0.8 +# NOTE: Generated By HttpRunner v3.0.9 # FROM: examples/postman_echo/request_methods/request_with_testcase_reference.yml import os @@ -26,7 +26,7 @@ class TestCaseRequestWithTestcaseReference(HttpRunner): RunTestCase("request with functions") .with_variables(**{"foo1": "override_bar1"}) .call(RequestWithFunctions) - .extract(*["session_foo2"]) + .export(*["session_foo2"]) ), Step( RunRequest("post form data") diff --git a/examples/postman_echo/request_methods/hardcode_test.py b/examples/postman_echo/request_methods/hardcode_test.py index 09fa0cfd..ffa7d91a 100644 --- a/examples/postman_echo/request_methods/hardcode_test.py +++ b/examples/postman_echo/request_methods/hardcode_test.py @@ -1,4 +1,4 @@ -# NOTICE: Generated By HttpRunner v3.0.8 +# NOTE: Generated By HttpRunner v3.0.9 # FROM: examples/postman_echo/request_methods/hardcode.yml from httprunner import HttpRunner, Config, Step, RunRequest, RunTestCase diff --git a/examples/postman_echo/request_methods/request_with_functions_test.py b/examples/postman_echo/request_methods/request_with_functions_test.py index 99a103e9..159d93b6 100644 --- a/examples/postman_echo/request_methods/request_with_functions_test.py +++ b/examples/postman_echo/request_methods/request_with_functions_test.py @@ -1,4 +1,4 @@ -# NOTICE: Generated By HttpRunner v3.0.8 +# NOTE: Generated By HttpRunner v3.0.9 # FROM: examples/postman_echo/request_methods/request_with_functions.yml from httprunner import HttpRunner, Config, Step, RunRequest, RunTestCase diff --git a/examples/postman_echo/request_methods/request_with_testcase_reference.yml b/examples/postman_echo/request_methods/request_with_testcase_reference.yml index 85138047..37871f2c 100644 --- a/examples/postman_echo/request_methods/request_with_testcase_reference.yml +++ b/examples/postman_echo/request_methods/request_with_testcase_reference.yml @@ -11,7 +11,7 @@ teststeps: variables: foo1: override_bar1 testcase: request_methods/request_with_functions.yml - extract: + export: - session_foo2 - name: post form data diff --git a/examples/postman_echo/request_methods/request_with_testcase_reference_test.py b/examples/postman_echo/request_methods/request_with_testcase_reference_test.py index 24a5d107..ece17405 100644 --- a/examples/postman_echo/request_methods/request_with_testcase_reference_test.py +++ b/examples/postman_echo/request_methods/request_with_testcase_reference_test.py @@ -1,4 +1,4 @@ -# NOTICE: Generated By HttpRunner v3.0.8 +# NOTE: Generated By HttpRunner v3.0.9 # FROM: examples/postman_echo/request_methods/request_with_testcase_reference.yml import os @@ -26,7 +26,7 @@ class TestCaseRequestWithTestcaseReference(HttpRunner): RunTestCase("request with functions") .with_variables(**{"foo1": "override_bar1"}) .call(RequestWithFunctions) - .extract(*["session_foo2"]) + .export(*["session_foo2"]) ), Step( RunRequest("post form data") diff --git a/examples/postman_echo/request_methods/request_with_variables_test.py b/examples/postman_echo/request_methods/request_with_variables_test.py index ecfa1586..aab91e97 100644 --- a/examples/postman_echo/request_methods/request_with_variables_test.py +++ b/examples/postman_echo/request_methods/request_with_variables_test.py @@ -1,4 +1,4 @@ -# NOTICE: Generated By HttpRunner v3.0.8 +# NOTE: Generated By HttpRunner v3.0.9 # FROM: examples/postman_echo/request_methods/request_with_variables.yml from httprunner import HttpRunner, Config, Step, RunRequest, RunTestCase diff --git a/examples/postman_echo/request_methods/validate_with_functions_test.py b/examples/postman_echo/request_methods/validate_with_functions_test.py index 4378c92b..5a730f1e 100644 --- a/examples/postman_echo/request_methods/validate_with_functions_test.py +++ b/examples/postman_echo/request_methods/validate_with_functions_test.py @@ -1,4 +1,4 @@ -# NOTICE: Generated By HttpRunner v3.0.8 +# NOTE: Generated By HttpRunner v3.0.9 # FROM: examples/postman_echo/request_methods/validate_with_functions.yml from httprunner import HttpRunner, Config, Step, RunRequest, RunTestCase diff --git a/examples/postman_echo/request_methods/validate_with_variables_test.py b/examples/postman_echo/request_methods/validate_with_variables_test.py index 3c3c0291..26c2467f 100644 --- a/examples/postman_echo/request_methods/validate_with_variables_test.py +++ b/examples/postman_echo/request_methods/validate_with_variables_test.py @@ -1,4 +1,4 @@ -# NOTICE: Generated By HttpRunner v3.0.8 +# NOTE: Generated By HttpRunner v3.0.9 # FROM: examples/postman_echo/request_methods/validate_with_variables.yml from httprunner import HttpRunner, Config, Step, RunRequest, RunTestCase diff --git a/httprunner/__init__.py b/httprunner/__init__.py index 5f9324f6..40f1a9c5 100644 --- a/httprunner/__init__.py +++ b/httprunner/__init__.py @@ -1,4 +1,4 @@ -__version__ = "3.0.8" +__version__ = "3.0.9" __description__ = "One-stop solution for HTTP(S) testing." from httprunner.runner import HttpRunner diff --git a/httprunner/app/routers/debug.py b/httprunner/app/routers/debug.py index 9d99f04c..192fbc6e 100644 --- a/httprunner/app/routers/debug.py +++ b/httprunner/app/routers/debug.py @@ -1,7 +1,7 @@ from fastapi import APIRouter from httprunner.runner import HttpRunner -from httprunner.schema import ProjectMeta, TestCase +from httprunner.models import ProjectMeta, TestCase router = APIRouter() runner = HttpRunner() diff --git a/httprunner/cli.py b/httprunner/cli.py index 0e2b7ae2..c7f89910 100644 --- a/httprunner/cli.py +++ b/httprunner/cli.py @@ -1,4 +1,5 @@ import argparse +import enum import os import sys @@ -23,7 +24,7 @@ def init_parser_run(subparsers): return sub_parser_run -def main_run(extra_args): +def main_run(extra_args) -> enum.IntEnum: capture_message("start to run") # keep compatibility with v2 extra_args = ensure_cli_args(extra_args) @@ -48,8 +49,11 @@ def main_run(extra_args): logger.error("No valid testcases found, exit 1.") sys.exit(1) + if "--tb=short" not in extra_args_new: + extra_args_new.append("--tb=short") + extra_args_new.extend(testcase_path_list) - sys.exit(pytest.main(extra_args_new)) + return pytest.main(extra_args_new) def main(): @@ -109,7 +113,7 @@ def main(): sys.exit(0) if sys.argv[1] == "run": - main_run(extra_args) + sys.exit(main_run(extra_args)) elif sys.argv[1] == "startproject": main_scaffold(args) elif sys.argv[1] == "har2case": diff --git a/httprunner/client.py b/httprunner/client.py index 01f10e0a..1580fecf 100644 --- a/httprunner/client.py +++ b/httprunner/client.py @@ -11,10 +11,9 @@ from requests.exceptions import ( MissingSchema, RequestException, ) -from sentry_sdk import capture_exception -from httprunner.schema import RequestData, ResponseData -from httprunner.schema import SessionData, ReqRespData +from httprunner.models import RequestData, ResponseData +from httprunner.models import SessionData, ReqRespData from httprunner.utils import lower_dict_keys, omit_long_data urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) @@ -51,12 +50,12 @@ def get_req_resp_record(resp_obj: Response) -> ReqRespData: except json.JSONDecodeError: # str: a=1&b=2 pass - except UnicodeDecodeError as ex: + except UnicodeDecodeError: # bytes/bytearray: request body in protobuf - capture_exception(ex) - except TypeError as ex: + pass + except TypeError: # neither str nor bytes/bytearray, e.g. - capture_exception(ex) + pass request_content_type = lower_dict_keys(request_headers).get("content-type") if request_content_type and "multipart/form-data" in request_content_type: diff --git a/httprunner/compat.py b/httprunner/compat.py index 54addff2..e683eaf5 100644 --- a/httprunner/compat.py +++ b/httprunner/compat.py @@ -7,8 +7,9 @@ from typing import List, Dict, Text, Union from loguru import logger +from httprunner import exceptions from httprunner.loader import load_project_meta -from httprunner.utils import sort_dict_by_custom_order +from httprunner.utils import sort_dict_by_custom_order, ensure_file_path_valid def convert_jmespath(raw: Text) -> Text: @@ -53,10 +54,12 @@ def convert_extractors(extractors: Union[List, Dict]) -> Dict: v3_extractors: Dict = {} if isinstance(extractors, List): + # [{"varA": "content.varA"}, {"varB": "json.varB"}] for extractor in extractors: for k, v in extractor.items(): v3_extractors[k] = v elif isinstance(extractors, Dict): + # {"varA": "body.varA", "varB": "body.varB"} v3_extractors = extractors else: logger.error(f"Invalid extractor: {extractors}") @@ -133,10 +136,10 @@ def ensure_step_attachment(step: Dict) -> Dict: test_dict["teardown_hooks"] = step["teardown_hooks"] if "extract" in step: - if step.get("request"): - test_dict["extract"] = convert_extractors(step["extract"]) - elif step.get("testcase"): - test_dict["extract"] = step["extract"] + test_dict["extract"] = convert_extractors(step["extract"]) + + if "export" in step: + test_dict["export"] = step["export"] if "validate" in step: test_dict["validate"] = convert_validators(step["validate"]) @@ -167,14 +170,16 @@ def ensure_testcase_v3(test_content: Dict) -> Dict: for step in test_content["teststeps"]: teststep = {} - teststep.update(ensure_step_attachment(step)) - if "request" in step: teststep["request"] = step.pop("request") elif "api" in step: teststep["testcase"] = step.pop("api") elif "testcase" in step: teststep["testcase"] = step.pop("testcase") + else: + raise exceptions.TestCaseFormatError(f"Invalid teststep: {step}") + + teststep.update(ensure_step_attachment(step)) teststep = sort_step_by_custom_order(teststep) v3_content["teststeps"].append(teststep) @@ -215,10 +220,8 @@ def generate_conftest_for_summary(args: List): sys.exit(1) project_meta = load_project_meta(test_path) - conftest_path = os.path.join(project_meta.PWD, "conftest.py") - if os.path.isfile(conftest_path): - return - + project_root_dir = ensure_file_path_valid(project_meta.RootDir) + conftest_path = os.path.join(project_root_dir, "conftest.py") conftest_content = '''# NOTICE: Generated By HttpRunner. import json import os @@ -286,8 +289,8 @@ def session_fixture(request): ''' test_path = os.path.abspath(test_path) - logs_dir_path = os.path.join(project_meta.PWD, "logs") - test_path_relative_path = test_path[len(project_meta.PWD) + 1 :] + logs_dir_path = os.path.join(project_root_dir, "logs") + test_path_relative_path = test_path[len(project_root_dir) + 1 :] if os.path.isdir(test_path): file_foder_path = os.path.join(logs_dir_path, test_path_relative_path) @@ -303,6 +306,10 @@ def session_fixture(request): "{{SUMMARY_PATH_PLACEHOLDER}}", summary_path ) + dir_path = os.path.dirname(conftest_path) + if not os.path.exists(dir_path): + os.makedirs(dir_path) + with open(conftest_path, "w", encoding="utf-8") as f: f.write(conftest_content) diff --git a/httprunner/ext/uploader/__init__.py b/httprunner/ext/uploader/__init__.py index 0d177f65..4b242502 100644 --- a/httprunner/ext/uploader/__init__.py +++ b/httprunner/ext/uploader/__init__.py @@ -49,7 +49,7 @@ from typing import Text, NoReturn from loguru import logger from httprunner.parser import parse_variables_mapping -from httprunner.schema import TStep, FunctionsMapping +from httprunner.models import TStep, FunctionsMapping try: import filetype @@ -139,7 +139,7 @@ def multipart_encoder(**kwargs): project_meta = load_project_meta(os.getcwd()) - _file_path = os.path.join(project_meta.PWD, value) + _file_path = os.path.join(project_meta.RootDir, value) is_exists_file = os.path.isfile(_file_path) if is_exists_file: diff --git a/httprunner/loader.py b/httprunner/loader.py index 821c9204..4982eb92 100644 --- a/httprunner/loader.py +++ b/httprunner/loader.py @@ -13,7 +13,7 @@ from pydantic import ValidationError from httprunner import builtin, utils from httprunner import exceptions -from httprunner.schema import TestCase, ProjectMeta, TestSuite +from httprunner.models import TestCase, ProjectMeta, TestSuite try: # PyYAML version >= 5.1 @@ -176,7 +176,7 @@ def load_csv_file(csv_file: Text) -> List[Dict]: raise exceptions.MyBaseFailure("load_project_meta() has not been called!") # make compatible with Windows/Linux - csv_file = os.path.join(project_meta.PWD, *csv_file.split("/")) + csv_file = os.path.join(project_meta.RootDir, *csv_file.split("/")) if not os.path.isfile(csv_file): # file path not exist @@ -265,7 +265,7 @@ def load_builtin_functions() -> Dict[Text, Callable]: def locate_file(start_path: Text, file_name: Text) -> Text: """ locate filename and return absolute file path. - searching will be recursive upward until current working directory or system root dir. + searching will be recursive upward until system root dir. Args: file_name (str): target locate file name @@ -289,13 +289,6 @@ def locate_file(start_path: Text, file_name: Text) -> Text: if os.path.isfile(file_path): return os.path.abspath(file_path) - # current working directory - cwd = os.getcwd() - if os.path.abspath(start_dir_path) == cwd: - raise exceptions.FileNotFound( - f"{file_name} not found for {start_path}\ncurrent working directory: {cwd}" - ) - # system root dir # Windows, e.g. 'E:\\' # Linux/Darwin, '/' @@ -327,14 +320,14 @@ def locate_debugtalk_py(start_path: Text) -> Text: return debugtalk_path -def locate_project_working_directory(test_path: Text) -> Tuple[Text, Text]: - """ locate debugtalk.py path as project working directory +def locate_project_root_directory(test_path: Text) -> Tuple[Text, Text]: + """ locate debugtalk.py path as project root directory Args: test_path: specified testfile path Returns: - (str, str): debugtalk.py path, project_working_directory + (str, str): debugtalk.py path, project_root_directory """ @@ -355,18 +348,18 @@ def locate_project_working_directory(test_path: Text) -> Tuple[Text, Text]: debugtalk_path = locate_debugtalk_py(test_path) if debugtalk_path: - # The folder contains debugtalk.py will be treated as PWD. - project_working_directory = os.path.dirname(debugtalk_path) + # The folder contains debugtalk.py will be treated as project RootDir. + project_root_directory = os.path.dirname(debugtalk_path) else: - # debugtalk.py not found, use os.getcwd() as PWD. - project_working_directory = os.getcwd() + # debugtalk.py not found, use os.getcwd() as project RootDir. + project_root_directory = os.getcwd() - return debugtalk_path, project_working_directory + return debugtalk_path, project_root_directory def load_debugtalk_functions() -> Dict[Text, Callable]: """ load project debugtalk.py module functions - debugtalk.py should be located in project working directory. + debugtalk.py should be located in project root directory. Returns: dict: debugtalk module functions mapping @@ -382,12 +375,12 @@ def load_debugtalk_functions() -> Dict[Text, Callable]: def load_project_meta(test_path: Text, reload: bool = False) -> ProjectMeta: - """ load api, testcases, .env, debugtalk.py functions. - api/testcases folder is relative to project_working_directory + """ load testcases, .env, debugtalk.py functions. + testcases folder is relative to project_root_directory by default, project_meta will be loaded only once, unless set reload to true. Args: - test_path (str): test file/folder path, locate pwd from this path. + test_path (str): test file/folder path, locate project RootDir from this path. reload: reload project meta if set true, default to false Returns: @@ -404,19 +397,20 @@ def load_project_meta(test_path: Text, reload: bool = False) -> ProjectMeta: if not test_path: return project_meta - debugtalk_path, project_working_directory = locate_project_working_directory( - test_path - ) + debugtalk_path, project_root_directory = locate_project_root_directory(test_path) - # add PWD to sys.path - sys.path.insert(0, project_working_directory) + # add project RootDir to sys.path + sys.path.insert(0, project_root_directory) # load .env file # NOTICE: # environment variable maybe loaded in debugtalk.py # thus .env file should be loaded before loading debugtalk.py - dot_env_path = os.path.join(project_working_directory, ".env") - project_meta.env = load_dot_env_file(dot_env_path) + dot_env_path = os.path.join(project_root_directory, ".env") + dot_env = load_dot_env_file(dot_env_path) + if dot_env: + project_meta.env = dot_env + project_meta.dot_env_path = dot_env_path if debugtalk_path: # load debugtalk.py functions @@ -424,11 +418,9 @@ def load_project_meta(test_path: Text, reload: bool = False) -> ProjectMeta: else: debugtalk_functions = {} - # locate PWD and load debugtalk.py functions - project_meta.PWD = project_working_directory + # locate project RootDir and load debugtalk.py functions + project_meta.RootDir = project_root_directory project_meta.functions = debugtalk_functions - project_meta.test_path = os.path.abspath(test_path)[ - len(project_working_directory) + 1 : - ] + project_meta.debugtalk_path = debugtalk_path return project_meta diff --git a/httprunner/make.py b/httprunner/make.py index 66fd9a1b..5ddc22b6 100644 --- a/httprunner/make.py +++ b/httprunner/make.py @@ -1,6 +1,6 @@ import os -import string import subprocess +from shutil import copyfile from typing import Text, List, Tuple, Dict, Set, NoReturn import jinja2 @@ -18,14 +18,18 @@ from httprunner.loader import ( ) from httprunner.parser import parse_data from httprunner.response import uniform_validator +from httprunner.utils import ensure_file_path_valid """ cache converted pytest files, avoid duplicate making """ -make_files_cache_set: Set = set() -pytest_files_set: Set = set() +pytest_files_made_cache_mapping: Dict[Text, Text] = {} + +""" save generated pytest files to run, except referenced testcase +""" +pytest_files_run_set: Set = set() __TEMPLATE__ = jinja2.Template( - """# NOTICE: Generated By HttpRunner v{{ version }} + """# NOTE: Generated By HttpRunner v{{ version }} # FROM: {{ testcase_path }} {% if imports_list %} import os @@ -54,24 +58,16 @@ if __name__ == "__main__": ) -def __ensure_file_name(path: Text) -> Text: - """ ensure file name not startswith digit - testcases/19.json => testcases/T19.json - """ - filename = os.path.basename(path) - if filename[0] in string.digits: - path = os.path.join(os.path.dirname(path), f"T{filename}") - - return path - - def __ensure_absolute(path: Text) -> Text: project_meta = load_project_meta(path) if os.path.isabs(path): absolute_path = path else: - absolute_path = os.path.join(project_meta.PWD, path) + absolute_path = os.path.join(project_meta.RootDir, path) + + if not os.path.isfile(absolute_path): + raise exceptions.ParamsError(f"Invalid testcase file path: {absolute_path}") return absolute_path @@ -102,24 +98,38 @@ def __ensure_testcase_module(path: Text) -> NoReturn: f.write("# NOTICE: Generated By HttpRunner. DO NOT EDIT!\n") +def __ensure_project_meta_files(tests_path: Text) -> NoReturn: + """ ensure project meta files exist in generated pytest folder files + include debugtalk.py and .env + """ + project_meta = load_project_meta(tests_path) + + # handle cases when generated pytest directory are different from original yaml/json testcases + debugtalk_path = project_meta.debugtalk_path + if debugtalk_path: + debugtalk_new_path = ensure_file_path_valid(debugtalk_path) + if debugtalk_new_path != debugtalk_path: + logger.info(f"copy debugtalk.py to {debugtalk_new_path}") + copyfile(debugtalk_path, debugtalk_new_path) + + global pytest_files_made_cache_mapping + pytest_files_made_cache_mapping[debugtalk_new_path] = "" + + dot_csv_path = project_meta.dot_env_path + if dot_csv_path: + dot_csv_new_path = ensure_file_path_valid(dot_csv_path) + if dot_csv_new_path != dot_csv_path: + logger.info(f"copy .env to {dot_csv_new_path}") + copyfile(dot_csv_path, dot_csv_new_path) + + def convert_testcase_path(testcase_path: Text) -> Tuple[Text, Text]: """convert single YAML/JSON testcase path to python file""" - if os.path.isdir(testcase_path): - # folder does not need to convert - return testcase_path, "" + testcase_new_path = ensure_file_path_valid(testcase_path) - testcase_path = __ensure_file_name(testcase_path) - raw_file_name, file_suffix = os.path.splitext(os.path.basename(testcase_path)) - - file_suffix = file_suffix.lower() - if file_suffix not in [".json", ".yml", ".yaml", ".har"]: - raise exceptions.ParamsError( - "testcase file should have .yaml/.yml/.json suffix" - ) - - file_name = raw_file_name.replace(" ", "_").replace(".", "_").replace("-", "_") - testcase_dir = os.path.dirname(testcase_path) - testcase_python_path = os.path.join(testcase_dir, f"{file_name}_test.py") + dir_path = os.path.dirname(testcase_new_path) + file_name, _ = os.path.splitext(os.path.basename(testcase_new_path)) + testcase_python_path = os.path.join(dir_path, f"{file_name}_test.py") # convert title case, e.g. request_with_variables => RequestWithVariables name_in_title_case = file_name.title().replace("_", "") @@ -220,18 +230,16 @@ def make_teststep_chain_style(teststep: Dict) -> Text: call_ref_testcase = f".call({testcase})" step_info += call_ref_testcase - extract_info = teststep.get("extract") - if extract_info: - if isinstance(extract_info, Dict): - # request step - step_info += ".extract()" - for extract_name, extract_path in extract_info.items(): - step_info += f'.with_jmespath("{extract_path}", "{extract_name}")' - elif isinstance(extract_info, List): - # reference testcase step - step_info += f".extract(*{extract_info})" - else: - raise exceptions.TestCaseFormatError(f"Invalid extract: {extract_info}") + if "extract" in teststep: + # request step + step_info += ".extract()" + for extract_name, extract_path in teststep["extract"].items(): + step_info += f'.with_jmespath("{extract_path}", "{extract_name}")' + + if "export" in teststep: + # reference testcase step + export: List[Text] = teststep["export"] + step_info += f".export(*{export})" if "validate" in teststep: step_info += ".validate()" @@ -253,9 +261,7 @@ def make_teststep_chain_style(teststep: Dict) -> Text: return f"Step({step_info})" -def make_testcase( - testcase: Dict, dir_path: Text = None, ref_flag: bool = False, -) -> Text: +def make_testcase(testcase: Dict, dir_path: Text = None) -> Text: """convert valid testcase dict to pytest file path""" # ensure compatibility with testcase format v2 testcase = ensure_testcase_v3(testcase) @@ -263,17 +269,17 @@ def make_testcase( # validate testcase format load_testcase(testcase) - testcase_path = __ensure_absolute(testcase["config"]["path"]) - logger.info(f"start to make testcase: {testcase_path}") + testcase_abs_path = __ensure_absolute(testcase["config"]["path"]) + logger.info(f"start to make testcase: {testcase_abs_path}") - testcase_python_path, testcase_cls_name = convert_testcase_path(testcase_path) + testcase_python_path, testcase_cls_name = convert_testcase_path(testcase_abs_path) if dir_path: testcase_python_path = os.path.join( dir_path, os.path.basename(testcase_python_path) ) - global make_files_cache_set - if testcase_python_path in make_files_cache_set: + global pytest_files_made_cache_mapping + if testcase_python_path in pytest_files_made_cache_mapping: return testcase_python_path config = testcase["config"] @@ -283,7 +289,7 @@ def make_testcase( config.setdefault("variables", {}) if isinstance(config["variables"], Text): # get variables by function, e.g. ${get_variables()} - project_meta = load_project_meta(testcase_path) + project_meta = load_project_meta(testcase_abs_path) config["variables"] = parse_data( config["variables"], {}, project_meta.functions ) @@ -297,12 +303,14 @@ def make_testcase( # make ref testcase pytest file ref_testcase_path = __ensure_absolute(teststep["testcase"]) - __make(ref_testcase_path, ref_flag=True) + test_content = load_test_file(ref_testcase_path) + test_content.setdefault("config", {})["path"] = ref_testcase_path + ref_testcase_python_path = make_testcase(test_content) # prepare ref testcase class name - ref_testcase_python_path, ref_testcase_cls_name = convert_testcase_path( - ref_testcase_path - ) + ref_testcase_cls_name = pytest_files_made_cache_mapping[ + ref_testcase_python_path + ] teststep["testcase"] = ref_testcase_cls_name # prepare import ref testcase @@ -315,7 +323,7 @@ def make_testcase( data = { "version": __version__, - "testcase_path": __ensure_cwd_relative(testcase_path), + "testcase_path": __ensure_cwd_relative(testcase_abs_path), "class_name": f"TestCase{testcase_cls_name}", "imports_list": imports_list, "config_chain_style": make_config_chain_style(config), @@ -325,16 +333,19 @@ def make_testcase( } content = __TEMPLATE__.render(data) + # ensure new file's directory exists + dir_path = os.path.dirname(testcase_python_path) + if not os.path.exists(dir_path): + os.makedirs(dir_path) + with open(testcase_python_path, "w", encoding="utf-8") as f: f.write(content) + pytest_files_made_cache_mapping[testcase_python_path] = testcase_cls_name __ensure_testcase_module(testcase_python_path) logger.info(f"generated testcase: {testcase_python_path}") - if not ref_flag: - make_files_cache_set.add(__ensure_cwd_relative(testcase_python_path)) - return testcase_python_path @@ -357,11 +368,10 @@ def make_testsuite(testsuite: Dict) -> NoReturn: logger.info(f"start to make testsuite: {testsuite_path}") # create directory with testsuite file name, put its testcases under this directory - testsuite_dir = os.path.join( - os.path.dirname(testsuite_path), - os.path.basename(testsuite_path).replace(".", "_"), - ) - os.makedirs(testsuite_dir, exist_ok=True) + testsuite_path = ensure_file_path_valid(testsuite_path) + testsuite_dir, file_suffix = os.path.splitext(testsuite_path) + # demo_testsuite.yml => demo_testsuite_yml + testsuite_dir = f"{testsuite_dir}_{file_suffix.lstrip('.')}" for testcase in testsuite["testcases"]: # get referenced testcase content @@ -386,16 +396,16 @@ def make_testsuite(testsuite: Dict) -> NoReturn: testcase_dict["config"]["variables"].update(testsuite_variables) # make testcase - make_testcase(testcase_dict, testsuite_dir) + testcase_pytest_path = make_testcase(testcase_dict, testsuite_dir) + pytest_files_run_set.add(testcase_pytest_path) -def __make(tests_path: Text, ref_flag: bool = False) -> NoReturn: +def __make(tests_path: Text) -> NoReturn: """ make testcase(s) with testcase/testsuite/folder absolute path - generated pytest file path will be cached in make_files_cache_set + generated pytest file path will be cached in pytest_files_made_cache_mapping Args: tests_path: should be in absolute path - ref_flag: flag if referenced test path """ test_files = [] @@ -409,7 +419,7 @@ def __make(tests_path: Text, ref_flag: bool = False) -> NoReturn: for test_file in test_files: if test_file.lower().endswith("_test.py"): - pytest_files_set.add(test_file) + pytest_files_run_set.add(test_file) continue try: @@ -422,12 +432,16 @@ def __make(tests_path: Text, ref_flag: bool = False) -> NoReturn: if "request" in test_content: test_content = ensure_testcase_v3_api(test_content) + if not (isinstance(test_content, Dict) and "config" in test_content): + raise exceptions.FileFormatError("Invalid testcase/testsuite v2/v3 format!") + test_content.setdefault("config", {})["path"] = test_file # testcase if "teststeps" in test_content: try: - make_testcase(test_content, ref_flag=ref_flag) + testcase_pytest_path = make_testcase(test_content) + pytest_files_run_set.add(testcase_pytest_path) except exceptions.TestCaseFormatError: continue @@ -452,11 +466,13 @@ def main_make(tests_paths: List[Text]) -> List[Text]: tests_path = os.path.join(os.getcwd(), tests_path) __make(tests_path) + __ensure_project_meta_files(tests_path) - pytest_files_set.update(make_files_cache_set) - pytest_files_list = list(pytest_files_set) - format_pytest_with_black(*pytest_files_list) - return pytest_files_list + # format pytest files + pytest_files_format_list = pytest_files_made_cache_mapping.keys() + format_pytest_with_black(*pytest_files_format_list) + + return list(pytest_files_run_set) def init_make_parser(subparsers): diff --git a/httprunner/schema.py b/httprunner/models.py similarity index 88% rename from httprunner/schema.py rename to httprunner/models.py index be60bf41..dcfd40f2 100644 --- a/httprunner/schema.py +++ b/httprunner/models.py @@ -66,7 +66,10 @@ class TStep(BaseModel): variables: VariablesMapping = {} setup_hooks: Hook = [] teardown_hooks: Hook = [] - extract: Union[Dict[Text, Text], List[Text]] = {} + # used to extract request's response field + extract: VariablesMapping = {} + # used to export session variables from referenced testcase + export: Export = [] validators: Validators = Field([], alias="validate") validate_script: List[Text] = [] @@ -78,10 +81,11 @@ class TestCase(BaseModel): class ProjectMeta(BaseModel): debugtalk_py: Text = "" # debugtalk.py file content - functions: FunctionsMapping = {} + debugtalk_path: Text = "" # debugtalk.py file path + dot_env_path: Text = "" # .env file path + functions: FunctionsMapping = {} # functions defined in debugtalk.py env: Env = {} - PWD: Text = os.getcwd() - test_path: Text = None # run with specified test path + RootDir: Text = os.getcwd() # project root directory, the path debugtalk.py located class TestsMapping(BaseModel): @@ -96,8 +100,8 @@ class TestCaseTime(BaseModel): class TestCaseInOut(BaseModel): - vars: VariablesMapping = {} - export: Dict = {} + config_vars: VariablesMapping = {} + export_vars: Dict = {} class RequestStat(BaseModel): @@ -145,7 +149,7 @@ class StepData(BaseModel): success: bool = False name: Text = "" # teststep name data: Union[SessionData, List[SessionData]] = None - export: Dict = {} + export_vars: VariablesMapping = {} class TestCaseSummary(BaseModel): diff --git a/httprunner/parser.py b/httprunner/parser.py index c36363ee..74f4f69e 100644 --- a/httprunner/parser.py +++ b/httprunner/parser.py @@ -6,7 +6,7 @@ from typing import Any, Set, Text, Callable, List, Dict from sentry_sdk import capture_exception from httprunner import loader, utils, exceptions -from httprunner.schema import VariablesMapping, FunctionsMapping +from httprunner.models import VariablesMapping, FunctionsMapping absolute_http_url_regexp = re.compile(r"^https?://", re.I) diff --git a/httprunner/response.py b/httprunner/response.py index 9a62c708..41171271 100644 --- a/httprunner/response.py +++ b/httprunner/response.py @@ -6,7 +6,7 @@ from loguru import logger from httprunner.exceptions import ValidationFailure, ParamsError from httprunner.parser import parse_data, parse_string_value, get_mapping_function -from httprunner.schema import VariablesMapping, Validators, FunctionsMapping +from httprunner.models import VariablesMapping, Validators, FunctionsMapping def get_uniform_comparator(comparator: Text): diff --git a/httprunner/runner.py b/httprunner/runner.py index b90886ea..c050deea 100644 --- a/httprunner/runner.py +++ b/httprunner/runner.py @@ -21,7 +21,7 @@ from httprunner.loader import load_project_meta, load_testcase_file from httprunner.parser import build_url, parse_data, parse_variables_mapping from httprunner.response import ResponseObject from httprunner.testcase import Config, Step -from httprunner.schema import ( +from httprunner.models import ( TConfig, TStep, VariablesMapping, @@ -43,10 +43,10 @@ class HttpRunner(object): __teststeps: List[TStep] __project_meta: ProjectMeta = None __case_id: Text = "" + __export: List[Text] = [] __step_datas: List[StepData] = None __session: HttpSession = None __session_variables: VariablesMapping = {} - __export_variables: VariablesMapping = {} # time __start_at: float = 0 __duration: float = 0 @@ -82,6 +82,10 @@ class HttpRunner(object): self.__session_variables = variables return self + def with_export(self, export: List[Text]) -> "HttpRunner": + self.__export = export + return self + def __run_step_request(self, step: TStep) -> StepData: """run teststep: request""" step_data = StepData(name=step.name) @@ -134,7 +138,7 @@ class HttpRunner(object): # extract extractors = step.extract extract_mapping = resp_obj.extract(extractors) - step_data.export = extract_mapping + step_data.export_vars = extract_mapping variables_mapping = step.variables variables_mapping.update(extract_mapping) @@ -166,6 +170,7 @@ class HttpRunner(object): """run teststep: referenced testcase""" step_data = StepData(name=step.name) step_variables = step.variables + step_export = step.export if hasattr(step.testcase, "config") and hasattr(step.testcase, "teststeps"): testcase_cls = step.testcase @@ -174,6 +179,7 @@ class HttpRunner(object): .with_session(self.__session) .with_case_id(self.__case_id) .with_variables(step_variables) + .with_export(step_export) .run() ) @@ -181,13 +187,16 @@ class HttpRunner(object): if os.path.isabs(step.testcase): ref_testcase_path = step.testcase else: - ref_testcase_path = os.path.join(self.__project_meta.PWD, step.testcase) + ref_testcase_path = os.path.join( + self.__project_meta.RootDir, step.testcase + ) case_result = ( HttpRunner() .with_session(self.__session) .with_case_id(self.__case_id) .with_variables(step_variables) + .with_export(step_export) .run_path(ref_testcase_path) ) @@ -197,10 +206,13 @@ class HttpRunner(object): ) step_data.data = case_result.get_step_datas() # list of step data - step_data.export = case_result.get_export_variables() + step_data.export_vars = case_result.get_export_variables() step_data.success = case_result.success self.success &= case_result.success + if step_data.export_vars: + logger.info(f"export variables: {step_data.export_vars}") + return step_data def __run_step(self, step: TStep) -> Dict: @@ -218,7 +230,7 @@ class HttpRunner(object): self.__step_datas.append(step_data) logger.info(f"run step end: {step.name} <<<<<<\n") - return step_data.export + return step_data.export_vars def __parse_config(self, config: TConfig) -> NoReturn: config.variables.update(self.__session_variables) @@ -252,7 +264,6 @@ class HttpRunner(object): self.__step_datas: List[StepData] = [] self.__session = self.__session or HttpSession() self.__session_variables = {} - self.__export_variables = {} # run teststeps for step in self.__teststeps: @@ -274,11 +285,6 @@ class HttpRunner(object): self.__session_variables.update(extract_mapping) self.__duration = time.time() - self.__start_at - - self.__export_variables = self.get_export_variables() - if self.__export_variables: - logger.info(f"export variables: {self.__export_variables}") - return self def run_path(self, path: Text) -> "HttpRunner": @@ -303,11 +309,10 @@ class HttpRunner(object): return self.__step_datas def get_export_variables(self) -> Dict: - if self.__export_variables: - return self.__export_variables - + # override testcase export vars with step export + export_var_names = self.__export or self.__config.export export_vars_mapping = {} - for var_name in self.__config.export: + for var_name in export_var_names: if var_name not in self.__session_variables: raise ParamsError( f"failed to export variable {var_name} from session variables {self.__session_variables}" @@ -331,7 +336,8 @@ class HttpRunner(object): duration=self.__duration, ), in_out=TestCaseInOut( - vars=self.__config.variables, export=self.get_export_variables() + config_vars=self.__config.variables, + export_vars=self.get_export_variables(), ), log=self.__log_path, step_datas=self.__step_datas, @@ -345,7 +351,7 @@ class HttpRunner(object): ) self.__case_id = self.__case_id or str(uuid.uuid4()) self.__log_path = self.__log_path or os.path.join( - self.__project_meta.PWD, "logs", f"{self.__case_id}.run.log" + self.__project_meta.RootDir, "logs", f"{self.__case_id}.run.log" ) log_handler = logger.add(self.__log_path, level="DEBUG") diff --git a/httprunner/testcase.py b/httprunner/testcase.py index fc738851..91601df9 100644 --- a/httprunner/testcase.py +++ b/httprunner/testcase.py @@ -1,7 +1,7 @@ import inspect from typing import Text, Any, Union, Callable -from httprunner.schema import ( +from httprunner.models import ( TConfig, TStep, TRequest, @@ -57,37 +57,43 @@ class Config(object): class StepRequestValidation(object): - def __init__(self, step: TStep): - self.__t_step = step + def __init__(self, step_context: TStep): + self.__step_context = step_context def assert_equal( self, jmes_path: Text, expected_value: Any ) -> "StepRequestValidation": - self.__t_step.validators.append({"equal": [jmes_path, expected_value]}) + self.__step_context.validators.append({"equal": [jmes_path, expected_value]}) return self def assert_not_equal( self, jmes_path: Text, expected_value: Any ) -> "StepRequestValidation": - self.__t_step.validators.append({"not_equal": [jmes_path, expected_value]}) + self.__step_context.validators.append( + {"not_equal": [jmes_path, expected_value]} + ) return self def assert_greater_than( self, jmes_path: Text, expected_value: Union[int, float] ) -> "StepRequestValidation": - self.__t_step.validators.append({"greater_than": [jmes_path, expected_value]}) + self.__step_context.validators.append( + {"greater_than": [jmes_path, expected_value]} + ) return self def assert_less_than( self, jmes_path: Text, expected_value: Union[int, float] ) -> "StepRequestValidation": - self.__t_step.validators.append({"less_than": [jmes_path, expected_value]}) + self.__step_context.validators.append( + {"less_than": [jmes_path, expected_value]} + ) return self def assert_greater_or_equals( self, jmes_path: Text, expected_value: Union[int, float] ) -> "StepRequestValidation": - self.__t_step.validators.append( + self.__step_context.validators.append( {"greater_or_equals": [jmes_path, expected_value]} ) return self @@ -95,19 +101,23 @@ class StepRequestValidation(object): def assert_less_or_equals( self, jmes_path: Text, expected_value: Union[int, float] ) -> "StepRequestValidation": - self.__t_step.validators.append({"less_or_equals": [jmes_path, expected_value]}) + self.__step_context.validators.append( + {"less_or_equals": [jmes_path, expected_value]} + ) return self def assert_length_equal( self, jmes_path: Text, expected_value: int ) -> "StepRequestValidation": - self.__t_step.validators.append({"length_equal": [jmes_path, expected_value]}) + self.__step_context.validators.append( + {"length_equal": [jmes_path, expected_value]} + ) return self def assert_length_greater_than( self, jmes_path: Text, expected_value: int ) -> "StepRequestValidation": - self.__t_step.validators.append( + self.__step_context.validators.append( {"length_greater_than": [jmes_path, expected_value]} ) return self @@ -115,7 +125,7 @@ class StepRequestValidation(object): def assert_length_less_than( self, jmes_path: Text, expected_value: int ) -> "StepRequestValidation": - self.__t_step.validators.append( + self.__step_context.validators.append( {"length_less_than": [jmes_path, expected_value]} ) return self @@ -123,7 +133,7 @@ class StepRequestValidation(object): def assert_length_greater_or_equals( self, jmes_path: Text, expected_value: int ) -> "StepRequestValidation": - self.__t_step.validators.append( + self.__step_context.validators.append( {"length_greater_or_equals": [jmes_path, expected_value]} ) return self @@ -131,7 +141,7 @@ class StepRequestValidation(object): def assert_length_less_or_equals( self, jmes_path: Text, expected_value: int ) -> "StepRequestValidation": - self.__t_step.validators.append( + self.__step_context.validators.append( {"length_less_or_equals": [jmes_path, expected_value]} ) return self @@ -139,55 +149,65 @@ class StepRequestValidation(object): def assert_string_equals( self, jmes_path: Text, expected_value: int ) -> "StepRequestValidation": - self.__t_step.validators.append({"string_equals": [jmes_path, expected_value]}) + self.__step_context.validators.append( + {"string_equals": [jmes_path, expected_value]} + ) return self def assert_startswith( self, jmes_path: Text, expected_value: Text ) -> "StepRequestValidation": - self.__t_step.validators.append({"startswith": [jmes_path, expected_value]}) + self.__step_context.validators.append( + {"startswith": [jmes_path, expected_value]} + ) return self def assert_endswith( self, jmes_path: Text, expected_value: Text ) -> "StepRequestValidation": - self.__t_step.validators.append({"endswith": [jmes_path, expected_value]}) + self.__step_context.validators.append({"endswith": [jmes_path, expected_value]}) return self def assert_regex_match( self, jmes_path: Text, expected_value: Text ) -> "StepRequestValidation": - self.__t_step.validators.append({"regex_match": [jmes_path, expected_value]}) + self.__step_context.validators.append( + {"regex_match": [jmes_path, expected_value]} + ) return self def assert_contains( self, jmes_path: Text, expected_value: Any ) -> "StepRequestValidation": - self.__t_step.validators.append({"contains": [jmes_path, expected_value]}) + self.__step_context.validators.append({"contains": [jmes_path, expected_value]}) return self def assert_contained_by( self, jmes_path: Text, expected_value: Any ) -> "StepRequestValidation": - self.__t_step.validators.append({"contained_by": [jmes_path, expected_value]}) + self.__step_context.validators.append( + {"contained_by": [jmes_path, expected_value]} + ) return self def assert_type_match( self, jmes_path: Text, expected_value: Text ) -> "StepRequestValidation": - self.__t_step.validators.append({"type_match": [jmes_path, expected_value]}) + self.__step_context.validators.append( + {"type_match": [jmes_path, expected_value]} + ) return self def perform(self) -> TStep: - return self.__t_step + return self.__step_context class StepRequestExtraction(object): - def __init__(self, step: TStep): - self.__t_step = step + def __init__(self, step_context: TStep): + self.__step_context = step_context def with_jmespath(self, jmes_path: Text, var_name: Text) -> "StepRequestExtraction": - self.__t_step.extract[var_name] = jmes_path + self.__step_context.extract[var_name] = jmes_path return self # def with_regex(self): @@ -199,135 +219,134 @@ class StepRequestExtraction(object): # pass def validate(self) -> StepRequestValidation: - return StepRequestValidation(self.__t_step) + return StepRequestValidation(self.__step_context) def perform(self) -> TStep: - return self.__t_step + return self.__step_context class RequestWithOptionalArgs(object): - def __init__(self, step: TStep): - self.__t_step = step + def __init__(self, step_context: TStep): + self.__step_context = step_context def with_params(self, **params) -> "RequestWithOptionalArgs": - self.__t_step.request.params.update(params) + self.__step_context.request.params.update(params) return self def with_headers(self, **headers) -> "RequestWithOptionalArgs": - self.__t_step.request.headers.update(headers) + self.__step_context.request.headers.update(headers) return self def with_cookies(self, **cookies) -> "RequestWithOptionalArgs": - self.__t_step.request.cookies.update(cookies) + self.__step_context.request.cookies.update(cookies) return self def with_data(self, data) -> "RequestWithOptionalArgs": - self.__t_step.request.data = data + self.__step_context.request.data = data return self def with_json(self, req_json) -> "RequestWithOptionalArgs": - self.__t_step.request.req_json = req_json + self.__step_context.request.req_json = req_json return self def set_timeout(self, timeout: float) -> "RequestWithOptionalArgs": - self.__t_step.request.timeout = timeout + self.__step_context.request.timeout = timeout return self def set_verify(self, verify: bool) -> "RequestWithOptionalArgs": - self.__t_step.request.verify = verify + self.__step_context.request.verify = verify return self def set_allow_redirects(self, allow_redirects: bool) -> "RequestWithOptionalArgs": - self.__t_step.request.allow_redirects = allow_redirects + self.__step_context.request.allow_redirects = allow_redirects return self def upload(self, **file_info) -> "RequestWithOptionalArgs": - self.__t_step.request.upload.update(file_info) + self.__step_context.request.upload.update(file_info) return self # def hooks(self): # pass def extract(self) -> StepRequestExtraction: - return StepRequestExtraction(self.__t_step) + return StepRequestExtraction(self.__step_context) def validate(self) -> StepRequestValidation: - return StepRequestValidation(self.__t_step) + return StepRequestValidation(self.__step_context) def perform(self) -> TStep: - return self.__t_step + return self.__step_context class RunRequest(object): def __init__(self, name: Text): - self.__t_step = TStep(name=name) + self.__step_context = TStep(name=name) def with_variables(self, **variables) -> "RunRequest": - self.__t_step.variables.update(variables) + self.__step_context.variables.update(variables) return self def get(self, url: Text) -> RequestWithOptionalArgs: - self.__t_step.request = TRequest(method=MethodEnum.GET, url=url) - return RequestWithOptionalArgs(self.__t_step) + self.__step_context.request = TRequest(method=MethodEnum.GET, url=url) + return RequestWithOptionalArgs(self.__step_context) def post(self, url: Text) -> RequestWithOptionalArgs: - self.__t_step.request = TRequest(method=MethodEnum.POST, url=url) - return RequestWithOptionalArgs(self.__t_step) + self.__step_context.request = TRequest(method=MethodEnum.POST, url=url) + return RequestWithOptionalArgs(self.__step_context) def put(self, url: Text) -> RequestWithOptionalArgs: - self.__t_step.request = TRequest(method=MethodEnum.PUT, url=url) - return RequestWithOptionalArgs(self.__t_step) + self.__step_context.request = TRequest(method=MethodEnum.PUT, url=url) + return RequestWithOptionalArgs(self.__step_context) def head(self, url: Text) -> RequestWithOptionalArgs: - self.__t_step.request = TRequest(method=MethodEnum.HEAD, url=url) - return RequestWithOptionalArgs(self.__t_step) + self.__step_context.request = TRequest(method=MethodEnum.HEAD, url=url) + return RequestWithOptionalArgs(self.__step_context) def delete(self, url: Text) -> RequestWithOptionalArgs: - self.__t_step.request = TRequest(method=MethodEnum.DELETE, url=url) - return RequestWithOptionalArgs(self.__t_step) + self.__step_context.request = TRequest(method=MethodEnum.DELETE, url=url) + return RequestWithOptionalArgs(self.__step_context) def options(self, url: Text) -> RequestWithOptionalArgs: - self.__t_step.request = TRequest(method=MethodEnum.OPTIONS, url=url) - return RequestWithOptionalArgs(self.__t_step) + self.__step_context.request = TRequest(method=MethodEnum.OPTIONS, url=url) + return RequestWithOptionalArgs(self.__step_context) def patch(self, url: Text) -> RequestWithOptionalArgs: - self.__t_step.request = TRequest(method=MethodEnum.PATCH, url=url) - return RequestWithOptionalArgs(self.__t_step) + self.__step_context.request = TRequest(method=MethodEnum.PATCH, url=url) + return RequestWithOptionalArgs(self.__step_context) class StepRefCase(object): - def __init__(self, step: TStep): - self.__t_step = step - self.__t_step.extract = [] + def __init__(self, step_context: TStep): + self.__step_context = step_context - def extract(self, *var_name: Text) -> "StepRefCase": - self.__t_step.extract.extend(var_name) + def export(self, *var_name: Text) -> "StepRefCase": + self.__step_context.export.extend(var_name) return self def perform(self) -> TStep: - return self.__t_step + return self.__step_context class RunTestCase(object): def __init__(self, name: Text): - self.__t_step = TStep(name=name) + self.__step_context = TStep(name=name) def with_variables(self, **variables) -> "RunTestCase": - self.__t_step.variables.update(variables) + self.__step_context.variables.update(variables) return self def call(self, testcase: Callable) -> StepRefCase: - self.__t_step.testcase = testcase - return StepRefCase(self.__t_step) + self.__step_context.testcase = testcase + return StepRefCase(self.__step_context) def perform(self) -> TStep: - return self.__t_step + return self.__step_context class Step(object): def __init__( self, - step: Union[ + step_context: Union[ StepRequestValidation, StepRequestExtraction, RequestWithOptionalArgs, @@ -335,15 +354,15 @@ class Step(object): StepRefCase, ], ): - self.__t_step = step.perform() + self.__step_context = step_context.perform() @property def request(self) -> TRequest: - return self.__t_step.request + return self.__step_context.request @property def testcase(self) -> TestCase: - return self.__t_step.testcase + return self.__step_context.testcase def perform(self) -> TStep: - return self.__t_step + return self.__step_context diff --git a/httprunner/utils.py b/httprunner/utils.py index 3b206eea..02d9b127 100644 --- a/httprunner/utils.py +++ b/httprunner/utils.py @@ -2,8 +2,9 @@ import collections import json import os.path import platform +import string import uuid -from typing import Dict, List, Any +from typing import Dict, List, Any, Text import sentry_sdk from loguru import logger @@ -176,3 +177,38 @@ def sort_dict_by_custom_order(raw_dict: Dict, custom_order: List): return dict( sorted(raw_dict.items(), key=lambda i: get_index_from_list(custom_order, i[0])) ) + + +def ensure_file_path_valid(file_path: Text) -> Text: + """ ensure file path valid for pytest, handle cases when directory name includes dot/hyphen/space + + Args: + file_path: absolute or relative file path + + Returns: + ensured valid absolute file path + + """ + raw_file_name, file_suffix = os.path.splitext(file_path) + file_suffix = file_suffix.lower() + + if os.path.isabs(file_path): + raw_file_relative_name = raw_file_name[len(os.getcwd()) + 1 :] + else: + raw_file_relative_name = raw_file_name + + path_names = [] + for name in raw_file_relative_name.rstrip(os.sep).split(os.sep): + + if name[0] in string.digits: + # ensure file name not startswith digit + # 19 => T19, 2C => T2C + name = f"T{name}" + + # handle cases when directory name includes dot/hyphen/space + name = name.replace(" ", "_").replace(".", "_").replace("-", "_") + + path_names.append(name) + + new_file_path = os.path.join(os.getcwd(), f"{os.sep.join(path_names)}{file_suffix}") + return new_file_path diff --git a/pyproject.toml b/pyproject.toml index 2b239b6a..23324ab6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "httprunner" -version = "3.0.8" +version = "3.0.9" description = "One-stop solution for HTTP(S) testing." license = "Apache-2.0" readme = "README.md" diff --git a/tests/cli_test.py b/tests/cli_test.py index 3ddd0efb..c84e294b 100644 --- a/tests/cli_test.py +++ b/tests/cli_test.py @@ -40,9 +40,10 @@ class TestCli(unittest.TestCase): self.assertIn(__description__, self.captured_output.getvalue().strip()) def test_debug_pytest(self): - pytest.main( + exit_code = pytest.main( [ "-s", "examples/postman_echo/request_methods/request_with_testcase_reference_test.py", ] ) + self.assertEqual(exit_code, 0) diff --git a/tests/compat_test.py b/tests/compat_test.py index 1b2dba3e..89707db7 100644 --- a/tests/compat_test.py +++ b/tests/compat_test.py @@ -153,9 +153,7 @@ class TestCompat(unittest.TestCase): compat.ensure_cli_args(args2), ["examples/postman_echo/request_methods/hardcode.yml"], ) - self.assertTrue( - os.path.isfile("examples/postman_echo/request_methods/conftest.py") - ) + self.assertTrue(os.path.isfile("examples/postman_echo/conftest.py")) args3 = [ "examples/postman_echo/request_methods/hardcode.yml", diff --git a/tests/data/a-b.c/1.yml b/tests/data/a-b.c/1.yml new file mode 100644 index 00000000..ffb63d09 --- /dev/null +++ b/tests/data/a-b.c/1.yml @@ -0,0 +1,30 @@ +config: + name: "request methods testcase with functions" + variables: + foo1: session_bar1 + base_url: "https://postman-echo.com" + verify: False + +teststeps: +- + name: get with params + variables: + foo1: bar1 + foo2: session_bar2 + sum_v: "${sum_two(1, 2)}" + request: + method: GET + url: /get + params: + foo1: $foo1 + foo2: $foo2 + sum_v: $sum_v + headers: + User-Agent: HttpRunner/${get_httprunner_version()} + extract: + session_foo2: "body.args.foo2" + validate: + - eq: ["status_code", 200] + - eq: ["body.args.foo1", "session_bar1"] + - eq: ["body.args.sum_v", "3"] + - eq: ["body.args.foo2", "session_bar2"] diff --git a/tests/data/a-b.c/2 3.yml b/tests/data/a-b.c/2 3.yml new file mode 100644 index 00000000..ef4961ed --- /dev/null +++ b/tests/data/a-b.c/2 3.yml @@ -0,0 +1,30 @@ +config: + name: "reference testcase unittest for abnormal folder path" + variables: + foo1: session_bar1 + base_url: "https://postman-echo.com" + verify: False + +teststeps: +- + name: request with functions + variables: + foo1: override_bar1 + testcase: 1.yml + export: + - session_foo2 +- + name: post form data + variables: + foo1: bar1 + request: + method: POST + url: /post + headers: + User-Agent: HttpRunner/${get_httprunner_version()} + Content-Type: "application/x-www-form-urlencoded" + data: "foo1=$foo1&foo2=$session_foo2" + validate: + - eq: ["status_code", 200] + - eq: ["body.form.foo1", "session_bar1"] + - eq: ["body.form.foo2", "session_bar2"] diff --git a/examples/postman_echo/cookie_manipulation/__init__.py b/tests/data/a-b.c/__init__.py similarity index 100% rename from examples/postman_echo/cookie_manipulation/__init__.py rename to tests/data/a-b.c/__init__.py diff --git a/tests/data/a-b.c/debugtalk.py b/tests/data/a-b.c/debugtalk.py new file mode 100644 index 00000000..af8b22eb --- /dev/null +++ b/tests/data/a-b.c/debugtalk.py @@ -0,0 +1,13 @@ +from httprunner import __version__ + + +def get_httprunner_version(): + return __version__ + + +def sum_two(m, n): + return m + n + + +def get_variables(): + return {"foo1": "session_bar1"} diff --git a/tests/make_test.py b/tests/make_test.py index 8eec7ca6..12d8943d 100644 --- a/tests/make_test.py +++ b/tests/make_test.py @@ -1,34 +1,45 @@ +import os import unittest from httprunner.make import ( main_make, convert_testcase_path, - make_files_cache_set, + pytest_files_made_cache_mapping, make_config_chain_style, make_teststep_chain_style, - pytest_files_set, + pytest_files_run_set, ) +from httprunner import loader class TestMake(unittest.TestCase): + def setUp(self) -> None: + pytest_files_made_cache_mapping.clear() + pytest_files_run_set.clear() + loader.project_meta = None + def test_make_testcase(self): path = ["examples/postman_echo/request_methods/request_with_variables.yml"] testcase_python_list = main_make(path) self.assertEqual( testcase_python_list[0], - "examples/postman_echo/request_methods/request_with_variables_test.py", + os.path.join( + os.getcwd(), + "examples/postman_echo/request_methods/request_with_variables_test.py", + ), ) def test_make_testcase_with_ref(self): path = [ "examples/postman_echo/request_methods/request_with_testcase_reference.yml" ] - make_files_cache_set.clear() - pytest_files_set.clear() testcase_python_list = main_make(path) self.assertEqual(len(testcase_python_list), 1) self.assertIn( - "examples/postman_echo/request_methods/request_with_testcase_reference_test.py", + os.path.join( + os.getcwd(), + "examples/postman_echo/request_methods/request_with_testcase_reference_test.py", + ), testcase_python_list, ) @@ -52,56 +63,51 @@ from examples.postman_echo.request_methods.request_with_functions_test import ( path = ["examples/postman_echo/request_methods/"] testcase_python_list = main_make(path) self.assertIn( - "examples/postman_echo/request_methods/request_with_functions_test.py", + os.path.join( + os.getcwd(), + "examples/postman_echo/request_methods/request_with_functions_test.py", + ), testcase_python_list, ) def test_convert_testcase_path(self): self.assertEqual( - convert_testcase_path("mubu.login.yml")[0], "mubu_login_test.py" + convert_testcase_path("mubu.login.yml"), + (os.path.join(os.getcwd(), "mubu_login_test.py"), "MubuLogin"), ) self.assertEqual( - convert_testcase_path("/path/to/mubu.login.yml")[0], - "/path/to/mubu_login_test.py", + convert_testcase_path(os.path.join(os.getcwd(), "path/to/mubu.login.yml")), + (os.path.join(os.getcwd(), "path/to/mubu_login_test.py"), "MubuLogin"), ) self.assertEqual( - convert_testcase_path("/path/to 2/mubu.login.yml")[0], - "/path/to 2/mubu_login_test.py", + convert_testcase_path("path/to 2/mubu.login.yml"), + (os.path.join(os.getcwd(), "path/to_2/mubu_login_test.py"), "MubuLogin"), ) self.assertEqual( - convert_testcase_path("/path/to 2/mubu.login.yml")[1], "MubuLogin" + convert_testcase_path("path/to-2/mubu login.yml"), + (os.path.join(os.getcwd(), "path/to_2/mubu_login_test.py"), "MubuLogin"), ) self.assertEqual( - convert_testcase_path("mubu login.yml")[0], "mubu_login_test.py" + convert_testcase_path("path/to.2/幕布login.yml"), + (os.path.join(os.getcwd(), "path/to_2/幕布login_test.py"), "幕布Login"), ) - self.assertEqual( - convert_testcase_path("/path/to 2/mubu login.yml")[1], "MubuLogin" - ) - self.assertEqual( - convert_testcase_path("/path/to 2/mubu-login.yml")[0], - "/path/to 2/mubu_login_test.py", - ) - self.assertEqual( - convert_testcase_path("/path/to 2/mubu-login.yml")[1], "MubuLogin" - ) - self.assertEqual( - convert_testcase_path("/path/to 2/幕布login.yml")[0], - "/path/to 2/幕布login_test.py", - ) - self.assertEqual(convert_testcase_path("/path/to/幕布login.yml")[1], "幕布Login") def test_make_testsuite(self): path = ["examples/postman_echo/request_methods/demo_testsuite.yml"] - make_files_cache_set.clear() - pytest_files_set.clear() testcase_python_list = main_make(path) self.assertEqual(len(testcase_python_list), 2) self.assertIn( - "examples/postman_echo/request_methods/demo_testsuite_yml/request_with_functions_test.py", + os.path.join( + os.getcwd(), + "examples/postman_echo/request_methods/demo_testsuite_yml/request_with_functions_test.py", + ), testcase_python_list, ) self.assertIn( - "examples/postman_echo/request_methods/demo_testsuite_yml/request_with_testcase_reference_test.py", + os.path.join( + os.getcwd(), + "examples/postman_echo/request_methods/demo_testsuite_yml/request_with_testcase_reference_test.py", + ), testcase_python_list, ) @@ -109,13 +115,13 @@ from examples.postman_echo.request_methods.request_with_functions_test import ( config = { "name": "request methods testcase: validate with functions", "variables": {"foo1": "bar1", "foo2": 22}, - "base_url": "https://postman-echo.com", + "base_url": "https://postman_echo.com", "verify": False, "path": "examples/postman_echo/request_methods/validate_with_functions_test.py", } self.assertEqual( make_config_chain_style(config), - """Config("request methods testcase: validate with functions").variables(**{'foo1': 'bar1', 'foo2': 22}).base_url("https://postman-echo.com").verify(False)""", + """Config("request methods testcase: validate with functions").variables(**{'foo1': 'bar1', 'foo2': 22}).base_url("https://postman_echo.com").verify(False)""", ) def test_make_teststep_chain_style(self): diff --git a/tests/runner_test.py b/tests/runner_test.py index f0dc4a87..b91fd3df 100644 --- a/tests/runner_test.py +++ b/tests/runner_test.py @@ -1,10 +1,14 @@ +import os import unittest +from httprunner import loader +from httprunner.cli import main_run from httprunner.runner import HttpRunner class TestHttpRunner(unittest.TestCase): def setUp(self): + loader.project_meta = None self.runner = HttpRunner() def test_run_testcase_by_path_request_only(self): @@ -26,3 +30,11 @@ class TestHttpRunner(unittest.TestCase): self.assertEqual(result.name, "request methods testcase: reference testcase") self.assertEqual(result.step_datas[0].name, "request with functions") self.assertEqual(len(result.step_datas), 2) + + def test_run_testcase_with_abnormal_path(self): + exit_code = main_run(["tests/data/a-b.c/2 3.yml"]) + self.assertEqual(exit_code, 0) + self.assertTrue(os.path.exists("tests/data/a_b_c/__init__.py")) + self.assertTrue(os.path.exists("tests/data/a_b_c/debugtalk.py")) + self.assertTrue(os.path.exists("tests/data/a_b_c/T1_test.py")) + self.assertTrue(os.path.exists("tests/data/a_b_c/T2_3_test.py")) diff --git a/tests/utils_test.py b/tests/utils_test.py index cfb86e85..b15a5c7f 100644 --- a/tests/utils_test.py +++ b/tests/utils_test.py @@ -2,6 +2,7 @@ import os import unittest from httprunner import loader, utils +from httprunner.utils import ensure_file_path_valid class TestUtils(unittest.TestCase): @@ -97,3 +98,21 @@ class TestUtils(unittest.TestCase): ), ["A", "D", "C", "B"], ) + + def test_ensure_file_path_valid(self): + self.assertEqual( + ensure_file_path_valid("examples/a-b.c/d f/hardcode.yml"), + os.path.join(os.getcwd(), "examples/a_b_c/d_f/hardcode.yml"), + ) + self.assertEqual( + ensure_file_path_valid("1/2B/3.yml"), + os.path.join(os.getcwd(), "T1/T2B/T3.yml"), + ) + self.assertEqual( + ensure_file_path_valid("examples/a-b.c/2B/hardcode.yml"), + os.path.join(os.getcwd(), "examples/a_b_c/T2B/hardcode.yml"), + ) + self.assertEqual( + ensure_file_path_valid("examples/postman_echo/request_methods/"), + os.path.join(os.getcwd(), "examples/postman_echo/request_methods"), + )