diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index e7a287da..46d791c1 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,17 @@ # Release History +## 3.0.10 (2020-06-07) + +**Added** + +- feat: implement step setup/teardown hooks +- feat: support alter response in teardown hooks + +**Fixed** + +- fix: ensure upload ready +- fix: add ExtendJSONEncoder to safely dump json data with python object, such as MultipartEncoder + ## 3.0.9 (2020-06-07) **Fixed** diff --git a/examples/httpbin/basic_test.py b/examples/httpbin/basic_test.py index bfa47499..4fe82d84 100644 --- a/examples/httpbin/basic_test.py +++ b/examples/httpbin/basic_test.py @@ -1,4 +1,4 @@ -# NOTE: Generated By HttpRunner v3.0.9 +# NOTE: Generated By HttpRunner v3.0.10 # FROM: examples/httpbin/basic.yml from httprunner import HttpRunner, Config, Step, RunRequest, RunTestCase diff --git a/examples/httpbin/debugtalk.py b/examples/httpbin/debugtalk.py index b31deacc..ef78aee0 100644 --- a/examples/httpbin/debugtalk.py +++ b/examples/httpbin/debugtalk.py @@ -126,7 +126,7 @@ def setup_hook_httpntlmauth(request): def alter_response(response): response.status_code = 500 response.headers["Content-Type"] = "html/text" - response.json["headers"]["Host"] = "127.0.0.1:8888" + response.body["headers"]["Host"] = "127.0.0.1:8888" response.new_attribute = "new_attribute_value" response.new_attribute_dict = {"key": 123} diff --git a/examples/httpbin/hooks.yml b/examples/httpbin/hooks.yml index 770d9926..250a7a8f 100644 --- a/examples/httpbin/hooks.yml +++ b/examples/httpbin/hooks.yml @@ -31,7 +31,7 @@ teststeps: teardown_hooks: - ${alter_response($response)} validate: - - eq: ["status_code", 200] - # TODO: implement hooks -# - eq: [body.headers."Content-Type", "html/text"] - - eq: [body.headers.Host, "httpbin.org"] + - eq: ["status_code", 500] + - eq: [headers."Content-Type", "html/text"] + - eq: [body.headers."Content-Type", "application/json"] + - eq: [body.headers.Host, "127.0.0.1:8888"] diff --git a/examples/httpbin/hooks_test.py b/examples/httpbin/hooks_test.py index cec6ad42..5ae2da61 100644 --- a/examples/httpbin/hooks_test.py +++ b/examples/httpbin/hooks_test.py @@ -1,4 +1,4 @@ -# NOTE: Generated By HttpRunner v3.0.9 +# NOTE: Generated By HttpRunner v3.0.10 # FROM: examples/httpbin/hooks.yml from httprunner import HttpRunner, Config, Step, RunRequest, RunTestCase @@ -11,7 +11,10 @@ class TestCaseHooks(HttpRunner): Step( RunRequest("headers") .with_variables(**{"a": 123}) + .setup_hook("${setup_hook_add_kwargs($request)}") + .setup_hook("${setup_hook_remove_kwargs($request)}") .get("/headers") + .teardown_hook("${teardown_hook_sleep_N_secs($response, 1)}") .validate() .assert_equal("status_code", 200) .assert_contained_by("body.headers.Host", "${get_httpbin_server()}") @@ -19,9 +22,12 @@ class TestCaseHooks(HttpRunner): Step( RunRequest("alter response") .get("/headers") + .teardown_hook("${alter_response($response)}") .validate() - .assert_equal("status_code", 200) - .assert_equal("body.headers.Host", "httpbin.org") + .assert_equal("status_code", 500) + .assert_equal('headers."Content-Type"', "html/text") + .assert_equal('body.headers."Content-Type"', "application/json") + .assert_equal("body.headers.Host", "127.0.0.1:8888") ), ] diff --git a/examples/httpbin/load_image_test.py b/examples/httpbin/load_image_test.py index cca17a7c..cd21fb69 100644 --- a/examples/httpbin/load_image_test.py +++ b/examples/httpbin/load_image_test.py @@ -1,4 +1,4 @@ -# NOTE: Generated By HttpRunner v3.0.9 +# NOTE: Generated By HttpRunner v3.0.10 # 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 6ed390a8..fa100f85 100644 --- a/examples/httpbin/upload_test.py +++ b/examples/httpbin/upload_test.py @@ -1,4 +1,4 @@ -# NOTE: Generated By HttpRunner v3.0.9 +# NOTE: Generated By HttpRunner v3.0.10 # 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 093d5075..281a94fb 100644 --- a/examples/httpbin/validate_test.py +++ b/examples/httpbin/validate_test.py @@ -1,4 +1,4 @@ -# NOTE: Generated By HttpRunner v3.0.9 +# NOTE: Generated By HttpRunner v3.0.10 # FROM: examples/httpbin/validate.yml from httprunner import HttpRunner, Config, Step, RunRequest, RunTestCase 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 da34ceca..0944f1c5 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 @@ -# NOTE: Generated By HttpRunner v3.0.9 +# NOTE: Generated By HttpRunner v3.0.10 # 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 af45b5ba..23fc387a 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 @@ -# NOTE: Generated By HttpRunner v3.0.9 +# NOTE: Generated By HttpRunner v3.0.10 # FROM: examples/postman_echo/request_methods/request_with_testcase_reference.yml import os diff --git a/examples/postman_echo/request_methods/hardcode_test.py b/examples/postman_echo/request_methods/hardcode_test.py index ffa7d91a..cc1ddb3b 100644 --- a/examples/postman_echo/request_methods/hardcode_test.py +++ b/examples/postman_echo/request_methods/hardcode_test.py @@ -1,4 +1,4 @@ -# NOTE: Generated By HttpRunner v3.0.9 +# NOTE: Generated By HttpRunner v3.0.10 # 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 159d93b6..5ee630e0 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 @@ -# NOTE: Generated By HttpRunner v3.0.9 +# NOTE: Generated By HttpRunner v3.0.10 # 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_test.py b/examples/postman_echo/request_methods/request_with_testcase_reference_test.py index ece17405..27b7fcb0 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 @@ -# NOTE: Generated By HttpRunner v3.0.9 +# NOTE: Generated By HttpRunner v3.0.10 # FROM: examples/postman_echo/request_methods/request_with_testcase_reference.yml import os 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 aab91e97..5f8c0484 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 @@ -# NOTE: Generated By HttpRunner v3.0.9 +# NOTE: Generated By HttpRunner v3.0.10 # 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 5a730f1e..185896af 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 @@ -# NOTE: Generated By HttpRunner v3.0.9 +# NOTE: Generated By HttpRunner v3.0.10 # 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 26c2467f..05daa1ae 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 @@ -# NOTE: Generated By HttpRunner v3.0.9 +# NOTE: Generated By HttpRunner v3.0.10 # 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 40f1a9c5..3c7e46bb 100644 --- a/httprunner/__init__.py +++ b/httprunner/__init__.py @@ -1,4 +1,4 @@ -__version__ = "3.0.9" +__version__ = "3.0.10" __description__ = "One-stop solution for HTTP(S) testing." from httprunner.runner import HttpRunner diff --git a/httprunner/compat.py b/httprunner/compat.py index 35b996f9..20c442f4 100644 --- a/httprunner/compat.py +++ b/httprunner/compat.py @@ -264,7 +264,7 @@ import time import pytest from loguru import logger -from httprunner.utils import get_platform +from httprunner.utils import get_platform, ExtendJSONEncoder @pytest.fixture(scope="session", autouse=True) @@ -316,7 +316,7 @@ def session_fixture(request): os.makedirs(summary_dir, exist_ok=True) with open(summary_path, "w", encoding="utf-8") as f: - json.dump(summary, f, indent=4) + json.dump(summary, f, indent=4, ensure_ascii=False, cls=ExtendJSONEncoder) logger.info(f"generated task summary: {summary_path}") diff --git a/httprunner/ext/uploader/__init__.py b/httprunner/ext/uploader/__init__.py index 4b242502..bcefd6a8 100644 --- a/httprunner/ext/uploader/__init__.py +++ b/httprunner/ext/uploader/__init__.py @@ -60,6 +60,22 @@ except ModuleNotFoundError: UPLOAD_READY = False +def ensure_upload_ready(): + if UPLOAD_READY: + return + + msg = """ + uploader extension dependencies uninstalled, install first and try again. + install with pip: + $ pip install requests_toolbelt filetype + + or you can install httprunner with optional upload dependencies: + $ pip install "httprunner[upload]" + """ + logger.error(msg) + sys.exit(1) + + def prepare_upload_step(step: TStep, functions: FunctionsMapping) -> "NoReturn": """ preprocess for upload test replace `upload` info with MultipartEncoder @@ -86,15 +102,7 @@ def prepare_upload_step(step: TStep, functions: FunctionsMapping) -> "NoReturn": if not step.request.upload: return - if not UPLOAD_READY: - msg = """ -uploader extension dependencies uninstalled, install first and try again. -install with pip: -$ pip install requests_toolbelt filetype -""" - logger.error(msg) - sys.exit(1) - + ensure_upload_ready() params_list = [] for key, value in step.request.upload.items(): step.variables[key] = value @@ -126,6 +134,7 @@ def multipart_encoder(**kwargs): else: return "text/html" + ensure_upload_ready() fields_dict = {} for key, value in kwargs.items(): @@ -165,4 +174,5 @@ def multipart_content_type(m_encoder) -> Text: content type """ + ensure_upload_ready() return m_encoder.content_type diff --git a/httprunner/make.py b/httprunner/make.py index 8dcdbb5c..967e8d74 100644 --- a/httprunner/make.py +++ b/httprunner/make.py @@ -228,6 +228,17 @@ def make_teststep_chain_style(teststep: Dict) -> Text: variables = teststep["variables"] step_info += f".with_variables(**{variables})" + if "setup_hooks" in teststep: + setup_hooks = teststep["setup_hooks"] + for hook in setup_hooks: + if isinstance(hook, Text): + step_info += f'.setup_hook("{hook}")' + elif isinstance(hook, Dict) and len(hook) == 1: + assign_var_name, hook_content = list(hook.items())[0] + step_info += f'.setup_hook("{hook}", "{assign_var_name}")' + else: + raise exceptions.TestCaseFormatError(f"Invalid setup hook: {hook}") + if teststep.get("request"): step_info += make_request_chain_style(teststep["request"]) elif teststep.get("testcase"): @@ -235,6 +246,17 @@ def make_teststep_chain_style(teststep: Dict) -> Text: call_ref_testcase = f".call({testcase})" step_info += call_ref_testcase + if "teardown_hooks" in teststep: + teardown_hooks = teststep["teardown_hooks"] + for hook in teardown_hooks: + if isinstance(hook, Text): + step_info += f'.teardown_hook("{hook}")' + elif isinstance(hook, Dict) and len(hook) == 1: + assign_var_name, hook_content = list(hook.items())[0] + step_info += f'.teardown_hook("{hook}", "{assign_var_name}")' + else: + raise exceptions.TestCaseFormatError(f"Invalid teardown hook: {hook}") + if "extract" in teststep: # request step step_info += ".extract()" diff --git a/httprunner/models.py b/httprunner/models.py index dcfd40f2..61061bff 100644 --- a/httprunner/models.py +++ b/httprunner/models.py @@ -15,7 +15,7 @@ FunctionsMapping = Dict[Text, Callable] Headers = Dict[Text, Text] Cookies = Dict[Text, Text] Verify = bool -Hook = List[Text] +Hooks = List[Union[Text, Dict[Text, Text]]] Export = List[Text] Validators = List[Dict] Env = Dict[Text, Any] @@ -37,8 +37,8 @@ class TConfig(BaseModel): base_url: BaseUrl = "" # Text: prepare variables in debugtalk.py, ${gen_variables()} variables: Union[VariablesMapping, Text] = {} - setup_hooks: Hook = [] - teardown_hooks: Hook = [] + # setup_hooks: Hooks = [] + # teardown_hooks: Hooks = [] export: Export = [] path: Text = None @@ -64,8 +64,8 @@ class TStep(BaseModel): request: Union[TRequest, None] = None testcase: Union[Text, Callable, None] = None variables: VariablesMapping = {} - setup_hooks: Hook = [] - teardown_hooks: Hook = [] + setup_hooks: Hooks = [] + teardown_hooks: Hooks = [] # used to extract request's response field extract: VariablesMapping = {} # used to export session variables from referenced testcase diff --git a/httprunner/response.py b/httprunner/response.py index 41171271..1efa945d 100644 --- a/httprunner/response.py +++ b/httprunner/response.py @@ -4,6 +4,7 @@ import jmespath import requests from loguru import logger +from httprunner import exceptions from httprunner.exceptions import ValidationFailure, ParamsError from httprunner.parser import parse_data, parse_string_value, get_mapping_function from httprunner.models import VariablesMapping, Validators, FunctionsMapping @@ -109,20 +110,36 @@ class ResponseObject(object): """ self.resp_obj = resp_obj - - try: - body = resp_obj.json() - except ValueError: - body = resp_obj.content - - 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 = {} + def __getattr__(self, key): + if key in ["json", "content", "body"]: + try: + value = self.resp_obj.json() + except ValueError: + value = self.resp_obj.content + elif key == "cookies": + value = self.resp_obj.cookies.get_dict() + else: + try: + value = getattr(self.resp_obj, key) + except AttributeError: + err_msg = "ResponseObject does not have attribute: {}".format(key) + logger.error(err_msg) + raise exceptions.ParamsError(err_msg) + + self.__dict__[key] = value + return value + + @property + def resp_obj_meta(self): + return { + "status_code": self.status_code, + "headers": self.headers, + "cookies": self.cookies, + "body": self.body, + } + def extract(self, extractors: Dict[Text, Text]) -> Dict[Text, Any]: if not extractors: return {} diff --git a/httprunner/runner.py b/httprunner/runner.py index c050deea..7dc18242 100644 --- a/httprunner/runner.py +++ b/httprunner/runner.py @@ -2,7 +2,7 @@ import os import time import uuid from datetime import datetime -from typing import List, Dict, Text, NoReturn +from typing import List, Dict, Text, NoReturn, Union try: import allure @@ -31,6 +31,7 @@ from httprunner.models import ( TestCaseInOut, ProjectMeta, TestCase, + Hooks, ) @@ -44,7 +45,7 @@ class HttpRunner(object): __project_meta: ProjectMeta = None __case_id: Text = "" __export: List[Text] = [] - __step_datas: List[StepData] = None + __step_datas: List[StepData] = [] __session: HttpSession = None __session_variables: VariablesMapping = {} # time @@ -86,6 +87,52 @@ class HttpRunner(object): self.__export = export return self + def __call_hooks( + self, hooks: Hooks, step_variables: VariablesMapping, hook_type: Text, + ) -> NoReturn: + """ call hook actions. + + Args: + hooks (list): each hook in hooks list maybe in two format. + + format1 (str): only call hook functions. + ${func()} + format2 (dict): assignment, the value returned by hook function will be assigned to variable. + {"var": "${func()}"} + + step_variables: current step variables to call hook, include two special variables + + request: parsed request dict + response: ResponseObject for current response + + hook_type: setup/teardown + + """ + logger.debug(f"call {hook_type} hook actions.") + + if not isinstance(hooks, List): + logger.error(f"Invalid hooks format: {hooks}") + return + + for hook in hooks: + if isinstance(hook, Text): + # format 1: ["${func()}"] + logger.debug(f"call hook function: {hook}") + parse_data(hook, step_variables, self.__project_meta.functions) + elif isinstance(hook, Dict) and len(hook) == 1: + # format 2: {"var": "${func()}"} + var_name, hook_content = list(hook.items())[0] + hook_content_eval = parse_data( + hook_content, step_variables, self.__project_meta.functions + ) + logger.debug( + f"call hook function: {hook_content}, got value: {hook_content_eval}" + ) + logger.debug(f"assign variable: {var_name} = {hook_content_eval}") + step_variables[var_name] = hook_content_eval + else: + logger.error(f"Invalid hook format: {hook}") + def __run_step_request(self, step: TStep) -> StepData: """run teststep: request""" step_data = StepData(name=step.name) @@ -101,6 +148,11 @@ class HttpRunner(object): "HRUN-Request-ID", f"HRUN-{self.__case_id}-{str(int(time.time() * 1000))[-6:]}", ) + step.variables["request"] = parsed_request_dict + + # setup hooks + if step.setup_hooks: + self.__call_hooks(step.setup_hooks, step.variables, "setup") # prepare arguments method = parsed_request_dict.pop("method") @@ -112,6 +164,11 @@ class HttpRunner(object): # request resp = self.__session.request(method, url, **parsed_request_dict) resp_obj = ResponseObject(resp) + step.variables["response"] = resp_obj + + # teardown hooks + if step.teardown_hooks: + self.__call_hooks(step.teardown_hooks, step.variables, "teardown") def log_req_resp_details(): err_msg = "\n{} DETAILED REQUEST & RESPONSE {}\n".format("*" * 32, "*" * 32) diff --git a/httprunner/testcase.py b/httprunner/testcase.py index 91601df9..03f8e68a 100644 --- a/httprunner/testcase.py +++ b/httprunner/testcase.py @@ -265,8 +265,15 @@ class RequestWithOptionalArgs(object): self.__step_context.request.upload.update(file_info) return self - # def hooks(self): - # pass + def teardown_hook( + self, hook: Text, assign_var_name: Text = None + ) -> "RequestWithOptionalArgs": + if assign_var_name: + self.__step_context.teardown_hooks.append({assign_var_name: hook}) + else: + self.__step_context.teardown_hooks.append(hook) + + return self def extract(self) -> StepRequestExtraction: return StepRequestExtraction(self.__step_context) @@ -286,6 +293,14 @@ class RunRequest(object): self.__step_context.variables.update(variables) return self + def setup_hook(self, hook: Text, assign_var_name: Text = None) -> "RunRequest": + if assign_var_name: + self.__step_context.setup_hooks.append({assign_var_name: hook}) + else: + self.__step_context.setup_hooks.append(hook) + + return self + def get(self, url: Text) -> RequestWithOptionalArgs: self.__step_context.request = TRequest(method=MethodEnum.GET, url=url) return RequestWithOptionalArgs(self.__step_context) diff --git a/httprunner/utils.py b/httprunner/utils.py index 02d9b127..117dfbbd 100644 --- a/httprunner/utils.py +++ b/httprunner/utils.py @@ -212,3 +212,14 @@ def ensure_file_path_valid(file_path: Text) -> Text: new_file_path = os.path.join(os.getcwd(), f"{os.sep.join(path_names)}{file_suffix}") return new_file_path + + +class ExtendJSONEncoder(json.JSONEncoder): + """ especially used to safely dump json data with python object, such as MultipartEncoder + """ + + def default(self, obj): + try: + return super(ExtendJSONEncoder, self).default(obj) + except (UnicodeDecodeError, TypeError): + return repr(obj) diff --git a/pyproject.toml b/pyproject.toml index 23324ab6..548b4bcf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "httprunner" -version = "3.0.9" +version = "3.0.10" description = "One-stop solution for HTTP(S) testing." license = "Apache-2.0" readme = "README.md" @@ -44,8 +44,8 @@ requests-toolbelt = {version = "^0.9.1", optional = true} filetype = {version = "^1.0.7", optional = true} [tool.poetry.extras] -allure = ["allure-pytest"] # poetry install -E allure -upload = ["requests-toolbelt", "filetype"] # poetry install -E upload +allure = ["allure-pytest"] # pip install "httprunner[allure]", poetry install -E allure +upload = ["requests-toolbelt", "filetype"] # pip install "httprunner[upload]", poetry install -E upload [tool.poetry.dev-dependencies] coverage = "^4.5.4" diff --git a/tests/utils_test.py b/tests/utils_test.py index b15a5c7f..9903c1d9 100644 --- a/tests/utils_test.py +++ b/tests/utils_test.py @@ -1,8 +1,10 @@ +import decimal +import json import os import unittest from httprunner import loader, utils -from httprunner.utils import ensure_file_path_valid +from httprunner.utils import ensure_file_path_valid, ExtendJSONEncoder class TestUtils(unittest.TestCase): @@ -116,3 +118,14 @@ class TestUtils(unittest.TestCase): ensure_file_path_valid("examples/postman_echo/request_methods/"), os.path.join(os.getcwd(), "examples/postman_echo/request_methods"), ) + + def test_safe_dump_json(self): + class A(object): + pass + + data = {"a": A(), "b": decimal.Decimal("1.45")} + + with self.assertRaises(TypeError): + json.dumps(data) + + json.dumps(data, cls=ExtendJSONEncoder)