diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index d07d72e0..2e794dd4 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,23 @@ # Release History +## 3.0.4 (2020-05-19) + +**Added** + +- feat: make testsuite and run testsuite +- feat: testcase/testsuite config support getting variables by function +- feat: har2case with request cookies +- feat: log request/response headers and body with indent + +**Fixed** + +- fix: extract response cookies +- fix: handle errors when no valid testcases generated + +**Changed** + +- change: har2case do not ignore request headers, except for header startswith : + ## 3.0.3 (2020-05-17) **Fixed** diff --git a/examples/httpbin/basic.yml b/examples/httpbin/basic.yml index d27d8604..f0e36fd6 100644 --- a/examples/httpbin/basic.yml +++ b/examples/httpbin/basic.yml @@ -19,7 +19,7 @@ teststeps: method: GET validate: - eq: ["status_code", 200] -# - startswith: [body.user-agent, "python-requests"] + - startswith: [body."user-agent", "python-requests"] - name: get without params @@ -58,7 +58,7 @@ teststeps: method: GET validate: - eq: ["status_code", 200] - # - eq: [cookies.name, "value"] + - eq: [body.cookies.name, "value"] - name: extract cookie @@ -67,7 +67,7 @@ teststeps: method: GET validate: - eq: ["status_code", 200] - # - eq: [cookies.name, "value"] + - eq: [body.cookies.name, "value"] - name: post data diff --git a/examples/httpbin/basic_test.py b/examples/httpbin/basic_test.py index 79634dbb..4d92b82a 100644 --- a/examples/httpbin/basic_test.py +++ b/examples/httpbin/basic_test.py @@ -27,7 +27,10 @@ class TestCaseBasic(HttpRunner): **{ "name": "user-agent", "request": {"url": "/user-agent", "method": "GET"}, - "validate": [{"eq": ["status_code", 200]}], + "validate": [ + {"eq": ["status_code", 200]}, + {"startswith": ['body."user-agent"', "python-requests"]}, + ], } ), TStep( @@ -61,14 +64,20 @@ class TestCaseBasic(HttpRunner): **{ "name": "set cookie", "request": {"url": "/cookies/set?name=value", "method": "GET"}, - "validate": [{"eq": ["status_code", 200]}], + "validate": [ + {"eq": ["status_code", 200]}, + {"eq": ["body.cookies.name", "value"]}, + ], } ), TStep( **{ "name": "extract cookie", "request": {"url": "/cookies", "method": "GET"}, - "validate": [{"eq": ["status_code", 200]}], + "validate": [ + {"eq": ["status_code", 200]}, + {"eq": ["body.cookies.name", "value"]}, + ], } ), TStep( diff --git a/examples/httpbin/hooks.yml b/examples/httpbin/hooks.yml index 765f5daf..770d9926 100644 --- a/examples/httpbin/hooks.yml +++ b/examples/httpbin/hooks.yml @@ -32,5 +32,6 @@ teststeps: - ${alter_response($response)} validate: - eq: ["status_code", 200] -# - eq: ["headers.content-type", "html/text"] + # TODO: implement hooks +# - eq: [body.headers."Content-Type", "html/text"] - eq: [body.headers.Host, "httpbin.org"] diff --git a/examples/postman_echo/debugtalk.py b/examples/postman_echo/debugtalk.py index 849bd537..d0502409 100644 --- a/examples/postman_echo/debugtalk.py +++ b/examples/postman_echo/debugtalk.py @@ -7,3 +7,9 @@ def get_httprunner_version(): def sum_two(m, n): return m + n + + +def get_variables(): + return { + "foo1": "session_bar1" + } diff --git a/examples/postman_echo/request_methods/demo_testsuite.yml b/examples/postman_echo/request_methods/demo_testsuite.yml new file mode 100644 index 00000000..946d37d7 --- /dev/null +++ b/examples/postman_echo/request_methods/demo_testsuite.yml @@ -0,0 +1,15 @@ +config: + name: "demo testsuite" + variables: ${get_variables()} + +testcases: +- + name: request with functions + testcase: request_methods/request_with_functions.yml + variables: + var1: testsuite_val1 +- + name: request with referenced testcase + testcase: request_methods/request_with_testcase_reference.yml + variables: + var2: testsuite_val2 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 new file mode 100644 index 00000000..887c0d28 --- /dev/null +++ b/examples/postman_echo/request_methods/demo_testsuite_yml/request_with_functions_test.py @@ -0,0 +1,89 @@ +# NOTICE: Generated By HttpRunner. DO'NOT EDIT! +# FROM: examples/postman_echo/request_methods/demo_testsuite_yml/request_with_functions.yml +from httprunner import HttpRunner, TConfig, TStep + + +class TestCaseRequestWithFunctions(HttpRunner): + config = TConfig( + **{ + "name": "request with functions", + "variables": {"foo1": "session_bar1", "var1": "testsuite_val1"}, + "base_url": "https://postman-echo.com", + "verify": False, + "path": "examples/postman_echo/request_methods/demo_testsuite_yml/request_with_functions_test.py", + } + ) + + teststeps = [ + TStep( + **{ + "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"]}, + ], + } + ), + TStep( + **{ + "name": "post raw text", + "variables": {"foo1": "hello world", "foo3": "$session_foo2"}, + "request": { + "method": "POST", + "url": "/post", + "headers": { + "User-Agent": "HttpRunner/${get_httprunner_version()}", + "Content-Type": "text/plain", + }, + "data": "This is expected to be sent back as part of response body: $foo1-$foo3.", + }, + "validate": [ + {"eq": ["status_code", 200]}, + { + "eq": [ + "body.data", + "This is expected to be sent back as part of response body: session_bar1-session_bar2.", + ] + }, + ], + } + ), + TStep( + **{ + "name": "post form data", + "variables": {"foo1": "bar1", "foo2": "bar2"}, + "request": { + "method": "POST", + "url": "/post", + "headers": { + "User-Agent": "HttpRunner/${get_httprunner_version()}", + "Content-Type": "application/x-www-form-urlencoded", + }, + "data": "foo1=$foo1&foo2=$foo2", + }, + "validate": [ + {"eq": ["status_code", 200]}, + {"eq": ["body.form.foo1", "session_bar1"]}, + {"eq": ["body.form.foo2", "bar2"]}, + ], + } + ), + ] + + +if __name__ == "__main__": + TestCaseRequestWithFunctions().test_start() 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 new file mode 100644 index 00000000..9a714755 --- /dev/null +++ b/examples/postman_echo/request_methods/demo_testsuite_yml/request_with_testcase_reference_test.py @@ -0,0 +1,29 @@ +# NOTICE: Generated By HttpRunner. DO'NOT EDIT! +# FROM: examples/postman_echo/request_methods/demo_testsuite_yml/request_with_testcase_reference.yml +from httprunner import HttpRunner, TConfig, TStep + + +class TestCaseRequestWithTestcaseReference(HttpRunner): + config = TConfig( + **{ + "name": "request with referenced testcase", + "variables": {"foo1": "session_bar1", "var2": "testsuite_val2"}, + "base_url": "https://postman-echo.com", + "verify": False, + "path": "examples/postman_echo/request_methods/demo_testsuite_yml/request_with_testcase_reference_test.py", + } + ) + + teststeps = [ + TStep( + **{ + "name": "request with functions", + "variables": {"foo1": "override_bar1"}, + "testcase": "request_methods/request_with_functions.yml", + } + ), + ] + + +if __name__ == "__main__": + TestCaseRequestWithTestcaseReference().test_start() diff --git a/examples/postman_echo/request_methods/hardcode_test.py b/examples/postman_echo/request_methods/hardcode_test.py index e60254c4..934c0d66 100644 --- a/examples/postman_echo/request_methods/hardcode_test.py +++ b/examples/postman_echo/request_methods/hardcode_test.py @@ -10,6 +10,7 @@ class TestCaseHardcode(HttpRunner): "base_url": "https://postman-echo.com", "verify": False, "path": "examples/postman_echo/request_methods/hardcode_test.py", + "variables": {}, } ) 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 7e139535..3b2bfbdc 100644 --- a/examples/postman_echo/request_methods/request_with_testcase_reference.yml +++ b/examples/postman_echo/request_methods/request_with_testcase_reference.yml @@ -7,7 +7,7 @@ config: teststeps: - - name: request with variables + name: request with functions variables: foo1: override_bar1 - testcase: request_methods/request_with_variables.yml + testcase: request_methods/request_with_functions.yml 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 55b3db9c..229c04fc 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 @@ -17,9 +17,9 @@ class TestCaseRequestWithTestcaseReference(HttpRunner): teststeps = [ TStep( **{ - "name": "request with variables", + "name": "request with functions", "variables": {"foo1": "override_bar1"}, - "testcase": "request_methods/request_with_variables.yml", + "testcase": "request_methods/request_with_functions.yml", } ), ] diff --git a/examples/postman_echo/request_methods/request_with_variables.yml b/examples/postman_echo/request_methods/request_with_variables.yml index 625e240f..b5d19cc2 100644 --- a/examples/postman_echo/request_methods/request_with_variables.yml +++ b/examples/postman_echo/request_methods/request_with_variables.yml @@ -1,7 +1,6 @@ config: name: "request methods testcase with variables" - variables: - foo1: session_bar1 + variables: ${get_variables()} base_url: "https://postman-echo.com" verify: False diff --git a/httprunner/__init__.py b/httprunner/__init__.py index 2b0bf6b7..1c9ba758 100644 --- a/httprunner/__init__.py +++ b/httprunner/__init__.py @@ -1,4 +1,4 @@ -__version__ = "3.0.3" +__version__ = "3.0.4" __description__ = "One-stop solution for HTTP(S) testing." from httprunner.runner import HttpRunner diff --git a/httprunner/cli.py b/httprunner/cli.py index 34d9d0ff..f5f5655f 100644 --- a/httprunner/cli.py +++ b/httprunner/cli.py @@ -3,6 +3,7 @@ import os import sys import pytest +from loguru import logger from httprunner import __description__, __version__, exceptions from httprunner.ext.har2case import init_har2case_parser, main_har2case @@ -19,25 +20,30 @@ def init_parser_run(subparsers): def main_run(extra_args): tests_path_list = [] - for index, item in enumerate(extra_args): + extra_args_new = [] + for item in extra_args: if not os.path.exists(item): # item is not file/folder path - continue - elif os.path.isfile(item): - # replace YAML/JSON file path with generated python file - extra_args[index], _ = convert_testcase_path(item) - - tests_path_list.append(item) + extra_args_new.append(item) + else: + # item is file/folder path + tests_path_list.append(item) if len(tests_path_list) == 0: # has not specified any testcase path - raise exceptions.ParamsError("Missed testcase path") + logger.error(f"No valid testcase path in cli arguments: {extra_args}") + sys.exit(1) - main_make(tests_path_list) + testcase_path_list = main_make(tests_path_list) + if not testcase_path_list: + logger.error("No valid testcases found, exit 1.") + sys.exit(1) - if "-s" not in extra_args: - extra_args.insert(0, "-s") - pytest.main(extra_args) + extra_args_new.extend(testcase_path_list) + if "-s" not in extra_args_new: + extra_args_new.insert(0, "-s") + + pytest.main(extra_args_new) def main(): diff --git a/httprunner/client.py b/httprunner/client.py index b9606e3f..b7e645a7 100644 --- a/httprunner/client.py +++ b/httprunner/client.py @@ -1,3 +1,4 @@ +import json import time import requests @@ -32,12 +33,19 @@ def get_req_resp_record(resp_obj: Response) -> ReqRespData: def log_print(req_or_resp, r_type): msg = f"\n================== {r_type} details ==================\n" for key, value in req_or_resp.dict().items(): - msg += "{:<16} : {}\n".format(key, repr(value)) + if isinstance(value, dict): + value = json.dumps(value, indent=4) + + msg += "{:<8} : {}\n".format(key, value) logger.debug(msg) # record actual request info request_headers = dict(resp_obj.request.headers) request_body = resp_obj.request.body + try: + request_body = json.loads(request_body) + except json.JSONDecodeError: + pass if request_body: request_content_type = lower_dict_keys(request_headers).get("content-type") @@ -195,10 +203,6 @@ class HttpSession(requests.Session): Safe mode has been removed from requests 1.x. """ try: - msg = "processed request:\n" - msg += f"> {method} {url}\n" - msg += f"> kwargs: {kwargs}" - logger.debug(msg) return requests.Session.request(self, method, url, **kwargs) except (MissingSchema, InvalidSchema, InvalidURL): raise diff --git a/httprunner/exceptions.py b/httprunner/exceptions.py index 66e8f7a5..39e45f7e 100644 --- a/httprunner/exceptions.py +++ b/httprunner/exceptions.py @@ -44,6 +44,10 @@ class TestCaseFormatError(MyBaseError): pass +class TestSuiteFormatError(MyBaseError): + pass + + class ParamsError(MyBaseError): pass diff --git a/httprunner/ext/har2case/core.py b/httprunner/ext/har2case/core.py index f7683eac..3130a91c 100644 --- a/httprunner/ext/har2case/core.py +++ b/httprunner/ext/har2case/core.py @@ -14,24 +14,6 @@ except ImportError: JSONDecodeError = ValueError -IGNORE_REQUEST_HEADERS = [ - "host", - "accept", - "content-length", - "connection", - "accept-encoding", - "accept-language", - "origin", - "cache-control", - "pragma", - "upgrade-insecure-requests", - ":authority", - ":method", - ":scheme", - ":path", -] - - class HarParser(object): def __init__(self, har_file_path, filter_str=None, exclude_str=None): self.har_file_path = har_file_path @@ -93,6 +75,14 @@ class HarParser(object): teststep_dict["request"]["method"] = method + def __make_request_cookies(self, teststep_dict, entry_json): + cookies = {} + for cookie in entry_json["request"].get("cookies", []): + cookies[cookie["name"]] = cookie["value"] + + if cookies: + teststep_dict["request"]["cookies"] = cookies + def __make_request_headers(self, teststep_dict, entry_json): """ parse HAR entry request headers, and make teststep headers. header in IGNORE_REQUEST_HEADERS will be ignored. @@ -119,7 +109,7 @@ class HarParser(object): """ teststep_headers = {} for header in entry_json["request"].get("headers", []): - if header["name"].lower() in IGNORE_REQUEST_HEADERS: + if header["name"] == "cookie" or header["name"].startswith(":"): continue teststep_headers[header["name"]] = header["value"] @@ -288,6 +278,7 @@ class HarParser(object): self.__make_request_url(teststep_dict, entry_json) self.__make_request_method(teststep_dict, entry_json) + self.__make_request_cookies(teststep_dict, entry_json) self.__make_request_headers(teststep_dict, entry_json) self._make_request_data(teststep_dict, entry_json) self._make_validate(teststep_dict, entry_json) diff --git a/httprunner/ext/make/__init__.py b/httprunner/ext/make/__init__.py index 7da40807..80dad535 100644 --- a/httprunner/ext/make/__init__.py +++ b/httprunner/ext/make/__init__.py @@ -1,13 +1,19 @@ import os import subprocess -from typing import Union, Text, List, Tuple +from typing import Union, Text, List, Tuple, Dict import jinja2 from loguru import logger from httprunner import exceptions -from httprunner.exceptions import TestCaseFormatError -from httprunner.loader import load_testcase_file, load_folder_files +from httprunner.loader import ( + load_folder_files, + load_test_file, + load_testcase, + load_testsuite, + load_project_meta, +) +from httprunner.parser import parse_data __TMPL__ = """# NOTICE: Generated By HttpRunner. DO'NOT EDIT! # FROM: {{ testcase_path }} @@ -39,7 +45,9 @@ def convert_testcase_path(testcase_path: Text) -> Tuple[Text, Text]: file_suffix = file_suffix.lower() if file_suffix not in [".json", ".yml", ".yaml"]: - raise exceptions.ParamsError("") + 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) @@ -51,18 +59,40 @@ def convert_testcase_path(testcase_path: Text) -> Tuple[Text, Text]: return testcase_python_path, name_in_title_case -def make_testcase(testcase_path: str) -> Union[str, None]: - logger.info(f"start to make testcase: {testcase_path}") +def format_pytest_with_black(python_paths: List[Text]): + logger.info("format pytest cases with black ...") try: - testcase, _ = load_testcase_file(testcase_path) - except TestCaseFormatError: - return None + subprocess.run(["black", *python_paths]) + except subprocess.CalledProcessError as ex: + logger.error(ex) + + +def make_testcase(testcase: Dict) -> Union[str, None]: + """convert valid testcase dict to pytest file path""" + try: + # validate testcase format + load_testcase(testcase) + except exceptions.TestCaseFormatError as ex: + logger.error(f"TestCaseFormatError: {ex}") + raise + + testcase_path = testcase["config"]["path"] + logger.info(f"start to make testcase: {testcase_path}") template = jinja2.Template(__TMPL__) testcase_python_path, name_in_title_case = convert_testcase_path(testcase_path) config = testcase["config"] + + config.setdefault("variables", {}) + if isinstance(config["variables"], Text): + # get variables by function, e.g. ${get_variables()} + project_meta = load_project_meta(testcase_path) + config["variables"] = parse_data( + config["variables"], {}, project_meta.functions + ) + config["path"] = testcase_python_path data = { "testcase_path": testcase_path, @@ -79,42 +109,119 @@ def make_testcase(testcase_path: str) -> Union[str, None]: return testcase_python_path -def format_with_black(tests_path: Text): - logger.info("format testcases with black ...") - tests_path, _ = convert_testcase_path(tests_path) - +def make_testsuite(testsuite: Dict) -> List[Text]: + """convert valid testsuite dict to pytest folder with testcases""" try: - subprocess.run(["black", tests_path]) - except subprocess.CalledProcessError as ex: - logger.error(ex) + # validate testcase format + load_testsuite(testsuite) + except exceptions.TestSuiteFormatError as ex: + logger.error(f"TestSuiteFormatError: {ex}") + raise + + config = testsuite["config"] + testsuite_path = config["path"] + project_meta = load_project_meta(testsuite_path) + project_working_directory = project_meta.PWD + + testsuite_variables = config.get("variables", {}) + if isinstance(testsuite_variables, Text): + # get variables by function, e.g. ${get_variables()} + testsuite_variables = parse_data( + testsuite_variables, {}, project_meta.functions + ) + + logger.info(f"start to make testsuite: {testsuite_path}") + + # create directory with testsuite file name, put its testcases under this directory + testsuite_dir = testsuite_path.replace(".", "_") + os.makedirs(testsuite_dir, exist_ok=True) + + testcase_files = [] + + for testcase in testsuite["testcases"]: + # get referenced testcase content + testcase_file = testcase["testcase"] + testcase_path = os.path.join(project_working_directory, testcase_file) + testcase_dict = load_test_file(testcase_path) + testcase_dict.setdefault("config", {}) + testcase_dict["config"]["path"] = os.path.join( + testsuite_dir, os.path.basename(testcase_path) + ) + + # override testcase name + testcase_dict["config"]["name"] = testcase["name"] + # override base_url + base_url = testsuite["config"].get("base_url") or testcase.get("base_url") + if base_url: + testcase_dict["config"]["base_url"] = base_url + # override variables + testcase_dict["config"].setdefault("variables", {}) + testcase_dict["config"]["variables"].update(testcase.get("variables", {})) + testcase_dict["config"]["variables"].update(testsuite_variables) + + # make testcase + testcase_path = make_testcase(testcase_dict) + testcase_files.append(testcase_path) + + return testcase_files -def make(tests_path: Text) -> List: - testcases = [] +def __make(tests_path: Text) -> List: + test_files = [] if os.path.isdir(tests_path): files_list = load_folder_files(tests_path) - testcases.extend(files_list) + test_files.extend(files_list) elif os.path.isfile(tests_path): - testcases.append(tests_path) + test_files.append(tests_path) else: raise exceptions.TestcaseNotFound(f"Invalid tests path: {tests_path}") testcase_path_list = [] - for testcase_path in testcases: - testcase_path = make_testcase(testcase_path) - if not testcase_path: + for test_file in test_files: + try: + test_content = load_test_file(test_file) + test_content.setdefault("config", {})["path"] = test_file + except (exceptions.FileNotFound, exceptions.FileFormatError) as ex: + logger.warning(ex) continue - testcase_path_list.append(testcase_path) - format_with_black(tests_path) + # testcase + if "teststeps" in test_content: + try: + testcase_file = make_testcase(test_content) + except exceptions.TestCaseFormatError: + continue + + testcase_path_list.append(testcase_file) + + # testsuite + elif "testcases" in test_content: + try: + testcase_files = make_testsuite(test_content) + except exceptions.TestSuiteFormatError: + continue + + testcase_path_list.extend(testcase_files) + + # invalid format + else: + raise exceptions.FileFormatError( + f"test file is neither testcase nor testsuite: {test_file}" + ) + + if not testcase_path_list: + logger.warning(f"No valid testcase generated on {tests_path}") + return [] + return testcase_path_list def main_make(tests_paths: List[Text]) -> List: testcase_path_list = [] for tests_path in tests_paths: - testcase_path_list.extend(make(tests_path)) + testcase_path_list.extend(__make(tests_path)) + format_pytest_with_black(testcase_path_list) return testcase_path_list diff --git a/httprunner/ext/make/make_test.py b/httprunner/ext/make/make_test.py index 44e309a6..7145807c 100644 --- a/httprunner/ext/make/make_test.py +++ b/httprunner/ext/make/make_test.py @@ -1,13 +1,14 @@ import unittest -from httprunner.ext.make import make_testcase, main_make, convert_testcase_path + +from httprunner.ext.make import main_make, convert_testcase_path class TestLoader(unittest.TestCase): def test_make_testcase(self): - path = "examples/postman_echo/request_methods/request_with_variables.yml" - testcase_python_path = make_testcase(path) + path = ["examples/postman_echo/request_methods/request_with_variables.yml"] + testcase_python_list = main_make(path) self.assertEqual( - testcase_python_path, + testcase_python_list[0], "examples/postman_echo/request_methods/request_with_variables_test.py", ) @@ -21,42 +22,47 @@ class TestLoader(unittest.TestCase): 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")[0], "mubu_login_test.py" ) self.assertEqual( convert_testcase_path("/path/to/mubu.login.yml")[0], - "/path/to/mubu_login_test.py" + "/path/to/mubu_login_test.py", ) self.assertEqual( convert_testcase_path("/path/to 2/mubu.login.yml")[0], - "/path/to 2/mubu_login_test.py" + "/path/to 2/mubu_login_test.py", ) self.assertEqual( - convert_testcase_path("/path/to 2/mubu.login.yml")[1], - "MubuLogin" + convert_testcase_path("/path/to 2/mubu.login.yml")[1], "MubuLogin" ) self.assertEqual( - convert_testcase_path("mubu login.yml")[0], - "mubu_login_test.py" + convert_testcase_path("mubu login.yml")[0], "mubu_login_test.py" ) self.assertEqual( - convert_testcase_path("/path/to 2/mubu login.yml")[1], - "MubuLogin" + 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" + "/path/to 2/mubu_login_test.py", ) self.assertEqual( - convert_testcase_path("/path/to 2/mubu-login.yml")[1], - "MubuLogin" + 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" + "/path/to 2/幕布login_test.py", ) - self.assertEqual( - convert_testcase_path("/path/to/幕布login.yml")[1], - "幕布Login" + 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"] + 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", + testcase_python_list, + ) + self.assertIn( + "examples/postman_echo/request_methods/demo_testsuite_yml/request_with_testcase_reference_test.py", + testcase_python_list, ) diff --git a/httprunner/loader.py b/httprunner/loader.py index 96de4c8f..937fabb0 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 +from httprunner.schema import TestCase, ProjectMeta, TestSuite try: # PyYAML version >= 5.1 @@ -34,7 +34,8 @@ def _load_yaml_file(yaml_file: Text) -> Dict: try: yaml_content = yaml.load(stream) except yaml.YAMLError as ex: - logger.error(str(ex)) + err_msg = f"YAMLError:\nfile: {yaml_file}\nerror: {ex}" + logger.error(err_msg) raise exceptions.FileFormatError return yaml_content @@ -46,42 +47,65 @@ def _load_json_file(json_file: Text) -> Dict: with io.open(json_file, encoding="utf-8") as data_file: try: json_content = json.load(data_file) - except json.JSONDecodeError: - err_msg = f"JSONDecodeError: JSON file format error: {json_file}" + except json.JSONDecodeError as ex: + err_msg = f"JSONDecodeError:\nfile: {json_file}\nerror: {ex}" logger.error(err_msg) raise exceptions.FileFormatError(err_msg) return json_content -def load_testcase_file(testcase_file: Text) -> Tuple[Dict, TestCase]: - """load testcase file and validate with pydantic model""" - if not os.path.isfile(testcase_file): - raise exceptions.FileNotFound(f"testcase file not exists: {testcase_file}") +def load_test_file(test_file: Text) -> Dict: + """load testcase/testsuite file content""" + if not os.path.isfile(test_file): + raise exceptions.FileNotFound(f"test file not exists: {test_file}") - file_suffix = os.path.splitext(testcase_file)[1].lower() + file_suffix = os.path.splitext(test_file)[1].lower() if file_suffix == ".json": - testcase_content = _load_json_file(testcase_file) + test_file_content = _load_json_file(test_file) elif file_suffix in [".yaml", ".yml"]: - testcase_content = _load_yaml_file(testcase_file) + test_file_content = _load_yaml_file(test_file) else: # '' or other suffix raise exceptions.FileFormatError( - f"testcase file should be YAML/JSON format, invalid testcase file: {testcase_file}" + f"testcase/testsuite file should be YAML/JSON format, invalid format file: {test_file}" ) + return test_file_content + + +def load_testcase(testcase: Dict) -> TestCase: + path = testcase["config"]["path"] try: # validate with pydantic TestCase model - testcase_obj = TestCase.parse_obj(testcase_content) + testcase_obj = TestCase.parse_obj(testcase) except ValidationError as ex: - err_msg = f"Invalid testcase format: {testcase_file}" - logger.error(f"{err_msg}\n{ex}") + err_msg = f"TestCase ValidationError:\nfile: {path}\nerror: {ex}" + logger.error(err_msg) raise exceptions.TestCaseFormatError(err_msg) - testcase_content["config"]["path"] = testcase_file - testcase_obj.config.path = testcase_file + return testcase_obj - return testcase_content, testcase_obj + +def load_testcase_file(testcase_file: Text) -> TestCase: + """load testcase file and validate with pydantic model""" + testcase_content = load_test_file(testcase_file) + testcase_content.setdefault("config", {})["path"] = testcase_file + testcase_obj = load_testcase(testcase_content) + return testcase_obj + + +def load_testsuite(testsuite: Dict) -> TestSuite: + path = testsuite["config"]["path"] + try: + # validate with pydantic TestCase model + testsuite_obj = TestSuite.parse_obj(testsuite) + except ValidationError as ex: + err_msg = f"TestSuite ValidationError:\nfile: {path}\nerror: {ex}" + logger.error(err_msg) + raise exceptions.TestSuiteFormatError(err_msg) + + return testsuite_obj def load_dot_env_file(dot_env_path: Text) -> Dict: @@ -349,6 +373,14 @@ def init_project_working_directory(test_path: Text) -> Tuple[Text, Text]: return debugtalk_path, project_working_directory +def get_project_working_directory(test_path: Text) -> Text: + global project_working_directory + if not project_working_directory: + init_project_working_directory(test_path) + + return project_working_directory + + def load_debugtalk_functions() -> Dict[Text, Callable]: """ load project debugtalk.py module functions debugtalk.py should be located in project working directory. diff --git a/httprunner/loader_test.py b/httprunner/loader_test.py index dac77804..c5e2ab36 100644 --- a/httprunner/loader_test.py +++ b/httprunner/loader_test.py @@ -7,14 +7,10 @@ from httprunner import exceptions, loader class TestLoader(unittest.TestCase): def test_load_testcase_file(self): path = "examples/postman_echo/request_methods/request_with_variables.yml" - testcase_json, testcase_obj = loader.load_testcase_file(path) - self.assertEqual( - testcase_json["config"]["name"], "request methods testcase with variables" - ) + testcase_obj = loader.load_testcase_file(path) self.assertEqual( testcase_obj.config.name, "request methods testcase with variables" ) - self.assertEqual(len(testcase_json["teststeps"]), 3) self.assertEqual(len(testcase_obj.teststeps), 3) def test_load_json_file_file_format_error(self): diff --git a/httprunner/response.py b/httprunner/response.py index b268d4b5..bc1f6891 100644 --- a/httprunner/response.py +++ b/httprunner/response.py @@ -124,6 +124,7 @@ class ResponseObject(object): self.resp_obj_meta = { "status_code": resp_obj.status_code, "headers": resp_obj.headers, + "cookies": dict(resp_obj.cookies), "body": body, } self.validation_results: Dict = {} @@ -163,6 +164,8 @@ class ResponseObject(object): # check item check_item = u_validator["check"] + # TODO: validate variable or function + # check_item = parse_data(check_item, variables_mapping, functions_mapping) check_value = jmespath.search(check_item, self.resp_obj_meta) check_value = parse_string_value(check_value) diff --git a/httprunner/runner.py b/httprunner/runner.py index ab833ead..df2824f5 100644 --- a/httprunner/runner.py +++ b/httprunner/runner.py @@ -65,12 +65,8 @@ class HttpRunner(object): method = parsed_request_dict.pop("method") url_path = parsed_request_dict.pop("url") url = build_url(self.config.base_url, url_path) - parsed_request_dict["json"] = parsed_request_dict.pop("req_json", {}) - logger.info(f"{method} {url}") - logger.debug(f"request kwargs(raw): {parsed_request_dict}") - # request self.__session = self.__session or HttpSession() resp = self.__session.request(method, url, **parsed_request_dict) @@ -148,7 +144,7 @@ class HttpRunner(object): def __run_step(self, step: TStep): """run teststep, teststep maybe a request or referenced testcase""" - logger.info(f"run step: {step.name}") + logger.info(f"run step begin: {step.name} >>>>>>") if step.request: step_data = self.__run_step_request(step) @@ -160,6 +156,7 @@ class HttpRunner(object): ) self.__step_datas.append(step_data) + logger.info(f"run step end: {step.name} <<<<<<\n") return step_data.export def run(self, testcase: TestCase): @@ -209,7 +206,7 @@ class HttpRunner(object): if not os.path.isfile(path): raise exceptions.ParamsError(f"Invalid testcase path: {path}") - _, testcase_obj = load_testcase_file(path) + testcase_obj = load_testcase_file(path) return self.run(testcase_obj) def get_step_datas(self) -> List[StepData]: diff --git a/httprunner/runner_test.py b/httprunner/runner_test.py index ca84584f..39d9d978 100644 --- a/httprunner/runner_test.py +++ b/httprunner/runner_test.py @@ -9,11 +9,11 @@ class TestHttpRunner(unittest.TestCase): def test_run_testcase_by_path_request_only(self): self.runner.run_path( - "examples/postman_echo/request_methods/request_with_variables.yml" + "examples/postman_echo/request_methods/request_with_functions.yml" ) result = self.runner.get_summary() self.assertTrue(result.success) - self.assertEqual(result.name, "request methods testcase with variables") + self.assertEqual(result.name, "request methods testcase with functions") self.assertEqual(result.step_datas[0].name, "get with params") self.assertEqual(len(result.step_datas), 3) @@ -24,5 +24,5 @@ class TestHttpRunner(unittest.TestCase): result = self.runner.get_summary() self.assertTrue(result.success) self.assertEqual(result.name, "request methods testcase: reference testcase") - self.assertEqual(result.step_datas[0].name, "request with variables") + self.assertEqual(result.step_datas[0].name, "request with functions") self.assertEqual(len(result.step_datas), 1) diff --git a/httprunner/schema.py b/httprunner/schema.py index 4ea1e797..feae1fb5 100644 --- a/httprunner/schema.py +++ b/httprunner/schema.py @@ -36,7 +36,8 @@ class TConfig(BaseModel): name: Name verify: Verify = False base_url: BaseUrl = "" - variables: VariablesMapping = {} + # Text: prepare variables in debugtalk.py, ${get_variable()} + variables: Union[VariablesMapping, Text] = {} setup_hooks: Hook = [] teardown_hooks: Hook = [] export: Export = [] @@ -160,6 +161,18 @@ class PlatformInfo(BaseModel): platform: Text +class TestCaseRef(BaseModel): + name: Text + base_url: Text = "" + testcase: Text + variables: VariablesMapping = {} + + +class TestSuite(BaseModel): + config: TConfig + testcases: List[TestCaseRef] + + class Stat(BaseModel): total: int = 0 success: int = 0 diff --git a/pyproject.toml b/pyproject.toml index ce3b3c40..6614464f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "httprunner" -version = "3.0.3" +version = "3.0.4" description = "One-stop solution for HTTP(S) testing." license = "Apache-2.0" readme = "README.md"