mirror of
https://github.com/httprunner/httprunner.git
synced 2026-05-12 02:21:29 +08:00
Merge pull request #786 from httprunner/leo_dev
2.4.1 - 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
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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**
|
||||
|
||||
51
docs/prepare/upload-case.md
Normal file
51
docs/prepare/upload-case.md
Normal file
@@ -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
|
||||
@@ -1,4 +1,4 @@
|
||||
__version__ = "2.4.0"
|
||||
__version__ = "2.4.1"
|
||||
__description__ = "One-stop solution for HTTP(S) testing."
|
||||
|
||||
__all__ = ["__version__", "__description__"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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):
|
||||
|
||||
143
httprunner/plugins/uploader/__init__.py
Normal file
143
httprunner/plugins/uploader/__init__.py
Normal file
@@ -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
|
||||
@@ -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
|
||||
))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
30
tests/httpbin/upload.v2.yml
Normal file
30
tests/httpbin/upload.v2.yml
Normal file
@@ -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"]
|
||||
@@ -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"]
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
Reference in New Issue
Block a user