From a95d4137c13f92ad50d850e1fb586b6f0a5d49c7 Mon Sep 17 00:00:00 2001 From: debugtalk Date: Wed, 13 May 2020 21:22:48 +0800 Subject: [PATCH] change: http client with pydantic models --- httprunner/api.py | 40 ++++----- httprunner/client.py | 178 ++++++++++++++++++----------------------- httprunner/response.py | 16 ---- httprunner/runner.py | 52 ++++++++---- httprunner/schema.py | 53 +++++++++--- 5 files changed, 173 insertions(+), 166 deletions(-) diff --git a/httprunner/api.py b/httprunner/api.py index a7a70fbf..48a79b8b 100644 --- a/httprunner/api.py +++ b/httprunner/api.py @@ -130,37 +130,32 @@ class HttpRunner(object): tests_results (list): list of testcase summary """ - testsuite_summary = { - "success": True, - "stat": { - "total": len(tests_results), - "success": 0, - "fail": 0 - }, - "time": {}, - "platform": report.get_platform(), - "testcases": [] - } + testsuite_summary = TestSuiteSummary( + success=True, + platform=report.get_platform(), + testcases=[] + ) + testsuite_summary.stat.total = len(tests_results) + testsuite_summary.stat.success = 0 + testsuite_summary.stat.fail = 0 for testcase_summary in tests_results: if testcase_summary.success: - testsuite_summary["stat"]["success"] += 1 + testsuite_summary.stat.success += 1 else: - testsuite_summary["stat"]["fail"] += 1 + testsuite_summary.stat.fail += 1 - testsuite_summary["success"] &= testcase_summary.success - - testsuite_summary["testcases"].append(testcase_summary) + testsuite_summary.success &= testcase_summary.success + testsuite_summary.testcases.append(testcase_summary) total_duration = tests_results[-1].time.start_at + tests_results[-1].time.duration \ - tests_results[0].time.start_at - testsuite_summary["time"] = { - "start_at": tests_results[0].time.start_at, - "start_at_iso_format": tests_results[0].time.start_at_iso_format, - "duration": total_duration - } - return TestSuiteSummary.parse_obj(testsuite_summary) + testsuite_summary.time.start_at = tests_results[0].time.start_at + testsuite_summary.time.start_at_iso_format = tests_results[0].time.start_at_iso_format + testsuite_summary.time.duration = total_duration + + return testsuite_summary def run_tests(self, tests_mapping) -> TestSuiteSummary: """ run testcase/testsuite data @@ -188,7 +183,6 @@ class HttpRunner(object): # generate html report self.exception_stage = "generate html report" - report.stringify_summary(self._summary) if self.save_tests: utils.dump_json_file( diff --git a/httprunner/client.py b/httprunner/client.py index 8c437439..c46db4d9 100644 --- a/httprunner/client.py +++ b/httprunner/client.py @@ -7,81 +7,13 @@ from requests import Request, Response from requests.exceptions import (InvalidSchema, InvalidURL, MissingSchema, RequestException) -from httprunner import response +from httprunner.schema import RequestData, ResponseData +from httprunner.schema import SessionData, ReqRespData from httprunner.utils import lower_dict_keys, omit_long_data -from httprunner.schema import SessionData urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) -def get_req_resp_record(resp_obj): - """ get request and response info from Response() object. - """ - def log_print(req_resp_dict, r_type): - msg = f"\n================== {r_type} details ==================\n" - for key, value in req_resp_dict[r_type].items(): - msg += "{:<16} : {}\n".format(key, repr(value)) - logger.debug(msg) - - req_resp_dict = { - "request": {}, - "response": {} - } - - # record actual request info - req_resp_dict["request"]["url"] = resp_obj.request.url - req_resp_dict["request"]["method"] = resp_obj.request.method - req_resp_dict["request"]["headers"] = dict(resp_obj.request.headers) - - request_body = resp_obj.request.body - if request_body: - request_content_type = lower_dict_keys( - req_resp_dict["request"]["headers"] - ).get("content-type") - if request_content_type and "multipart/form-data" in request_content_type: - # upload file type - req_resp_dict["request"]["body"] = "upload file stream (OMITTED)" - else: - req_resp_dict["request"]["body"] = request_body - - # log request details in debug mode - log_print(req_resp_dict, "request") - - # record response info - req_resp_dict["response"]["ok"] = resp_obj.ok - req_resp_dict["response"]["url"] = resp_obj.url - req_resp_dict["response"]["status_code"] = resp_obj.status_code - req_resp_dict["response"]["reason"] = resp_obj.reason - req_resp_dict["response"]["cookies"] = resp_obj.cookies or {} - req_resp_dict["response"]["encoding"] = resp_obj.encoding - resp_headers = dict(resp_obj.headers) - req_resp_dict["response"]["headers"] = resp_headers - - lower_resp_headers = lower_dict_keys(resp_headers) - content_type = lower_resp_headers.get("content-type", "") - req_resp_dict["response"]["content_type"] = content_type - - if "image" in content_type: - # response is image type, record bytes content only - req_resp_dict["response"]["body"] = resp_obj.content - else: - try: - # try to record json data - if isinstance(resp_obj, response.ResponseObject): - req_resp_dict["response"]["body"] = resp_obj.json - else: - req_resp_dict["response"]["body"] = resp_obj.json() - except ValueError: - # only record at most 512 text charactors - resp_text = resp_obj.text - req_resp_dict["response"]["body"] = omit_long_data(resp_text) - - # log response details in debug mode - log_print(req_resp_dict, "response") - - return req_resp_dict - - class ApiResponse(Response): def raise_for_status(self): @@ -90,6 +22,73 @@ class ApiResponse(Response): Response.raise_for_status(self) +def get_req_resp_record(resp_obj: Response) -> ReqRespData: + """ get request and response info from Response() object. + """ + def log_print(req_or_resp, r_type): + msg = f"\n================== {r_type} details ==================\n" + for key, value in req_or_resp.dict().items(): + msg += "{:<16} : {}\n".format(key, repr(value)) + logger.debug(msg) + + # record actual request info + request_data = RequestData( + method=resp_obj.request.method, + url=resp_obj.request.url, + headers=dict(resp_obj.request.headers), + body=resp_obj.request.body + ) + + request_body = resp_obj.request.body + if request_body: + request_content_type = lower_dict_keys( + request_data.headers + ).get("content-type") + if request_content_type and "multipart/form-data" in request_content_type: + # upload file type + request_data.body = "upload file stream (OMITTED)" + else: + request_data.body = request_body + + # log request details in debug mode + log_print(request_data, "request") + + # record response info + resp_headers = dict(resp_obj.headers) + lower_resp_headers = lower_dict_keys(resp_headers) + content_type = lower_resp_headers.get("content-type", "") + + if "image" in content_type: + # response is image type, record bytes content only + response_body = resp_obj.content + else: + try: + # try to record json data + response_body = resp_obj.json() + except ValueError: + # only record at most 512 text charactors + resp_text = resp_obj.text + response_body = omit_long_data(resp_text) + + response_data = ResponseData( + status_code=resp_obj.status_code, + cookies=resp_obj.cookies or {}, + encoding=resp_obj.encoding, + headers=resp_headers, + content_type=content_type, + body=response_body + ) + + # log response details in debug mode + log_print(response_data, "response") + + req_resp_data = ReqRespData( + request=request_data, + response=response_data + ) + return req_resp_data + + class HttpSession(requests.Session): """ Class for performing HTTP requests and holding (session-) cookies between requests (in order @@ -101,35 +100,15 @@ class HttpSession(requests.Session): """ def __init__(self): super(HttpSession, self).__init__() - self.data = None - - def init_session_data(self): - """ initialize session data, it will store detail data of request and response - """ - self.data = SessionData( - req_resp=[ - { - "request": { - "url": "N/A", - "method": "N/A", - "headers": {} - }, - "response": { - "status_code": "N/A", - "headers": {}, - "encoding": None, - "content_type": "" - } - } - ] - ) + self.data = SessionData() def update_last_req_resp_record(self, resp_obj): """ update request and response info from Response() object. """ - self.data.req_resp.pop() - self.data.req_resp.append(get_req_resp_record(resp_obj)) + # TODO: fix + self.data.req_resps.pop() + self.data.req_resps.append(get_req_resp_record(resp_obj)) def request(self, method, url, name=None, **kwargs): """ @@ -170,15 +149,10 @@ class HttpSession(requests.Session): :param cert: (optional) if String, path to ssl client cert file (.pem). If Tuple, ('cert', 'key') pair. """ - self.init_session_data() - # record test name - self.data.name = name + self.data = SessionData() - # record original request info - self.data.req_resp[0]["request"]["method"] = method - self.data.req_resp[0]["request"]["url"] = url + # timeout default to 120 seconds kwargs.setdefault("timeout", 120) - self.data.req_resp[0]["request"].update(kwargs) start_timestamp = time.time() response = self._send_request_safe_mode(method, url, **kwargs) @@ -198,7 +172,7 @@ class HttpSession(requests.Session): # record request and response histories, include 30X redirection response_list = response.history + [response] - self.data.req_resp = [ + self.data.req_resps = [ get_req_resp_record(resp_obj) for resp_obj in response_list ] diff --git a/httprunner/response.py b/httprunner/response.py index a77bc9dc..18d91bee 100644 --- a/httprunner/response.py +++ b/httprunner/response.py @@ -114,22 +114,6 @@ class ResponseObject(object): } self.validation_results: Dict = {} - def __getattr__(self, key): - try: - if key == "json": - value = self.resp_obj.json() - elif key == "cookies": - value = self.resp_obj.cookies.get_dict() - else: - value = getattr(self.resp_obj, key) - - self.__dict__[key] = value - return value - except AttributeError: - err_msg = f"ResponseObject does not have attribute: {key}" - logger.error(err_msg) - raise ParamsError(err_msg) - def extract(self, extractors: Dict[Text, Text]) -> Dict[Text, Any]: if not extractors: return {} diff --git a/httprunner/runner.py b/httprunner/runner.py index 2cae578e..a5bef224 100644 --- a/httprunner/runner.py +++ b/httprunner/runner.py @@ -7,7 +7,7 @@ from httprunner.client import HttpSession from httprunner.exceptions import ValidationFailure, ParamsError from httprunner.parser import build_url, parse_data, parse_variables_mapping from httprunner.response import ResponseObject -from httprunner.schema import TestsConfig, TestStep, VariablesMapping, TestCase, SessionData +from httprunner.schema import TestsConfig, TestStep, VariablesMapping, TestCase, StepData class TestCaseRunner(object): @@ -15,9 +15,10 @@ class TestCaseRunner(object): config: TestsConfig = {} teststeps: List[TestStep] = [] session: HttpSession = None - step_datas: List[SessionData] = [] + step_datas: List[StepData] = [] validation_results: Dict = {} session_variables: Dict = {} + success: bool = True # indicate testcase execution result def init(self, testcase: TestCase) -> "TestCaseRunner": self.config = testcase.config @@ -34,6 +35,10 @@ class TestCaseRunner(object): def __run_step_request(self, step: TestStep): """run teststep: request""" + step_data = StepData( + name=step.name + ) + # parse request_dict = step.request.dict() parsed_request_dict = parse_data(request_dict, step.variables, self.config.functions) @@ -70,14 +75,15 @@ class TestCaseRunner(object): # log response err_msg += "====== response details ======\n" - err_msg += f"status_code: {resp_obj.status_code}\n" - err_msg += f"headers: {resp_obj.headers}\n" - err_msg += f"body: {repr(resp_obj.text)}\n" + err_msg += f"status_code: {resp.status_code}\n" + err_msg += f"headers: {resp.headers}\n" + err_msg += f"body: {repr(resp.text)}\n" logger.error(err_msg) # extract extractors = step.extract extract_mapping = resp_obj.extract(extractors) + step_data.export = extract_mapping variables_mapping = step.variables variables_mapping.update(extract_mapping) @@ -86,33 +92,49 @@ class TestCaseRunner(object): validators = step.validators try: resp_obj.validate(validators, variables_mapping, self.config.functions) - self.session.data.status = "passed" + self.session.data.success = True except ValidationFailure: - self.session.data.status = "failed" + self.session.data.success = False log_req_resp_details() raise finally: self.validation_results = resp_obj.validation_results # save request & response meta data self.session.data.validators = self.validation_results - self.session.data.name = step.name - self.step_datas.append(self.session.data) + self.success &= self.session.data.success - return extract_mapping + step_data.success = self.session.data.success + step_data.data = self.session.data + return step_data def __run_step_testcase(self, step): """run teststep: referenced testcase""" + step_data = StepData( + name=step.name + ) step_variables = step.variables - testcase: TestCaseRunner = step.testcase - res = testcase.with_variables(**step_variables).run() - return res.get_export_variables() + testcase: TestCaseRunner = step.testcase() # TODO: fix + case_result = testcase.with_variables(**step_variables).run() + step_data.data = case_result.step_datas # list of step data + step_data.export = case_result.get_export_variables() + step_data.success = case_result.success + self.success &= case_result.success + + return step_data def __run_step(self, step: TestStep): + """run teststep, teststep maybe a request or referenced testcase""" logger.info(f"run step: {step.name}") + if step.request: - return self.__run_step_request(step) + step_data = self.__run_step_request(step) elif step.testcase: - return self.__run_step_testcase(step) + step_data = self.__run_step_testcase(step) + else: + raise ParamsError(f"teststep is neither a request nor a referenced testcase: {step.dict()}") + + self.step_datas.append(step_data) + return step_data.export def test_start(self): """main entrance""" diff --git a/httprunner/schema.py b/httprunner/schema.py index 8307f896..e92b6727 100644 --- a/httprunner/schema.py +++ b/httprunner/schema.py @@ -43,6 +43,7 @@ class TestsConfig(BaseModel): class Request(BaseModel): + """requests.Request model""" method: MethodEnum = MethodEnum.GET url: Url params: Dict[Text, Text] = {} @@ -84,9 +85,9 @@ class TestsMapping(BaseModel): class TestCaseTime(BaseModel): - start_at: float - start_at_iso_format: Text - duration: float + start_at: float = 0 + start_at_iso_format: Text = "" + duration: float = 0 class TestCaseInOut(BaseModel): @@ -100,14 +101,46 @@ class RequestStat(BaseModel): elapsed_ms: float = 0 +class RequestData(BaseModel): + method: MethodEnum = MethodEnum.GET + url: Url + headers: Headers = {} + # TODO: add cookies + body: Union[Text, Dict] = {} + + +class ResponseData(BaseModel): + status_code: int + cookies: Dict + encoding: Text + headers: Dict + content_type: Text + body: Union[Text, bytes, Dict] + + +class ReqRespData(BaseModel): + request: RequestData + response: ResponseData + + class SessionData(BaseModel): - status: Text = "" - name: Text = "" - req_resp: List[Dict] = [] + """request session data, including request, response, validators and stat data""" + success: bool = False + # in most cases, req_resps only contains one request & response + # while when 30X redirect occurs, req_resps will contain multiple request & response + req_resps: List[ReqRespData] = [] stat: RequestStat = RequestStat() validators: Dict = {} +class StepData(BaseModel): + """teststep data, each step maybe corresponding to one request or one testcase""" + success: bool = False + name: Text = "" # teststep name + data: Union[SessionData, List[SessionData]] = None + export: Dict = {} + + class TestCaseSummary(BaseModel): name: Text = "" success: bool @@ -116,7 +149,7 @@ class TestCaseSummary(BaseModel): time: TestCaseTime in_out: TestCaseInOut = {} log: Text = "" - step_datas: List[SessionData] = [] + step_datas: List[StepData] = [] class PlatformInfo(BaseModel): @@ -132,8 +165,8 @@ class Stat(BaseModel): class TestSuiteSummary(BaseModel): - success: bool - stat: Stat - time: TestCaseTime + success: bool = False + stat: Stat = Stat() + time: TestCaseTime = TestCaseTime() platform: PlatformInfo testcases: List[TestCaseSummary]