Merge pull request #902 from httprunner/v3

## 3.0.4 (2020-05-19)

**Added**

- feat: make testsuite and run testsuite
- feat: testcase/testsuite config support getting variables by function
- feat: har2case with request cookies
- feat: log request/response headers and body with indent

**Fixed**

- fix: extract response cookies
- fix: handle errors when no valid testcases generated

**Changed**

- change: har2case do not ignore request headers, except for header startswith :
This commit is contained in:
debugtalk
2020-05-19 20:56:09 +08:00
committed by GitHub
26 changed files with 457 additions and 131 deletions

View File

@@ -1,5 +1,23 @@
# Release History # Release History
## 3.0.4 (2020-05-19)
**Added**
- feat: make testsuite and run testsuite
- feat: testcase/testsuite config support getting variables by function
- feat: har2case with request cookies
- feat: log request/response headers and body with indent
**Fixed**
- fix: extract response cookies
- fix: handle errors when no valid testcases generated
**Changed**
- change: har2case do not ignore request headers, except for header startswith :
## 3.0.3 (2020-05-17) ## 3.0.3 (2020-05-17)
**Fixed** **Fixed**

View File

@@ -19,7 +19,7 @@ teststeps:
method: GET method: GET
validate: validate:
- eq: ["status_code", 200] - eq: ["status_code", 200]
# - startswith: [body.user-agent, "python-requests"] - startswith: [body."user-agent", "python-requests"]
- -
name: get without params name: get without params
@@ -58,7 +58,7 @@ teststeps:
method: GET method: GET
validate: validate:
- eq: ["status_code", 200] - eq: ["status_code", 200]
# - eq: [cookies.name, "value"] - eq: [body.cookies.name, "value"]
- -
name: extract cookie name: extract cookie
@@ -67,7 +67,7 @@ teststeps:
method: GET method: GET
validate: validate:
- eq: ["status_code", 200] - eq: ["status_code", 200]
# - eq: [cookies.name, "value"] - eq: [body.cookies.name, "value"]
- -
name: post data name: post data

View File

@@ -27,7 +27,10 @@ class TestCaseBasic(HttpRunner):
**{ **{
"name": "user-agent", "name": "user-agent",
"request": {"url": "/user-agent", "method": "GET"}, "request": {"url": "/user-agent", "method": "GET"},
"validate": [{"eq": ["status_code", 200]}], "validate": [
{"eq": ["status_code", 200]},
{"startswith": ['body."user-agent"', "python-requests"]},
],
} }
), ),
TStep( TStep(
@@ -61,14 +64,20 @@ class TestCaseBasic(HttpRunner):
**{ **{
"name": "set cookie", "name": "set cookie",
"request": {"url": "/cookies/set?name=value", "method": "GET"}, "request": {"url": "/cookies/set?name=value", "method": "GET"},
"validate": [{"eq": ["status_code", 200]}], "validate": [
{"eq": ["status_code", 200]},
{"eq": ["body.cookies.name", "value"]},
],
} }
), ),
TStep( TStep(
**{ **{
"name": "extract cookie", "name": "extract cookie",
"request": {"url": "/cookies", "method": "GET"}, "request": {"url": "/cookies", "method": "GET"},
"validate": [{"eq": ["status_code", 200]}], "validate": [
{"eq": ["status_code", 200]},
{"eq": ["body.cookies.name", "value"]},
],
} }
), ),
TStep( TStep(

View File

@@ -32,5 +32,6 @@ teststeps:
- ${alter_response($response)} - ${alter_response($response)}
validate: validate:
- eq: ["status_code", 200] - eq: ["status_code", 200]
# - eq: ["headers.content-type", "html/text"] # TODO: implement hooks
# - eq: [body.headers."Content-Type", "html/text"]
- eq: [body.headers.Host, "httpbin.org"] - eq: [body.headers.Host, "httpbin.org"]

View File

@@ -7,3 +7,9 @@ def get_httprunner_version():
def sum_two(m, n): def sum_two(m, n):
return m + n return m + n
def get_variables():
return {
"foo1": "session_bar1"
}

View File

@@ -0,0 +1,15 @@
config:
name: "demo testsuite"
variables: ${get_variables()}
testcases:
-
name: request with functions
testcase: request_methods/request_with_functions.yml
variables:
var1: testsuite_val1
-
name: request with referenced testcase
testcase: request_methods/request_with_testcase_reference.yml
variables:
var2: testsuite_val2

View File

@@ -0,0 +1,89 @@
# NOTICE: Generated By HttpRunner. DO'NOT EDIT!
# FROM: examples/postman_echo/request_methods/demo_testsuite_yml/request_with_functions.yml
from httprunner import HttpRunner, TConfig, TStep
class TestCaseRequestWithFunctions(HttpRunner):
config = TConfig(
**{
"name": "request with functions",
"variables": {"foo1": "session_bar1", "var1": "testsuite_val1"},
"base_url": "https://postman-echo.com",
"verify": False,
"path": "examples/postman_echo/request_methods/demo_testsuite_yml/request_with_functions_test.py",
}
)
teststeps = [
TStep(
**{
"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.sum_v", 3]},
{"eq": ["body.args.foo2", "session_bar2"]},
],
}
),
TStep(
**{
"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.",
]
},
],
}
),
TStep(
**{
"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"]},
],
}
),
]
if __name__ == "__main__":
TestCaseRequestWithFunctions().test_start()

View File

@@ -0,0 +1,29 @@
# NOTICE: Generated By HttpRunner. DO'NOT EDIT!
# FROM: examples/postman_echo/request_methods/demo_testsuite_yml/request_with_testcase_reference.yml
from httprunner import HttpRunner, TConfig, TStep
class TestCaseRequestWithTestcaseReference(HttpRunner):
config = TConfig(
**{
"name": "request with referenced testcase",
"variables": {"foo1": "session_bar1", "var2": "testsuite_val2"},
"base_url": "https://postman-echo.com",
"verify": False,
"path": "examples/postman_echo/request_methods/demo_testsuite_yml/request_with_testcase_reference_test.py",
}
)
teststeps = [
TStep(
**{
"name": "request with functions",
"variables": {"foo1": "override_bar1"},
"testcase": "request_methods/request_with_functions.yml",
}
),
]
if __name__ == "__main__":
TestCaseRequestWithTestcaseReference().test_start()

View File

@@ -10,6 +10,7 @@ class TestCaseHardcode(HttpRunner):
"base_url": "https://postman-echo.com", "base_url": "https://postman-echo.com",
"verify": False, "verify": False,
"path": "examples/postman_echo/request_methods/hardcode_test.py", "path": "examples/postman_echo/request_methods/hardcode_test.py",
"variables": {},
} }
) )

View File

@@ -7,7 +7,7 @@ config:
teststeps: teststeps:
- -
name: request with variables name: request with functions
variables: variables:
foo1: override_bar1 foo1: override_bar1
testcase: request_methods/request_with_variables.yml testcase: request_methods/request_with_functions.yml

View File

@@ -17,9 +17,9 @@ class TestCaseRequestWithTestcaseReference(HttpRunner):
teststeps = [ teststeps = [
TStep( TStep(
**{ **{
"name": "request with variables", "name": "request with functions",
"variables": {"foo1": "override_bar1"}, "variables": {"foo1": "override_bar1"},
"testcase": "request_methods/request_with_variables.yml", "testcase": "request_methods/request_with_functions.yml",
} }
), ),
] ]

View File

@@ -1,7 +1,6 @@
config: config:
name: "request methods testcase with variables" name: "request methods testcase with variables"
variables: variables: ${get_variables()}
foo1: session_bar1
base_url: "https://postman-echo.com" base_url: "https://postman-echo.com"
verify: False verify: False

View File

@@ -1,4 +1,4 @@
__version__ = "3.0.3" __version__ = "3.0.4"
__description__ = "One-stop solution for HTTP(S) testing." __description__ = "One-stop solution for HTTP(S) testing."
from httprunner.runner import HttpRunner from httprunner.runner import HttpRunner

View File

@@ -3,6 +3,7 @@ import os
import sys import sys
import pytest import pytest
from loguru import logger
from httprunner import __description__, __version__, exceptions from httprunner import __description__, __version__, exceptions
from httprunner.ext.har2case import init_har2case_parser, main_har2case from httprunner.ext.har2case import init_har2case_parser, main_har2case
@@ -19,25 +20,30 @@ def init_parser_run(subparsers):
def main_run(extra_args): def main_run(extra_args):
tests_path_list = [] tests_path_list = []
for index, item in enumerate(extra_args): extra_args_new = []
for item in extra_args:
if not os.path.exists(item): if not os.path.exists(item):
# item is not file/folder path # item is not file/folder path
continue extra_args_new.append(item)
elif os.path.isfile(item): else:
# replace YAML/JSON file path with generated python file # item is file/folder path
extra_args[index], _ = convert_testcase_path(item) tests_path_list.append(item)
tests_path_list.append(item)
if len(tests_path_list) == 0: if len(tests_path_list) == 0:
# has not specified any testcase path # has not specified any testcase path
raise exceptions.ParamsError("Missed testcase path") logger.error(f"No valid testcase path in cli arguments: {extra_args}")
sys.exit(1)
main_make(tests_path_list) testcase_path_list = main_make(tests_path_list)
if not testcase_path_list:
logger.error("No valid testcases found, exit 1.")
sys.exit(1)
if "-s" not in extra_args: extra_args_new.extend(testcase_path_list)
extra_args.insert(0, "-s") if "-s" not in extra_args_new:
pytest.main(extra_args) extra_args_new.insert(0, "-s")
pytest.main(extra_args_new)
def main(): def main():

View File

@@ -1,3 +1,4 @@
import json
import time import time
import requests import requests
@@ -32,12 +33,19 @@ def get_req_resp_record(resp_obj: Response) -> ReqRespData:
def log_print(req_or_resp, r_type): def log_print(req_or_resp, r_type):
msg = f"\n================== {r_type} details ==================\n" msg = f"\n================== {r_type} details ==================\n"
for key, value in req_or_resp.dict().items(): for key, value in req_or_resp.dict().items():
msg += "{:<16} : {}\n".format(key, repr(value)) if isinstance(value, dict):
value = json.dumps(value, indent=4)
msg += "{:<8} : {}\n".format(key, value)
logger.debug(msg) logger.debug(msg)
# record actual request info # record actual request info
request_headers = dict(resp_obj.request.headers) request_headers = dict(resp_obj.request.headers)
request_body = resp_obj.request.body request_body = resp_obj.request.body
try:
request_body = json.loads(request_body)
except json.JSONDecodeError:
pass
if request_body: if request_body:
request_content_type = lower_dict_keys(request_headers).get("content-type") request_content_type = lower_dict_keys(request_headers).get("content-type")
@@ -195,10 +203,6 @@ class HttpSession(requests.Session):
Safe mode has been removed from requests 1.x. Safe mode has been removed from requests 1.x.
""" """
try: try:
msg = "processed request:\n"
msg += f"> {method} {url}\n"
msg += f"> kwargs: {kwargs}"
logger.debug(msg)
return requests.Session.request(self, method, url, **kwargs) return requests.Session.request(self, method, url, **kwargs)
except (MissingSchema, InvalidSchema, InvalidURL): except (MissingSchema, InvalidSchema, InvalidURL):
raise raise

View File

@@ -44,6 +44,10 @@ class TestCaseFormatError(MyBaseError):
pass pass
class TestSuiteFormatError(MyBaseError):
pass
class ParamsError(MyBaseError): class ParamsError(MyBaseError):
pass pass

View File

@@ -14,24 +14,6 @@ except ImportError:
JSONDecodeError = ValueError JSONDecodeError = ValueError
IGNORE_REQUEST_HEADERS = [
"host",
"accept",
"content-length",
"connection",
"accept-encoding",
"accept-language",
"origin",
"cache-control",
"pragma",
"upgrade-insecure-requests",
":authority",
":method",
":scheme",
":path",
]
class HarParser(object): class HarParser(object):
def __init__(self, har_file_path, filter_str=None, exclude_str=None): def __init__(self, har_file_path, filter_str=None, exclude_str=None):
self.har_file_path = har_file_path self.har_file_path = har_file_path
@@ -93,6 +75,14 @@ class HarParser(object):
teststep_dict["request"]["method"] = method teststep_dict["request"]["method"] = method
def __make_request_cookies(self, teststep_dict, entry_json):
cookies = {}
for cookie in entry_json["request"].get("cookies", []):
cookies[cookie["name"]] = cookie["value"]
if cookies:
teststep_dict["request"]["cookies"] = cookies
def __make_request_headers(self, teststep_dict, entry_json): def __make_request_headers(self, teststep_dict, entry_json):
""" parse HAR entry request headers, and make teststep headers. """ parse HAR entry request headers, and make teststep headers.
header in IGNORE_REQUEST_HEADERS will be ignored. header in IGNORE_REQUEST_HEADERS will be ignored.
@@ -119,7 +109,7 @@ class HarParser(object):
""" """
teststep_headers = {} teststep_headers = {}
for header in entry_json["request"].get("headers", []): for header in entry_json["request"].get("headers", []):
if header["name"].lower() in IGNORE_REQUEST_HEADERS: if header["name"] == "cookie" or header["name"].startswith(":"):
continue continue
teststep_headers[header["name"]] = header["value"] teststep_headers[header["name"]] = header["value"]
@@ -288,6 +278,7 @@ class HarParser(object):
self.__make_request_url(teststep_dict, entry_json) self.__make_request_url(teststep_dict, entry_json)
self.__make_request_method(teststep_dict, entry_json) self.__make_request_method(teststep_dict, entry_json)
self.__make_request_cookies(teststep_dict, entry_json)
self.__make_request_headers(teststep_dict, entry_json) self.__make_request_headers(teststep_dict, entry_json)
self._make_request_data(teststep_dict, entry_json) self._make_request_data(teststep_dict, entry_json)
self._make_validate(teststep_dict, entry_json) self._make_validate(teststep_dict, entry_json)

View File

@@ -1,13 +1,19 @@
import os import os
import subprocess import subprocess
from typing import Union, Text, List, Tuple from typing import Union, Text, List, Tuple, Dict
import jinja2 import jinja2
from loguru import logger from loguru import logger
from httprunner import exceptions from httprunner import exceptions
from httprunner.exceptions import TestCaseFormatError from httprunner.loader import (
from httprunner.loader import load_testcase_file, load_folder_files load_folder_files,
load_test_file,
load_testcase,
load_testsuite,
load_project_meta,
)
from httprunner.parser import parse_data
__TMPL__ = """# NOTICE: Generated By HttpRunner. DO'NOT EDIT! __TMPL__ = """# NOTICE: Generated By HttpRunner. DO'NOT EDIT!
# FROM: {{ testcase_path }} # FROM: {{ testcase_path }}
@@ -39,7 +45,9 @@ def convert_testcase_path(testcase_path: Text) -> Tuple[Text, Text]:
file_suffix = file_suffix.lower() file_suffix = file_suffix.lower()
if file_suffix not in [".json", ".yml", ".yaml"]: if file_suffix not in [".json", ".yml", ".yaml"]:
raise exceptions.ParamsError("") raise exceptions.ParamsError(
"testcase file should have .yaml/.yml/.json suffix"
)
file_name = raw_file_name.replace(" ", "_").replace(".", "_").replace("-", "_") file_name = raw_file_name.replace(" ", "_").replace(".", "_").replace("-", "_")
testcase_dir = os.path.dirname(testcase_path) testcase_dir = os.path.dirname(testcase_path)
@@ -51,18 +59,40 @@ def convert_testcase_path(testcase_path: Text) -> Tuple[Text, Text]:
return testcase_python_path, name_in_title_case return testcase_python_path, name_in_title_case
def make_testcase(testcase_path: str) -> Union[str, None]: def format_pytest_with_black(python_paths: List[Text]):
logger.info(f"start to make testcase: {testcase_path}") logger.info("format pytest cases with black ...")
try: try:
testcase, _ = load_testcase_file(testcase_path) subprocess.run(["black", *python_paths])
except TestCaseFormatError: except subprocess.CalledProcessError as ex:
return None logger.error(ex)
def make_testcase(testcase: Dict) -> Union[str, None]:
"""convert valid testcase dict to pytest file path"""
try:
# validate testcase format
load_testcase(testcase)
except exceptions.TestCaseFormatError as ex:
logger.error(f"TestCaseFormatError: {ex}")
raise
testcase_path = testcase["config"]["path"]
logger.info(f"start to make testcase: {testcase_path}")
template = jinja2.Template(__TMPL__) template = jinja2.Template(__TMPL__)
testcase_python_path, name_in_title_case = convert_testcase_path(testcase_path) testcase_python_path, name_in_title_case = convert_testcase_path(testcase_path)
config = testcase["config"] config = testcase["config"]
config.setdefault("variables", {})
if isinstance(config["variables"], Text):
# get variables by function, e.g. ${get_variables()}
project_meta = load_project_meta(testcase_path)
config["variables"] = parse_data(
config["variables"], {}, project_meta.functions
)
config["path"] = testcase_python_path config["path"] = testcase_python_path
data = { data = {
"testcase_path": testcase_path, "testcase_path": testcase_path,
@@ -79,42 +109,119 @@ def make_testcase(testcase_path: str) -> Union[str, None]:
return testcase_python_path return testcase_python_path
def format_with_black(tests_path: Text): def make_testsuite(testsuite: Dict) -> List[Text]:
logger.info("format testcases with black ...") """convert valid testsuite dict to pytest folder with testcases"""
tests_path, _ = convert_testcase_path(tests_path)
try: try:
subprocess.run(["black", tests_path]) # validate testcase format
except subprocess.CalledProcessError as ex: load_testsuite(testsuite)
logger.error(ex) except exceptions.TestSuiteFormatError as ex:
logger.error(f"TestSuiteFormatError: {ex}")
raise
config = testsuite["config"]
testsuite_path = config["path"]
project_meta = load_project_meta(testsuite_path)
project_working_directory = project_meta.PWD
testsuite_variables = config.get("variables", {})
if isinstance(testsuite_variables, Text):
# get variables by function, e.g. ${get_variables()}
testsuite_variables = parse_data(
testsuite_variables, {}, project_meta.functions
)
logger.info(f"start to make testsuite: {testsuite_path}")
# create directory with testsuite file name, put its testcases under this directory
testsuite_dir = testsuite_path.replace(".", "_")
os.makedirs(testsuite_dir, exist_ok=True)
testcase_files = []
for testcase in testsuite["testcases"]:
# get referenced testcase content
testcase_file = testcase["testcase"]
testcase_path = os.path.join(project_working_directory, testcase_file)
testcase_dict = load_test_file(testcase_path)
testcase_dict.setdefault("config", {})
testcase_dict["config"]["path"] = os.path.join(
testsuite_dir, os.path.basename(testcase_path)
)
# override testcase name
testcase_dict["config"]["name"] = testcase["name"]
# override base_url
base_url = testsuite["config"].get("base_url") or testcase.get("base_url")
if base_url:
testcase_dict["config"]["base_url"] = base_url
# override variables
testcase_dict["config"].setdefault("variables", {})
testcase_dict["config"]["variables"].update(testcase.get("variables", {}))
testcase_dict["config"]["variables"].update(testsuite_variables)
# make testcase
testcase_path = make_testcase(testcase_dict)
testcase_files.append(testcase_path)
return testcase_files
def make(tests_path: Text) -> List: def __make(tests_path: Text) -> List:
testcases = [] test_files = []
if os.path.isdir(tests_path): if os.path.isdir(tests_path):
files_list = load_folder_files(tests_path) files_list = load_folder_files(tests_path)
testcases.extend(files_list) test_files.extend(files_list)
elif os.path.isfile(tests_path): elif os.path.isfile(tests_path):
testcases.append(tests_path) test_files.append(tests_path)
else: else:
raise exceptions.TestcaseNotFound(f"Invalid tests path: {tests_path}") raise exceptions.TestcaseNotFound(f"Invalid tests path: {tests_path}")
testcase_path_list = [] testcase_path_list = []
for testcase_path in testcases: for test_file in test_files:
testcase_path = make_testcase(testcase_path) try:
if not testcase_path: test_content = load_test_file(test_file)
test_content.setdefault("config", {})["path"] = test_file
except (exceptions.FileNotFound, exceptions.FileFormatError) as ex:
logger.warning(ex)
continue continue
testcase_path_list.append(testcase_path)
format_with_black(tests_path) # testcase
if "teststeps" in test_content:
try:
testcase_file = make_testcase(test_content)
except exceptions.TestCaseFormatError:
continue
testcase_path_list.append(testcase_file)
# testsuite
elif "testcases" in test_content:
try:
testcase_files = make_testsuite(test_content)
except exceptions.TestSuiteFormatError:
continue
testcase_path_list.extend(testcase_files)
# invalid format
else:
raise exceptions.FileFormatError(
f"test file is neither testcase nor testsuite: {test_file}"
)
if not testcase_path_list:
logger.warning(f"No valid testcase generated on {tests_path}")
return []
return testcase_path_list return testcase_path_list
def main_make(tests_paths: List[Text]) -> List: def main_make(tests_paths: List[Text]) -> List:
testcase_path_list = [] testcase_path_list = []
for tests_path in tests_paths: for tests_path in tests_paths:
testcase_path_list.extend(make(tests_path)) testcase_path_list.extend(__make(tests_path))
format_pytest_with_black(testcase_path_list)
return testcase_path_list return testcase_path_list

View File

@@ -1,13 +1,14 @@
import unittest import unittest
from httprunner.ext.make import make_testcase, main_make, convert_testcase_path
from httprunner.ext.make import main_make, convert_testcase_path
class TestLoader(unittest.TestCase): class TestLoader(unittest.TestCase):
def test_make_testcase(self): def test_make_testcase(self):
path = "examples/postman_echo/request_methods/request_with_variables.yml" path = ["examples/postman_echo/request_methods/request_with_variables.yml"]
testcase_python_path = make_testcase(path) testcase_python_list = main_make(path)
self.assertEqual( self.assertEqual(
testcase_python_path, testcase_python_list[0],
"examples/postman_echo/request_methods/request_with_variables_test.py", "examples/postman_echo/request_methods/request_with_variables_test.py",
) )
@@ -21,42 +22,47 @@ class TestLoader(unittest.TestCase):
def test_convert_testcase_path(self): def test_convert_testcase_path(self):
self.assertEqual( self.assertEqual(
convert_testcase_path("mubu.login.yml")[0], convert_testcase_path("mubu.login.yml")[0], "mubu_login_test.py"
"mubu_login_test.py"
) )
self.assertEqual( self.assertEqual(
convert_testcase_path("/path/to/mubu.login.yml")[0], convert_testcase_path("/path/to/mubu.login.yml")[0],
"/path/to/mubu_login_test.py" "/path/to/mubu_login_test.py",
) )
self.assertEqual( self.assertEqual(
convert_testcase_path("/path/to 2/mubu.login.yml")[0], convert_testcase_path("/path/to 2/mubu.login.yml")[0],
"/path/to 2/mubu_login_test.py" "/path/to 2/mubu_login_test.py",
) )
self.assertEqual( self.assertEqual(
convert_testcase_path("/path/to 2/mubu.login.yml")[1], convert_testcase_path("/path/to 2/mubu.login.yml")[1], "MubuLogin"
"MubuLogin"
) )
self.assertEqual( self.assertEqual(
convert_testcase_path("mubu login.yml")[0], convert_testcase_path("mubu login.yml")[0], "mubu_login_test.py"
"mubu_login_test.py"
) )
self.assertEqual( self.assertEqual(
convert_testcase_path("/path/to 2/mubu login.yml")[1], convert_testcase_path("/path/to 2/mubu login.yml")[1], "MubuLogin"
"MubuLogin"
) )
self.assertEqual( self.assertEqual(
convert_testcase_path("/path/to 2/mubu-login.yml")[0], convert_testcase_path("/path/to 2/mubu-login.yml")[0],
"/path/to 2/mubu_login_test.py" "/path/to 2/mubu_login_test.py",
) )
self.assertEqual( self.assertEqual(
convert_testcase_path("/path/to 2/mubu-login.yml")[1], convert_testcase_path("/path/to 2/mubu-login.yml")[1], "MubuLogin"
"MubuLogin"
) )
self.assertEqual( self.assertEqual(
convert_testcase_path("/path/to 2/幕布login.yml")[0], convert_testcase_path("/path/to 2/幕布login.yml")[0],
"/path/to 2/幕布login_test.py" "/path/to 2/幕布login_test.py",
) )
self.assertEqual( self.assertEqual(convert_testcase_path("/path/to/幕布login.yml")[1], "幕布Login")
convert_testcase_path("/path/to/幕布login.yml")[1],
"幕布Login" def test_make_testsuite(self):
path = ["examples/postman_echo/request_methods/demo_testsuite.yml"]
testcase_python_list = main_make(path)
self.assertEqual(len(testcase_python_list), 2)
self.assertIn(
"examples/postman_echo/request_methods/demo_testsuite_yml/request_with_functions_test.py",
testcase_python_list,
)
self.assertIn(
"examples/postman_echo/request_methods/demo_testsuite_yml/request_with_testcase_reference_test.py",
testcase_python_list,
) )

View File

@@ -13,7 +13,7 @@ from pydantic import ValidationError
from httprunner import builtin, utils from httprunner import builtin, utils
from httprunner import exceptions from httprunner import exceptions
from httprunner.schema import TestCase, ProjectMeta from httprunner.schema import TestCase, ProjectMeta, TestSuite
try: try:
# PyYAML version >= 5.1 # PyYAML version >= 5.1
@@ -34,7 +34,8 @@ def _load_yaml_file(yaml_file: Text) -> Dict:
try: try:
yaml_content = yaml.load(stream) yaml_content = yaml.load(stream)
except yaml.YAMLError as ex: except yaml.YAMLError as ex:
logger.error(str(ex)) err_msg = f"YAMLError:\nfile: {yaml_file}\nerror: {ex}"
logger.error(err_msg)
raise exceptions.FileFormatError raise exceptions.FileFormatError
return yaml_content return yaml_content
@@ -46,42 +47,65 @@ def _load_json_file(json_file: Text) -> Dict:
with io.open(json_file, encoding="utf-8") as data_file: with io.open(json_file, encoding="utf-8") as data_file:
try: try:
json_content = json.load(data_file) json_content = json.load(data_file)
except json.JSONDecodeError: except json.JSONDecodeError as ex:
err_msg = f"JSONDecodeError: JSON file format error: {json_file}" err_msg = f"JSONDecodeError:\nfile: {json_file}\nerror: {ex}"
logger.error(err_msg) logger.error(err_msg)
raise exceptions.FileFormatError(err_msg) raise exceptions.FileFormatError(err_msg)
return json_content return json_content
def load_testcase_file(testcase_file: Text) -> Tuple[Dict, TestCase]: def load_test_file(test_file: Text) -> Dict:
"""load testcase file and validate with pydantic model""" """load testcase/testsuite file content"""
if not os.path.isfile(testcase_file): if not os.path.isfile(test_file):
raise exceptions.FileNotFound(f"testcase file not exists: {testcase_file}") raise exceptions.FileNotFound(f"test file not exists: {test_file}")
file_suffix = os.path.splitext(testcase_file)[1].lower() file_suffix = os.path.splitext(test_file)[1].lower()
if file_suffix == ".json": if file_suffix == ".json":
testcase_content = _load_json_file(testcase_file) test_file_content = _load_json_file(test_file)
elif file_suffix in [".yaml", ".yml"]: elif file_suffix in [".yaml", ".yml"]:
testcase_content = _load_yaml_file(testcase_file) test_file_content = _load_yaml_file(test_file)
else: else:
# '' or other suffix # '' or other suffix
raise exceptions.FileFormatError( raise exceptions.FileFormatError(
f"testcase file should be YAML/JSON format, invalid testcase file: {testcase_file}" f"testcase/testsuite file should be YAML/JSON format, invalid format file: {test_file}"
) )
return test_file_content
def load_testcase(testcase: Dict) -> TestCase:
path = testcase["config"]["path"]
try: try:
# validate with pydantic TestCase model # validate with pydantic TestCase model
testcase_obj = TestCase.parse_obj(testcase_content) testcase_obj = TestCase.parse_obj(testcase)
except ValidationError as ex: except ValidationError as ex:
err_msg = f"Invalid testcase format: {testcase_file}" err_msg = f"TestCase ValidationError:\nfile: {path}\nerror: {ex}"
logger.error(f"{err_msg}\n{ex}") logger.error(err_msg)
raise exceptions.TestCaseFormatError(err_msg) raise exceptions.TestCaseFormatError(err_msg)
testcase_content["config"]["path"] = testcase_file return testcase_obj
testcase_obj.config.path = testcase_file
return testcase_content, testcase_obj
def load_testcase_file(testcase_file: Text) -> TestCase:
"""load testcase file and validate with pydantic model"""
testcase_content = load_test_file(testcase_file)
testcase_content.setdefault("config", {})["path"] = testcase_file
testcase_obj = load_testcase(testcase_content)
return testcase_obj
def load_testsuite(testsuite: Dict) -> TestSuite:
path = testsuite["config"]["path"]
try:
# validate with pydantic TestCase model
testsuite_obj = TestSuite.parse_obj(testsuite)
except ValidationError as ex:
err_msg = f"TestSuite ValidationError:\nfile: {path}\nerror: {ex}"
logger.error(err_msg)
raise exceptions.TestSuiteFormatError(err_msg)
return testsuite_obj
def load_dot_env_file(dot_env_path: Text) -> Dict: def load_dot_env_file(dot_env_path: Text) -> Dict:
@@ -349,6 +373,14 @@ def init_project_working_directory(test_path: Text) -> Tuple[Text, Text]:
return debugtalk_path, project_working_directory return debugtalk_path, project_working_directory
def get_project_working_directory(test_path: Text) -> Text:
global project_working_directory
if not project_working_directory:
init_project_working_directory(test_path)
return project_working_directory
def load_debugtalk_functions() -> Dict[Text, Callable]: def load_debugtalk_functions() -> Dict[Text, Callable]:
""" load project debugtalk.py module functions """ load project debugtalk.py module functions
debugtalk.py should be located in project working directory. debugtalk.py should be located in project working directory.

View File

@@ -7,14 +7,10 @@ from httprunner import exceptions, loader
class TestLoader(unittest.TestCase): class TestLoader(unittest.TestCase):
def test_load_testcase_file(self): def test_load_testcase_file(self):
path = "examples/postman_echo/request_methods/request_with_variables.yml" path = "examples/postman_echo/request_methods/request_with_variables.yml"
testcase_json, testcase_obj = loader.load_testcase_file(path) testcase_obj = loader.load_testcase_file(path)
self.assertEqual(
testcase_json["config"]["name"], "request methods testcase with variables"
)
self.assertEqual( self.assertEqual(
testcase_obj.config.name, "request methods testcase with variables" testcase_obj.config.name, "request methods testcase with variables"
) )
self.assertEqual(len(testcase_json["teststeps"]), 3)
self.assertEqual(len(testcase_obj.teststeps), 3) self.assertEqual(len(testcase_obj.teststeps), 3)
def test_load_json_file_file_format_error(self): def test_load_json_file_file_format_error(self):

View File

@@ -124,6 +124,7 @@ class ResponseObject(object):
self.resp_obj_meta = { self.resp_obj_meta = {
"status_code": resp_obj.status_code, "status_code": resp_obj.status_code,
"headers": resp_obj.headers, "headers": resp_obj.headers,
"cookies": dict(resp_obj.cookies),
"body": body, "body": body,
} }
self.validation_results: Dict = {} self.validation_results: Dict = {}
@@ -163,6 +164,8 @@ class ResponseObject(object):
# check item # check item
check_item = u_validator["check"] check_item = u_validator["check"]
# TODO: validate variable or function
# check_item = parse_data(check_item, variables_mapping, functions_mapping)
check_value = jmespath.search(check_item, self.resp_obj_meta) check_value = jmespath.search(check_item, self.resp_obj_meta)
check_value = parse_string_value(check_value) check_value = parse_string_value(check_value)

View File

@@ -65,12 +65,8 @@ class HttpRunner(object):
method = parsed_request_dict.pop("method") method = parsed_request_dict.pop("method")
url_path = parsed_request_dict.pop("url") url_path = parsed_request_dict.pop("url")
url = build_url(self.config.base_url, url_path) url = build_url(self.config.base_url, url_path)
parsed_request_dict["json"] = parsed_request_dict.pop("req_json", {}) parsed_request_dict["json"] = parsed_request_dict.pop("req_json", {})
logger.info(f"{method} {url}")
logger.debug(f"request kwargs(raw): {parsed_request_dict}")
# request # request
self.__session = self.__session or HttpSession() self.__session = self.__session or HttpSession()
resp = self.__session.request(method, url, **parsed_request_dict) resp = self.__session.request(method, url, **parsed_request_dict)
@@ -148,7 +144,7 @@ class HttpRunner(object):
def __run_step(self, step: TStep): def __run_step(self, step: TStep):
"""run teststep, teststep maybe a request or referenced testcase""" """run teststep, teststep maybe a request or referenced testcase"""
logger.info(f"run step: {step.name}") logger.info(f"run step begin: {step.name} >>>>>>")
if step.request: if step.request:
step_data = self.__run_step_request(step) step_data = self.__run_step_request(step)
@@ -160,6 +156,7 @@ class HttpRunner(object):
) )
self.__step_datas.append(step_data) self.__step_datas.append(step_data)
logger.info(f"run step end: {step.name} <<<<<<\n")
return step_data.export return step_data.export
def run(self, testcase: TestCase): def run(self, testcase: TestCase):
@@ -209,7 +206,7 @@ class HttpRunner(object):
if not os.path.isfile(path): if not os.path.isfile(path):
raise exceptions.ParamsError(f"Invalid testcase path: {path}") raise exceptions.ParamsError(f"Invalid testcase path: {path}")
_, testcase_obj = load_testcase_file(path) testcase_obj = load_testcase_file(path)
return self.run(testcase_obj) return self.run(testcase_obj)
def get_step_datas(self) -> List[StepData]: def get_step_datas(self) -> List[StepData]:

View File

@@ -9,11 +9,11 @@ class TestHttpRunner(unittest.TestCase):
def test_run_testcase_by_path_request_only(self): def test_run_testcase_by_path_request_only(self):
self.runner.run_path( self.runner.run_path(
"examples/postman_echo/request_methods/request_with_variables.yml" "examples/postman_echo/request_methods/request_with_functions.yml"
) )
result = self.runner.get_summary() result = self.runner.get_summary()
self.assertTrue(result.success) self.assertTrue(result.success)
self.assertEqual(result.name, "request methods testcase with variables") self.assertEqual(result.name, "request methods testcase with functions")
self.assertEqual(result.step_datas[0].name, "get with params") self.assertEqual(result.step_datas[0].name, "get with params")
self.assertEqual(len(result.step_datas), 3) self.assertEqual(len(result.step_datas), 3)
@@ -24,5 +24,5 @@ class TestHttpRunner(unittest.TestCase):
result = self.runner.get_summary() result = self.runner.get_summary()
self.assertTrue(result.success) self.assertTrue(result.success)
self.assertEqual(result.name, "request methods testcase: reference testcase") self.assertEqual(result.name, "request methods testcase: reference testcase")
self.assertEqual(result.step_datas[0].name, "request with variables") self.assertEqual(result.step_datas[0].name, "request with functions")
self.assertEqual(len(result.step_datas), 1) self.assertEqual(len(result.step_datas), 1)

View File

@@ -36,7 +36,8 @@ class TConfig(BaseModel):
name: Name name: Name
verify: Verify = False verify: Verify = False
base_url: BaseUrl = "" base_url: BaseUrl = ""
variables: VariablesMapping = {} # Text: prepare variables in debugtalk.py, ${get_variable()}
variables: Union[VariablesMapping, Text] = {}
setup_hooks: Hook = [] setup_hooks: Hook = []
teardown_hooks: Hook = [] teardown_hooks: Hook = []
export: Export = [] export: Export = []
@@ -160,6 +161,18 @@ class PlatformInfo(BaseModel):
platform: Text platform: Text
class TestCaseRef(BaseModel):
name: Text
base_url: Text = ""
testcase: Text
variables: VariablesMapping = {}
class TestSuite(BaseModel):
config: TConfig
testcases: List[TestCaseRef]
class Stat(BaseModel): class Stat(BaseModel):
total: int = 0 total: int = 0
success: int = 0 success: int = 0

View File

@@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "httprunner" name = "httprunner"
version = "3.0.3" version = "3.0.4"
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"