diff --git a/.travis.yml b/.travis.yml index 27a688c0..ca7c82ec 100644 --- a/.travis.yml +++ b/.travis.yml @@ -13,7 +13,11 @@ matrix: install: - pip install poetry - poetry install -vvv + - poetry build + - ls dist/*.whl | xargs pip install # test installation script: + - hrun -V + - cd tests/httpbin && hrun basic.yml --log-level debug --failfast && cd - - python -m httprunner.cli hrun -V - python -m httprunner.cli hrun -h - poetry build diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 72f41ab8..d5a4b732 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,19 @@ # Release History +## 2.4.1 (2019-12-12) + +**Added** + +- feat: add `upload` keyword for upload test, see [doc](https://docs.httprunner.org/prepare/upload-case/) +- test: pip install package +- test: hrun command + +**Fixed** + +- fix: typo testfile_paths +- fix: check if locustio installed +- fix: dump json file name is empty when running relative testfile + ## 2.4.0 (2019-12-11) **Added** diff --git a/docs/prepare/upload-case.md b/docs/prepare/upload-case.md new file mode 100644 index 00000000..58a5ba23 --- /dev/null +++ b/docs/prepare/upload-case.md @@ -0,0 +1,51 @@ + +对于上传文件类型的测试场景,HttpRunner 集成 [requests_toolbelt][1] 实现了上传功能。 + +在使用之前,确保已安装如下依赖库: + +- [requests_toolbelt](https://github.com/requests/toolbelt) +- [filetype](https://github.com/h2non/filetype.py) + +使用内置 `upload` 关键字,可轻松实现上传功能(适用版本:2.4.1+)。 + +```yaml +- test: + name: upload file + request: + url: http://httpbin.org/upload + method: POST + headers: + Cookie: session=AAA-BBB-CCC + upload: + file: "data/file_to_upload" + field1: "value1" + field2: "value2" + validate: + - eq: ["status_code", 200] +``` + +同时,你也可以继续使用之前描述形式(适用版本:2.0+)。 + +```yaml +- test: + name: upload file + variables: + file: "data/file_to_upload" + field1: "value1" + field2: "value2" + m_encoder: ${multipart_encoder(file=$file, field1=$field1, field2=$field2)} + request: + url: http://httpbin.org/upload + method: POST + headers: + Content-Type: ${multipart_content_type($m_encoder)} + Cookie: session=AAA-BBB-CCC + data: $m_encoder + validate: + - eq: ["status_code", 200] +``` + +参考案例:[httprunner/tests/httpbin/upload.v2.yml][2] + +[1]: https://toolbelt.readthedocs.io/en/latest/uploading-data.html +[2]: https://github.com/httprunner/httprunner/blob/master/tests/httpbin/upload.v2.yml \ No newline at end of file diff --git a/httprunner/__init__.py b/httprunner/__init__.py index cb8ce2a1..ef6aac10 100644 --- a/httprunner/__init__.py +++ b/httprunner/__init__.py @@ -1,4 +1,4 @@ -__version__ = "2.4.0" +__version__ = "2.4.1" __description__ = "One-stop solution for HTTP(S) testing." __all__ = ["__version__", "__description__"] diff --git a/httprunner/builtin/functions.py b/httprunner/builtin/functions.py index 0cca0295..d5b31c7a 100644 --- a/httprunner/builtin/functions.py +++ b/httprunner/builtin/functions.py @@ -3,19 +3,13 @@ Built-in functions used in YAML/JSON testcases. """ import datetime -import os import random import string import time -import filetype -from requests_toolbelt import MultipartEncoder - from httprunner.compat import builtin_str, integer_types from httprunner.exceptions import ParamsError -PWD = os.getcwd() - def gen_random_string(str_len): """ generate random string with specified length @@ -44,62 +38,3 @@ def sleep(n_secs): """ time.sleep(n_secs) - -""" -upload files with requests-toolbelt -e.g. - - - test: - name: upload file - variables: - file_path: "data/test.env" - multipart_encoder: ${multipart_encoder(file=$file_path)} - request: - url: /post - method: POST - headers: - Content-Type: ${multipart_content_type($multipart_encoder)} - data: $multipart_encoder - validate: - - eq: ["status_code", 200] - - startswith: ["content.files.file", "UserName=test"] -""" - - -def multipart_encoder(**kwargs): - """ initialize MultipartEncoder with uploading fields. - """ - - def get_filetype(file_path): - file_type = filetype.guess(file_path) - if file_type: - return file_type.mime - else: - return "text/html" - - fields_dict = {} - for key, value in kwargs.items(): - - if os.path.isabs(value): - _file_path = value - is_file = True - else: - global PWD - _file_path = os.path.join(PWD, value) - is_file = os.path.isfile(_file_path) - - if is_file: - filename = os.path.basename(_file_path) - with open(_file_path, 'rb') as f: - mime_type = get_filetype(_file_path) - fields_dict[key] = (filename, f.read(), mime_type) - else: - fields_dict[key] = value - - return MultipartEncoder(fields=fields_dict) - - -def multipart_content_type(multipart_encoder): - """ prepare Content-Type for request headers - """ - return multipart_encoder.content_type diff --git a/httprunner/cli.py b/httprunner/cli.py index 559e7012..a2356591 100644 --- a/httprunner/cli.py +++ b/httprunner/cli.py @@ -91,7 +91,7 @@ def main(): err_code = 0 try: - for path in args.testcase_paths: + for path in args.testfile_paths: summary = runner.run(path, dot_env_path=args.dot_env_path) report_dir = args.report_dir or os.path.join(runner.project_working_directory, "reports") gen_html_report( diff --git a/httprunner/loader/__init__.py b/httprunner/loader/__init__.py index 99bbdf3c..46e18ad5 100644 --- a/httprunner/loader/__init__.py +++ b/httprunner/loader/__init__.py @@ -9,6 +9,7 @@ HttpRunner loader """ from httprunner.loader.check import is_testcase_path, is_testcases, validate_json_file +from httprunner.loader.locate import get_project_working_directory as get_pwd from httprunner.loader.load import load_csv_file, load_builtin_functions from httprunner.loader.buildup import load_cases, load_project_data @@ -16,6 +17,7 @@ __all__ = [ "is_testcase_path", "is_testcases", "validate_json_file", + "get_pwd", "load_csv_file", "load_builtin_functions", "load_project_data", diff --git a/httprunner/loader/buildup.py b/httprunner/loader/buildup.py index b2e9a16d..d0c6a642 100644 --- a/httprunner/loader/buildup.py +++ b/httprunner/loader/buildup.py @@ -2,7 +2,6 @@ import importlib import os from httprunner import exceptions, logger, utils -from httprunner.builtin import functions from httprunner.loader.load import load_module_functions, load_folder_content, load_file, load_dot_env_file, \ load_folder_files from httprunner.loader.locate import init_project_working_directory, get_project_working_directory @@ -335,7 +334,6 @@ def load_test_file(path): """ raw_content = load_file(path) - loaded_content = None if isinstance(raw_content, dict): @@ -480,11 +478,9 @@ def load_project_data(test_path, dot_env_path=None): debugtalk_functions = {} # locate PWD and load debugtalk.py functions - project_mapping["PWD"] = project_working_directory - functions.PWD = project_working_directory # TODO: remove project_mapping["functions"] = debugtalk_functions - project_mapping["test_path"] = test_path + project_mapping["test_path"] = os.path.abspath(test_path) # load api tests_def_mapping["api"] = load_api_folder(os.path.join(project_working_directory, "api")) diff --git a/httprunner/parser.py b/httprunner/parser.py index dccb5ed6..cc6f13aa 100644 --- a/httprunner/parser.py +++ b/httprunner/parser.py @@ -428,6 +428,11 @@ def get_mapping_function(function_name, functions_mapping): elif function_name in ["environ", "ENV"]: return utils.get_os_environ + elif function_name in ["multipart_encoder", "multipart_content_type"]: + # plugin for upload test + from httprunner.plugins import uploader + return getattr(uploader, function_name) + try: # check if HttpRunner builtin functions built_in_functions = loader.load_builtin_functions() @@ -439,8 +444,9 @@ def get_mapping_function(function_name, functions_mapping): # check if Python builtin functions return getattr(builtins, function_name) except AttributeError: - # is not builtin function - raise exceptions.FunctionNotFound("{} is not found.".format(function_name)) + pass + + raise exceptions.FunctionNotFound("{} is not found.".format(function_name)) def parse_function_params(params): @@ -1146,13 +1152,18 @@ def __prepare_testcase_tests(tests, config, project_mapping, session_variables_s api_def_dict = test_dict.pop("api_def") _extend_with_api(test_dict, api_def_dict) + # verify priority: testcase teststep > testcase config + if "request" in test_dict: + if "verify" not in test_dict["request"]: + test_dict["request"]["verify"] = config_verify + + if "upload" in test_dict["request"]: + from httprunner.plugins.uploader import prepare_upload_test + prepare_upload_test(test_dict) + # current teststep variables teststep_variables_set |= set(test_dict.get("variables", {}).keys()) - # verify priority: testcase teststep > testcase config - if "request" in test_dict and "verify" not in test_dict["request"]: - test_dict["request"]["verify"] = config_verify - # move extracted variable to session variables if "extract" in test_dict: extract_mapping = utils.ensure_mapping_format(test_dict["extract"]) diff --git a/httprunner/plugins/locusts/cli.py b/httprunner/plugins/locusts/cli.py index 882719e0..364ab22f 100644 --- a/httprunner/plugins/locusts/cli.py +++ b/httprunner/plugins/locusts/cli.py @@ -2,6 +2,7 @@ try: # monkey patch ssl at beginning to avoid RecursionError when running locust. from gevent import monkey monkey.patch_ssl() + from locust import main as locust_main except ImportError: msg = """ Locust is not installed, install first and try again. @@ -61,8 +62,7 @@ def gen_locustfile(testcase_file_path): def start_locust_main(): - from locust.main import main - main() + locust_main.main() def start_master(sys_argv): diff --git a/httprunner/plugins/uploader/__init__.py b/httprunner/plugins/uploader/__init__.py new file mode 100644 index 00000000..b432a1a6 --- /dev/null +++ b/httprunner/plugins/uploader/__init__.py @@ -0,0 +1,143 @@ +""" upload test plugin. + +If you want to use this plugin, you should install the following dependencies first. + +- requests_toolbelt +- filetype + +Then you can write upload test script as below: + + - test: + name: upload file + request: + url: http://httpbin.org/upload + method: POST + headers: + Cookie: session=AAA-BBB-CCC + upload: + file: "data/file_to_upload" + field1: "value1" + field2: "value2" + validate: + - eq: ["status_code", 200] + +For compatibility, you can also write upload test script in old way: + + - test: + name: upload file + variables: + file: "data/file_to_upload" + field1: "value1" + field2: "value2" + m_encoder: ${multipart_encoder(file=$file, field1=$field1, field2=$field2)} + request: + url: http://httpbin.org/upload + method: POST + headers: + Content-Type: ${multipart_content_type($m_encoder)} + Cookie: session=AAA-BBB-CCC + data: $m_encoder + validate: + - eq: ["status_code", 200] + +""" + +import os +import sys + +try: + import filetype + from requests_toolbelt import MultipartEncoder +except ImportError: + msg = """ +uploader plugin dependencies uninstalled, install first and try again. +install with pip: +$ pip install requests_toolbelt filetype +""" + print(msg) + sys.exit(0) + +from httprunner.exceptions import ParamsError + + +def prepare_upload_test(test_dict): + """ preprocess for upload test + replace `upload` info with MultipartEncoder + + Args: + test_dict (dict): + + { + "variables": {}, + "request": { + "url": "http://httpbin.org/upload", + "method": "POST", + "headers": { + "Cookie": "session=AAA-BBB-CCC" + }, + "upload": { + "file": "data/file_to_upload" + "md5": "123" + } + } + } + + """ + upload_json = test_dict["request"].pop("upload", {}) + if not upload_json: + raise ParamsError("invalid upload info: {}".format(upload_json)) + + params_list = [] + for key, value in upload_json.items(): + test_dict["variables"][key] = value + params_list.append("{}=${}".format(key, key)) + + params_str = ", ".join(params_list) + test_dict["variables"]["m_encoder"] = "${multipart_encoder(" + params_str + ")}" + + test_dict["request"].setdefault("headers", {}) + test_dict["request"]["headers"]["Content-Type"] = "${multipart_content_type($m_encoder)}" + + test_dict["request"]["data"] = "$m_encoder" + + +def multipart_encoder(**kwargs): + """ initialize MultipartEncoder with uploading fields. + """ + + def get_filetype(file_path): + file_type = filetype.guess(file_path) + if file_type: + return file_type.mime + else: + return "text/html" + + fields_dict = {} + for key, value in kwargs.items(): + + if os.path.isabs(value): + # value is absolute file path + _file_path = value + is_exists_file = os.path.isfile(value) + else: + # value is not absolute file path, check if it is relative file path + from httprunner.loader import get_pwd + _file_path = os.path.join(get_pwd(), value) + is_exists_file = os.path.isfile(_file_path) + + if is_exists_file: + # value is file path to upload + filename = os.path.basename(_file_path) + with open(_file_path, 'rb') as f: + mime_type = get_filetype(_file_path) + fields_dict[key] = (filename, f.read(), mime_type) + else: + fields_dict[key] = value + + return MultipartEncoder(fields=fields_dict) + + +def multipart_content_type(m_encoder): + """ prepare Content-Type for request headers + """ + return m_encoder.content_type diff --git a/httprunner/utils.py b/httprunner/utils.py index efb38835..5124290d 100644 --- a/httprunner/utils.py +++ b/httprunner/utils.py @@ -579,6 +579,7 @@ def dump_json_file(json_data, json_file_abs_path): json_data, indent=4, separators=(',', ':'), + encoding="utf8", ensure_ascii=False, cls=PythonObjectEncoder )) diff --git a/mkdocs.yml b/mkdocs.yml index e73dea8b..2ef012ca 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,5 +1,9 @@ # require mkdocs-material 3.x +# +# pip install mkdocs +# pip install mkdocs-material + # Project information site_name: HttpRunner V2.x 中文使用文档 site_description: HttpRunner V2.x User Documentation @@ -64,6 +68,7 @@ nav: - 参数化数据驱动: prepare/parameters.md - Validate & Prettify: prepare/validate-pretty.md - 信息安全: prepare/security.md + - 文件上传场景: prepare/upload-case.md - 测试执行: - 运行测试(CLI): run-tests/cli.md - 测试报告: run-tests/report.md diff --git a/pyproject.toml b/pyproject.toml index 6b14c7d1..168a025b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "httprunner" -version = "2.4.0" +version = "2.4.1" description = "One-stop solution for HTTP(S) testing." license = "Apache-2.0" readme = "README.md" diff --git a/tests/api_server.py b/tests/api_server.py index c7c73eee..4cf6b0fb 100644 --- a/tests/api_server.py +++ b/tests/api_server.py @@ -15,8 +15,8 @@ try: except ImportError: httpbin_app = None HTTPBIN_HOST = "httpbin.org" - HTTPBIN_PORT = 443 - HTTPBIN_SERVER = "https://{}:{}".format(HTTPBIN_HOST, HTTPBIN_PORT) + HTTPBIN_PORT = 80 + HTTPBIN_SERVER = "http://{}:{}".format(HTTPBIN_HOST, HTTPBIN_PORT) FLASK_APP_PORT = 5000 SECRET_KEY = "DebugTalk" diff --git a/tests/httpbin/basic.yml b/tests/httpbin/basic.yml index b3f9aca9..05fb8f56 100644 --- a/tests/httpbin/basic.yml +++ b/tests/httpbin/basic.yml @@ -2,14 +2,15 @@ name: basic test with httpbin base_url: https://httpbin.org/ -- test: - name: index - request: - url: / - method: GET - validate: - - eq: ["status_code", 200] - - contains: [content, "HTTP Request & Response Service"] +#- test: +# TODO: fix compatibility with Python 2.7, UnicodeDecodeError +# name: index +# request: +# url: / +# method: GET +# validate: +# - eq: ["status_code", 200] +# - contains: [content, "HTTP Request & Response Service"] - test: name: headers diff --git a/tests/httpbin/upload.v2.yml b/tests/httpbin/upload.v2.yml new file mode 100644 index 00000000..1f96d037 --- /dev/null +++ b/tests/httpbin/upload.v2.yml @@ -0,0 +1,30 @@ +config: + name: test upload file with httpbin + base_url: ${get_httpbin_server()} + +teststeps: +- + name: upload file + variables: + file_path: "data/test.env" + m_encoder: ${multipart_encoder(file=$file_path)} + request: + url: /post + method: POST + headers: + Content-Type: ${multipart_content_type($m_encoder)} + data: $m_encoder + validate: + - eq: ["status_code", 200] + - startswith: ["content.files.file", "UserName=test"] + +- + name: upload file with keyword + request: + url: /post + method: POST + upload: + file: "data/test.env" + validate: + - eq: ["status_code", 200] + - startswith: ["content.files.file", "UserName=test"] diff --git a/tests/httpbin/upload.yml b/tests/httpbin/upload.yml index 344b90bd..a858cb05 100644 --- a/tests/httpbin/upload.yml +++ b/tests/httpbin/upload.yml @@ -6,14 +6,24 @@ name: upload file variables: file_path: "data/test.env" - multipart_encoder: ${multipart_encoder(file=$file_path)} + m_encoder: ${multipart_encoder(file=$file_path)} request: url: /post method: POST headers: - Content-Type: ${multipart_content_type($multipart_encoder)} - data: $multipart_encoder + Content-Type: ${multipart_content_type($m_encoder)} + data: $m_encoder validate: - eq: ["status_code", 200] - startswith: ["content.files.file", "UserName=test"] +- test: + name: upload file with keyword + request: + url: /post + method: POST + upload: + file: "data/test.env" + validate: + - eq: ["status_code", 200] + - startswith: ["content.files.file", "UserName=test"] diff --git a/tests/test_api.py b/tests/test_api.py index cca20309..9b77d8bd 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -222,12 +222,17 @@ class TestHttpRunner(ApiServerUnittest): self.assertIn("records", summary["details"][0]) def test_run_yaml_upload(self): - summary = self.runner.run("tests/httpbin/upload.yml") - self.assertTrue(summary["success"]) - self.assertEqual(summary["stat"]["testcases"]["total"], 1) - self.assertEqual(summary["stat"]["teststeps"]["total"], 1) - self.assertIn("details", summary) - self.assertIn("records", summary["details"][0]) + upload_cases_list = [ + "tests/httpbin/upload.yml", + "tests/httpbin/upload.v2.yml" + ] + for upload_case in upload_cases_list: + summary = self.runner.run(upload_case) + self.assertTrue(summary["success"]) + self.assertEqual(summary["stat"]["testcases"]["total"], 1) + self.assertEqual(summary["stat"]["teststeps"]["total"], 2) + self.assertIn("details", summary) + self.assertIn("records", summary["details"][0]) def test_run_post_data(self): testcases = [ @@ -550,12 +555,11 @@ class TestHttpRunner(ApiServerUnittest): } ) - # def test_validate_response_content(self): - # # TODO: fix compatibility with Python 2.7 - # testcase_file_path = os.path.join( - # os.getcwd(), 'tests/httpbin/basic.yml') - # summary = self.runner.run(testcase_file_path) - # self.assertTrue(summary["success"]) + def test_validate_response_content(self): + testcase_file_path = os.path.join( + os.getcwd(), 'tests/httpbin/basic.yml') + summary = self.runner.run(testcase_file_path) + self.assertTrue(summary["success"]) def test_html_report_xss(self): testcases = [