Merge pull request #949 from httprunner/v3

## 3.1.2 (2020-06-29)

**Fixed**

- fix: missing setup/teardown hooks for referenced testcase
- fix: compatibility for `black` on Android termux that does not support multiprocessing well
- fix: mishandling of request header `Content-Length` for GET method
- fix: validate with jmespath containing variable or function, e.g. `body.locations[$index].name`

**Changed**

- change: import locust at beginning to monkey patch all modules
- change: open file in binary mode
This commit is contained in:
debugtalk
2020-06-29 20:41:22 +08:00
committed by GitHub
31 changed files with 171 additions and 55 deletions

View File

@@ -1,6 +1,12 @@
name: integration_test name: integration_test
on: [push] on:
push:
branches:
- master
pull_request:
branches:
- master
jobs: jobs:
integration_test: integration_test:

View File

@@ -1,6 +1,12 @@
name: unittest name: unittest
on: [push] on:
push:
branches:
- master
pull_request:
branches:
- master
jobs: jobs:
unittest: unittest:

View File

@@ -1,5 +1,19 @@
# Release History # Release History
## 3.1.2 (2020-06-29)
**Fixed**
- fix: missing setup/teardown hooks for referenced testcase
- fix: compatibility for `black` on Android termux that does not support multiprocessing well
- fix: mishandling of request header `Content-Length` for GET method
- fix: validate with jmespath containing variable or function, e.g. `body.locations[$index].name`
**Changed**
- change: import locust at beginning to monkey patch all modules
- change: open file in binary mode
## 3.1.1 (2020-06-23) ## 3.1.1 (2020-06-23)
**Added** **Added**
@@ -8,7 +22,7 @@
**Fixed** **Fixed**
- fix #942: type_match None - fix: ValueError when type_match None
- fix: override referenced testcase export in teststep - fix: override referenced testcase export in teststep
- fix: avoid duplicate import - fix: avoid duplicate import
- fix: override locust weight - fix: override locust weight

View File

@@ -1,4 +1,4 @@
# NOTE: Generated By HttpRunner v3.1.0 # NOTE: Generated By HttpRunner v3.1.2
# FROM: basic.yml # FROM: basic.yml
from httprunner import HttpRunner, Config, Step, RunRequest, RunTestCase from httprunner import HttpRunner, Config, Step, RunRequest, RunTestCase

View File

@@ -33,5 +33,4 @@ teststeps:
validate: validate:
- eq: ["status_code", 500] - eq: ["status_code", 500]
- eq: [headers."Content-Type", "html/text"] - eq: [headers."Content-Type", "html/text"]
- eq: [body.headers."Content-Type", "application/json"]
- eq: [body.headers.Host, "127.0.0.1:8888"] - eq: [body.headers.Host, "127.0.0.1:8888"]

View File

@@ -1,4 +1,4 @@
# NOTE: Generated By HttpRunner v3.1.0 # NOTE: Generated By HttpRunner v3.1.2
# FROM: hooks.yml # FROM: hooks.yml
from httprunner import HttpRunner, Config, Step, RunRequest, RunTestCase from httprunner import HttpRunner, Config, Step, RunRequest, RunTestCase
@@ -26,7 +26,6 @@ class TestCaseHooks(HttpRunner):
.validate() .validate()
.assert_equal("status_code", 500) .assert_equal("status_code", 500)
.assert_equal('headers."Content-Type"', "html/text") .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") .assert_equal("body.headers.Host", "127.0.0.1:8888")
), ),
] ]

View File

@@ -1,4 +1,4 @@
# NOTE: Generated By HttpRunner v3.1.0 # NOTE: Generated By HttpRunner v3.1.2
# FROM: load_image.yml # FROM: load_image.yml
from httprunner import HttpRunner, Config, Step, RunRequest, RunTestCase from httprunner import HttpRunner, Config, Step, RunRequest, RunTestCase

View File

@@ -1,4 +1,4 @@
# NOTE: Generated By HttpRunner v3.1.0 # NOTE: Generated By HttpRunner v3.1.2
# FROM: upload.yml # FROM: upload.yml
from httprunner import HttpRunner, Config, Step, RunRequest, RunTestCase from httprunner import HttpRunner, Config, Step, RunRequest, RunTestCase

View File

@@ -1,4 +1,4 @@
# NOTE: Generated By HttpRunner v3.1.0 # NOTE: Generated By HttpRunner v3.1.2
# FROM: validate.yml # FROM: validate.yml
from httprunner import HttpRunner, Config, Step, RunRequest, RunTestCase from httprunner import HttpRunner, Config, Step, RunRequest, RunTestCase

View File

@@ -1,4 +1,4 @@
# NOTE: Generated By HttpRunner v3.1.0 # NOTE: Generated By HttpRunner v3.1.2
# FROM: request_methods/request_with_functions.yml # FROM: request_methods/request_with_functions.yml
from httprunner import HttpRunner, Config, Step, RunRequest, RunTestCase from httprunner import HttpRunner, Config, Step, RunRequest, RunTestCase

View File

@@ -1,4 +1,4 @@
# NOTE: Generated By HttpRunner v3.1.0 # NOTE: Generated By HttpRunner v3.1.2
# FROM: request_methods/request_with_testcase_reference.yml # FROM: request_methods/request_with_testcase_reference.yml
import sys import sys
@@ -35,7 +35,9 @@ class TestCaseRequestWithTestcaseReference(HttpRunner):
.with_variables( .with_variables(
**{"foo1": "testcase_ref_bar1", "expect_foo1": "testcase_ref_bar1"} **{"foo1": "testcase_ref_bar1", "expect_foo1": "testcase_ref_bar1"}
) )
.setup_hook("${sleep(0.1)}")
.call(RequestWithFunctions) .call(RequestWithFunctions)
.teardown_hook("${sleep(0.2)}")
.export(*["foo3"]) .export(*["foo3"])
), ),
Step( Step(

View File

@@ -1,4 +1,4 @@
# NOTE: Generated By HttpRunner v3.1.0 # NOTE: Generated By HttpRunner v3.1.2
# FROM: request_methods/hardcode.yml # FROM: request_methods/hardcode.yml
from httprunner import HttpRunner, Config, Step, RunRequest, RunTestCase from httprunner import HttpRunner, Config, Step, RunRequest, RunTestCase

View File

@@ -1,4 +1,4 @@
# NOTE: Generated By HttpRunner v3.1.0 # NOTE: Generated By HttpRunner v3.1.2
# FROM: request_methods/request_with_functions.yml # FROM: request_methods/request_with_functions.yml
from httprunner import HttpRunner, Config, Step, RunRequest, RunTestCase from httprunner import HttpRunner, Config, Step, RunRequest, RunTestCase

View File

@@ -13,7 +13,11 @@ teststeps:
variables: variables:
foo1: testcase_ref_bar1 foo1: testcase_ref_bar1
expect_foo1: testcase_ref_bar1 expect_foo1: testcase_ref_bar1
setup_hooks:
- ${sleep(0.1)}
testcase: request_methods/request_with_functions.yml testcase: request_methods/request_with_functions.yml
teardown_hooks:
- ${sleep(0.2)}
export: export:
- foo3 - foo3
- -

View File

@@ -1,4 +1,4 @@
# NOTE: Generated By HttpRunner v3.1.0 # NOTE: Generated By HttpRunner v3.1.2
# FROM: request_methods/request_with_testcase_reference.yml # FROM: request_methods/request_with_testcase_reference.yml
import sys import sys
@@ -33,7 +33,9 @@ class TestCaseRequestWithTestcaseReference(HttpRunner):
.with_variables( .with_variables(
**{"foo1": "testcase_ref_bar1", "expect_foo1": "testcase_ref_bar1"} **{"foo1": "testcase_ref_bar1", "expect_foo1": "testcase_ref_bar1"}
) )
.setup_hook("${sleep(0.1)}")
.call(RequestWithFunctions) .call(RequestWithFunctions)
.teardown_hook("${sleep(0.2)}")
.export(*["foo3"]) .export(*["foo3"])
), ),
Step( Step(

View File

@@ -1,4 +1,4 @@
# NOTE: Generated By HttpRunner v3.1.0 # NOTE: Generated By HttpRunner v3.1.2
# FROM: request_methods/request_with_variables.yml # FROM: request_methods/request_with_variables.yml
from httprunner import HttpRunner, Config, Step, RunRequest, RunTestCase from httprunner import HttpRunner, Config, Step, RunRequest, RunTestCase

View File

@@ -1,4 +1,4 @@
# NOTE: Generated By HttpRunner v3.1.0 # NOTE: Generated By HttpRunner v3.1.2
# FROM: request_methods/validate_with_functions.yml # FROM: request_methods/validate_with_functions.yml
from httprunner import HttpRunner, Config, Step, RunRequest, RunTestCase from httprunner import HttpRunner, Config, Step, RunRequest, RunTestCase

View File

@@ -1,4 +1,4 @@
# NOTE: Generated By HttpRunner v3.1.0 # NOTE: Generated By HttpRunner v3.1.2
# FROM: request_methods/validate_with_variables.yml # FROM: request_methods/validate_with_variables.yml
from httprunner import HttpRunner, Config, Step, RunRequest, RunTestCase from httprunner import HttpRunner, Config, Step, RunRequest, RunTestCase

View File

@@ -1,4 +1,4 @@
__version__ = "3.1.1" __version__ = "3.1.2"
__description__ = "One-stop solution for HTTP(S) testing." __description__ = "One-stop solution for HTTP(S) testing."
# import firstly for monkey patch if needed # import firstly for monkey patch if needed

View File

@@ -1,4 +1,3 @@
import io
import json import json
import sys import sys
from json.decoder import JSONDecodeError from json.decoder import JSONDecodeError
@@ -28,9 +27,9 @@ def load_har_log_entries(file_path):
] ]
""" """
with io.open(file_path, "r+", encoding="utf-8-sig") as f: with open(file_path, mode="rb") as f:
try: try:
content_json = json.loads(f.read()) content_json = json.load(f)
return content_json["log"]["entries"] return content_json["log"]["entries"]
except (TypeError, JSONDecodeError) as ex: except (TypeError, JSONDecodeError) as ex:
logger.error(f"failed to load HAR file {file_path}: {ex}") logger.error(f"failed to load HAR file {file_path}: {ex}")
@@ -108,7 +107,7 @@ def dump_yaml(testcase, yaml_file):
""" """
logger.info("dump testcase to YAML format.") logger.info("dump testcase to YAML format.")
with io.open(yaml_file, "w", encoding="utf-8") as outfile: with open(yaml_file, "w", encoding="utf-8") as outfile:
yaml.dump( yaml.dump(
testcase, outfile, allow_unicode=True, default_flow_style=False, indent=4 testcase, outfile, allow_unicode=True, default_flow_style=False, indent=4
) )
@@ -121,7 +120,7 @@ def dump_json(testcase, json_file):
""" """
logger.info("dump testcase to JSON format.") logger.info("dump testcase to JSON format.")
with io.open(json_file, "w", encoding="utf-8") as outfile: with open(json_file, "w", encoding="utf-8") as outfile:
my_json_str = json.dumps(testcase, ensure_ascii=False, indent=4) my_json_str = json.dumps(testcase, ensure_ascii=False, indent=4)
if isinstance(my_json_str, bytes): if isinstance(my_json_str, bytes):
my_json_str = my_json_str.decode("utf-8") my_json_str = my_json_str.decode("utf-8")

View File

@@ -1,16 +1,12 @@
import importlib.util
import inspect
import os
import sys import sys
from typing import List
if sys.argv[0].endswith("locusts"): if "locust" in sys.argv[0]:
try: try:
# monkey patch ssl at beginning to avoid RecursionError when running locust. # monkey patch all at beginning to avoid RecursionError when running locust.
from gevent import monkey # `from gevent import monkey; monkey.patch_all()` will be triggered when importing locust
monkey.patch_ssl()
from locust import main as locust_main from locust import main as locust_main
print("NOTICE: gevent monkey patches have been applied !!!")
except ImportError: except ImportError:
msg = """ msg = """
Locust is not installed, install first and try again. Locust is not installed, install first and try again.
@@ -20,9 +16,14 @@ $ pip install locust
print(msg) print(msg)
sys.exit(1) sys.exit(1)
import importlib.util
import inspect
import os
from typing import List
from loguru import logger from loguru import logger
""" converted pytest files from YAML/JSON testcases """ converted pytest files from YAML/JSON testcases
""" """
pytest_files: List = [] pytest_files: List = []

View File

@@ -1,6 +1,5 @@
import csv import csv
import importlib import importlib
import io
import json import json
import os import os
import sys import sys
@@ -29,7 +28,7 @@ project_meta: Union[ProjectMeta, None] = None
def _load_yaml_file(yaml_file: Text) -> Dict: def _load_yaml_file(yaml_file: Text) -> Dict:
""" load yaml file and check file content format """ load yaml file and check file content format
""" """
with io.open(yaml_file, "r", encoding="utf-8") as stream: with open(yaml_file, mode="rb") as stream:
try: try:
yaml_content = yaml.load(stream) yaml_content = yaml.load(stream)
except yaml.YAMLError as ex: except yaml.YAMLError as ex:
@@ -43,7 +42,7 @@ def _load_yaml_file(yaml_file: Text) -> Dict:
def _load_json_file(json_file: Text) -> Dict: def _load_json_file(json_file: Text) -> Dict:
""" load json file and check file content format """ load json file and check file content format
""" """
with io.open(json_file, encoding="utf-8") as data_file: with open(json_file, mode="rb") as data_file:
try: try:
json_content = json.load(data_file) json_content = json.load(data_file)
except json.JSONDecodeError as ex: except json.JSONDecodeError as ex:
@@ -128,17 +127,19 @@ def load_dot_env_file(dot_env_path: Text) -> Dict:
logger.info(f"Loading environment variables from {dot_env_path}") logger.info(f"Loading environment variables from {dot_env_path}")
env_variables_mapping = {} env_variables_mapping = {}
with io.open(dot_env_path, "r", encoding="utf-8") as fp: with open(dot_env_path, mode="rb") as fp:
for line in fp: for line in fp:
# maxsplit=1 # maxsplit=1
if "=" in line: if b"=" in line:
variable, value = line.split("=", 1) variable, value = line.split(b"=", 1)
elif ":" in line: elif b":" in line:
variable, value = line.split(":", 1) variable, value = line.split(b":", 1)
else: else:
raise exceptions.FileFormatError(".env format error") raise exceptions.FileFormatError(".env format error")
env_variables_mapping[variable.strip()] = value.strip() env_variables_mapping[
variable.strip().decode("utf-8")
] = value.strip().decode("utf-8")
utils.set_os_environ(env_variables_mapping) utils.set_os_environ(env_variables_mapping)
return env_variables_mapping return env_variables_mapping
@@ -182,7 +183,7 @@ def load_csv_file(csv_file: Text) -> List[Dict]:
csv_content_list = [] csv_content_list = []
with io.open(csv_file, encoding="utf-8") as csvfile: with open(csv_file, encoding="utf-8") as csvfile:
reader = csv.DictReader(csvfile) reader = csv.DictReader(csvfile)
for row in reader: for row in reader:
csv_content_list.append(row) csv_content_list.append(row)

View File

@@ -24,7 +24,7 @@ from httprunner.loader import (
convert_relative_project_root_dir, convert_relative_project_root_dir,
) )
from httprunner.response import uniform_validator from httprunner.response import uniform_validator
from httprunner.utils import override_config_variables from httprunner.utils import override_config_variables, is_support_multiprocessing
""" cache converted pytest files, avoid duplicate making """ cache converted pytest files, avoid duplicate making
""" """
@@ -160,7 +160,13 @@ def convert_testcase_path(testcase_abs_path: Text) -> Tuple[Text, Text]:
def format_pytest_with_black(*python_paths: Text) -> NoReturn: def format_pytest_with_black(*python_paths: Text) -> NoReturn:
logger.info("format pytest cases with black ...") logger.info("format pytest cases with black ...")
try: try:
subprocess.run(["black", *python_paths]) if is_support_multiprocessing() or len(python_paths) <= 1:
subprocess.run(["black", *python_paths])
else:
logger.warning(
f"this system does not support multiprocessing well, format files one by one ..."
)
[subprocess.run(["black", path]) for path in python_paths]
except subprocess.CalledProcessError as ex: except subprocess.CalledProcessError as ex:
capture_exception(ex) capture_exception(ex)
logger.error(ex) logger.error(ex)

View File

@@ -51,8 +51,8 @@ class TRequest(BaseModel):
url: Url url: Url
params: Dict[Text, Text] = {} params: Dict[Text, Text] = {}
headers: Headers = {} headers: Headers = {}
req_json: Union[Dict, List] = Field({}, alias="json") req_json: Union[Dict, List] = Field(None, alias="json")
data: Union[Text, Dict[Text, Any]] = "" data: Union[Text, Dict[Text, Any]] = None
cookies: Cookies = {} cookies: Cookies = {}
timeout: float = 120 timeout: float = 120
allow_redirects: bool = True allow_redirects: bool = True

View File

@@ -171,6 +171,9 @@ class ResponseObject(object):
functions_mapping: FunctionsMapping = None, functions_mapping: FunctionsMapping = None,
) -> NoReturn: ) -> NoReturn:
variables_mapping = variables_mapping or {}
functions_mapping = functions_mapping or {}
self.validation_results = {} self.validation_results = {}
if not validators: if not validators:
return return

View File

@@ -89,7 +89,7 @@ class HttpRunner(object):
return self return self
def __call_hooks( def __call_hooks(
self, hooks: Hooks, step_variables: VariablesMapping, hook_type: Text, self, hooks: Hooks, step_variables: VariablesMapping, hook_msg: Text,
) -> NoReturn: ) -> NoReturn:
""" call hook actions. """ call hook actions.
@@ -106,10 +106,10 @@ class HttpRunner(object):
request: parsed request dict request: parsed request dict
response: ResponseObject for current response response: ResponseObject for current response
hook_type: setup/teardown hook_msg: setup/teardown request/testcase
""" """
logger.debug(f"call {hook_type} hook actions.") logger.info(f"call hook actions: {hook_msg}")
if not isinstance(hooks, List): if not isinstance(hooks, List):
logger.error(f"Invalid hooks format: {hooks}") logger.error(f"Invalid hooks format: {hooks}")
@@ -153,7 +153,7 @@ class HttpRunner(object):
# setup hooks # setup hooks
if step.setup_hooks: if step.setup_hooks:
self.__call_hooks(step.setup_hooks, step.variables, "setup") self.__call_hooks(step.setup_hooks, step.variables, "setup request")
# prepare arguments # prepare arguments
method = parsed_request_dict.pop("method") method = parsed_request_dict.pop("method")
@@ -169,7 +169,7 @@ class HttpRunner(object):
# teardown hooks # teardown hooks
if step.teardown_hooks: if step.teardown_hooks:
self.__call_hooks(step.teardown_hooks, step.variables, "teardown") self.__call_hooks(step.teardown_hooks, step.variables, "teardown request")
def log_req_resp_details(): def log_req_resp_details():
err_msg = "\n{} DETAILED REQUEST & RESPONSE {}\n".format("*" * 32, "*" * 32) err_msg = "\n{} DETAILED REQUEST & RESPONSE {}\n".format("*" * 32, "*" * 32)
@@ -236,6 +236,10 @@ class HttpRunner(object):
step_variables = step.variables step_variables = step.variables
step_export = step.export step_export = step.export
# setup hooks
if step.setup_hooks:
self.__call_hooks(step.setup_hooks, step_variables, "setup testcase")
if hasattr(step.testcase, "config") and hasattr(step.testcase, "teststeps"): if hasattr(step.testcase, "config") and hasattr(step.testcase, "teststeps"):
testcase_cls = step.testcase testcase_cls = step.testcase
case_result = ( case_result = (
@@ -269,6 +273,10 @@ class HttpRunner(object):
f"Invalid teststep referenced testcase: {step.dict()}" f"Invalid teststep referenced testcase: {step.dict()}"
) )
# teardown hooks
if step.teardown_hooks:
self.__call_hooks(step.teardown_hooks, step.variables, "teardown testcase")
step_data.data = case_result.get_step_datas() # list of step data step_data.data = case_result.get_step_datas() # list of step data
step_data.export_vars = case_result.get_export_variables() step_data.export_vars = case_result.get_export_variables()
step_data.success = case_result.success step_data.success = case_result.success

View File

@@ -49,7 +49,7 @@ def create_scaffold(project_name):
print(msg) print(msg)
def create_file(path, file_content=""): def create_file(path, file_content=""):
with open(path, "w") as f: with open(path, "w", encoding="utf-8") as f:
f.write(file_content) f.write(file_content)
msg = f"created file: {path}" msg = f"created file: {path}"
print(msg) print(msg)

View File

@@ -350,6 +350,14 @@ class StepRefCase(object):
def __init__(self, step_context: TStep): def __init__(self, step_context: TStep):
self.__step_context = step_context self.__step_context = step_context
def teardown_hook(self, hook: Text, assign_var_name: Text = None) -> "StepRefCase":
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 export(self, *var_name: Text) -> "StepRefCase": def export(self, *var_name: Text) -> "StepRefCase":
self.__step_context.export.extend(var_name) self.__step_context.export.extend(var_name)
return self return self
@@ -366,6 +374,14 @@ class RunTestCase(object):
self.__step_context.variables.update(variables) self.__step_context.variables.update(variables)
return self return self
def setup_hook(self, hook: Text, assign_var_name: Text = None) -> "RunTestCase":
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 call(self, testcase: Callable) -> StepRefCase: def call(self, testcase: Callable) -> StepRefCase:
self.__step_context.testcase = testcase self.__step_context.testcase = testcase
return StepRefCase(self.__step_context) return StepRefCase(self.__step_context)

View File

@@ -4,13 +4,15 @@ import json
import os.path import os.path
import platform import platform
import uuid import uuid
from multiprocessing import Queue
from typing import Dict, List, Any from typing import Dict, List, Any
import sentry_sdk import sentry_sdk
from loguru import logger
from httprunner import __version__ from httprunner import __version__
from httprunner import exceptions from httprunner import exceptions
from httprunner.models import VariablesMapping from httprunner.models import VariablesMapping
from loguru import logger
def init_sentry_sdk(): def init_sentry_sdk():
@@ -209,3 +211,12 @@ def override_config_variables(
variables = copy.deepcopy(config_variables) variables = copy.deepcopy(config_variables)
variables.update(step_new_variables) variables.update(step_new_variables)
return variables return variables
def is_support_multiprocessing() -> bool:
try:
Queue()
return True
except (ImportError, OSError):
# system that does not support semaphores(dependency of multiprocessing), like Android termux
return False

View File

@@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "httprunner" name = "httprunner"
version = "3.1.1" version = "3.1.2"
description = "One-stop solution for HTTP(S) testing." description = "One-stop solution for HTTP(S) testing."
license = "Apache-2.0" license = "Apache-2.0"
readme = "README.md" readme = "README.md"

39
tests/response_test.py Normal file
View File

@@ -0,0 +1,39 @@
import unittest
import requests
from httprunner.response import ResponseObject
class TestResponse(unittest.TestCase):
def setUp(self) -> None:
resp = requests.post(
"https://httpbin.org/anything",
json={
"locations": [
{"name": "Seattle", "state": "WA"},
{"name": "New York", "state": "NY"},
{"name": "Bellevue", "state": "WA"},
{"name": "Olympia", "state": "WA"},
]
},
)
self.resp_obj = ResponseObject(resp)
def test_extract(self):
extract_mapping = self.resp_obj.extract(
{"var_1": "body.json.locations[0]", "var_2": "body.json.locations[3].name"}
)
self.assertEqual(extract_mapping["var_1"], {"name": "Seattle", "state": "WA"})
self.assertEqual(extract_mapping["var_2"], "Olympia")
def test_validate(self):
variables_mapping = {"index": 1}
self.resp_obj.validate(
[
{"eq": ["body.json.locations[0].name", "Seattle"]},
{"eq": ["body.json.locations[0]", {"name": "Seattle", "state": "WA"}]},
{"eq": ["body.json.locations[$index].name", "New York"]},
],
variables_mapping=variables_mapping,
)