mirror of
https://github.com/httprunner/httprunner.git
synced 2026-05-11 18:11:21 +08:00
v3 feat: support function calls
This commit is contained in:
@@ -3,3 +3,7 @@ from httprunner import __version__
|
||||
|
||||
def get_httprunner_version():
|
||||
return __version__
|
||||
|
||||
|
||||
def sum_two(m, n):
|
||||
return m + n
|
||||
|
||||
61
examples/postman_echo/request_methods/with_functions.yml
Normal file
61
examples/postman_echo/request_methods/with_functions.yml
Normal file
@@ -0,0 +1,61 @@
|
||||
config:
|
||||
name: "request methods testcase with functions"
|
||||
variables:
|
||||
foo1: session_bar1
|
||||
base_url: "https://postman-echo.com"
|
||||
verify: False
|
||||
|
||||
teststeps:
|
||||
-
|
||||
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.foo2", "session_bar2"]
|
||||
- eq: ["body.args.sum_v", "3"]
|
||||
-
|
||||
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."]
|
||||
-
|
||||
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"]
|
||||
98
examples/postman_echo/request_methods/with_functions_test.py
Normal file
98
examples/postman_echo/request_methods/with_functions_test.py
Normal file
@@ -0,0 +1,98 @@
|
||||
from httprunner.v3.runner import TestCaseRunner
|
||||
from httprunner.v3.schema import TestsConfig, TestStep
|
||||
from examples.postman_echo import debugtalk
|
||||
|
||||
|
||||
class TestCaseRequestMethodsWithFunctions(TestCaseRunner):
|
||||
config = TestsConfig(**{
|
||||
"name": "request methods testcase with functions",
|
||||
"variables": {
|
||||
"foo1": "session_bar1"
|
||||
},
|
||||
"functions": {
|
||||
"get_httprunner_version": debugtalk.get_httprunner_version,
|
||||
"sum_two": debugtalk.sum_two
|
||||
},
|
||||
"base_url": "https://postman-echo.com",
|
||||
"verify": False
|
||||
})
|
||||
|
||||
teststeps = [
|
||||
TestStep(**{
|
||||
"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.foo2", "session_bar2"]},
|
||||
{"eq": ["body.args.sum_v", "3"]}
|
||||
]
|
||||
}),
|
||||
TestStep(**{
|
||||
"name": "post raw text",
|
||||
"variables": {
|
||||
"foo1": "hello world",
|
||||
"foo3": "$session_foo2"
|
||||
},
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "/post",
|
||||
"data": "This is expected to be sent back as part of response body: $foo1-$foo3.",
|
||||
"headers": {
|
||||
"User-Agent": "HttpRunner/${get_httprunner_version()}",
|
||||
"Content-Type": "text/plain"
|
||||
}
|
||||
},
|
||||
"validate": [
|
||||
{"eq": ["status_code", 200]},
|
||||
{"eq": [
|
||||
"body.data",
|
||||
"This is expected to be sent back as part of response body: session_bar1-session_bar2."
|
||||
]},
|
||||
]
|
||||
}),
|
||||
TestStep(**{
|
||||
"name": "post form data",
|
||||
"variables": {
|
||||
"foo1": "session_bar1",
|
||||
"foo2": "bar2"
|
||||
},
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "/post",
|
||||
"data": "foo1=$foo1&foo2=$foo2",
|
||||
"headers": {
|
||||
"User-Agent": "HttpRunner/${get_httprunner_version()}",
|
||||
"Content-Type": "application/x-www-form-urlencoded"
|
||||
}
|
||||
},
|
||||
"validate": [
|
||||
{"eq": ["status_code", 200]},
|
||||
{"eq": ["body.form.foo1", "session_bar1"]},
|
||||
{"eq": ["body.form.foo2", "bar2"]}
|
||||
]
|
||||
})
|
||||
]
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
TestCaseRequestMethodsWithFunctions().run()
|
||||
@@ -1,9 +1,9 @@
|
||||
import ast
|
||||
import re
|
||||
from typing import Any, Set, Text
|
||||
from typing import Dict
|
||||
from typing import Any, Set, Text, Callable, Tuple, List, Dict, Union
|
||||
|
||||
from httprunner.v3 import exceptions
|
||||
from httprunner.v3.exceptions import VariableNotFound
|
||||
from httprunner.v3.exceptions import VariableNotFound, FunctionNotFound
|
||||
|
||||
absolute_http_url_regexp = re.compile(r"^https?://", re.I)
|
||||
|
||||
@@ -15,6 +15,22 @@ variable_regex_compile = re.compile(r"\$\{(\w+)\}|\$(\w+)")
|
||||
function_regex_compile = re.compile(r"\$\{(\w+)\(([\$\w\.\-/\s=,]*)\)\}")
|
||||
|
||||
|
||||
def parse_string_value(str_value: Text) -> Any:
|
||||
""" parse string to number if possible
|
||||
e.g. "123" => 123
|
||||
"12.2" => 12.3
|
||||
"abc" => "abc"
|
||||
"$var" => "$var"
|
||||
"""
|
||||
try:
|
||||
return ast.literal_eval(str_value)
|
||||
except ValueError:
|
||||
return str_value
|
||||
except SyntaxError:
|
||||
# e.g. $var, ${func}
|
||||
return str_value
|
||||
|
||||
|
||||
def build_url(base_url, path):
|
||||
""" prepend url with base_url unless it's already an absolute URL """
|
||||
if absolute_http_url_regexp.match(path):
|
||||
@@ -25,7 +41,7 @@ def build_url(base_url, path):
|
||||
raise exceptions.ParamsError("base url missed!")
|
||||
|
||||
|
||||
def regex_findall_variables(content):
|
||||
def regex_findall_variables(content: Text) -> List[Text]:
|
||||
""" extract all variable names from content, which is in format $variable
|
||||
|
||||
Args:
|
||||
@@ -59,6 +75,88 @@ def regex_findall_variables(content):
|
||||
return []
|
||||
|
||||
|
||||
def regex_findall_functions(content: Text) -> List[Text]:
|
||||
""" extract all functions from string content, which are in format ${fun()}
|
||||
|
||||
Args:
|
||||
content (str): string content
|
||||
|
||||
Returns:
|
||||
list: functions list extracted from string content
|
||||
|
||||
Examples:
|
||||
>>> regex_findall_functions("${func(5)}")
|
||||
["func(5)"]
|
||||
|
||||
>>> regex_findall_functions("${func(a=1, b=2)}")
|
||||
["func(a=1, b=2)"]
|
||||
|
||||
>>> regex_findall_functions("/api/1000?_t=${get_timestamp()}")
|
||||
["get_timestamp()"]
|
||||
|
||||
>>> regex_findall_functions("/api/${add(1, 2)}")
|
||||
["add(1, 2)"]
|
||||
|
||||
>>> regex_findall_functions("/api/${add(1, 2)}?_t=${get_timestamp()}")
|
||||
["add(1, 2)", "get_timestamp()"]
|
||||
|
||||
"""
|
||||
try:
|
||||
return function_regex_compile.findall(content)
|
||||
except TypeError:
|
||||
return []
|
||||
|
||||
|
||||
def parse_args_str(arg_str: Text) -> Tuple[List, Dict]:
|
||||
""" parse function args and kwargs from function.
|
||||
|
||||
Args:
|
||||
arg_str (str): function str contains args and kwargs
|
||||
|
||||
Returns:
|
||||
dict: function meta dict
|
||||
|
||||
{
|
||||
"func_name": "xxx",
|
||||
"args": [],
|
||||
"kwargs": {}
|
||||
}
|
||||
|
||||
Examples:
|
||||
>>> parse_args_str("")
|
||||
{'args': [], 'kwargs': {}}
|
||||
|
||||
>>> parse_args_str("5")
|
||||
{'args': [5], 'kwargs': {}}
|
||||
|
||||
>>> parse_args_str("1, 2")
|
||||
{'args': [1, 2], 'kwargs': {}}
|
||||
|
||||
>>> parse_args_str("a=1, b=2")
|
||||
{'args': [], 'kwargs': {'a': 1, 'b': 2}}
|
||||
|
||||
>>> parse_args_str("1, 2, a=3, b=4")
|
||||
{'args': [1, 2], 'kwargs': {'a':3, 'b':4}}
|
||||
|
||||
"""
|
||||
args = []
|
||||
kwargs = {}
|
||||
arg_str = arg_str.strip()
|
||||
if arg_str == "":
|
||||
return args, kwargs
|
||||
|
||||
arg_list = arg_str.split(',')
|
||||
for arg in arg_list:
|
||||
arg = arg.strip()
|
||||
if '=' in arg:
|
||||
key, value = arg.split('=')
|
||||
kwargs[key.strip()] = parse_string_value(value.strip())
|
||||
else:
|
||||
args.append(parse_string_value(arg))
|
||||
|
||||
return args, kwargs
|
||||
|
||||
|
||||
def extract_variables(content: Any) -> Set:
|
||||
""" extract all variables in content recursively.
|
||||
"""
|
||||
@@ -80,7 +178,59 @@ def extract_variables(content: Any) -> Set:
|
||||
return set()
|
||||
|
||||
|
||||
def parse_string_variables(content, variables_mapping):
|
||||
def parse_string_functions(
|
||||
content: Text,
|
||||
variables_mapping: Dict[Text, Any],
|
||||
functions_mapping: Dict[Text, Callable]) -> Text:
|
||||
""" parse string content with functions mapping.
|
||||
|
||||
Args:
|
||||
content (str): string content to be parsed.
|
||||
variables_mapping (dict): variables mapping.
|
||||
functions_mapping (dict): functions mapping.
|
||||
|
||||
Returns:
|
||||
str: parsed string content.
|
||||
|
||||
Examples:
|
||||
>>> content = "abc${add_one(3)}def"
|
||||
>>> functions_mapping = {"add_one": lambda x: x + 1}
|
||||
>>> parse_string_functions(content, {}, functions_mapping)
|
||||
"abc4def"
|
||||
|
||||
"""
|
||||
functions_list = regex_findall_functions(content)
|
||||
for func_meta_tuple in functions_list:
|
||||
func_name, args_str = func_meta_tuple
|
||||
args, kwargs = parse_args_str(args_str)
|
||||
|
||||
args = parse_content(args, variables_mapping, functions_mapping)
|
||||
kwargs = parse_content(kwargs, variables_mapping, functions_mapping)
|
||||
|
||||
try:
|
||||
func = functions_mapping[func_name]
|
||||
except KeyError:
|
||||
raise FunctionNotFound(f"{func_name} not found in {functions_mapping}")
|
||||
|
||||
eval_value = func(*args, **kwargs)
|
||||
|
||||
func_content = "${" + func_name + f"({args_str})" + "}"
|
||||
if func_content == content:
|
||||
# content is a function, e.g. "${add_one(3)}"
|
||||
content = eval_value
|
||||
else:
|
||||
# content contains one or many functions, e.g. "abc${add_one(3)}def"
|
||||
content = content.replace(
|
||||
func_content,
|
||||
str(eval_value), 1
|
||||
)
|
||||
|
||||
return content
|
||||
|
||||
|
||||
def parse_string_variables(
|
||||
content: Text,
|
||||
variables_mapping: Dict[Text, Any]) -> Text:
|
||||
""" parse string content with variables mapping.
|
||||
|
||||
Args:
|
||||
@@ -92,7 +242,7 @@ def parse_string_variables(content, variables_mapping):
|
||||
|
||||
Examples:
|
||||
>>> content = "/api/users/$uid"
|
||||
>>> variables_mapping = {"$uid": 1000}
|
||||
>>> variables_mapping = {"uid": 1000}
|
||||
>>> parse_string_variables(content, variables_mapping)
|
||||
"/api/users/1000"
|
||||
|
||||
@@ -102,7 +252,7 @@ def parse_string_variables(content, variables_mapping):
|
||||
try:
|
||||
variable_value = variables_mapping[variable_name]
|
||||
except KeyError:
|
||||
raise VariableNotFound(f"{variable_name} not in {variables_mapping}")
|
||||
raise VariableNotFound(f"{variable_name} not found in {variables_mapping}")
|
||||
|
||||
# TODO: replace variable label from $var to {{var}}
|
||||
if f"${variable_name}" == content:
|
||||
@@ -121,7 +271,10 @@ def parse_string_variables(content, variables_mapping):
|
||||
return content
|
||||
|
||||
|
||||
def parse_content(content: Any, variables_mapping: Dict[str, Any] = None, functions_mapping=None):
|
||||
def parse_content(
|
||||
content: Any,
|
||||
variables_mapping: Dict[Text, Any] = None,
|
||||
functions_mapping: Dict[Text, Callable] = None) -> Any:
|
||||
""" parse content with evaluated variables mapping.
|
||||
Notice: variables_mapping should not contain any variable or function.
|
||||
"""
|
||||
@@ -136,8 +289,8 @@ def parse_content(content: Any, variables_mapping: Dict[str, Any] = None, functi
|
||||
content = content.strip()
|
||||
|
||||
# replace functions with evaluated value
|
||||
# Notice: _eval_content_functions must be called before _eval_content_variables
|
||||
# content = parse_string_functions(content, variables_mapping, functions_mapping)
|
||||
# Notice: parse_string_functions must be called before parse_string_variables
|
||||
content = parse_string_functions(content, variables_mapping, functions_mapping)
|
||||
|
||||
# replace variables with binding value
|
||||
content = parse_string_variables(content, variables_mapping)
|
||||
@@ -146,15 +299,15 @@ def parse_content(content: Any, variables_mapping: Dict[str, Any] = None, functi
|
||||
|
||||
elif isinstance(content, (list, set, tuple)):
|
||||
return [
|
||||
parse_content(item, variables_mapping)
|
||||
parse_content(item, variables_mapping, functions_mapping)
|
||||
for item in content
|
||||
]
|
||||
|
||||
elif isinstance(content, dict):
|
||||
parsed_content = {}
|
||||
for key, value in content.items():
|
||||
parsed_key = parse_content(key, variables_mapping)
|
||||
parsed_value = parse_content(value, variables_mapping)
|
||||
parsed_key = parse_content(key, variables_mapping, functions_mapping)
|
||||
parsed_value = parse_content(value, variables_mapping, functions_mapping)
|
||||
parsed_content[parsed_key] = parsed_value
|
||||
|
||||
return parsed_content
|
||||
@@ -162,7 +315,9 @@ def parse_content(content: Any, variables_mapping: Dict[str, Any] = None, functi
|
||||
return content
|
||||
|
||||
|
||||
def parse_variables_mapping(variables_mapping: Dict[Text, Any]) -> Dict[Text, Any]:
|
||||
def parse_variables_mapping(
|
||||
variables_mapping: Dict[Text, Any],
|
||||
functions_mapping: Dict[Text, Callable] = None) -> Dict[Text, Any]:
|
||||
|
||||
parsed_variables: Dict[Text, Any] = {}
|
||||
|
||||
@@ -194,7 +349,8 @@ def parse_variables_mapping(variables_mapping: Dict[Text, Any]) -> Dict[Text, An
|
||||
raise VariableNotFound(not_defined_variables)
|
||||
|
||||
try:
|
||||
parsed_value = parse_content(var_value, parsed_variables)
|
||||
parsed_value = parse_content(
|
||||
var_value, parsed_variables, functions_mapping)
|
||||
except VariableNotFound:
|
||||
continue
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ class TestCaseRunner(object):
|
||||
|
||||
# parse
|
||||
request_dict = step.request.dict()
|
||||
parsed_request_dict = parse_content(request_dict, step.variables)
|
||||
parsed_request_dict = parse_content(request_dict, step.variables, self.config.functions)
|
||||
|
||||
# prepare arguments
|
||||
method = parsed_request_dict.pop("method")
|
||||
@@ -62,7 +62,7 @@ class TestCaseRunner(object):
|
||||
# update with session variables extracted from former step
|
||||
step.variables.update(session_variables)
|
||||
# parse variables
|
||||
step.variables = parse_variables_mapping(step.variables)
|
||||
step.variables = parse_variables_mapping(step.variables, self.config.functions)
|
||||
# run step
|
||||
extract_mapping = self.run_step(step)
|
||||
# save extracted variables to session variables
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
from typing import Dict, List, Text, Union
|
||||
from typing import Dict, List, Text, Union, Callable
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import HttpUrl
|
||||
@@ -34,6 +34,7 @@ class TestsConfig(BaseModel):
|
||||
verify: Verify = False
|
||||
base_url: BaseUrl = ""
|
||||
variables: Variables = {}
|
||||
functions: Dict[Text, Callable]
|
||||
setup_hooks: Hook = []
|
||||
teardown_hooks: Hook = []
|
||||
export: Export = []
|
||||
|
||||
Reference in New Issue
Block a user