From 31e5803bcf9bfb0c1794f7b740f081cc944b0782 Mon Sep 17 00:00:00 2001 From: debugtalk Date: Tue, 24 Mar 2020 21:44:40 +0800 Subject: [PATCH 001/169] bump version 3.0.1 --- docs/CHANGELOG.md | 7 ++++++- httprunner/__init__.py | 2 +- pyproject.toml | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 897e8fb6..c8d2ed94 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,11 @@ # Release History +## 3.0.1 (2020-03-24) + +**Changed** + +- remove sentry sdk + ## 3.0.0 (2020-03-10) **Added** @@ -16,7 +22,6 @@ - generate reports/logs folder in current working directory - remove cli `--validate` - remove cli `--pretty` -- remove sentry sdk ## 2.5.7 (2020-02-21) diff --git a/httprunner/__init__.py b/httprunner/__init__.py index a4787d42..831c6293 100644 --- a/httprunner/__init__.py +++ b/httprunner/__init__.py @@ -1,4 +1,4 @@ -__version__ = "3.0.0" +__version__ = "3.0.1" __description__ = "One-stop solution for HTTP(S) testing." __all__ = ["__version__", "__description__"] diff --git a/pyproject.toml b/pyproject.toml index 4ad9d170..a0d6fd5a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "httprunner" -version = "3.0.0" +version = "3.0.1" description = "One-stop solution for HTTP(S) testing." license = "Apache-2.0" readme = "README.md" From 2a0607299ffa8242429b9fb7003dc819047d906b Mon Sep 17 00:00:00 2001 From: debugtalk Date: Tue, 24 Mar 2020 21:44:40 +0800 Subject: [PATCH 002/169] bump version 3.0.1 --- docs/CHANGELOG.md | 7 ++++++- httprunner/__init__.py | 2 +- pyproject.toml | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 897e8fb6..c8d2ed94 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,11 @@ # Release History +## 3.0.1 (2020-03-24) + +**Changed** + +- remove sentry sdk + ## 3.0.0 (2020-03-10) **Added** @@ -16,7 +22,6 @@ - generate reports/logs folder in current working directory - remove cli `--validate` - remove cli `--pretty` -- remove sentry sdk ## 2.5.7 (2020-02-21) diff --git a/httprunner/__init__.py b/httprunner/__init__.py index a4787d42..831c6293 100644 --- a/httprunner/__init__.py +++ b/httprunner/__init__.py @@ -1,4 +1,4 @@ -__version__ = "3.0.0" +__version__ = "3.0.1" __description__ = "One-stop solution for HTTP(S) testing." __all__ = ["__version__", "__description__"] diff --git a/pyproject.toml b/pyproject.toml index 4ad9d170..a0d6fd5a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "httprunner" -version = "3.0.0" +version = "3.0.1" description = "One-stop solution for HTTP(S) testing." license = "Apache-2.0" readme = "README.md" From 6f001af00d74c2447bd58f5f935e29f75c698b82 Mon Sep 17 00:00:00 2001 From: debugtalk Date: Tue, 31 Mar 2020 15:48:38 +0800 Subject: [PATCH 003/169] change: remove sentry sdk deps --- poetry.lock | 32 +------------------------------- pyproject.toml | 1 - 2 files changed, 1 insertion(+), 32 deletions(-) diff --git a/poetry.lock b/poetry.lock index de982f13..323313b0 100644 --- a/poetry.lock +++ b/poetry.lock @@ -349,32 +349,6 @@ version = "0.9.1" [package.dependencies] requests = ">=2.0.1,<3.0.0" -[[package]] -category = "main" -description = "Python client for Sentry (https://getsentry.com)" -name = "sentry-sdk" -optional = false -python-versions = "*" -version = "0.13.5" - -[package.dependencies] -certifi = "*" -urllib3 = ">=1.10.0" - -[package.extras] -aiohttp = ["aiohttp (>=3.5)"] -beam = ["beam (>=2.12)"] -bottle = ["bottle (>=0.12.13)"] -celery = ["celery (>=3)"] -django = ["django (>=1.8)"] -falcon = ["falcon (>=1.4)"] -flask = ["flask (>=0.11)", "blinker (>=1.1)"] -pyspark = ["pyspark (>=2.4.4)"] -rq = ["0.6"] -sanic = ["sanic (>=0.8)"] -sqlalchemy = ["sqlalchemy (>=1.2)"] -tornado = ["tornado (>=5)"] - [[package]] category = "main" description = "Python 2 and 3 compatibility utilities" @@ -481,7 +455,7 @@ docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] testing = ["pathlib2", "contextlib2", "unittest2"] [metadata] -content-hash = "57ff78f24ca37a3421d5c64007bd71eba394d6751fdbb2d0b446f523cfed9c62" +content-hash = "8ef61f687de82f9bd0c92c521e2cc890454a4da6ca7f80d0f30973551540c4ae" python-versions = "^3.6" [metadata.files] @@ -704,10 +678,6 @@ requests-toolbelt = [ {file = "requests-toolbelt-0.9.1.tar.gz", hash = "sha256:968089d4584ad4ad7c171454f0a5c6dac23971e9472521ea3b6d49d610aa6fc0"}, {file = "requests_toolbelt-0.9.1-py2.py3-none-any.whl", hash = "sha256:380606e1d10dc85c3bd47bf5a6095f815ec007be7a8b69c878507068df059e6f"}, ] -sentry-sdk = [ - {file = "sentry-sdk-0.13.5.tar.gz", hash = "sha256:c6b919623e488134a728f16326c6f0bcdab7e3f59e7f4c472a90eea4d6d8fe82"}, - {file = "sentry_sdk-0.13.5-py2.py3-none-any.whl", hash = "sha256:05285942901d38c7ce2498aba50d8e87b361fc603281a5902dda98f3f8c5e145"}, -] six = [ {file = "six-1.13.0-py2.py3-none-any.whl", hash = "sha256:1f1b7d42e254082a9db6279deae68afb421ceba6158efa6131de7b3003ee93fd"}, {file = "six-1.13.0.tar.gz", hash = "sha256:30f610279e8b2578cab6db20741130331735c781b56053c59c4076da27f06b66"}, diff --git a/pyproject.toml b/pyproject.toml index a0d6fd5a..0bdb58e0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,6 @@ jinja2 = "^2.10.3" har2case = "^0.3.1" filetype = "^1.0.5" jsonpath = "^0.82" -sentry-sdk = "^0.13.5" jsonschema = "^3.2.0" pydantic = "^1.4" loguru = "^0.4.1" From 3f0cbc2245b7f353b831216c6a5af352fd262e8c Mon Sep 17 00:00:00 2001 From: debugtalk Date: Thu, 9 Apr 2020 10:59:27 +0800 Subject: [PATCH 004/169] change: remove compatibility with testcase format v1 --- docs/CHANGELOG.md | 6 + httprunner/loader/buildup.py | 105 +++---------- httprunner/loader/check.py | 16 -- .../loader/schemas/testcase.schema.v1.json | 138 ------------------ 4 files changed, 27 insertions(+), 238 deletions(-) delete mode 100644 httprunner/loader/schemas/testcase.schema.v1.json diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index c8d2ed94..20dfa5d9 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,11 @@ # Release History +## 3.0.2 (2020-04-09) + +**Changed** + +- remove compatibility with testcase format v1 + ## 3.0.1 (2020-03-24) **Changed** diff --git a/httprunner/loader/buildup.py b/httprunner/loader/buildup.py index 12f2bc7f..55f36287 100644 --- a/httprunner/loader/buildup.py +++ b/httprunner/loader/buildup.py @@ -78,11 +78,8 @@ def __extend_with_testcase_ref(raw_testinfo): ) loaded_testcase = load_file(testcase_path) - if isinstance(loaded_testcase, list): - # make compatible with version < 2.2.0 - testcase_dict = load_testcase(loaded_testcase) - elif isinstance(loaded_testcase, dict) and "teststeps" in loaded_testcase: - # format version 2, implemented in 2.2.0 + # TODO: validate with pydantic + if isinstance(loaded_testcase, dict) and "teststeps" in loaded_testcase: testcase_dict = load_testcase_v2(loaded_testcase) else: raise exceptions.FileFormatError( @@ -147,57 +144,6 @@ def load_teststep(raw_testinfo): return raw_testinfo -def load_testcase(raw_testcase): - """ load testcase with api/testcase references. - - Args: - raw_testcase (list): raw testcase content loaded from JSON/YAML file: - [ - # config part - { - "config": { - "name": "XXXX", - "base_url": "https://debugtalk.com" - } - }, - # teststeps part - { - "test": {...} - }, - { - "test": {...} - } - ] - - Returns: - dict: loaded testcase content - { - "config": {}, - "teststeps": [test11, test12] - } - - """ - JsonSchemaChecker.validate_testcase_v1_format(raw_testcase) - config = {} - tests = [] - - for item in raw_testcase: - key, test_block = item.popitem() - if key == "config": - config.update(test_block) - elif key == "test": - tests.append(load_teststep(test_block)) - else: - logger.warning( - f"unexpected block key: {key}. block key should only be 'config' or 'test'." - ) - - return { - "config": config, - "teststeps": tests - } - - def load_testcase_v2(raw_testcase): """ load testcase in format version 2. @@ -311,7 +257,7 @@ def load_testsuite(raw_testsuite): return raw_testsuite -def load_test_file(path): +def load_test_file(path: str) -> dict: """ load test file, file maybe testcase/testsuite/api Args: @@ -347,38 +293,29 @@ def load_test_file(path): """ raw_content = load_file(path) - if isinstance(raw_content, dict): + if not isinstance(raw_content, dict): + # invalid format + raise exceptions.FileFormatError("Invalid test file format!") - if "testcases" in raw_content: - # file_type: testsuite - loaded_content = load_testsuite(raw_content) - loaded_content["path"] = path - loaded_content["type"] = "testsuite" + if "testcases" in raw_content: + # file_type: testsuite + loaded_content = load_testsuite(raw_content) + loaded_content["path"] = path + loaded_content["type"] = "testsuite" - elif "teststeps" in raw_content: - # file_type: testcase (format version 2) - loaded_content = load_testcase_v2(raw_content) - loaded_content["path"] = path - loaded_content["type"] = "testcase" - - elif "request" in raw_content: - # file_type: api - JsonSchemaChecker.validate_api_format(raw_content) - loaded_content = raw_content - loaded_content["path"] = path - loaded_content["type"] = "api" - - else: - # invalid format - raise exceptions.FileFormatError("Invalid test file format!") - - elif isinstance(raw_content, list) and len(raw_content) > 0: - # file_type: testcase - # make compatible with version < 2.2.0 - loaded_content = load_testcase(raw_content) + elif "teststeps" in raw_content: + # file_type: testcase (format version 2) + loaded_content = load_testcase_v2(raw_content) loaded_content["path"] = path loaded_content["type"] = "testcase" + elif "request" in raw_content: + # file_type: api + JsonSchemaChecker.validate_api_format(raw_content) + loaded_content = raw_content + loaded_content["path"] = path + loaded_content["type"] = "api" + else: # invalid format raise exceptions.FileFormatError("Invalid test file format!") diff --git a/httprunner/loader/check.py b/httprunner/loader/check.py index ba248ec7..63d7149b 100644 --- a/httprunner/loader/check.py +++ b/httprunner/loader/check.py @@ -11,7 +11,6 @@ from httprunner import exceptions schemas_root_dir = os.path.join(os.path.dirname(__file__), "schemas") common_schema_path = os.path.join(schemas_root_dir, "common.schema.json") api_schema_path = os.path.join(schemas_root_dir, "api.schema.json") -testcase_schema_v1_path = os.path.join(schemas_root_dir, "testcase.schema.v1.json") testcase_schema_v2_path = os.path.join(schemas_root_dir, "testcase.schema.v2.json") testsuite_schema_v1_path = os.path.join(schemas_root_dir, "testsuite.schema.v1.json") testsuite_schema_v2_path = os.path.join(schemas_root_dir, "testsuite.schema.v2.json") @@ -29,9 +28,6 @@ with io.open(common_schema_path, encoding='utf-8') as f: common_schema = json.load(f) resolver = jsonschema.RefResolver(absolute_base_path, common_schema) -with io.open(testcase_schema_v1_path, encoding='utf-8') as f: - testcase_schema_v1 = json.load(f) - with io.open(testcase_schema_v2_path, encoding='utf-8') as f: testcase_schema_v2 = json.load(f) @@ -62,12 +58,6 @@ class JsonSchemaChecker(object): """ return JsonSchemaChecker.validate_format(content, api_schema) - @staticmethod - def validate_testcase_v1_format(content): - """ check testcase format v1 if valid - """ - return JsonSchemaChecker.validate_format(content, testcase_schema_v1) - @staticmethod def validate_testcase_v2_format(content): """ check testcase format v2 if valid @@ -175,12 +165,6 @@ def is_test_content(data_structure): except exceptions.FileFormatError: pass - try: - JsonSchemaChecker.validate_testcase_v2_format(item) - is_testcase = True - except exceptions.FileFormatError: - pass - if not is_testcase: return False diff --git a/httprunner/loader/schemas/testcase.schema.v1.json b/httprunner/loader/schemas/testcase.schema.v1.json deleted file mode 100644 index 823399cc..00000000 --- a/httprunner/loader/schemas/testcase.schema.v1.json +++ /dev/null @@ -1,138 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema", - "description": "httprunner testcase schema v1 definition", - "type": "array", - "definitions": { - "test": { - "type": "object", - "oneOf": [ - { - "properties": { - "name": { - "$ref": "common.schema.json#/definitions/name" - }, - "request": { - "description": "define api request directly", - "$ref": "common.schema.json#/definitions/request" - }, - "variables": { - "$ref": "common.schema.json#/definitions/variables" - }, - "extract": { - "$ref": "common.schema.json#/definitions/extract" - }, - "validate": { - "$ref": "common.schema.json#/definitions/validate" - }, - "setup_hooks": { - "$ref": "common.schema.json#/definitions/hook" - }, - "teardown_hooks": { - "$ref": "common.schema.json#/definitions/hook" - } - }, - "required": [ - "name", - "request" - ] - }, - { - "properties": { - "name": { - "$ref": "common.schema.json#/definitions/name" - }, - "api": { - "description": "api reference, value is api file relative path", - "type": "string" - }, - "variables": { - "$ref": "common.schema.json#/definitions/variables" - }, - "extract": { - "oneOf": [ - { - "type": "array", - "items": { - "type": "string" - } - }, - { - "$ref": "common.schema.json#/definitions/extract" - } - ] - }, - "validate": { - "$ref": "common.schema.json#/definitions/validate" - }, - "setup_hooks": { - "$ref": "common.schema.json#/definitions/hook" - }, - "teardown_hooks": { - "$ref": "common.schema.json#/definitions/hook" - } - }, - "required": [ - "name", - "api" - ] - }, - { - "properties": { - "name": { - "$ref": "common.schema.json#/definitions/name" - }, - "testcase": { - "description": "testcase reference, value is testcase file relative path", - "type": "string" - }, - "variables": { - "$ref": "common.schema.json#/definitions/variables" - }, - "extract": { - "type": "array", - "items": { - "type": "string" - } - }, - "setup_hooks": { - "$ref": "common.schema.json#/definitions/hook" - }, - "teardown_hooks": { - "$ref": "common.schema.json#/definitions/hook" - } - }, - "required": [ - "name", - "testcase" - ] - } - ] - } - }, - "items": { - "type": "object", - "oneOf": [ - { - "type": "object", - "properties": { - "config": { - "$ref": "common.schema.json#/definitions/config" - } - }, - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "test": { - "$ref": "testcase.schema.v1.json#/definitions/test" - } - }, - "additionalProperties": false - } - ], - "minProperties": 1, - "maxProperties": 1 - }, - "minItems": 2 -} \ No newline at end of file From 456e966570c55dcc4052a0244b9c0c69ba8910d3 Mon Sep 17 00:00:00 2001 From: debugtalk Date: Thu, 9 Apr 2020 11:08:59 +0800 Subject: [PATCH 005/169] change: remove compatibility with testsuite format v1 --- docs/CHANGELOG.md | 2 +- httprunner/loader/buildup.py | 45 +++---------- httprunner/loader/check.py | 16 ----- .../loader/schemas/testsuite.schema.v1.json | 66 ------------------- 4 files changed, 10 insertions(+), 119 deletions(-) delete mode 100644 httprunner/loader/schemas/testsuite.schema.v1.json diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 20dfa5d9..1daf3c66 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -4,7 +4,7 @@ **Changed** -- remove compatibility with testcase format v1 +- remove compatibility with testcase/testsuite format v1 ## 3.0.1 (2020-03-24) diff --git a/httprunner/loader/buildup.py b/httprunner/loader/buildup.py index 55f36287..b18b6eaf 100644 --- a/httprunner/loader/buildup.py +++ b/httprunner/loader/buildup.py @@ -189,23 +189,6 @@ def load_testsuite(raw_testsuite): Args: raw_testsuite (dict): raw testsuite content loaded from JSON/YAML file: - # version 1, compatible with version < 2.2.0 - { - "config": { - "name": "xxx", - "variables": {} - } - "testcases": { - "testcase1": { - "testcase": "/path/to/testcase", - "variables": {...}, - "parameters": {...} - }, - "testcase2": {} - } - } - - # version 2, implemented in 2.2.0 { "config": { "name": "xxx", @@ -232,28 +215,18 @@ def load_testsuite(raw_testsuite): """ raw_testcases = raw_testsuite["testcases"] - if isinstance(raw_testcases, dict): - # format version 1, make compatible with version < 2.2.0 - JsonSchemaChecker.validate_testsuite_v1_format(raw_testsuite) - raw_testsuite["testcases"] = {} - for name, raw_testcase in raw_testcases.items(): - __extend_with_testcase_ref(raw_testcase) - raw_testcase.setdefault("name", name) - raw_testsuite["testcases"][name] = raw_testcase - - elif isinstance(raw_testcases, list): - # format version 2, implemented in 2.2.0 - JsonSchemaChecker.validate_testsuite_v2_format(raw_testsuite) - raw_testsuite["testcases"] = {} - for raw_testcase in raw_testcases: - __extend_with_testcase_ref(raw_testcase) - testcase_name = raw_testcase["name"] - raw_testsuite["testcases"][testcase_name] = raw_testcase - - else: + # TODO: validate with pydantic + if not isinstance(raw_testcases, list): # invalid format raise exceptions.FileFormatError("Invalid testsuite format!") + JsonSchemaChecker.validate_testsuite_v2_format(raw_testsuite) + raw_testsuite["testcases"] = {} + for raw_testcase in raw_testcases: + __extend_with_testcase_ref(raw_testcase) + testcase_name = raw_testcase["name"] + raw_testsuite["testcases"][testcase_name] = raw_testcase + return raw_testsuite diff --git a/httprunner/loader/check.py b/httprunner/loader/check.py index 63d7149b..29730e3a 100644 --- a/httprunner/loader/check.py +++ b/httprunner/loader/check.py @@ -12,7 +12,6 @@ schemas_root_dir = os.path.join(os.path.dirname(__file__), "schemas") common_schema_path = os.path.join(schemas_root_dir, "common.schema.json") api_schema_path = os.path.join(schemas_root_dir, "api.schema.json") testcase_schema_v2_path = os.path.join(schemas_root_dir, "testcase.schema.v2.json") -testsuite_schema_v1_path = os.path.join(schemas_root_dir, "testsuite.schema.v1.json") testsuite_schema_v2_path = os.path.join(schemas_root_dir, "testsuite.schema.v2.json") with io.open(api_schema_path, encoding='utf-8') as f: @@ -31,9 +30,6 @@ with io.open(common_schema_path, encoding='utf-8') as f: with io.open(testcase_schema_v2_path, encoding='utf-8') as f: testcase_schema_v2 = json.load(f) -with io.open(testsuite_schema_v1_path, encoding='utf-8') as f: - testsuite_schema_v1 = json.load(f) - with io.open(testsuite_schema_v2_path, encoding='utf-8') as f: testsuite_schema_v2 = json.load(f) @@ -64,12 +60,6 @@ class JsonSchemaChecker(object): """ return JsonSchemaChecker.validate_format(content, testcase_schema_v2) - @staticmethod - def validate_testsuite_v1_format(content): - """ check testsuite format v1 if valid - """ - return JsonSchemaChecker.validate_format(content, testsuite_schema_v1) - @staticmethod def validate_testsuite_v2_format(content): """ check testsuite format v2 if valid @@ -178,12 +168,6 @@ def is_test_content(data_structure): for item in testsuites: is_testcase = False - try: - JsonSchemaChecker.validate_testsuite_v1_format(item) - is_testcase = True - except exceptions.FileFormatError: - pass - try: JsonSchemaChecker.validate_testsuite_v2_format(item) is_testcase = True diff --git a/httprunner/loader/schemas/testsuite.schema.v1.json b/httprunner/loader/schemas/testsuite.schema.v1.json deleted file mode 100644 index 85a8a72a..00000000 --- a/httprunner/loader/schemas/testsuite.schema.v1.json +++ /dev/null @@ -1,66 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema", - "description": "httprunner testsuite schema v1 definition", - "type": "object", - "definitions": { - "testcase": { - "type": "object", - "properties": { - "name": { - "$ref": "common.schema.json#/definitions/name" - }, - "variables": { - "$ref": "common.schema.json#/definitions/variables" - }, - "parameters": { - "description": "generate cartesian product variables with parameters, each group of variables will be run once", - "type": "object" - }, - "testcase": { - "description": "testcase reference, value is testcase file relative path", - "type": "string" - } - }, - "required": [ - "testcase" - ] - } - }, - "properties": { - "config": { - "$ref": "common.schema.json#/definitions/config" - }, - "testcases": { - "description": "testcase of a testsuite", - "type": "object", - "minProperties": 1, - "patternProperties": { - ".*": { - "description": "testcase definition", - "$ref": "testsuite.schema.v1.json#/definitions/testcase" - } - } - } - }, - "required": [ - "config", - "testcases" - ], - "examples": [ - { - "config": { - "name": "testsuite name" - }, - "testcases": { - "testcase 1": { - "name": "testcase 1", - "testcase": "/path/to/testcase1" - }, - "testcase 2": { - "name": "testcase 2", - "testcase": "/path/to/testcase2" - } - } - } - ] -} \ No newline at end of file From d98fbc8da730dabe7a44269fe8b615024cc2c271 Mon Sep 17 00:00:00 2001 From: debugtalk Date: Thu, 9 Apr 2020 11:15:54 +0800 Subject: [PATCH 006/169] change: remove format v2 symbol --- httprunner/loader/buildup.py | 12 ++++---- httprunner/loader/check.py | 28 +++++++++---------- ...se.schema.v2.json => testcase.schema.json} | 0 ...e.schema.v2.json => testsuite.schema.json} | 0 4 files changed, 20 insertions(+), 20 deletions(-) rename httprunner/loader/schemas/{testcase.schema.v2.json => testcase.schema.json} (100%) rename httprunner/loader/schemas/{testsuite.schema.v2.json => testsuite.schema.json} (100%) diff --git a/httprunner/loader/buildup.py b/httprunner/loader/buildup.py index b18b6eaf..9652bded 100644 --- a/httprunner/loader/buildup.py +++ b/httprunner/loader/buildup.py @@ -80,7 +80,7 @@ def __extend_with_testcase_ref(raw_testinfo): # TODO: validate with pydantic if isinstance(loaded_testcase, dict) and "teststeps" in loaded_testcase: - testcase_dict = load_testcase_v2(loaded_testcase) + testcase_dict = load_testcase(loaded_testcase) else: raise exceptions.FileFormatError( f"Invalid format testcase: {testcase_path}") @@ -144,8 +144,8 @@ def load_teststep(raw_testinfo): return raw_testinfo -def load_testcase_v2(raw_testcase): - """ load testcase in format version 2. +def load_testcase(raw_testcase): + """ load testcase. Args: raw_testcase (dict): raw testcase content loaded from JSON/YAML file: @@ -174,7 +174,7 @@ def load_testcase_v2(raw_testcase): } """ - JsonSchemaChecker.validate_testcase_v2_format(raw_testcase) + JsonSchemaChecker.validate_testcase_format(raw_testcase) raw_teststeps = raw_testcase.pop("teststeps") raw_testcase["teststeps"] = [ load_teststep(teststep) @@ -220,7 +220,7 @@ def load_testsuite(raw_testsuite): # invalid format raise exceptions.FileFormatError("Invalid testsuite format!") - JsonSchemaChecker.validate_testsuite_v2_format(raw_testsuite) + JsonSchemaChecker.validate_testsuite_format(raw_testsuite) raw_testsuite["testcases"] = {} for raw_testcase in raw_testcases: __extend_with_testcase_ref(raw_testcase) @@ -278,7 +278,7 @@ def load_test_file(path: str) -> dict: elif "teststeps" in raw_content: # file_type: testcase (format version 2) - loaded_content = load_testcase_v2(raw_content) + loaded_content = load_testcase(raw_content) loaded_content["path"] = path loaded_content["type"] = "testcase" diff --git a/httprunner/loader/check.py b/httprunner/loader/check.py index 29730e3a..4e6ce8e8 100644 --- a/httprunner/loader/check.py +++ b/httprunner/loader/check.py @@ -11,8 +11,8 @@ from httprunner import exceptions schemas_root_dir = os.path.join(os.path.dirname(__file__), "schemas") common_schema_path = os.path.join(schemas_root_dir, "common.schema.json") api_schema_path = os.path.join(schemas_root_dir, "api.schema.json") -testcase_schema_v2_path = os.path.join(schemas_root_dir, "testcase.schema.v2.json") -testsuite_schema_v2_path = os.path.join(schemas_root_dir, "testsuite.schema.v2.json") +testcase_schema_path = os.path.join(schemas_root_dir, "testcase.schema.json") +testsuite_schema_path = os.path.join(schemas_root_dir, "testsuite.schema.json") with io.open(api_schema_path, encoding='utf-8') as f: api_schema = json.load(f) @@ -27,11 +27,11 @@ with io.open(common_schema_path, encoding='utf-8') as f: common_schema = json.load(f) resolver = jsonschema.RefResolver(absolute_base_path, common_schema) -with io.open(testcase_schema_v2_path, encoding='utf-8') as f: - testcase_schema_v2 = json.load(f) +with io.open(testcase_schema_path, encoding='utf-8') as f: + testcase_schema = json.load(f) -with io.open(testsuite_schema_v2_path, encoding='utf-8') as f: - testsuite_schema_v2 = json.load(f) +with io.open(testsuite_schema_path, encoding='utf-8') as f: + testsuite_schema = json.load(f) class JsonSchemaChecker(object): @@ -55,16 +55,16 @@ class JsonSchemaChecker(object): return JsonSchemaChecker.validate_format(content, api_schema) @staticmethod - def validate_testcase_v2_format(content): - """ check testcase format v2 if valid + def validate_testcase_format(content): + """ check testcase format if valid """ - return JsonSchemaChecker.validate_format(content, testcase_schema_v2) + return JsonSchemaChecker.validate_format(content, testcase_schema) @staticmethod - def validate_testsuite_v2_format(content): - """ check testsuite format v2 if valid + def validate_testsuite_format(content): + """ check testsuite format if valid """ - return JsonSchemaChecker.validate_format(content, testsuite_schema_v2) + return JsonSchemaChecker.validate_format(content, testsuite_schema) def is_test_path(path): @@ -150,7 +150,7 @@ def is_test_content(data_structure): for item in testcases: is_testcase = False try: - JsonSchemaChecker.validate_testcase_v2_format(item) + JsonSchemaChecker.validate_testcase_format(item) is_testcase = True except exceptions.FileFormatError: pass @@ -169,7 +169,7 @@ def is_test_content(data_structure): for item in testsuites: is_testcase = False try: - JsonSchemaChecker.validate_testsuite_v2_format(item) + JsonSchemaChecker.validate_testsuite_format(item) is_testcase = True except exceptions.FileFormatError: pass diff --git a/httprunner/loader/schemas/testcase.schema.v2.json b/httprunner/loader/schemas/testcase.schema.json similarity index 100% rename from httprunner/loader/schemas/testcase.schema.v2.json rename to httprunner/loader/schemas/testcase.schema.json diff --git a/httprunner/loader/schemas/testsuite.schema.v2.json b/httprunner/loader/schemas/testsuite.schema.json similarity index 100% rename from httprunner/loader/schemas/testsuite.schema.v2.json rename to httprunner/loader/schemas/testsuite.schema.json From 7ba22b1389c2b0f237ce942dc63e57616f2ae727 Mon Sep 17 00:00:00 2001 From: debugtalk Date: Thu, 9 Apr 2020 11:46:13 +0800 Subject: [PATCH 007/169] change: update unit tests for removing v1 format --- docs/prepare/upload-case.md | 4 +- httprunner/loader/buildup_test.py | 48 ---------- httprunner/loader/load_test.py | 22 ----- .../loader/schemas/testcase.schema.json | 4 +- .../loader/schemas/testsuite.schema.json | 4 +- tests/data/bugfix_type_match.yml | 5 +- tests/data/bugfix_verify.yml | 5 +- tests/data/demo_testcase.yml | 5 +- tests/data/demo_testcase_cli.yml | 16 ++-- tests/data/demo_testcase_functions.yml | 9 +- tests/data/demo_testcase_hardcode.json | 87 ------------------- tests/data/demo_testcase_hardcode.yml | 67 -------------- tests/data/demo_testcase_layer.yml | 29 ++++--- tests/data/demo_testcase_variables.yml | 9 +- tests/httpbin/basic.yml | 31 +++---- tests/httpbin/hooks.yml | 7 +- tests/httpbin/load_image.yml | 11 +-- tests/httpbin/upload.v2.yml | 30 ------- tests/httpbin/upload.yml | 7 +- tests/httpbin/validate.yml | 7 +- tests/locust_tests/demo_locusts.yml | 22 ++--- tests/test_api.py | 35 ++------ tests/test_runner.py | 17 ---- tests/testcases/create_user.json | 40 ++++----- tests/testcases/create_user.v2.json | 30 ------- tests/testcases/create_user.v2.yml | 21 ----- tests/testcases/create_user.yml | 9 +- tests/testcases/deps/check_and_create.yml | 9 +- tests/testcases/setup.json | 80 +++++++---------- tests/testcases/setup.v2.json | 43 --------- tests/testcases/setup.v2.yml | 32 ------- tests/testcases/setup.yml | 8 +- tests/testsuites/create_users.json | 40 +++++---- tests/testsuites/create_users.v2.json | 31 ------- tests/testsuites/create_users.v2.yml | 24 ----- tests/testsuites/create_users.yml | 26 +++--- .../create_users_with_parameters.yml | 17 ++-- 37 files changed, 205 insertions(+), 686 deletions(-) delete mode 100644 tests/data/demo_testcase_hardcode.json delete mode 100644 tests/data/demo_testcase_hardcode.yml delete mode 100644 tests/httpbin/upload.v2.yml delete mode 100644 tests/testcases/create_user.v2.json delete mode 100644 tests/testcases/create_user.v2.yml delete mode 100644 tests/testcases/setup.v2.json delete mode 100644 tests/testcases/setup.v2.yml delete mode 100644 tests/testsuites/create_users.v2.json delete mode 100644 tests/testsuites/create_users.v2.yml diff --git a/docs/prepare/upload-case.md b/docs/prepare/upload-case.md index 58a5ba23..21b00cb0 100644 --- a/docs/prepare/upload-case.md +++ b/docs/prepare/upload-case.md @@ -45,7 +45,7 @@ - eq: ["status_code", 200] ``` -参考案例:[httprunner/tests/httpbin/upload.v2.yml][2] +参考案例:[httprunner/tests/httpbin/upload.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 +[2]: https://github.com/httprunner/httprunner/blob/master/tests/httpbin/upload.yml \ No newline at end of file diff --git a/httprunner/loader/buildup_test.py b/httprunner/loader/buildup_test.py index 1d1b78e8..2792264b 100644 --- a/httprunner/loader/buildup_test.py +++ b/httprunner/loader/buildup_test.py @@ -115,18 +115,6 @@ class TestSuiteLoader(unittest.TestCase): self.assertIn("teststeps", loaded_content) self.assertEqual(len(loaded_content["teststeps"]), 2) - def test_load_test_file_testcase_v2(self): - for loaded_content in [ - buildup.load_test_file("tests/testcases/setup.v2.yml"), - buildup.load_test_file("tests/testcases/setup.v2.json") - ]: - self.assertEqual(loaded_content["type"], "testcase") - self.assertIn("path", loaded_content) - self.assertIn("config", loaded_content) - self.assertEqual(loaded_content["config"]["name"], "setup and reset all.") - self.assertIn("teststeps", loaded_content) - self.assertEqual(len(loaded_content["teststeps"]), 2) - def test_load_test_file_testsuite(self): for loaded_content in [ buildup.load_test_file("tests/testsuites/create_users.yml"), @@ -143,22 +131,6 @@ class TestSuiteLoader(unittest.TestCase): "create user and check result." ) - def test_load_test_file_testsuite_v2(self): - for loaded_content in [ - buildup.load_test_file("tests/testsuites/create_users.v2.yml"), - buildup.load_test_file("tests/testsuites/create_users.v2.json") - ]: - self.assertEqual(loaded_content["type"], "testsuite") - - testcases = loaded_content["testcases"] - self.assertEqual(len(testcases), 2) - self.assertIn('create user 1000 and check result.', testcases) - self.assertIn('testcase_def', testcases["create user 1000 and check result."]) - self.assertEqual( - testcases["create user 1000 and check result."]["testcase_def"]["config"]["name"], - "create user and check result." - ) - def test_load_tests_api_file(self): path = os.path.join( os.getcwd(), 'tests/api/create_user.yml') @@ -168,26 +140,6 @@ class TestSuiteLoader(unittest.TestCase): self.assertEqual(len(api_list), 1) self.assertEqual(api_list[0]["request"]["url"], "/api/users/$uid") - def test_load_tests_testcase_file(self): - # absolute file path - path = os.path.join( - os.getcwd(), 'tests/data/demo_testcase_hardcode.json') - tests_mapping = loader.load_cases(path) - project_mapping = tests_mapping["project_mapping"] - testcases_list = tests_mapping["testcases"] - self.assertEqual(len(testcases_list), 1) - self.assertEqual(len(testcases_list[0]["teststeps"]), 3) - self.assertIn("get_sign", project_mapping["functions"]) - - # relative file path - path = 'tests/data/demo_testcase_hardcode.yml' - tests_mapping = loader.load_cases(path) - project_mapping = tests_mapping["project_mapping"] - testcases_list = tests_mapping["testcases"] - self.assertEqual(len(testcases_list), 1) - self.assertEqual(len(testcases_list[0]["teststeps"]), 3) - self.assertIn("get_sign", project_mapping["functions"]) - def test_load_tests_testcase_file_2(self): testcase_file_path = os.path.join( os.getcwd(), 'tests/data/demo_testcase.yml') diff --git a/httprunner/loader/load_test.py b/httprunner/loader/load_test.py index 4a2e0f91..87e666b4 100644 --- a/httprunner/loader/load_test.py +++ b/httprunner/loader/load_test.py @@ -62,28 +62,6 @@ class TestFileLoader(unittest.TestCase): with self.assertRaises(exceptions.FileNotFound): load.load_file(testcase_file_path) - def test_load_json_testcases(self): - testcase_file_path = os.path.join( - os.getcwd(), 'tests/data/demo_testcase_hardcode.json') - testcases = load.load_file(testcase_file_path) - self.assertEqual(len(testcases), 3) - test = testcases[0]["test"] - self.assertIn('name', test) - self.assertIn('request', test) - self.assertIn('url', test['request']) - self.assertIn('method', test['request']) - - def test_load_yaml_testcases(self): - testcase_file_path = os.path.join( - os.getcwd(), 'tests/data/demo_testcase_hardcode.yml') - testcases = load.load_file(testcase_file_path) - self.assertEqual(len(testcases), 3) - test = testcases[0]["test"] - self.assertIn('name', test) - self.assertIn('request', test) - self.assertIn('url', test['request']) - self.assertIn('method', test['request']) - def test_load_csv_file_one_parameter(self): csv_file_path = os.path.join( os.getcwd(), 'tests/data/user_agent.csv') diff --git a/httprunner/loader/schemas/testcase.schema.json b/httprunner/loader/schemas/testcase.schema.json index 665b1419..9d098ef8 100644 --- a/httprunner/loader/schemas/testcase.schema.json +++ b/httprunner/loader/schemas/testcase.schema.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema", - "description": "httprunner testcase schema v2 definition", + "description": "httprunner testcase schema definition", "type": "object", "definitions": { "teststep": { @@ -118,7 +118,7 @@ "type": "array", "minItems": 1, "items": { - "$ref": "testcase.schema.v2.json#/definitions/teststep" + "$ref": "testcase.schema.json#/definitions/teststep" } } }, diff --git a/httprunner/loader/schemas/testsuite.schema.json b/httprunner/loader/schemas/testsuite.schema.json index 5eb7eff2..0b3d35ef 100644 --- a/httprunner/loader/schemas/testsuite.schema.json +++ b/httprunner/loader/schemas/testsuite.schema.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema", - "description": "httprunner testsuite schema v2 definition", + "description": "httprunner testsuite schema definition", "type": "object", "definitions": { "testcase": { @@ -35,7 +35,7 @@ "type": "array", "minItems": 1, "items": { - "$ref": "testsuite.schema.v2.json#/definitions/testcase" + "$ref": "testsuite.schema.json#/definitions/testcase" } } }, diff --git a/tests/data/bugfix_type_match.yml b/tests/data/bugfix_type_match.yml index 5d3bd3d2..954a4596 100644 --- a/tests/data/bugfix_type_match.yml +++ b/tests/data/bugfix_type_match.yml @@ -1,8 +1,9 @@ -- config: +config: name: "bugfix testcases." base_url: http://127.0.0.1:5000 -- test: +teststeps: +- name: bugfix type_match #84 request: url: http://127.0.0.1:5000/api/users/1000 diff --git a/tests/data/bugfix_verify.yml b/tests/data/bugfix_verify.yml index d3cd0c47..ddac8282 100644 --- a/tests/data/bugfix_verify.yml +++ b/tests/data/bugfix_verify.yml @@ -1,9 +1,10 @@ -- config: +config: name: basic test with httpbin base_url: https://httpbin.org/ verify: False -- test: +teststeps: +- name: headers request: url: /headers diff --git a/tests/data/demo_testcase.yml b/tests/data/demo_testcase.yml index 2a495b98..72f7973e 100644 --- a/tests/data/demo_testcase.yml +++ b/tests/data/demo_testcase.yml @@ -1,4 +1,4 @@ -- config: +config: name: "123t$var_a" variables: var_a: 1 @@ -8,7 +8,8 @@ var_e: $var_d PROJECT_KEY: ${ENV(PROJECT_KEY)} -- test: +teststeps: +- name: testcase1-$var_a request: url: /api1 diff --git a/tests/data/demo_testcase_cli.yml b/tests/data/demo_testcase_cli.yml index 27db9390..5e092a3f 100644 --- a/tests/data/demo_testcase_cli.yml +++ b/tests/data/demo_testcase_cli.yml @@ -1,4 +1,8 @@ -- test: +config: + name: xxx + +teststeps: +- name: get token request: url: http://127.0.0.1:5000/api/get-token @@ -25,7 +29,7 @@ - {"check": "status_code", "comparator": "sum_status_code", "expect": 2} - sum_status_code: ["status_code", 2] -- test: +- name: create user which does not exist request: url: http://127.0.0.1:5000/api/users/1000 @@ -47,7 +51,7 @@ - {"check": "status_code", "comparator": "eq", "expect": 201} - {"check": "content.success", "comparator": "eq", "expect": true} -- test: +- name: create user which existed times: 2 request: @@ -67,7 +71,7 @@ - {"check": "status_code", "comparator": "eq", "expect": 500} - {"check": "content.success", "comparator": "eq", "expect": false} -- test: +- name: create user which existed (skip unconditionally) skip: skip this test unconditionally times: 2 @@ -88,7 +92,7 @@ - {"check": "status_code", "comparator": "eq", "expect": 500} - {"check": "content.success", "comparator": "eq", "expect": false} -- test: +- name: create user which existed (skip if condition) skipIf: ${skip_test_in_production_env()} times: 2 @@ -109,7 +113,7 @@ - {"check": "status_code", "comparator": "eq", "expect": 500} - {"check": "content.success", "comparator": "eq", "expect": false} -- test: +- name: create user which existed (skip unless condition) skipUnless: ${skip_test_in_production_env()} times: 2 diff --git a/tests/data/demo_testcase_functions.yml b/tests/data/demo_testcase_functions.yml index 801b618b..759d8e02 100644 --- a/tests/data/demo_testcase_functions.yml +++ b/tests/data/demo_testcase_functions.yml @@ -1,4 +1,4 @@ -- config: +config: name: "create user testcases." variables: user_agent: 'iOS/10.3' @@ -7,7 +7,8 @@ app_version: '2.8.6' base_url: ${get_base_url()} -- test: +teststeps: +- name: get token request: url: /api/get-token @@ -25,7 +26,7 @@ - {"check": "status_code", "comparator": "eq", "expect": 200} - {"check": "content.token", "comparator": "len_eq", "expect": 16} -- test: +- name: create user which does not exist variables: user_name: "user1" @@ -44,7 +45,7 @@ - {"check": "status_code", "comparator": "eq", "expect": 201} - {"check": "content.success", "comparator": "eq", "expect": true} -- test: +- name: create user which does not exist request: url: /api/users/1000 diff --git a/tests/data/demo_testcase_hardcode.json b/tests/data/demo_testcase_hardcode.json deleted file mode 100644 index efe42738..00000000 --- a/tests/data/demo_testcase_hardcode.json +++ /dev/null @@ -1,87 +0,0 @@ -[ - { - "test": { - "name": "get token", - "request": { - "url": "http://127.0.0.1:5000/api/get-token", - "method": "POST", - "headers": { - "content-type": "application/json", - "user_agent": "iOS/10.3", - "device_sn": "HZfFBh6tU59EdXJ", - "os_platform": "ios", - "app_version": "2.8.6" - }, - "json": { - "sign": "5188962c489d1a35effa99e9346dd5efd4fdabad" - } - }, - "variables": [ - {"expect_status_code": 200}, - {"token_len": 16} - ], - "extract": { - "token": "content.token" - }, - "validate": [ - {"check": "status_code", "comparator": "eq", "expect": 200}, - {"eq": ["status_code", "$expect_status_code"]}, - {"check": "$token", "comparator": "len_eq", "expect": 16}, - {"len_eq": ["$token", "$token_len"]}, - {"len_eq": ["content.token", 16]}, - {"check": "status_code", "comparator": "sum_status_code", "expect": 2}, - {"sum_status_code": ["status_code", 2]} - ] - } - }, - { - "test": { - "name": "create user which does not exist", - "request": { - "url": "http://127.0.0.1:5000/api/users/2000", - "method": "POST", - "headers": { - "content-type": "application/json", - "device_sn": "HZfFBh6tU59EdXJ", - "token": "$token" - }, - "json": { - "name": "user1", - "password": "123456" - } - }, - "validate": [ - {"eq": ["status_code", 201]}, - {"eq": ["content.success", true]}, - {"check": "status_code", "comparator": "eq", "expect": 201}, - {"sum_status_code": ["status_code", 3]}, - {"check": "content.success", "comparator": "eq", "expect": true} - ] - } - }, - { - "test": { - "name": "create user which existed", - "request": { - "url": "http://127.0.0.1:5000/api/users/2000", - "method": "POST", - "headers": { - "content-type": "application/json", - "device_sn": "HZfFBh6tU59EdXJ", - "token": "$token" - }, - "json": { - "name": "user1", - "password": "123456" - } - }, - "validate": [ - {"eq": ["status_code", 500]}, - {"eq": ["content.success", false]}, - {"check": "status_code", "comparator": "eq", "expect": 500}, - {"sum_status_code": ["status_code", 5]}, - {"check": "content.success", "comparator": "eq", "expect": false} - ] - } - } -] \ No newline at end of file diff --git a/tests/data/demo_testcase_hardcode.yml b/tests/data/demo_testcase_hardcode.yml deleted file mode 100644 index 05df6b4a..00000000 --- a/tests/data/demo_testcase_hardcode.yml +++ /dev/null @@ -1,67 +0,0 @@ -- test: - name: get token - request: - url: http://127.0.0.1:5000/api/get-token - method: POST - headers: - Content-Type: application/json - user_agent: 'iOS/10.3' - device_sn: 'HZfFBh6tU59EdXJ' - os_platform: 'ios' - app_version: '2.8.6' - json: - sign: 5188962c489d1a35effa99e9346dd5efd4fdabad - variables: - expect_status_code: 200 - token_len: 16 - extract: - token: content.token - validate: - - {"check": "status_code", "comparator": "eq", "expect": 200} - - eq: ["status_code", $expect_status_code] - - {"check": "$token", "comparator": "len_eq", "expect": 16} - - len_eq: ["$token", $token_len] - - len_eq: ["content.token", 16] - - {"check": "status_code", "comparator": "sum_status_code", "expect": 2} - - sum_status_code: ["status_code", 2] - -- test: - name: create user which does not exist - request: - url: http://127.0.0.1:5000/api/users/1000 - method: POST - headers: - Content-Type: application/json - device_sn: 'HZfFBh6tU59EdXJ' - token: $token - json: - name: "user1" - password: "123456" - extract: - success: content.success - validate: - - eq: ["status_code", 201] - - sum_status_code: ["status_code", 3] - - eq: ["$success", True] - - eq: ["abc$success", "abcTrue"] - - {"check": "status_code", "comparator": "eq", "expect": 201} - - {"check": "content.success", "comparator": "eq", "expect": true} - -- test: - name: create user which existed - request: - url: http://127.0.0.1:5000/api/users/1000 - method: POST - headers: - Content-Type: application/json - device_sn: 'HZfFBh6tU59EdXJ' - token: $token - json: - name: "user1" - password: "123456" - validate: - - "eq": ["status_code", 500] - - sum_status_code: ["status_code", 5] - - "eq": ["content.success", false] - - {"check": "status_code", "comparator": "eq", "expect": 500} - - {"check": "content.success", "comparator": "eq", "expect": false} \ No newline at end of file diff --git a/tests/data/demo_testcase_layer.yml b/tests/data/demo_testcase_layer.yml index 48145514..bfcaba16 100644 --- a/tests/data/demo_testcase_layer.yml +++ b/tests/data/demo_testcase_layer.yml @@ -1,4 +1,4 @@ -- config: +config: name: "user management testcase." variables: user_agent: 'iOS/10.3' @@ -9,7 +9,8 @@ export: - token -- test: +teststeps: +- name: get token with $user_agent, $app_version api: api/get_token.yml extract: @@ -19,7 +20,7 @@ - "len_eq": ["content.token", 16] - "contains": [{"a": 1, "b": 2}, "b"] -- test: +- name: reset all users api: api/reset_all.yml variables: @@ -28,7 +29,7 @@ - {"check": "status_code", "expect": 200} - {"check": "content.success", "expect": true} -- test: +- name: get user that does not exist api: api/get_user.yml variables: @@ -38,7 +39,7 @@ - {"check": "status_code", "expect": 404} - {"check": "content.success", "expect": false} -- test: +- name: create user which does not exist variables: uid: 1000 @@ -50,7 +51,7 @@ - {"check": "status_code", "expect": 201} - {"check": "content.success", "expect": true} -- test: +- name: get user that has been created api: api/get_user.yml variables: @@ -61,7 +62,7 @@ - {"check": "content.success", "expect": true} - {"check": "content.data.password", "expect": "123456"} -- test: +- name: create user which exists variables: uid: 1000 @@ -73,7 +74,7 @@ - {"check": "status_code", "expect": 500} - {"check": "content.success", "expect": false} -- test: +- name: update user which exists variables: uid: 1000 @@ -85,7 +86,7 @@ - {"check": "status_code", "expect": 200} - {"check": "content.success", "expect": true} -- test: +- name: get user that has been updated api: api/get_user.yml variables: @@ -96,7 +97,7 @@ - {"check": "content.success", "expect": true} - {"check": "content.data.password", "expect": "654321"} -- test: +- name: get users api: api/get_users.yml variables: @@ -105,7 +106,7 @@ - {"check": "status_code", "expect": 200} - {"check": "content.count", "expect": 1} -- test: +- name: delete user that exists api: api/delete_user.yml variables: @@ -115,7 +116,7 @@ - {"check": "status_code", "expect": 200} - {"check": "content.success", "expect": true} -- test: +- name: get users api: api/get_users.yml variables: @@ -124,7 +125,7 @@ - {"check": "status_code", "expect": 200} - {"check": "content.count", "expect": 0} -- test: +- name: create user which has been deleted variables: uid: 1000 @@ -136,7 +137,7 @@ - {"check": "status_code", "expect": 201} - {"check": "content.success", "expect": true} -- test: +- name: get users api: api/get_users.yml variables: diff --git a/tests/data/demo_testcase_variables.yml b/tests/data/demo_testcase_variables.yml index 2510d514..84c7a0f8 100644 --- a/tests/data/demo_testcase_variables.yml +++ b/tests/data/demo_testcase_variables.yml @@ -1,10 +1,11 @@ -- config: +config: name: "create user testcases." variables: device_sn: 'HZfFBh6tU59EdXJ' base_url: ${get_base_url()} -- test: +teststeps: +- name: get token variables: user_agent: 'iOS/10.3' @@ -28,7 +29,7 @@ - {"check": "status_code", "comparator": "eq", "expect": 200} - {"check": "content.token", "comparator": "len_eq", "expect": 16} -- test: +- name: create user which does not exist variables: user_name: "user1" @@ -47,7 +48,7 @@ - {"check": "status_code", "comparator": "eq", "expect": 201} - {"check": "content.success", "comparator": "eq", "expect": true} -- test: +- name: create user which does not exist request: url: /api/users/1000 diff --git a/tests/httpbin/basic.yml b/tests/httpbin/basic.yml index 05fb8f56..ff4b51f2 100644 --- a/tests/httpbin/basic.yml +++ b/tests/httpbin/basic.yml @@ -1,18 +1,9 @@ -- config: +config: name: basic test with httpbin base_url: https://httpbin.org/ -#- 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: +teststeps: +- name: headers request: url: /headers @@ -21,7 +12,7 @@ - eq: ["status_code", 200] - eq: [content.headers.Host, "httpbin.org"] -- test: +- name: user-agent request: url: /user-agent @@ -30,7 +21,7 @@ - eq: ["status_code", 200] - startswith: [content.user-agent, "python-requests"] -- test: +- name: get without params request: url: /get @@ -39,7 +30,7 @@ - eq: ["status_code", 200] - eq: [content.args, {}] -- test: +- name: get with params in url request: url: /get?a=1&b=2 @@ -48,7 +39,7 @@ - eq: ["status_code", 200] - eq: [content.args, {'a': '1', 'b': '2'}] -- test: +- name: get with params in params field request: url: /get @@ -60,7 +51,7 @@ - eq: ["status_code", 200] - eq: [content.args, {'a': '1', 'b': '2'}] -- test: +- name: set cookie request: url: /cookies/set?name=value @@ -69,7 +60,7 @@ - eq: ["status_code", 200] # - eq: [cookies.name, "value"] -- test: +- name: extract cookie request: url: /cookies @@ -78,7 +69,7 @@ - eq: ["status_code", 200] # - eq: [cookies.name, "value"] -- test: +- name: post data request: url: /post @@ -89,7 +80,7 @@ validate: - eq: ["status_code", 200] -- test: +- name: validate content length request: url: /spec.json diff --git a/tests/httpbin/hooks.yml b/tests/httpbin/hooks.yml index e4670772..280803ce 100644 --- a/tests/httpbin/hooks.yml +++ b/tests/httpbin/hooks.yml @@ -1,4 +1,4 @@ -- config: +config: name: basic test with httpbin base_url: ${get_httpbin_server()} setup_hooks: @@ -6,7 +6,8 @@ teardown_hooks: - ${hook_print(teardown)} -- test: +teststeps: +- name: headers request: url: /headers @@ -20,7 +21,7 @@ - eq: ["status_code", 200] - contained_by: [content.headers.Host, "${get_httpbin_server()}"] -- test: +- name: alter response request: url: /headers diff --git a/tests/httpbin/load_image.yml b/tests/httpbin/load_image.yml index 4ea6da75..7a2ada65 100644 --- a/tests/httpbin/load_image.yml +++ b/tests/httpbin/load_image.yml @@ -1,8 +1,9 @@ -- config: +config: name: load images base_url: ${get_httpbin_server()} -- test: +teststeps: +- name: get png image request: url: /image/png @@ -10,7 +11,7 @@ validate: - eq: ["status_code", 200] -- test: +- name: get jpeg image request: url: /image/jpeg @@ -18,7 +19,7 @@ validate: - eq: ["status_code", 200] -- test: +- name: get webp image request: url: /image/webp @@ -26,7 +27,7 @@ validate: - eq: ["status_code", 200] -- test: +- name: get svg image request: url: /image/svg diff --git a/tests/httpbin/upload.v2.yml b/tests/httpbin/upload.v2.yml deleted file mode 100644 index 1f96d037..00000000 --- a/tests/httpbin/upload.v2.yml +++ /dev/null @@ -1,30 +0,0 @@ -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 a858cb05..1f96d037 100644 --- a/tests/httpbin/upload.yml +++ b/tests/httpbin/upload.yml @@ -1,8 +1,9 @@ -- config: +config: name: test upload file with httpbin base_url: ${get_httpbin_server()} -- test: +teststeps: +- name: upload file variables: file_path: "data/test.env" @@ -17,7 +18,7 @@ - eq: ["status_code", 200] - startswith: ["content.files.file", "UserName=test"] -- test: +- name: upload file with keyword request: url: /post diff --git a/tests/httpbin/validate.yml b/tests/httpbin/validate.yml index 0be60af8..b50547a4 100644 --- a/tests/httpbin/validate.yml +++ b/tests/httpbin/validate.yml @@ -1,8 +1,9 @@ -- config: +config: name: basic test with httpbin base_url: http://httpbin.org/ -- test: +teststeps: +- name: validate response with json path request: url: /get @@ -18,7 +19,7 @@ - "assert status_code == 200" -- test: +- name: validate response with python script request: url: /get diff --git a/tests/locust_tests/demo_locusts.yml b/tests/locust_tests/demo_locusts.yml index 4ae14cde..62ab8d69 100644 --- a/tests/locust_tests/demo_locusts.yml +++ b/tests/locust_tests/demo_locusts.yml @@ -5,14 +5,16 @@ config: base_url: "http://127.0.0.1:5000" testcases: - create user 1000 and check result.: - testcase: testcases/create_user.yml - weight: 2 - variables: - uid: 1000 +- + name: create user 1000 and check result. + testcase: testcases/create_user.yml + weight: 2 + variables: + uid: 1000 - create user 1001 and check result.: - testcase: testcases/create_user.yml - weight: 3 - variables: - uid: 1001 +- + name: create user 1001 and check result. + testcase: testcases/create_user.yml + weight: 3 + variables: + uid: 1001 diff --git a/tests/test_api.py b/tests/test_api.py index 948f629d..1bf1f8b5 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -14,12 +14,6 @@ class TestHttpRunner(ApiServerUnittest): def setUp(self): self.testcase_cli_path = "tests/data/demo_testcase_cli.yml" - self.testcase_file_path_list = [ - os.path.join( - os.getcwd(), 'tests/data/demo_testcase_hardcode.yml'), - os.path.join( - os.getcwd(), 'tests/data/demo_testcase_hardcode.json') - ] testcases = [{ 'config': { 'name': 'testcase description', @@ -223,17 +217,12 @@ class TestHttpRunner(ApiServerUnittest): self.assertIn("records", summary["details"][0]) def test_run_yaml_upload(self): - 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]) + 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"], 2) + self.assertIn("details", summary) + self.assertIn("records", summary["details"][0]) def test_run_post_data(self): testcases = [ @@ -470,14 +459,6 @@ class TestHttpRunner(ApiServerUnittest): self.assertEqual(summary["details"][1]["stat"]["total"], 1) self.assertEqual(summary["details"][2]["stat"]["total"], 1) - def test_run_testcase_hardcode(self): - for testcase_file_path in self.testcase_file_path_list: - summary = self.runner.run(testcase_file_path) - self.assertTrue(summary["success"]) - self.assertEqual(summary["stat"]["testcases"]["total"], 1) - self.assertEqual(summary["stat"]["teststeps"]["total"], 3) - self.assertEqual(summary["stat"]["teststeps"]["successes"], 3) - def test_run_testcase_template_variables(self): testcase_file_path = os.path.join( os.getcwd(), 'tests/data/demo_testcase_variables.yml') @@ -693,9 +674,7 @@ class TestApi(ApiServerUnittest): def test_testcase_complex_run_suite(self): for testcase_path in [ "tests/testcases/create_user.yml", - "tests/testcases/create_user.v2.yml", - "tests/testcases/create_user.json", - "tests/testcases/create_user.v2.json" + "tests/testcases/create_user.json" ]: tests_mapping = loader.load_cases(testcase_path) testcases = parser.parse_tests(tests_mapping) diff --git a/tests/test_runner.py b/tests/test_runner.py index c724ab60..fe44ff2b 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -25,23 +25,6 @@ class TestRunner(ApiServerUnittest): headers = self.get_authenticated_headers() return self.api_client.get(url, headers=headers) - def test_run_single_testcase(self): - testcase_file_path_list = [ - os.path.join( - os.getcwd(), 'tests/data/demo_testcase_hardcode.yml'), - os.path.join( - os.getcwd(), 'tests/data/demo_testcase_hardcode.json') - ] - - for testcase_file_path in testcase_file_path_list: - tests_mapping = loader.load_cases(testcase_file_path) - parsed_testcases = parser.parse_tests(tests_mapping) - parsed_testcase = parsed_testcases[0] - test_runner = runner.Runner(parsed_testcase["config"]) - test_runner.run_test(parsed_testcase["teststeps"][0]) - test_runner.run_test(parsed_testcase["teststeps"][1]) - test_runner.run_test(parsed_testcase["teststeps"][2]) - def test_run_testcase_with_hooks(self): start_time = time.time() diff --git a/tests/testcases/create_user.json b/tests/testcases/create_user.json index 30af7ce8..12f537e9 100644 --- a/tests/testcases/create_user.json +++ b/tests/testcases/create_user.json @@ -1,34 +1,30 @@ -[ - { - "config": { - "id": "create_user", - "variables": { - "device_sn": "TESTCASE_CREATE_XXX", - "uid": 9001 - }, - "output": [ - "session_token" - ], - "base_url": "http://127.0.0.1:5000", - "name": "create user and check result." - } +{ + "config": { + "id": "create_user", + "variables": { + "device_sn": "TESTCASE_CREATE_XXX", + "uid": 9001 + }, + "output": [ + "session_token" + ], + "base_url": "http://127.0.0.1:5000", + "name": "create user and check result." }, - { - "test": { + "teststeps": [ + { "testcase": "testcases/setup.yml", "extract": [ "session_token" ], "name": "setup and reset all (override) for $device_sn." - } - }, - { - "test": { + }, + { "testcase": "testcases/deps/check_and_create.yml", "variables": { "token": "$session_token" }, "name": "create user and check result." } - } -] \ No newline at end of file + ] +} \ No newline at end of file diff --git a/tests/testcases/create_user.v2.json b/tests/testcases/create_user.v2.json deleted file mode 100644 index 12f537e9..00000000 --- a/tests/testcases/create_user.v2.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "config": { - "id": "create_user", - "variables": { - "device_sn": "TESTCASE_CREATE_XXX", - "uid": 9001 - }, - "output": [ - "session_token" - ], - "base_url": "http://127.0.0.1:5000", - "name": "create user and check result." - }, - "teststeps": [ - { - "testcase": "testcases/setup.yml", - "extract": [ - "session_token" - ], - "name": "setup and reset all (override) for $device_sn." - }, - { - "testcase": "testcases/deps/check_and_create.yml", - "variables": { - "token": "$session_token" - }, - "name": "create user and check result." - } - ] -} \ No newline at end of file diff --git a/tests/testcases/create_user.v2.yml b/tests/testcases/create_user.v2.yml deleted file mode 100644 index f88dbd8a..00000000 --- a/tests/testcases/create_user.v2.yml +++ /dev/null @@ -1,21 +0,0 @@ -config: - name: "create user and check result." - id: create_user - base_url: "http://127.0.0.1:5000" - variables: - uid: 9001 - device_sn: "TESTCASE_CREATE_XXX" - export: - - session_token - -teststeps: -- - name: setup and reset all (override) for $device_sn. - testcase: testcases/setup.yml - extract: - - session_token -- - name: create user and check result. - variables: - token: $session_token - testcase: testcases/deps/check_and_create.yml diff --git a/tests/testcases/create_user.yml b/tests/testcases/create_user.yml index 9a392438..f88dbd8a 100644 --- a/tests/testcases/create_user.yml +++ b/tests/testcases/create_user.yml @@ -1,5 +1,4 @@ - -- config: +config: name: "create user and check result." id: create_user base_url: "http://127.0.0.1:5000" @@ -9,13 +8,13 @@ export: - session_token -- test: +teststeps: +- name: setup and reset all (override) for $device_sn. testcase: testcases/setup.yml extract: - session_token - -- test: +- name: create user and check result. variables: token: $session_token diff --git a/tests/testcases/deps/check_and_create.yml b/tests/testcases/deps/check_and_create.yml index 8a6b2d0a..243d344b 100644 --- a/tests/testcases/deps/check_and_create.yml +++ b/tests/testcases/deps/check_and_create.yml @@ -1,4 +1,4 @@ -- config: +config: name: "create user and check result." id: create_and_check base_url: "http://127.0.0.1:5000" @@ -6,7 +6,8 @@ uid: 9001 device_sn: "TESTCASE_CREATE_XXX" -- test: +teststeps: +- name: make sure user $uid does not exist api: api/get_user.yml variables: @@ -16,7 +17,7 @@ - eq: ["status_code", 404] - eq: ["content.success", false] -- test: +- name: create user $uid for $device_sn api: api/create_user.yml variables: @@ -28,7 +29,7 @@ - eq: ["status_code", 201] - eq: ["content.success", true] -- test: +- name: check if user $uid exists api: api/get_user.yml variables: diff --git a/tests/testcases/setup.json b/tests/testcases/setup.json index d8690447..bbc2d3ed 100644 --- a/tests/testcases/setup.json +++ b/tests/testcases/setup.json @@ -1,59 +1,43 @@ -[ - { - "config": { - "name": "setup and reset all.", - "output": [ - "session_token" - ], - "verify": false, - "variables": { - "device_sn": "TESTCASE_SETUP_XXX", - "app_version": "2.8.6", - "os_platform": "ios", - "user_agent": "iOS/10.3" - }, - "base_url": "http://127.0.0.1:5000", - "id": "setup_and_reset" - } +{ + "config": { + "name": "setup and reset all.", + "base_url": "http://127.0.0.1:5000", + "variables": { + "device_sn": "TESTCASE_SETUP_XXX", + "app_version": "2.8.6", + "os_platform": "ios", + "user_agent": "iOS/10.3" + }, + "id": "setup_and_reset", + "verify": false, + "output": [ + "session_token" + ] }, - { - "test": { - "validate": [ - { - "eq": [ - "status_code", - 200 - ] - }, - { - "len_eq": [ - "content.token", - 16 - ] - } - ], + "teststeps": [ + { + "name": "get token (setup)", "api": "api/get_token.yml", - "extract": [ - { - "session_token": "content.token" - } - ], "variables": { "device_sn": "$device_sn", "app_version": "2.8.6", "os_platform": "ios", "user_agent": "iOS/10.3" }, - "name": "get token (setup)" - } - }, - { - "test": { + "extract": [ + {"session_token": "content.token"} + ], + "validate": [ + {"eq": ["status_code", 200]}, + {"len_eq": ["content.token", 16]} + ] + }, + { + "name": "reset all users", + "api": "api/reset_all.yml", "variables": { "token": "$session_token" - }, - "api": "api/reset_all.yml", - "name": "reset all users" + } } - } -] \ No newline at end of file + ] +} \ No newline at end of file diff --git a/tests/testcases/setup.v2.json b/tests/testcases/setup.v2.json deleted file mode 100644 index bbc2d3ed..00000000 --- a/tests/testcases/setup.v2.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "config": { - "name": "setup and reset all.", - "base_url": "http://127.0.0.1:5000", - "variables": { - "device_sn": "TESTCASE_SETUP_XXX", - "app_version": "2.8.6", - "os_platform": "ios", - "user_agent": "iOS/10.3" - }, - "id": "setup_and_reset", - "verify": false, - "output": [ - "session_token" - ] - }, - "teststeps": [ - { - "name": "get token (setup)", - "api": "api/get_token.yml", - "variables": { - "device_sn": "$device_sn", - "app_version": "2.8.6", - "os_platform": "ios", - "user_agent": "iOS/10.3" - }, - "extract": [ - {"session_token": "content.token"} - ], - "validate": [ - {"eq": ["status_code", 200]}, - {"len_eq": ["content.token", 16]} - ] - }, - { - "name": "reset all users", - "api": "api/reset_all.yml", - "variables": { - "token": "$session_token" - } - } - ] -} \ No newline at end of file diff --git a/tests/testcases/setup.v2.yml b/tests/testcases/setup.v2.yml deleted file mode 100644 index d9945928..00000000 --- a/tests/testcases/setup.v2.yml +++ /dev/null @@ -1,32 +0,0 @@ -config: - name: "setup and reset all." - id: setup_and_reset - variables: - user_agent: 'iOS/10.3' - device_sn: "TESTCASE_SETUP_XXX" - os_platform: 'ios' - app_version: '2.8.6' - base_url: "http://127.0.0.1:5000" - verify: False - export: - - session_token - -teststeps: -- - name: get token (setup) - api: api/get_token.yml - variables: - user_agent: 'iOS/10.3' - device_sn: $device_sn - os_platform: 'ios' - app_version: '2.8.6' - extract: - - session_token: content.token - validate: - - eq: ["status_code", 200] - - len_eq: ["content.token", 16] -- - name: reset all users - api: api/reset_all.yml - variables: - token: $session_token diff --git a/tests/testcases/setup.yml b/tests/testcases/setup.yml index ade11425..d9945928 100644 --- a/tests/testcases/setup.yml +++ b/tests/testcases/setup.yml @@ -1,4 +1,4 @@ -- config: +config: name: "setup and reset all." id: setup_and_reset variables: @@ -11,7 +11,8 @@ export: - session_token -- test: +teststeps: +- name: get token (setup) api: api/get_token.yml variables: @@ -24,8 +25,7 @@ validate: - eq: ["status_code", 200] - len_eq: ["content.token", 16] - -- test: +- name: reset all users api: api/reset_all.yml variables: diff --git a/tests/testsuites/create_users.json b/tests/testsuites/create_users.json index d504c99f..de4d5d27 100644 --- a/tests/testsuites/create_users.json +++ b/tests/testsuites/create_users.json @@ -1,22 +1,4 @@ { - "testcases": { - "create user 1001 and check result.": { - "testcase": "testcases/create_user.yml", - "variables": { - "var_d": "$var_c", - "var_c": "${gen_random_string(5)}", - "uid": 1001 - } - }, - "create user 1000 and check result.": { - "testcase": "testcases/create_user.yml", - "variables": { - "var_d": "$var_c", - "var_c": "${gen_random_string(5)}", - "uid": 1000 - } - } - }, "config": { "variables": { "device_sn": "${gen_random_string(15)}", @@ -25,5 +7,25 @@ }, "name": "create users with uid", "base_url": "http://127.0.0.1:5000" - } + }, + "testcases": [ + { + "name": "create user 1000 and check result.", + "testcase": "testcases/create_user.yml", + "variables": { + "var_d": "$var_c", + "var_c": "${gen_random_string(5)}", + "uid": 1000 + } + }, + { + "name": "create user 1001 and check result.", + "testcase": "testcases/create_user.yml", + "variables": { + "var_d": "$var_c", + "var_c": "${gen_random_string(5)}", + "uid": 1001 + } + } + ] } \ No newline at end of file diff --git a/tests/testsuites/create_users.v2.json b/tests/testsuites/create_users.v2.json deleted file mode 100644 index 450a27aa..00000000 --- a/tests/testsuites/create_users.v2.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "config": { - "variables": { - "device_sn": "${gen_random_string(15)}", - "var_b": "$var_a", - "var_a": "${gen_random_string(5)}" - }, - "name": "create users with uid", - "base_url": "http://127.0.0.1:5000" - }, - "testcases": [ - { - "name": "create user 1000 and check result.", - "testcase": "testcases/create_user.v2.yml", - "variables": { - "var_d": "$var_c", - "var_c": "${gen_random_string(5)}", - "uid": 1000 - } - }, - { - "name": "create user 1001 and check result.", - "testcase": "testcases/create_user.v2.yml", - "variables": { - "var_d": "$var_c", - "var_c": "${gen_random_string(5)}", - "uid": 1001 - } - } - ] -} \ No newline at end of file diff --git a/tests/testsuites/create_users.v2.yml b/tests/testsuites/create_users.v2.yml deleted file mode 100644 index 8fcdd930..00000000 --- a/tests/testsuites/create_users.v2.yml +++ /dev/null @@ -1,24 +0,0 @@ -config: - name: create users with uid - variables: - device_sn: ${gen_random_string(15)} - var_a: ${gen_random_string(5)} - var_b: $var_a - base_url: "http://127.0.0.1:5000" - -testcases: -- - name: create user 1000 and check result. - testcase: testcases/create_user.v2.yml - variables: - uid: 1000 - var_c: ${gen_random_string(5)} - var_d: $var_c - -- - name: create user 1001 and check result. - testcase: testcases/create_user.v2.yml - variables: - uid: 1001 - var_c: ${gen_random_string(5)} - var_d: $var_c diff --git a/tests/testsuites/create_users.yml b/tests/testsuites/create_users.yml index 25c567a5..0714d82f 100644 --- a/tests/testsuites/create_users.yml +++ b/tests/testsuites/create_users.yml @@ -7,16 +7,18 @@ config: base_url: "http://127.0.0.1:5000" testcases: - create user 1000 and check result.: - testcase: testcases/create_user.yml - variables: - uid: 1000 - var_c: ${gen_random_string(5)} - var_d: $var_c +- + name: create user 1000 and check result. + testcase: testcases/create_user.yml + variables: + uid: 1000 + var_c: ${gen_random_string(5)} + var_d: $var_c - create user 1001 and check result.: - testcase: testcases/create_user.yml - variables: - uid: 1001 - var_c: ${gen_random_string(5)} - var_d: $var_c +- + name: create user 1001 and check result. + testcase: testcases/create_user.yml + variables: + uid: 1001 + var_c: ${gen_random_string(5)} + var_d: $var_c diff --git a/tests/testsuites/create_users_with_parameters.yml b/tests/testsuites/create_users_with_parameters.yml index a134e956..53a87e2e 100644 --- a/tests/testsuites/create_users_with_parameters.yml +++ b/tests/testsuites/create_users_with_parameters.yml @@ -5,11 +5,12 @@ config: base_url: "http://127.0.0.1:5000" testcases: - create user $uid and check result for $device_sn.: - testcase: testcases/create_user.yml - variables: - uid: 1000 - device_sn: TESTSUITE_XXX - parameters: - uid: [101, 102, 103] - device_sn: [TESTSUITE_X1, TESTSUITE_X2] +- + name: create user $uid and check result for $device_sn. + testcase: testcases/create_user.yml + variables: + uid: 1000 + device_sn: TESTSUITE_XXX + parameters: + uid: [101, 102, 103] + device_sn: [TESTSUITE_X1, TESTSUITE_X2] From 647368e003dc85e6e676333bf08a2fda57a228a6 Mon Sep 17 00:00:00 2001 From: debugtalk Date: Thu, 9 Apr 2020 21:44:37 +0800 Subject: [PATCH 008/169] fix: base model for TestSuite --- httprunner/loader/check.py | 6 +++--- httprunner/schema/__init__.py | 2 +- httprunner/schema/api.py | 4 +++- httprunner/schema/common.py | 23 +++++++++++------------ httprunner/schema/testcase.py | 10 ++++------ httprunner/schema/testsuite.py | 11 +++++++++-- tests/locust_tests/demo_locusts.yml | 2 +- 7 files changed, 32 insertions(+), 26 deletions(-) diff --git a/httprunner/loader/check.py b/httprunner/loader/check.py index c899634a..d428b75e 100644 --- a/httprunner/loader/check.py +++ b/httprunner/loader/check.py @@ -17,7 +17,7 @@ class JsonSchemaChecker(object): Api.parse_obj(content) except ValidationError as ex: logger.error(ex) - raise exceptions.FileFormatError + raise exceptions.FileFormatError(ex) @staticmethod def validate_testcase_format(content): @@ -27,7 +27,7 @@ class JsonSchemaChecker(object): TestCase.parse_obj(content) except ValidationError as ex: logger.error(ex) - raise exceptions.FileFormatError + raise exceptions.FileFormatError(ex) @staticmethod def validate_testsuite_format(content): @@ -37,7 +37,7 @@ class JsonSchemaChecker(object): TestSuite.parse_obj(content) except ValidationError as ex: logger.error(ex) - raise exceptions.FileFormatError + raise exceptions.FileFormatError(ex) def is_test_path(path): diff --git a/httprunner/schema/__init__.py b/httprunner/schema/__init__.py index 12be9833..6ff26ad9 100644 --- a/httprunner/schema/__init__.py +++ b/httprunner/schema/__init__.py @@ -1,3 +1,3 @@ from .api import Api -from .testcase import ProjectMeta, TestCase, TestCases +from .testcase import ProjectMeta, TestCase from .testsuite import TestSuite diff --git a/httprunner/schema/api.py b/httprunner/schema/api.py index 48f73e38..eb7c0b5e 100644 --- a/httprunner/schema/api.py +++ b/httprunner/schema/api.py @@ -1,3 +1,5 @@ +from typing import Dict, Text + from pydantic import BaseModel, Field from httprunner.schema import common @@ -10,5 +12,5 @@ class Api(BaseModel): base_url: common.BaseUrl = "" setup_hooks: common.Hook = [] teardown_hooks: common.Hook = [] - extract: common.Extract = {} + extract: Dict[Text, Text] = {} validation: common.Validate = Field([], alias="validate") diff --git a/httprunner/schema/common.py b/httprunner/schema/common.py index c146ca06..bc97e9ff 100644 --- a/httprunner/schema/common.py +++ b/httprunner/schema/common.py @@ -1,22 +1,21 @@ from enum import Enum -from typing import Dict, List, Any +from typing import Dict, List, Any, Text from pydantic import BaseModel, HttpUrl, Field -Name = str -Url = str +Name = Text +Url = Text BaseUrl = HttpUrl -Variables = Dict[str, Any] -Headers = Dict[str, str] +Variables = Dict[Text, Any] +Headers = Dict[Text, Text] Verify = bool -Hook = List[str] -Export = List[str] -Extract = Dict[str, str] +Hook = List[Text] +Export = List[Text] Validate = List[Dict] -Env = Dict[str, Any] +Env = Dict[Text, Any] -class MethodEnum(str, Enum): +class MethodEnum(Text, Enum): GET = 'GET' POST = 'POST' PUT = "PUT" @@ -52,10 +51,10 @@ class TestsConfig(BaseModel): class Request(BaseModel): method: MethodEnum = MethodEnum.GET url: Url - params: Dict[str, str] = {} + params: Dict[Text, Text] = {} headers: Headers = {} req_json: Dict = Field({}, alias="json") - cookies: Dict[str, str] = {} + cookies: Dict[Text, Text] = {} timeout: int = 120 allow_redirects: bool = True verify: Verify = False diff --git a/httprunner/schema/testcase.py b/httprunner/schema/testcase.py index 72d92287..c4aec71f 100644 --- a/httprunner/schema/testcase.py +++ b/httprunner/schema/testcase.py @@ -1,4 +1,4 @@ -from typing import Dict, List, Text +from typing import Dict, List, Text, Union from pydantic import BaseModel, Field @@ -13,9 +13,10 @@ class ProjectMeta(BaseModel): class TestStep(BaseModel): name: common.Name - api: str = None # TODO: replace with FilePath + api: Text = None # TODO: replace with FilePath + testcase: Text = None request: common.Request = None - extract: Dict[str, str] = {} + extract: Union[Dict[Text, Text], List[Text]] = {} validation: common.Validate = Field([], alias="validate") @@ -81,6 +82,3 @@ class TestCase(BaseModel): } ] } - - -TestCases = List[TestCase] diff --git a/httprunner/schema/testsuite.py b/httprunner/schema/testsuite.py index 841479fb..f973824a 100644 --- a/httprunner/schema/testsuite.py +++ b/httprunner/schema/testsuite.py @@ -1,8 +1,15 @@ -from typing import List +from typing import List, Text from pydantic import BaseModel -from httprunner.schema import common, TestCase +from httprunner.schema import common + + +class TestCase(BaseModel): + name: common.Name + testcase: Text + weight: int = 1 + variables: common.Variables = {} class TestSuite(BaseModel): diff --git a/tests/locust_tests/demo_locusts.yml b/tests/locust_tests/demo_locusts.yml index 62ab8d69..2ec2d342 100644 --- a/tests/locust_tests/demo_locusts.yml +++ b/tests/locust_tests/demo_locusts.yml @@ -1,7 +1,7 @@ config: name: create users with uid variables: - - device_sn: ${gen_random_string(15)} + device_sn: ${gen_random_string(15)} base_url: "http://127.0.0.1:5000" testcases: From 05a8f939c5aed6085a9cb9a9c3d8a03cf5f031ab Mon Sep 17 00:00:00 2001 From: debugtalk Date: Thu, 9 Apr 2020 22:10:06 +0800 Subject: [PATCH 009/169] fix: api variables missing default value --- httprunner/schema/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/httprunner/schema/api.py b/httprunner/schema/api.py index eb7c0b5e..6744dd79 100644 --- a/httprunner/schema/api.py +++ b/httprunner/schema/api.py @@ -8,7 +8,7 @@ from httprunner.schema import common class Api(BaseModel): name: common.Name request: common.Request - variables: common.Variables + variables: common.Variables = {} base_url: common.BaseUrl = "" setup_hooks: common.Hook = [] teardown_hooks: common.Hook = [] From bf2ec777656599a1fd9d752ecda216e317d98c81 Mon Sep 17 00:00:00 2001 From: debugtalk Date: Thu, 9 Apr 2020 22:23:19 +0800 Subject: [PATCH 010/169] fix: unittests --- httprunner/schema/common.py | 4 ++-- tests/data/demo_testcase_cli.yml | 4 ++-- tests/data/demo_testcase_functions.yml | 2 +- tests/data/demo_testcase_layer.yml | 2 +- tests/data/demo_testcase_variables.yml | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/httprunner/schema/common.py b/httprunner/schema/common.py index bc97e9ff..40dcbb09 100644 --- a/httprunner/schema/common.py +++ b/httprunner/schema/common.py @@ -1,11 +1,11 @@ from enum import Enum -from typing import Dict, List, Any, Text +from typing import Dict, List, Any, Text, Union from pydantic import BaseModel, HttpUrl, Field Name = Text Url = Text -BaseUrl = HttpUrl +BaseUrl = Union[HttpUrl, Text] Variables = Dict[Text, Any] Headers = Dict[Text, Text] Verify = bool diff --git a/tests/data/demo_testcase_cli.yml b/tests/data/demo_testcase_cli.yml index 5e092a3f..6989e528 100644 --- a/tests/data/demo_testcase_cli.yml +++ b/tests/data/demo_testcase_cli.yml @@ -19,7 +19,7 @@ teststeps: expect_status_code: 200 token_len: 16 extract: - - token: content.token + token: content.token validate: - {"check": "status_code", "comparator": "eq", "expect": 200} - eq: ["status_code", $expect_status_code] @@ -42,7 +42,7 @@ teststeps: name: "user1" password: "123456" extract: - - success: content.success + success: content.success validate: - eq: ["status_code", 201] - sum_status_code: ["status_code", 3] diff --git a/tests/data/demo_testcase_functions.yml b/tests/data/demo_testcase_functions.yml index 759d8e02..b3653434 100644 --- a/tests/data/demo_testcase_functions.yml +++ b/tests/data/demo_testcase_functions.yml @@ -21,7 +21,7 @@ teststeps: json: sign: ${get_sign($device_sn, $os_platform, $app_version)} extract: - - token: content.token + token: content.token validate: - {"check": "status_code", "comparator": "eq", "expect": 200} - {"check": "content.token", "comparator": "len_eq", "expect": 16} diff --git a/tests/data/demo_testcase_layer.yml b/tests/data/demo_testcase_layer.yml index bfcaba16..8b53a074 100644 --- a/tests/data/demo_testcase_layer.yml +++ b/tests/data/demo_testcase_layer.yml @@ -14,7 +14,7 @@ teststeps: name: get token with $user_agent, $app_version api: api/get_token.yml extract: - - token: content.token + token: content.token validate: - "eq": ["status_code", 200] - "len_eq": ["content.token", 16] diff --git a/tests/data/demo_testcase_variables.yml b/tests/data/demo_testcase_variables.yml index 84c7a0f8..447d9717 100644 --- a/tests/data/demo_testcase_variables.yml +++ b/tests/data/demo_testcase_variables.yml @@ -24,7 +24,7 @@ teststeps: json: sign: $sign extract: - - token: content.token + token: content.token validate: - {"check": "status_code", "comparator": "eq", "expect": 200} - {"check": "content.token", "comparator": "len_eq", "expect": 16} From 4a50c4b58341fdb689fcf987f2c8b7e798598ac5 Mon Sep 17 00:00:00 2001 From: debugtalk Date: Sun, 12 Apr 2020 11:12:42 +0800 Subject: [PATCH 011/169] update --- httprunner/schema/testcase.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/httprunner/schema/testcase.py b/httprunner/schema/testcase.py index c4aec71f..16bdea29 100644 --- a/httprunner/schema/testcase.py +++ b/httprunner/schema/testcase.py @@ -13,9 +13,10 @@ class ProjectMeta(BaseModel): class TestStep(BaseModel): name: common.Name - api: Text = None # TODO: replace with FilePath + api: Text = None testcase: Text = None request: common.Request = None + variables: common.Variables = {} extract: Union[Dict[Text, Text], List[Text]] = {} validation: common.Validate = Field([], alias="validate") @@ -60,11 +61,9 @@ class TestCase(BaseModel): "user_agent": "iOS/10.3", "device_sn": "$device_sn" }, - "extract": [ - { - "token": "content.token" - } - ], + "extract": { + "token": "content.token" + }, "validate": [ { "eq": ["status_code", 200] From 2f73d5021b00c1a228195beb9d3164493413e148 Mon Sep 17 00:00:00 2001 From: debugtalk Date: Sun, 12 Apr 2020 16:56:19 +0800 Subject: [PATCH 012/169] change: make startproject as hrun sub-command, usage: hrun startproject --- docs/CHANGELOG.md | 3 +- httprunner/cli.py | 90 ++++++++++------ httprunner/ext/scaffold/__init__.py | 129 +++++++++++++++++++++++ httprunner/ext/scaffold/test_scaffold.py | 19 ++++ httprunner/utils.py | 113 -------------------- httprunner/utils_test.py | 12 --- 6 files changed, 206 insertions(+), 160 deletions(-) create mode 100644 httprunner/ext/scaffold/__init__.py create mode 100644 httprunner/ext/scaffold/test_scaffold.py diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 818a173c..16725d43 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,11 +1,12 @@ # Release History -## 3.0.2 (2020-04-09) +## 3.0.2 (2020-04-12) **Changed** - replace jsonschema validation with pydantic - remove compatibility with testcase/testsuite format v1 +- make `startproject` as hrun sub-command, usage: `hrun startproject ` ## 3.0.1 (2020-03-24) diff --git a/httprunner/cli.py b/httprunner/cli.py index 2fe9fe41..f84d9af4 100644 --- a/httprunner/cli.py +++ b/httprunner/cli.py @@ -6,64 +6,46 @@ from loguru import logger from httprunner import __description__, __version__ from httprunner.api import HttpRunner +from httprunner.ext.scaffold import init_parser_scaffold, main_scaffold from httprunner.report import gen_html_report -from httprunner.utils import create_scaffold -def main(): - """ API test: parse command line options and run commands. - """ - parser = argparse.ArgumentParser(description=__description__) - parser.add_argument( - '-V', '--version', dest='version', action='store_true', - help="show version") - parser.add_argument( +def init_parser_run(subparsers): + sub_parser_run = subparsers.add_parser( + "run", help="Run HttpRunner testcases.") + + sub_parser_run.add_argument( 'testfile_paths', nargs='*', help="Specify api/testcase/testsuite file paths to run.") - parser.add_argument( + sub_parser_run.add_argument( '--log-level', default='INFO', help="Specify logging level, default is INFO.") - parser.add_argument( + sub_parser_run.add_argument( '--log-file', help="Write logs to specified file path.") - parser.add_argument( + sub_parser_run.add_argument( '--dot-env-path', help="Specify .env file path, which is useful for keeping sensitive data.") - parser.add_argument( + sub_parser_run.add_argument( '--report-template', help="Specify report template path.") - parser.add_argument( + sub_parser_run.add_argument( '--report-dir', help="Specify report save directory.") - parser.add_argument( + sub_parser_run.add_argument( '--report-file', help="Specify report file path, this has higher priority than specifying report dir.") - parser.add_argument( + sub_parser_run.add_argument( '--save-tests', action='store_true', default=False, help="Save loaded/parsed/vars_out/summary json data to JSON files.") - parser.add_argument( + sub_parser_run.add_argument( '--failfast', action='store_true', default=False, help="Stop the test run on the first error or failure.") - parser.add_argument( - '--startproject', - help="Specify new project name.") - args = parser.parse_args() + return sub_parser_run - if len(sys.argv) == 1: - # no argument passed - parser.print_help() - sys.exit(0) - - if args.version: - print(f"{__version__}") - sys.exit(0) - - project_name = args.startproject - if project_name: - create_scaffold(project_name) - sys.exit(0) +def main_run(args): runner = HttpRunner( failfast=args.failfast, save_tests=args.save_tests, @@ -90,5 +72,45 @@ def main(): sys.exit(err_code) +def main(): + """ API test: parse command line options and run commands. + """ + parser = argparse.ArgumentParser(description=__description__) + parser.add_argument( + '-V', '--version', dest='version', action='store_true', + help="show version") + + subparsers = parser.add_subparsers(help='sub-command help') + sub_parser_run = init_parser_run(subparsers) + sub_parser_scaffold = init_parser_scaffold(subparsers) + + args = parser.parse_args() + + if args.version: + print(f"{__version__}") + sys.exit(0) + + if len(sys.argv) == 1: + # hrun + parser.print_help() + sys.exit(0) + + elif sys.argv[1] == "run": + # hrun run + if len(sys.argv) == 2: + sub_parser_run.print_help() + sys.exit(0) + + main_run(args) + + elif sys.argv[1] == "startproject": + # hrun startproject + if len(sys.argv) == 2: + sub_parser_scaffold.print_help() + sys.exit(0) + + main_scaffold(args) + + if __name__ == '__main__': main() diff --git a/httprunner/ext/scaffold/__init__.py b/httprunner/ext/scaffold/__init__.py new file mode 100644 index 00000000..2f0fd787 --- /dev/null +++ b/httprunner/ext/scaffold/__init__.py @@ -0,0 +1,129 @@ +import os.path +import sys + +from loguru import logger + + +def init_parser_scaffold(subparsers): + sub_parser_scaffold = subparsers.add_parser( + "startproject", help="Create a new project with template structure.") + sub_parser_scaffold.add_argument("project_name", type=str, nargs="?", help="Specify new project name.") + return sub_parser_scaffold + + +def create_scaffold(project_name): + """ create scaffold with specified project name. + """ + if os.path.isdir(project_name): + logger.warning(f"Folder {project_name} exists, please specify a new folder name.") + return + + logger.info(f"Start to create new project: {project_name}") + logger.info(f"CWD: {os.getcwd()}") + + def create_folder(path): + os.makedirs(path) + msg = f"created folder: {path}" + logger.info(msg) + + def create_file(path, file_content=""): + with open(path, 'w') as f: + f.write(file_content) + msg = f"created file: {path}" + logger.info(msg) + + demo_api_content = """ +name: demo api +variables: + var1: value1 + var2: value2 +request: + url: /api/path/$var1 + method: POST + headers: + Content-Type: "application/json" + json: + key: $var2 +validate: + - eq: ["status_code", 200] +""" + demo_testcase_content = """ +config: + name: "demo testcase" + variables: + device_sn: "ABC" + username: ${ENV(USERNAME)} + password: ${ENV(PASSWORD)} + base_url: "http://127.0.0.1:5000" + +teststeps: +- + name: demo step 1 + api: path/to/api1.yml + variables: + user_agent: 'iOS/10.3' + device_sn: $device_sn + extract: + token: content.token + validate: + - eq: ["status_code", 200] +- + name: demo step 2 + api: path/to/api2.yml + variables: + token: $token +""" + demo_testsuite_content = """ +config: + name: "demo testsuite" + variables: + device_sn: "XYZ" + base_url: "http://127.0.0.1:5000" + +testcases: +- + name: call demo_testcase with data 1 + testcase: path/to/demo_testcase.yml + variables: + device_sn: $device_sn +- + name: call demo_testcase with data 2 + testcase: path/to/demo_testcase.yml + variables: + device_sn: $device_sn +""" + ignore_content = "\n".join([ + ".env", + "reports/*", + "__pycache__/*", + "*.pyc", + ".python-version", + "logs/*" + ]) + demo_debugtalk_content = """ +import time + +def sleep(n_secs): + time.sleep(n_secs) +""" + demo_env_content = "\n".join([ + "USERNAME=leolee", + "PASSWORD=123456" + ]) + + create_folder(project_name) + create_folder(os.path.join(project_name, "api")) + create_folder(os.path.join(project_name, "testcases")) + create_folder(os.path.join(project_name, "testsuites")) + create_folder(os.path.join(project_name, "reports")) + create_file(os.path.join(project_name, "api", "demo_api.yml"), demo_api_content) + create_file(os.path.join(project_name, "testcases", "demo_testcase.yml"), demo_testcase_content) + create_file(os.path.join(project_name, "testsuites", "demo_testsuite.yml"), demo_testsuite_content) + create_file(os.path.join(project_name, "debugtalk.py"), demo_debugtalk_content) + create_file(os.path.join(project_name, ".env"), demo_env_content) + create_file(os.path.join(project_name, ".gitignore"), ignore_content) + + +def main_scaffold(args): + create_scaffold(args.project_name) + sys.exit(0) diff --git a/httprunner/ext/scaffold/test_scaffold.py b/httprunner/ext/scaffold/test_scaffold.py new file mode 100644 index 00000000..6f53a213 --- /dev/null +++ b/httprunner/ext/scaffold/test_scaffold.py @@ -0,0 +1,19 @@ +import os +import shutil +import unittest + +from httprunner.ext.scaffold import create_scaffold + + +class TestUtils(unittest.TestCase): + + def test_create_scaffold(self): + project_name = "projectABC" + create_scaffold(project_name) + self.assertTrue(os.path.isdir(os.path.join(project_name, "api"))) + self.assertTrue(os.path.isdir(os.path.join(project_name, "testcases"))) + self.assertTrue(os.path.isdir(os.path.join(project_name, "testsuites"))) + self.assertTrue(os.path.isdir(os.path.join(project_name, "reports"))) + self.assertTrue(os.path.isfile(os.path.join(project_name, "debugtalk.py"))) + self.assertTrue(os.path.isfile(os.path.join(project_name, ".env"))) + shutil.rmtree(project_name) diff --git a/httprunner/utils.py b/httprunner/utils.py index e9c4cafa..5d8601b2 100644 --- a/httprunner/utils.py +++ b/httprunner/utils.py @@ -349,119 +349,6 @@ def print_info(info_mapping): logger.info(content) -def create_scaffold(project_name): - """ create scaffold with specified project name. - """ - if os.path.isdir(project_name): - logger.warning(f"Folder {project_name} exists, please specify a new folder name.") - return - - logger.info(f"Start to create new project: {project_name}") - logger.info(f"CWD: {os.getcwd()}") - - def create_folder(path): - os.makedirs(path) - msg = f"created folder: {path}" - logger.info(msg) - - def create_file(path, file_content=""): - with open(path, 'w') as f: - f.write(file_content) - msg = f"created file: {path}" - logger.info(msg) - - demo_api_content = """ -name: demo api -variables: - var1: value1 - var2: value2 -request: - url: /api/path/$var1 - method: POST - headers: - Content-Type: "application/json" - json: - key: $var2 -validate: - - eq: ["status_code", 200] -""" - demo_testcase_content = """ -config: - name: "demo testcase" - variables: - device_sn: "ABC" - username: ${ENV(USERNAME)} - password: ${ENV(PASSWORD)} - base_url: "http://127.0.0.1:5000" - -teststeps: -- - name: demo step 1 - api: path/to/api1.yml - variables: - user_agent: 'iOS/10.3' - device_sn: $device_sn - extract: - - token: content.token - validate: - - eq: ["status_code", 200] -- - name: demo step 2 - api: path/to/api2.yml - variables: - token: $token -""" - demo_testsuite_content = """ -config: - name: "demo testsuite" - variables: - device_sn: "XYZ" - base_url: "http://127.0.0.1:5000" - -testcases: -- - name: call demo_testcase with data 1 - testcase: path/to/demo_testcase.yml - variables: - device_sn: $device_sn -- - name: call demo_testcase with data 2 - testcase: path/to/demo_testcase.yml - variables: - device_sn: $device_sn -""" - ignore_content = "\n".join([ - ".env", - "reports/*", - "__pycache__/*", - "*.pyc", - ".python-version", - "logs/*" - ]) - demo_debugtalk_content = """ -import time - -def sleep(n_secs): - time.sleep(n_secs) -""" - demo_env_content = "\n".join([ - "USERNAME=leolee", - "PASSWORD=123456" - ]) - - create_folder(project_name) - create_folder(os.path.join(project_name, "api")) - create_folder(os.path.join(project_name, "testcases")) - create_folder(os.path.join(project_name, "testsuites")) - create_folder(os.path.join(project_name, "reports")) - create_file(os.path.join(project_name, "api", "demo_api.yml"), demo_api_content) - create_file(os.path.join(project_name, "testcases", "demo_testcase.yml"), demo_testcase_content) - create_file(os.path.join(project_name, "testsuites", "demo_testsuite.yml"), demo_testsuite_content) - create_file(os.path.join(project_name, "debugtalk.py"), demo_debugtalk_content) - create_file(os.path.join(project_name, ".env"), demo_env_content) - create_file(os.path.join(project_name, ".gitignore"), ignore_content) - - def gen_cartesian_product(*args): """ generate cartesian product for lists diff --git a/httprunner/utils_test.py b/httprunner/utils_test.py index e41761d7..d37dce91 100644 --- a/httprunner/utils_test.py +++ b/httprunner/utils_test.py @@ -1,6 +1,5 @@ import io import os -import shutil import unittest from httprunner import exceptions, loader, utils @@ -210,17 +209,6 @@ class TestUtils(unittest.TestCase): self.assertEqual(id(new_data["c"]), id(data["c"])) # self.assertEqual(id(new_data["d"]), id(data["d"])) - def test_create_scaffold(self): - project_name = "projectABC" - utils.create_scaffold(project_name) - self.assertTrue(os.path.isdir(os.path.join(project_name, "api"))) - self.assertTrue(os.path.isdir(os.path.join(project_name, "testcases"))) - self.assertTrue(os.path.isdir(os.path.join(project_name, "testsuites"))) - self.assertTrue(os.path.isdir(os.path.join(project_name, "reports"))) - self.assertTrue(os.path.isfile(os.path.join(project_name, "debugtalk.py"))) - self.assertTrue(os.path.isfile(os.path.join(project_name, ".env"))) - shutil.rmtree(project_name) - def test_cartesian_product_one(self): parameters_content_list = [ [ From 47d4aad8e029227a034165914ba1688f947e4a73 Mon Sep 17 00:00:00 2001 From: debugtalk Date: Sun, 12 Apr 2020 17:15:37 +0800 Subject: [PATCH 013/169] fix: github action scripts --- .github/workflows/integration_test.yml | 2 +- .github/workflows/unittest.yml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/integration_test.yml b/.github/workflows/integration_test.yml index 4e8c90d9..9e6c52c9 100644 --- a/.github/workflows/integration_test.yml +++ b/.github/workflows/integration_test.yml @@ -33,4 +33,4 @@ jobs: locusts -V - name: Run smoketest for hrun command run: | - cd tests/httpbin && hrun basic.yml --failfast && cd - + cd tests/httpbin && hrun run basic.yml --failfast && cd - diff --git a/.github/workflows/unittest.yml b/.github/workflows/unittest.yml index 53a24129..f93bc138 100644 --- a/.github/workflows/unittest.yml +++ b/.github/workflows/unittest.yml @@ -27,8 +27,8 @@ jobs: poetry install -vv - name: Run unittest for httprunner run: | - poetry run python -m httprunner.cli hrun -V - poetry run python -m httprunner.cli hrun -h + poetry run python -m httprunner.cli -V + poetry run python -m httprunner.cli -h poetry run coverage run --source=httprunner -m unittest discover poetry run coverage xml poetry run coverage report -m From 8a39bbb06ada6c3be05ca8ed5c874c8b402cd06e Mon Sep 17 00:00:00 2001 From: debugtalk Date: Sun, 12 Apr 2020 17:41:29 +0800 Subject: [PATCH 014/169] change: make har2case as hrun sub-command, usage: hrun har2case -h --- docs/CHANGELOG.md | 1 + httprunner/cli.py | 10 + httprunner/ext/har2case/__init__.py | 55 +++ httprunner/ext/har2case/core.py | 366 ++++++++++++++++++ .../ext/har2case/data/demo-quickstart.har | 223 +++++++++++ httprunner/ext/har2case/data/demo.har | 148 +++++++ httprunner/ext/har2case/test_core.py | 206 ++++++++++ httprunner/ext/har2case/test_utils.py | 65 ++++ httprunner/ext/har2case/utils.py | 130 +++++++ poetry.lock | 17 +- pyproject.toml | 1 - 11 files changed, 1205 insertions(+), 17 deletions(-) create mode 100644 httprunner/ext/har2case/core.py create mode 100644 httprunner/ext/har2case/data/demo-quickstart.har create mode 100644 httprunner/ext/har2case/data/demo.har create mode 100644 httprunner/ext/har2case/test_core.py create mode 100644 httprunner/ext/har2case/test_utils.py create mode 100644 httprunner/ext/har2case/utils.py diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 16725d43..694c0d28 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,7 @@ - replace jsonschema validation with pydantic - remove compatibility with testcase/testsuite format v1 - make `startproject` as hrun sub-command, usage: `hrun startproject ` +- make `har2case` as hrun sub-command, usage: `hrun har2case -h` ## 3.0.1 (2020-03-24) diff --git a/httprunner/cli.py b/httprunner/cli.py index f84d9af4..85dd5ea2 100644 --- a/httprunner/cli.py +++ b/httprunner/cli.py @@ -6,6 +6,7 @@ from loguru import logger from httprunner import __description__, __version__ from httprunner.api import HttpRunner +from httprunner.ext.har2case import init_har2case_parser, main_har2case from httprunner.ext.scaffold import init_parser_scaffold, main_scaffold from httprunner.report import gen_html_report @@ -83,6 +84,7 @@ def main(): subparsers = parser.add_subparsers(help='sub-command help') sub_parser_run = init_parser_run(subparsers) sub_parser_scaffold = init_parser_scaffold(subparsers) + sub_parser_har2case = init_har2case_parser(subparsers) args = parser.parse_args() @@ -111,6 +113,14 @@ def main(): main_scaffold(args) + elif sys.argv[1] == "har2case": + # hrun har2case + if len(sys.argv) == 2: + sub_parser_har2case.print_help() + sys.exit(0) + + main_har2case(args) + if __name__ == '__main__': main() diff --git a/httprunner/ext/har2case/__init__.py b/httprunner/ext/har2case/__init__.py index e69de29b..0b1e0b6f 100644 --- a/httprunner/ext/har2case/__init__.py +++ b/httprunner/ext/har2case/__init__.py @@ -0,0 +1,55 @@ +""" Convert HAR (HTTP Archive) to YAML/JSON testcase for HttpRunner. + +Usage: + # convert to JSON format testcase + $ hrun har2case demo.har + + # convert to YAML format testcase + $ hrun har2case demo.har -2y + +""" +import os +import sys + +from loguru import logger + +from httprunner.ext.har2case.core import HarParser + + +def init_har2case_parser(subparsers): + """ HAR converter: parse command line options and run commands. + """ + parser = subparsers.add_parser( + "har2case", help="Convert HAR(HTTP Archive) to YAML/JSON testcases for HttpRunner.") + parser.add_argument('har_source_file', nargs='?', + help="Specify HAR source file") + parser.add_argument( + '-2y', '--to-yml', '--to-yaml', + dest='to_yaml', action='store_true', + help="Convert to YAML format, if not specified, convert to JSON format by default.") + parser.add_argument( + '--filter', help="Specify filter keyword, only url include filter string will be converted.") + parser.add_argument( + '--exclude', + help="Specify exclude keyword, url that includes exclude string will be ignored, " + "multiple keywords can be joined with '|'") + + return parser + + +def main_har2case(args): + har_source_file = args.har_source_file + if not har_source_file or not har_source_file.endswith(".har"): + logger.error("HAR file not specified.") + sys.exit(1) + + if not os.path.isfile(har_source_file): + logger.error(f"HAR file not exists: {har_source_file}") + sys.exit(1) + + output_file_type = "YML" if args.to_yaml else "JSON" + HarParser( + har_source_file, args.filter, args.exclude + ).gen_testcase(output_file_type) + + return 0 diff --git a/httprunner/ext/har2case/core.py b/httprunner/ext/har2case/core.py new file mode 100644 index 00000000..c69e1591 --- /dev/null +++ b/httprunner/ext/har2case/core.py @@ -0,0 +1,366 @@ +import base64 +import json +import os +import sys +import urllib.parse as urlparse + +from loguru import logger + +from httprunner.ext.har2case import utils + +try: + from json.decoder import JSONDecodeError +except ImportError: + 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): + + def __init__(self, har_file_path, filter_str=None, exclude_str=None): + self.har_file_path = har_file_path + self.filter_str = filter_str + self.exclude_str = exclude_str or "" + + def __make_request_url(self, teststep_dict, entry_json): + """ parse HAR entry request url and queryString, and make teststep url and params + + Args: + entry_json (dict): + { + "request": { + "url": "https://httprunner.top/home?v=1&w=2", + "queryString": [ + {"name": "v", "value": "1"}, + {"name": "w", "value": "2"} + ], + }, + "response": {} + } + + Returns: + { + "name: "/home", + "request": { + url: "https://httprunner.top/home", + params: {"v": "1", "w": "2"} + } + } + + """ + request_params = utils.convert_list_to_dict( + entry_json["request"].get("queryString", []) + ) + + url = entry_json["request"].get("url") + if not url: + logger.exception("url missed in request.") + sys.exit(1) + + parsed_object = urlparse.urlparse(url) + if request_params: + parsed_object = parsed_object._replace(query='') + teststep_dict["request"]["url"] = parsed_object.geturl() + teststep_dict["request"]["params"] = request_params + else: + teststep_dict["request"]["url"] = url + + teststep_dict["name"] = parsed_object.path + + def __make_request_method(self, teststep_dict, entry_json): + """ parse HAR entry request method, and make teststep method. + """ + method = entry_json["request"].get("method") + if not method: + logger.exception("method missed in request.") + sys.exit(1) + + teststep_dict["request"]["method"] = method + + def __make_request_headers(self, teststep_dict, entry_json): + """ parse HAR entry request headers, and make teststep headers. + header in IGNORE_REQUEST_HEADERS will be ignored. + + Args: + entry_json (dict): + { + "request": { + "headers": [ + {"name": "Host", "value": "httprunner.top"}, + {"name": "Content-Type", "value": "application/json"}, + {"name": "User-Agent", "value": "iOS/10.3"} + ], + }, + "response": {} + } + + Returns: + { + "request": { + headers: {"Content-Type": "application/json"} + } + + """ + teststep_headers = {} + for header in entry_json["request"].get("headers", []): + if header["name"].lower() in IGNORE_REQUEST_HEADERS: + continue + + teststep_headers[header["name"]] = header["value"] + + if teststep_headers: + teststep_dict["request"]["headers"] = teststep_headers + + def _make_request_data(self, teststep_dict, entry_json): + """ parse HAR entry request data, and make teststep request data + + Args: + entry_json (dict): + { + "request": { + "method": "POST", + "postData": { + "mimeType": "application/x-www-form-urlencoded; charset=utf-8", + "params": [ + {"name": "a", "value": 1}, + {"name": "b", "value": "2"} + } + }, + }, + "response": {...} + } + + + Returns: + { + "request": { + "method": "POST", + "data": {"v": "1", "w": "2"} + } + } + + """ + method = entry_json["request"].get("method") + if method in ["POST", "PUT", "PATCH"]: + postData = entry_json["request"].get("postData", {}) + mimeType = postData.get("mimeType") + + # Note that text and params fields are mutually exclusive. + if "text" in postData: + post_data = postData.get("text") + else: + params = postData.get("params", []) + post_data = utils.convert_list_to_dict(params) + + request_data_key = "data" + if not mimeType: + pass + elif mimeType.startswith("application/json"): + try: + post_data = json.loads(post_data) + request_data_key = "json" + except JSONDecodeError: + pass + elif mimeType.startswith("application/x-www-form-urlencoded"): + post_data = utils.convert_x_www_form_urlencoded_to_dict(post_data) + else: + # TODO: make compatible with more mimeType + pass + + teststep_dict["request"][request_data_key] = post_data + + def _make_validate(self, teststep_dict, entry_json): + """ parse HAR entry response and make teststep validate. + + Args: + entry_json (dict): + { + "request": {}, + "response": { + "status": 200, + "headers": [ + { + "name": "Content-Type", + "value": "application/json; charset=utf-8" + }, + ], + "content": { + "size": 71, + "mimeType": "application/json; charset=utf-8", + "text": "eyJJc1N1Y2Nlc3MiOnRydWUsIkNvZGUiOjIwMCwiTWVzc2FnZSI6bnVsbCwiVmFsdWUiOnsiQmxuUmVzdWx0Ijp0cnVlfX0=", + "encoding": "base64" + } + } + } + + Returns: + { + "validate": [ + {"eq": ["status_code", 200]} + ] + } + + """ + teststep_dict["validate"].append( + {"eq": ["status_code", entry_json["response"].get("status")]} + ) + + resp_content_dict = entry_json["response"].get("content") + + headers_mapping = utils.convert_list_to_dict( + entry_json["response"].get("headers", []) + ) + if "Content-Type" in headers_mapping: + teststep_dict["validate"].append( + {"eq": ["headers.Content-Type", headers_mapping["Content-Type"]]} + ) + + text = resp_content_dict.get("text") + if not text: + return + + mime_type = resp_content_dict.get("mimeType") + if mime_type and mime_type.startswith("application/json"): + + encoding = resp_content_dict.get("encoding") + if encoding and encoding == "base64": + content = base64.b64decode(text).decode('utf-8') + else: + content = text + + try: + resp_content_json = json.loads(content) + except JSONDecodeError: + logger.warning( + "response content can not be loaded as json: {}".format(content.encode("utf-8")) + ) + return + + if not isinstance(resp_content_json, dict): + return + + for key, value in resp_content_json.items(): + if isinstance(value, (dict, list)): + continue + + teststep_dict["validate"].append( + {"eq": ["content.{}".format(key), value]} + ) + + def _prepare_teststep(self, entry_json): + """ extract info from entry dict and make teststep + + Args: + entry_json (dict): + { + "request": { + "method": "POST", + "url": "https://httprunner.top/api/v1/Account/Login", + "headers": [], + "queryString": [], + "postData": {}, + }, + "response": { + "status": 200, + "headers": [], + "content": {} + } + } + + """ + teststep_dict = { + "name": "", + "request": {}, + "validate": [] + } + + self.__make_request_url(teststep_dict, entry_json) + self.__make_request_method(teststep_dict, entry_json) + self.__make_request_headers(teststep_dict, entry_json) + self._make_request_data(teststep_dict, entry_json) + self._make_validate(teststep_dict, entry_json) + + return teststep_dict + + def _prepare_config(self): + """ prepare config block. + """ + return { + "name": "testcase description", + "variables": {} + } + + def _prepare_teststeps(self): + """ make teststep list. + teststeps list are parsed from HAR log entries list. + + """ + def is_exclude(url, exclude_str): + exclude_str_list = exclude_str.split("|") + for exclude_str in exclude_str_list: + if exclude_str and exclude_str in url: + return True + + return False + + teststeps = [] + log_entries = utils.load_har_log_entries(self.har_file_path) + for entry_json in log_entries: + url = entry_json["request"].get("url") + if self.filter_str and self.filter_str not in url: + continue + + if is_exclude(url, self.exclude_str): + continue + + teststeps.append( + self._prepare_teststep(entry_json) + ) + + return teststeps + + def _make_testcase(self): + """ Extract info from HAR file and prepare for testcase + """ + logger.info("Extract info from HAR file and prepare for testcase.") + + config = self._prepare_config() + teststeps = self._prepare_teststeps() + + testcase = { + "config": config, + "teststeps": teststeps + } + return testcase + + def gen_testcase(self, file_type="JSON"): + logger.info(f"Start to generate testcase from {self.har_file_path}") + harfile = os.path.splitext(self.har_file_path)[0] + output_testcase_file = "{}.{}".format(harfile, file_type.lower()) + + testcase = self._make_testcase() + logger.debug("prepared testcase: {}".format(testcase)) + + if file_type == "JSON": + utils.dump_json(testcase, output_testcase_file) + else: + utils.dump_yaml(testcase, output_testcase_file) + + logger.info(f"generated testcase: {output_testcase_file}") diff --git a/httprunner/ext/har2case/data/demo-quickstart.har b/httprunner/ext/har2case/data/demo-quickstart.har new file mode 100644 index 00000000..f4de4473 --- /dev/null +++ b/httprunner/ext/har2case/data/demo-quickstart.har @@ -0,0 +1,223 @@ +{ + "log": { + "version": "1.2", + "creator": { + "name": "Charles Proxy", + "version": "4.2.1" + }, + "entries": [ + { + "startedDateTime": "2018-02-19T17:30:00.904+08:00", + "time": 3, + "request": { + "method": "POST", + "url": "http://127.0.0.1:5000/api/get-token", + "httpVersion": "HTTP/1.1", + "cookies": [], + "headers": [ + { + "name": "Host", + "value": "127.0.0.1:5000" + }, + { + "name": "User-Agent", + "value": "python-requests/2.18.4" + }, + { + "name": "Accept-Encoding", + "value": "gzip, deflate" + }, + { + "name": "Accept", + "value": "*/*" + }, + { + "name": "Connection", + "value": "keep-alive" + }, + { + "name": "device_sn", + "value": "FwgRiO7CNA50DSU" + }, + { + "name": "user_agent", + "value": "iOS/10.3" + }, + { + "name": "os_platform", + "value": "ios" + }, + { + "name": "app_version", + "value": "2.8.6" + }, + { + "name": "Content-Length", + "value": "52" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ], + "queryString": [], + "postData": { + "mimeType": "application/json", + "text": "{\"sign\": \"958a05393efef0ac7c0fb80a7eac45e24fd40c27\"}" + }, + "headersSize": 299, + "bodySize": 52 + }, + "response": { + "_charlesStatus": "COMPLETE", + "status": 200, + "statusText": "OK", + "httpVersion": "HTTP/1.0", + "cookies": [], + "headers": [ + { + "name": "Content-Type", + "value": "application/json" + }, + { + "name": "Content-Length", + "value": "46" + }, + { + "name": "Server", + "value": "Werkzeug/0.14.1 Python/3.6.4" + }, + { + "name": "Date", + "value": "Mon, 19 Feb 2018 09:30:00 GMT" + }, + { + "name": "Proxy-Connection", + "value": "Close" + } + ], + "content": { + "size": 46, + "mimeType": "application/json", + "text": "eyJzdWNjZXNzIjogdHJ1ZSwgInRva2VuIjogImJhTkxYMXpoRllQMTFTZWIifQ\u003d\u003d", + "encoding": "base64" + }, + "headersSize": 175, + "bodySize": 46 + }, + "serverIPAddress": "127.0.0.1", + "cache": {}, + "timings": { + "dns": 1, + "connect": 0, + "ssl": -1, + "send": 0, + "wait": 1, + "receive": 1 + } + }, + { + "startedDateTime": "2018-02-19T17:30:00.911+08:00", + "time": 3, + "request": { + "method": "POST", + "url": "http://127.0.0.1:5000/api/users/1000", + "httpVersion": "HTTP/1.1", + "cookies": [], + "headers": [ + { + "name": "Host", + "value": "127.0.0.1:5000" + }, + { + "name": "User-Agent", + "value": "python-requests/2.18.4" + }, + { + "name": "Accept-Encoding", + "value": "gzip, deflate" + }, + { + "name": "Accept", + "value": "*/*" + }, + { + "name": "Connection", + "value": "keep-alive" + }, + { + "name": "device_sn", + "value": "FwgRiO7CNA50DSU" + }, + { + "name": "token", + "value": "baNLX1zhFYP11Seb" + }, + { + "name": "Content-Length", + "value": "39" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ], + "queryString": [], + "postData": { + "mimeType": "application/json", + "text": "{\"name\": \"user1\", \"password\": \"123456\"}" + }, + "headersSize": 265, + "bodySize": 39 + }, + "response": { + "_charlesStatus": "COMPLETE", + "status": 201, + "statusText": "CREATED", + "httpVersion": "HTTP/1.0", + "cookies": [], + "headers": [ + { + "name": "Content-Type", + "value": "application/json" + }, + { + "name": "Content-Length", + "value": "54" + }, + { + "name": "Server", + "value": "Werkzeug/0.14.1 Python/3.6.4" + }, + { + "name": "Date", + "value": "Mon, 19 Feb 2018 09:30:00 GMT" + }, + { + "name": "Proxy-Connection", + "value": "Close" + } + ], + "content": { + "size": 54, + "mimeType": "application/json", + "text": "eyJzdWNjZXNzIjogdHJ1ZSwgIm1zZyI6ICJ1c2VyIGNyZWF0ZWQgc3VjY2Vzc2Z1bGx5LiJ9", + "encoding": "base64" + }, + "headersSize": 77, + "bodySize": 54 + }, + "serverIPAddress": "127.0.0.1", + "cache": {}, + "timings": { + "dns": 0, + "connect": 0, + "ssl": -1, + "send": 0, + "wait": 3, + "receive": 0 + } + } + ] + } +} \ No newline at end of file diff --git a/httprunner/ext/har2case/data/demo.har b/httprunner/ext/har2case/data/demo.har new file mode 100644 index 00000000..f56e7450 --- /dev/null +++ b/httprunner/ext/har2case/data/demo.har @@ -0,0 +1,148 @@ +{ + "log": { + "version": "1.2", + "creator": { + "name": "Charles Proxy", + "version": "4.2" + }, + "entries": [ + { + "startedDateTime": "2017-11-13T11:40:07.212+08:00", + "time": 35, + "request": { + "method": "POST", + "url": "https://httprunner.top/api/v1/Account/Login", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "lang", + "value": "zh" + } + ], + "headers": [ + { + "name": "Host", + "value": "httprunner.top" + }, + { + "name": "Connection", + "value": "keep-alive" + }, + { + "name": "Content-Length", + "value": "50" + }, + { + "name": "Accept", + "value": "application/json" + }, + { + "name": "Origin", + "value": "https://httprunner.top" + }, + { + "name": "User-Agent", + "value": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/61.0.3163.100 Safari/537.36" + }, + { + "name": "Content-Type", + "value": "application/json" + }, + { + "name": "Referer", + "value": "https://httprunner.top/login" + }, + { + "name": "Accept-Encoding", + "value": "gzip, deflate, br" + }, + { + "name": "Accept-Language", + "value": "en-US,en;q=0.8,zh-CN;q=0.6,zh;q=0.4" + } + ], + "queryString": [], + "postData": { + "mimeType": "application/json", + "text": "{\"UserName\":\"test001\",\"Pwd\":\"123\",\"VerCode\":\"\"}" + }, + "headersSize": 640, + "bodySize": 50 + }, + "response": { + "_charlesStatus": "COMPLETE", + "status": 200, + "statusText": "OK", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "lang", + "value": "zh", + "path": "/", + "domain": ".httprunner.top", + "expires": null, + "httpOnly": false, + "secure": false, + "comment": null, + "_maxAge": null + } + ], + "headers": [ + { + "name": "Date", + "value": "Mon, 13 Nov 2017 03:40:07 GMT" + }, + { + "name": "Content-Type", + "value": "application/json; charset=utf-8" + }, + { + "name": "Content-Length", + "value": "71" + }, + { + "name": "Cache-Control", + "value": "no-cache" + }, + { + "name": "Pragma", + "value": "no-cache" + }, + { + "name": "Expires", + "value": "-1" + }, + { + "name": "Server", + "value": "Microsoft-IIS/8.5" + }, + { + "name": "X-AspNet-Version", + "value": "4.0.30319" + } + ], + "content": { + "size": 71, + "mimeType": "application/json; charset=utf-8", + "text": "eyJJc1N1Y2Nlc3MiOnRydWUsIkNvZGUiOjIwMCwiTWVzc2FnZSI6bnVsbCwiVmFsdWUiOnsiQmxuUmVzdWx0Ijp0cnVlfX0=", + "encoding": "base64" + }, + "redirectURL": null, + "headersSize": 0, + "bodySize": 71 + }, + "serverIPAddress": "192.168.1.169", + "cache": {}, + "timings": { + "dns": -1, + "connect": -1, + "ssl": -1, + "send": 6, + "wait": 28, + "receive": 1 + } + } + + ] + } +} \ No newline at end of file diff --git a/httprunner/ext/har2case/test_core.py b/httprunner/ext/har2case/test_core.py new file mode 100644 index 00000000..d491b353 --- /dev/null +++ b/httprunner/ext/har2case/test_core.py @@ -0,0 +1,206 @@ +import os + +from httprunner.ext.har2case.utils import load_har_log_entries +from httprunner.ext.har2case.core import HarParser +from httprunner.ext.har2case.test_utils import TestUtils + + +class TestHar(TestUtils): + + def setUp(self): + self.har_parser = HarParser(self.har_path) + + def test_prepare_teststep(self): + log_entries = load_har_log_entries(self.har_path) + teststep_dict = self.har_parser._prepare_teststep(log_entries[0]) + self.assertIn("name", teststep_dict) + self.assertIn("request", teststep_dict) + self.assertIn("validate", teststep_dict) + + validators_mapping = { + validator["eq"][0]: validator["eq"][1] + for validator in teststep_dict["validate"] + } + self.assertEqual( + validators_mapping["status_code"], 200 + ) + self.assertEqual( + validators_mapping["content.IsSuccess"], True + ) + self.assertEqual( + validators_mapping["content.Code"], 200 + ) + self.assertEqual( + validators_mapping["content.Message"], None + ) + + def test_prepare_teststeps(self): + teststeps = self.har_parser._prepare_teststeps() + self.assertIsInstance(teststeps, list) + self.assertIn("name", teststeps[0]) + self.assertIn("request", teststeps[0]) + self.assertIn("validate", teststeps[0]) + + def test_gen_testcase_yaml(self): + yaml_file = os.path.join( + os.path.dirname(__file__), "data", "demo.yaml") + + self.har_parser.gen_testcase(file_type="YAML") + self.assertTrue(os.path.isfile(yaml_file)) + os.remove(yaml_file) + + def test_gen_testcase_json(self): + json_file = os.path.join( + os.path.dirname(__file__), "data", "demo.json") + + self.har_parser.gen_testcase(file_type="JSON") + self.assertTrue(os.path.isfile(json_file)) + os.remove(json_file) + + def test_filter(self): + filter_str = "httprunner" + har_parser = HarParser(self.har_path, filter_str) + teststeps = har_parser._prepare_teststeps() + self.assertEqual( + teststeps[0]["request"]["url"], + "https://httprunner.top/api/v1/Account/Login" + ) + + filter_str = "debugtalk" + har_parser = HarParser(self.har_path, filter_str) + teststeps = har_parser._prepare_teststeps() + self.assertEqual(teststeps, []) + + def test_exclude(self): + exclude_str = "debugtalk" + har_parser = HarParser(self.har_path, exclude_str=exclude_str) + teststeps = har_parser._prepare_teststeps() + self.assertEqual( + teststeps[0]["request"]["url"], + "https://httprunner.top/api/v1/Account/Login" + ) + + exclude_str = "httprunner" + har_parser = HarParser(self.har_path, exclude_str=exclude_str) + teststeps = har_parser._prepare_teststeps() + self.assertEqual(teststeps, []) + + def test_exclude_multiple(self): + exclude_str = "httprunner|v2" + har_parser = HarParser(self.har_path, exclude_str=exclude_str) + teststeps = har_parser._prepare_teststeps() + self.assertEqual(teststeps, []) + + exclude_str = "http2|v1" + har_parser = HarParser(self.har_path, exclude_str=exclude_str) + teststeps = har_parser._prepare_teststeps() + self.assertEqual(teststeps, []) + + def test_make_request_data_params(self): + testcase_dict = { + "name": "", + "request": {}, + "validate": [] + } + entry_json = { + "request": { + "method": "POST", + "postData": { + "mimeType": "application/x-www-form-urlencoded; charset=utf-8", + "params": [ + {"name": "a", "value": 1}, + {"name": "b", "value": "2"} + ] + }, + } + } + self.har_parser._make_request_data(testcase_dict, entry_json) + self.assertEqual(testcase_dict["request"]["data"]["a"], 1) + self.assertEqual(testcase_dict["request"]["data"]["b"], "2") + + def test_make_request_data_json(self): + testcase_dict = { + "name": "", + "request": {}, + "validate": [] + } + entry_json = { + "request": { + "method": "POST", + "postData": { + "mimeType": "application/json; charset=utf-8", + "text": "{\"a\":\"1\",\"b\":\"2\"}" + }, + } + } + self.har_parser._make_request_data(testcase_dict, entry_json) + self.assertEqual( + testcase_dict["request"]["json"], + {'a': '1', 'b': '2'} + ) + + def test_make_request_data_text_empty(self): + testcase_dict = { + "name": "", + "request": {}, + "validate": [] + } + entry_json = { + "request": { + "method": "POST", + "postData": { + "mimeType": "application/json; charset=utf-8", + "text": "" + }, + } + } + self.har_parser._make_request_data(testcase_dict, entry_json) + self.assertEqual( + testcase_dict["request"]["data"], + "" + ) + + def test_make_validate(self): + testcase_dict = { + "name": "", + "request": {}, + "validate": [] + } + entry_json = { + "request": {}, + "response": { + "status": 200, + "headers": [ + { + "name": "Content-Type", + "value": "application/json; charset=utf-8" + }, + ], + "content": { + "size": 71, + "mimeType": "application/json; charset=utf-8", + # raw response content text is application/jose type + "text": "ZXlKaGJHY2lPaUpTVTBFeFh6VWlMQ0psYm1NaU9pSkJNVEk0UTBKRExV", + "encoding": "base64" + } + } + } + self.har_parser._make_validate(testcase_dict, entry_json) + self.assertEqual( + testcase_dict["validate"][0], + {"eq": ["status_code", 200]} + ) + self.assertEqual( + testcase_dict["validate"][1], + {"eq": ["headers.Content-Type", "application/json; charset=utf-8"]} + ) + + def test_make_testcase(self): + har_path = os.path.join( + os.path.dirname(__file__), "data", "demo-quickstart.har") + har_parser = HarParser(har_path) + testcase = har_parser._make_testcase() + self.assertIsInstance(testcase, dict) + self.assertIn("config", testcase) + self.assertIn("teststeps", testcase) + self.assertEqual(len(testcase["teststeps"]), 2) diff --git a/httprunner/ext/har2case/test_utils.py b/httprunner/ext/har2case/test_utils.py new file mode 100644 index 00000000..f718e854 --- /dev/null +++ b/httprunner/ext/har2case/test_utils.py @@ -0,0 +1,65 @@ +import json +import os +import unittest + +from httprunner.ext.har2case import utils + + +class TestUtils(unittest.TestCase): + + @staticmethod + def create_har_file(file_name, content): + file_path = os.path.join( + os.path.dirname(__file__), "data", "{}.har".format(file_name)) + with open(file_path, "w") as f: + f.write(json.dumps(content)) + + return file_path + + @classmethod + def setUpClass(cls): + cls.har_path = os.path.join( + os.path.dirname(__file__), "data", "demo.har") + cls.empty_file_path = TestUtils.create_har_file(file_name="empty", content="") + cls.empty_json_file_path = TestUtils.create_har_file(file_name="empty_json", content={}) + + @classmethod + def tearDownClass(cls): + os.remove(cls.empty_file_path) + os.remove(cls.empty_json_file_path) + + def test_load_har_log_entries(self): + log_entries = utils.load_har_log_entries(self.har_path) + self.assertIsInstance(log_entries, list) + self.assertIn("request", log_entries[0]) + self.assertIn("response", log_entries[0]) + + def test_load_har_log_key_error(self): + with self.assertRaises(SystemExit): + utils.load_har_log_entries(self.empty_json_file_path) + + def test_load_har_log_empty_error(self): + with self.assertRaises(SystemExit): + utils.load_har_log_entries(self.empty_file_path) + + # def test_x_www_form_urlencoded(self): + # origin_dict = {"a":1, "b": "2"} + # self.assertIn("a=1", utils.x_www_form_urlencoded(origin_dict)) + # self.assertIn("b=2", utils.x_www_form_urlencoded(origin_dict)) + + def test_convert_list_to_dict(self): + origin_list = [ + {"name": "v", "value": "1"}, + {"name": "w", "value": "2"} + ] + self.assertEqual( + utils.convert_list_to_dict(origin_list), + {"v": "1", "w": "2"} + ) + + def test_convert_x_www_form_urlencoded_to_dict(self): + origin_str = "a=1&b=2" + converted_dict = utils.convert_x_www_form_urlencoded_to_dict(origin_str) + self.assertIsInstance(converted_dict, dict) + self.assertEqual(converted_dict["a"], "1") + self.assertEqual(converted_dict["b"], "2") diff --git a/httprunner/ext/har2case/utils.py b/httprunner/ext/har2case/utils.py new file mode 100644 index 00000000..0b9293b6 --- /dev/null +++ b/httprunner/ext/har2case/utils.py @@ -0,0 +1,130 @@ +import io +import json +import logging +import sys +from json.decoder import JSONDecodeError +from urllib.parse import unquote + +import yaml + + +def load_har_log_entries(file_path): + """ load HAR file and return log entries list + + Args: + file_path (str) + + Returns: + list: entries + [ + { + "request": {}, + "response": {} + }, + { + "request": {}, + "response": {} + } + ] + + """ + with io.open(file_path, "r+", encoding="utf-8-sig") as f: + try: + content_json = json.loads(f.read()) + return content_json["log"]["entries"] + except (KeyError, TypeError, JSONDecodeError): + logging.error("HAR file content error: {}".format(file_path)) + sys.exit(1) + + +def x_www_form_urlencoded(post_data): + """ convert origin dict to x-www-form-urlencoded + + Args: + post_data (dict): + {"a": 1, "b":2} + + Returns: + str: + a=1&b=2 + + """ + if isinstance(post_data, dict): + return "&".join([ + u"{}={}".format(key, value) + for key, value in post_data.items() + ]) + else: + return post_data + + +def convert_x_www_form_urlencoded_to_dict(post_data): + """ convert x_www_form_urlencoded data to dict + + Args: + post_data (str): a=1&b=2 + + Returns: + dict: {"a":1, "b":2} + + """ + if isinstance(post_data, str): + converted_dict = {} + for k_v in post_data.split("&"): + try: + key, value = k_v.split("=") + except ValueError: + raise Exception( + "Invalid x_www_form_urlencoded data format: {}".format(post_data) + ) + converted_dict[key] = unquote(value) + return converted_dict + else: + return post_data + + +def convert_list_to_dict(origin_list): + """ convert HAR data list to mapping + + Args: + origin_list (list) + [ + {"name": "v", "value": "1"}, + {"name": "w", "value": "2"} + ] + + Returns: + dict: + {"v": "1", "w": "2"} + + """ + return { + item["name"]: item.get("value") + for item in origin_list + } + + +def dump_yaml(testcase, yaml_file): + """ dump HAR entries to yaml testcase + """ + logging.info("dump testcase to YAML format.") + + with io.open(yaml_file, 'w', encoding="utf-8") as outfile: + yaml.dump(testcase, outfile, allow_unicode=True, default_flow_style=False, indent=4) + + logging.info("Generate YAML testcase successfully: {}".format(yaml_file)) + + +def dump_json(testcase, json_file): + """ dump HAR entries to json testcase + """ + logging.info("dump testcase to JSON format.") + + with io.open(json_file, 'w', encoding="utf-8") as outfile: + my_json_str = json.dumps(testcase, ensure_ascii=False, indent=4) + if isinstance(my_json_str, bytes): + my_json_str = my_json_str.decode("utf-8") + + outfile.write(my_json_str) + + logging.info("Generate JSON testcase successfully: {}".format(json_file)) diff --git a/poetry.lock b/poetry.lock index 5d03bd2f..3dfab901 100644 --- a/poetry.lock +++ b/poetry.lock @@ -122,17 +122,6 @@ optional = false python-versions = "*" version = "0.9.0" -[[package]] -category = "main" -description = "Convert HAR(HTTP Archive) to YAML/JSON testcases for HttpRunner." -name = "har2case" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, <4" -version = "0.3.1" - -[package.dependencies] -PyYAML = "*" - [[package]] category = "dev" description = "A collection of framework independent HTTP protocol utils." @@ -356,7 +345,7 @@ version = "1.0.1" dev = ["pytest (>=4.6.2)", "black (>=19.3b0)"] [metadata] -content-hash = "ff30b34e5d7a0934029a6ea20ebd43fc9ea769c4bbb2e63890de6d399d756da0" +content-hash = "e1204ede1ab227bc33783b362d866c2a0b1fb8faba283216b2973e2261b0b966" python-versions = "^3.6" [metadata.files] @@ -437,10 +426,6 @@ h11 = [ {file = "h11-0.9.0-py2.py3-none-any.whl", hash = "sha256:4bc6d6a1238b7615b266ada57e0618568066f57dd6fa967d1290ec9309b2f2f1"}, {file = "h11-0.9.0.tar.gz", hash = "sha256:33d4bca7be0fa039f4e84d50ab00531047e53d6ee8ffbc83501ea602c169cae1"}, ] -har2case = [ - {file = "har2case-0.3.1-py2.py3-none-any.whl", hash = "sha256:84d3a5cc9fbb16e45372e7e880a936c59bbe8e9b66bad81927769e64f608e2af"}, - {file = "har2case-0.3.1.tar.gz", hash = "sha256:8f159ec7cba82ec4282f46af4a9dac89f65e62796521b2426d3c89c3c9fd8579"}, -] httptools = [ {file = "httptools-0.1.1-cp35-cp35m-macosx_10_13_x86_64.whl", hash = "sha256:a2719e1d7a84bb131c4f1e0cb79705034b48de6ae486eb5297a139d6a3296dce"}, {file = "httptools-0.1.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:fa3cd71e31436911a44620473e873a256851e1f53dee56669dae403ba41756a4"}, diff --git a/pyproject.toml b/pyproject.toml index 93c568bb..943d0b91 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,6 @@ requests = "^2.22.0" requests-toolbelt = "^0.9.1" pyyaml = "^5.1.2" jinja2 = "^2.10.3" -har2case = "^0.3.1" filetype = "^1.0.5" jsonpath = "^0.82" pydantic = "^1.4" From b9392d7cce0e57048726441e7399faedf0c12d21 Mon Sep 17 00:00:00 2001 From: debugtalk Date: Sun, 12 Apr 2020 23:29:00 +0800 Subject: [PATCH 015/169] change: make `locusts` as hrun sub-command, usage: `hrun locusts -h` --- docs/CHANGELOG.md | 1 + httprunner/cli.py | 21 ++- httprunner/ext/locusts/__init__.py | 79 ++++++++ httprunner/ext/locusts/__main__.py | 4 - httprunner/ext/locusts/cli.py | 174 ------------------ httprunner/ext/locusts/core.py | 110 +++++++++++ .../ext/locusts/data}/demo_locusts.yml | 0 .../ext/locusts/utils_test.py | 2 +- pyproject.toml | 1 - tests/test_extension/__init__.py | 0 10 files changed, 211 insertions(+), 181 deletions(-) delete mode 100644 httprunner/ext/locusts/__main__.py delete mode 100644 httprunner/ext/locusts/cli.py create mode 100644 httprunner/ext/locusts/core.py rename {tests/locust_tests => httprunner/ext/locusts/data}/demo_locusts.yml (100%) rename tests/test_extension/test_locusts.py => httprunner/ext/locusts/utils_test.py (89%) delete mode 100644 tests/test_extension/__init__.py diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 694c0d28..981c1f59 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -8,6 +8,7 @@ - remove compatibility with testcase/testsuite format v1 - make `startproject` as hrun sub-command, usage: `hrun startproject ` - make `har2case` as hrun sub-command, usage: `hrun har2case -h` +- make `locusts` as hrun sub-command, usage: `hrun locusts -h` ## 3.0.1 (2020-03-24) diff --git a/httprunner/cli.py b/httprunner/cli.py index 85dd5ea2..f2fcdece 100644 --- a/httprunner/cli.py +++ b/httprunner/cli.py @@ -4,10 +4,16 @@ import sys from loguru import logger +if len(sys.argv) >= 2 and sys.argv[1] == "locusts": + # monkey patch ssl at beginning to avoid RecursionError when running locust. + from gevent import monkey + monkey.patch_ssl() + from httprunner import __description__, __version__ from httprunner.api import HttpRunner from httprunner.ext.har2case import init_har2case_parser, main_har2case from httprunner.ext.scaffold import init_parser_scaffold, main_scaffold +from httprunner.ext.locusts import init_parser_locusts, main_locusts from httprunner.report import gen_html_report @@ -85,8 +91,13 @@ def main(): sub_parser_run = init_parser_run(subparsers) sub_parser_scaffold = init_parser_scaffold(subparsers) sub_parser_har2case = init_har2case_parser(subparsers) + sub_parser_locusts = init_parser_locusts(subparsers) - args = parser.parse_args() + extra_args = [] + if len(sys.argv) >= 2 and sys.argv[1] == "locusts": + args, extra_args = parser.parse_known_args() + else: + args = parser.parse_args() if args.version: print(f"{__version__}") @@ -121,6 +132,14 @@ def main(): main_har2case(args) + elif sys.argv[1] == "locusts": + # hrun locusts + if len(sys.argv) == 2: + sub_parser_locusts.print_help() + sys.exit(0) + + main_locusts(args, extra_args) + if __name__ == '__main__': main() diff --git a/httprunner/ext/locusts/__init__.py b/httprunner/ext/locusts/__init__.py index e69de29b..7c05d6f2 100644 --- a/httprunner/ext/locusts/__init__.py +++ b/httprunner/ext/locusts/__init__.py @@ -0,0 +1,79 @@ +import multiprocessing +import sys + +from loguru import logger + +from httprunner import __version__ +from httprunner.ext.locusts.core import start_locust_main, parse_locustfile, quick_run_locusts, start_master, \ + start_slaves + +CPU_COUNT = multiprocessing.cpu_count() + + +def init_parser_locusts(subparsers): + sub_parser_locusts = subparsers.add_parser( + "locusts", help="Run load test with locust.") + sub_parser_locusts.add_argument( + '--locust-help', action='store_true', default=False, + help="Show locust help.") + sub_parser_locusts.add_argument( + "--master", action='store_true', default=False, help="Start locust master.") + sub_parser_locusts.add_argument( + "--slaves", type=int, help="Specify locust slave number.") + sub_parser_locusts.add_argument( + "--quickstart", action='store_true', default=False, + help=f"Start locust master with {CPU_COUNT} slaves.") + return sub_parser_locusts + + +def main_locusts(args, extra_args): + """ Performance test with locust: parse command line options and run commands. + """ + logger.info(f"HttpRunner version: {__version__}") + sys.argv = ["locust", *extra_args] + + if args.locust_help: + sys.argv = ["locust", "-h"] + start_locust_main() + + def get_arg_index(*target_args): + for arg in target_args: + if arg not in sys.argv: + continue + + return sys.argv.index(arg) + 1 + + return None + + # set logging level + loglevel_index = get_arg_index("-L", "--loglevel") + if loglevel_index and loglevel_index < len(sys.argv): + loglevel = sys.argv[loglevel_index] + loglevel = loglevel.upper() + else: + # default + loglevel = "INFO" + + logger.remove() + logger.add(sys.stdout, level=loglevel) + + # convert httprunner yaml/json case to locustfile.py + try: + testcase_index = get_arg_index("-f", "--locustfile") + assert testcase_index and testcase_index < len(sys.argv) + testcase_file_path = sys.argv[testcase_index] + sys.argv[testcase_index] = parse_locustfile(testcase_file_path) + except AssertionError: + print("Testcase file is not specified, exit.") + sys.exit(1) + + manager = multiprocessing.Manager() + try: + if args.quickstart: + quick_run_locusts(CPU_COUNT) + elif args.master: + start_master(sys.argv) + elif args.slaves: + start_slaves(args.slaves) + except KeyboardInterrupt: + manager.shutdown() diff --git a/httprunner/ext/locusts/__main__.py b/httprunner/ext/locusts/__main__.py deleted file mode 100644 index bc8d706f..00000000 --- a/httprunner/ext/locusts/__main__.py +++ /dev/null @@ -1,4 +0,0 @@ -from httprunner.ext.locusts.cli import main - -if __name__ == "__main__": - main() diff --git a/httprunner/ext/locusts/cli.py b/httprunner/ext/locusts/cli.py deleted file mode 100644 index 22e80c7c..00000000 --- a/httprunner/ext/locusts/cli.py +++ /dev/null @@ -1,174 +0,0 @@ -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. -install with pip: -$ pip install locustio -""" - print(msg) - import sys - sys.exit(0) - -import io -import multiprocessing -import os -import sys - -from loguru import logger - -from httprunner import __version__ - - -def parse_locustfile(file_path): - """ parse testcase file and return locustfile path. - if file_path is a Python file, assume it is a locustfile - if file_path is a YAML/JSON file, convert it to locustfile - """ - if not os.path.isfile(file_path): - logger.error("file path invalid, exit.") - sys.exit(1) - - file_suffix = os.path.splitext(file_path)[1] - if file_suffix == ".py": - locustfile_path = file_path - elif file_suffix in ['.yaml', '.yml', '.json']: - locustfile_path = gen_locustfile(file_path) - else: - # '' or other suffix - logger.error("file type should be YAML/JSON/Python, exit.") - sys.exit(1) - - return locustfile_path - - -def gen_locustfile(testcase_file_path): - """ generate locustfile from template. - """ - locustfile_path = 'locustfile.py' - template_path = os.path.join( - os.path.dirname(os.path.realpath(__file__)), - "locustfile_template.py" - ) - - with io.open(template_path, encoding='utf-8') as template: - with io.open(locustfile_path, 'w', encoding='utf-8') as locustfile: - template_content = template.read() - template_content = template_content.replace("$TESTCASE_FILE", testcase_file_path) - locustfile.write(template_content) - - return locustfile_path - - -def start_locust_main(): - locust_main.main() - - -def start_master(sys_argv): - sys_argv.append("--master") - sys.argv = sys_argv - start_locust_main() - - -def start_slave(sys_argv): - if "--slave" not in sys_argv: - sys_argv.extend(["--slave"]) - - sys.argv = sys_argv - start_locust_main() - - -def run_locusts_with_processes(sys_argv, processes_count): - processes = [] - manager = multiprocessing.Manager() - - for _ in range(processes_count): - p_slave = multiprocessing.Process(target=start_slave, args=(sys_argv,)) - p_slave.daemon = True - p_slave.start() - processes.append(p_slave) - - try: - if "--slave" in sys_argv: - [process.join() for process in processes] - else: - start_master(sys_argv) - except KeyboardInterrupt: - manager.shutdown() - - -def main(): - """ Performance test with locust: parse command line options and run commands. - """ - print(f"HttpRunner version: {__version__}") - sys.argv[0] = 'locust' - if len(sys.argv) == 1: - sys.argv.extend(["-h"]) - - if sys.argv[1] in ["-h", "--help", "-V", "--version"]: - start_locust_main() - - def get_arg_index(*target_args): - for arg in target_args: - if arg not in sys.argv: - continue - - return sys.argv.index(arg) + 1 - - return None - - # set logging level - loglevel_index = get_arg_index("-L", "--loglevel") - if loglevel_index and loglevel_index < len(sys.argv): - loglevel = sys.argv[loglevel_index] - loglevel = loglevel.upper() - else: - # default - loglevel = "WARNING" - - logger.remove() - logger.add(sys.stdout, level=loglevel) - - # get testcase file path - try: - testcase_index = get_arg_index("-f", "--locustfile") - assert testcase_index and testcase_index < len(sys.argv) - except AssertionError: - print("Testcase file is not specified, exit.") - sys.exit(1) - - testcase_file_path = sys.argv[testcase_index] - sys.argv[testcase_index] = parse_locustfile(testcase_file_path) - - if "--processes" in sys.argv: - """ locusts -f locustfile.py --processes 4 - """ - if "--no-web" in sys.argv: - logger.error("conflict parameter args: --processes & --no-web. \nexit.") - sys.exit(1) - - processes_index = sys.argv.index('--processes') - processes_count_index = processes_index + 1 - if processes_count_index >= len(sys.argv): - """ do not specify processes count explicitly - locusts -f locustfile.py --processes - """ - processes_count = multiprocessing.cpu_count() - logger.warning(f"processes count not specified, use {processes_count} by default.") - else: - try: - """ locusts -f locustfile.py --processes 4 """ - processes_count = int(sys.argv[processes_count_index]) - sys.argv.pop(processes_count_index) - except ValueError: - """ locusts -f locustfile.py --processes -P 8888 """ - processes_count = multiprocessing.cpu_count() - logger.warning(f"processes count not specified, use {processes_count} by default.") - - sys.argv.pop(processes_index) - run_locusts_with_processes(sys.argv, processes_count) - else: - start_locust_main() diff --git a/httprunner/ext/locusts/core.py b/httprunner/ext/locusts/core.py new file mode 100644 index 00000000..c03049aa --- /dev/null +++ b/httprunner/ext/locusts/core.py @@ -0,0 +1,110 @@ +import io +import multiprocessing +import os +import sys + +from loguru import logger + +try: + from locust import main as locust_main +except ImportError: + msg = """ +Locust is not installed, install first and try again. +install with pip: +$ pip install locustio +""" + logger.error(msg) + sys.exit(0) + + +def parse_locustfile(file_path): + """ parse testcase file and return locustfile path. + if file_path is a Python file, assume it is a locustfile + if file_path is a YAML/JSON file, convert it to locustfile + """ + if not os.path.isfile(file_path): + logger.error("file path invalid, exit.") + sys.exit(1) + + file_suffix = os.path.splitext(file_path)[1] + if file_suffix == ".py": + locustfile_path = file_path + elif file_suffix in ['.yaml', '.yml', '.json']: + locustfile_path = gen_locustfile(file_path) + else: + # '' or other suffix + logger.error("file type should be YAML/JSON/Python, exit.") + sys.exit(1) + + return locustfile_path + + +def gen_locustfile(testcase_file_path): + """ generate locustfile from template. + """ + locustfile_path = 'locustfile.py' + template_path = os.path.join( + os.path.dirname(os.path.realpath(__file__)), + "locustfile_template.py" + ) + + with io.open(template_path, encoding='utf-8') as template: + with io.open(locustfile_path, 'w', encoding='utf-8') as locustfile: + template_content = template.read() + template_content = template_content.replace("$TESTCASE_FILE", testcase_file_path) + locustfile.write(template_content) + + return locustfile_path + + +def start_locust_main(): + logger.info(f"run command: {sys.argv}") + locust_main.main() + + +def start_master(sys_argv): + sys_argv.append("--master") + sys.argv = sys_argv + start_locust_main() + + +def start_slave(sys_argv): + if "--slave" not in sys_argv: + sys_argv.extend(["--slave"]) + + sys.argv = sys_argv + start_locust_main() + + +def init_slave_processes(slave_num): + """ init specified number of locust slave processes.""" + processes = [] + + for _ in range(slave_num): + p_slave = multiprocessing.Process(target=start_slave, args=(sys.argv,)) + p_slave.daemon = True + p_slave.start() + processes.append(p_slave) + + return processes + + +def start_slaves(slave_num): + logger.info(f"Start {slave_num} locust slaves ...") + processes = init_slave_processes(slave_num) + [process.join() for process in processes] + + +def quick_run_locusts(slave_num): + """ quick start locust master and multiple slaves. + + Args: + slave_num: locust slaves number + """ + logger.info(f"Start locust master with {slave_num} slaves ...") + + processes = init_slave_processes(slave_num) + processes.append( + multiprocessing.Process(target=start_master, args=(sys.argv,)) + ) + [process.join() for process in processes] diff --git a/tests/locust_tests/demo_locusts.yml b/httprunner/ext/locusts/data/demo_locusts.yml similarity index 100% rename from tests/locust_tests/demo_locusts.yml rename to httprunner/ext/locusts/data/demo_locusts.yml diff --git a/tests/test_extension/test_locusts.py b/httprunner/ext/locusts/utils_test.py similarity index 89% rename from tests/test_extension/test_locusts.py rename to httprunner/ext/locusts/utils_test.py index 59d957ff..c76b0b52 100644 --- a/tests/test_extension/test_locusts.py +++ b/httprunner/ext/locusts/utils_test.py @@ -8,7 +8,7 @@ class TestLocust(unittest.TestCase): def test_prepare_locust_tests(self): path = os.path.join( - os.getcwd(), 'tests/locust_tests/demo_locusts.yml') + os.path.dirname(__file__), "data", "demo_locusts.yml") locust_tests = prepare_locust_tests(path) self.assertEqual(len(locust_tests), 2 + 3) name_list = [ diff --git a/pyproject.toml b/pyproject.toml index 943d0b91..3de0634c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,7 +48,6 @@ fastapi = "^0.49.0" hrun = "httprunner.cli:main" ate = "httprunner.cli:main" httprunner = "httprunner.cli:main" -locusts = "httprunner.ext.locusts.cli:main" [build-system] requires = ["poetry>=1.0.0"] diff --git a/tests/test_extension/__init__.py b/tests/test_extension/__init__.py deleted file mode 100644 index e69de29b..00000000 From ade7935e4eb6e51ddba2e1bddcd7dd74fc5732a6 Mon Sep 17 00:00:00 2001 From: debugtalk Date: Sun, 12 Apr 2020 23:37:29 +0800 Subject: [PATCH 016/169] refactor: rename unittest file name --- httprunner/cli.py | 4 ++-- httprunner/ext/har2case/{test_core.py => core_test.py} | 0 httprunner/ext/har2case/{test_utils.py => utils_test.py} | 0 .../ext/scaffold/{test_scaffold.py => scaffold_test.py} | 0 4 files changed, 2 insertions(+), 2 deletions(-) rename httprunner/ext/har2case/{test_core.py => core_test.py} (100%) rename httprunner/ext/har2case/{test_utils.py => utils_test.py} (100%) rename httprunner/ext/scaffold/{test_scaffold.py => scaffold_test.py} (100%) diff --git a/httprunner/cli.py b/httprunner/cli.py index f2fcdece..a68cedc1 100644 --- a/httprunner/cli.py +++ b/httprunner/cli.py @@ -2,13 +2,13 @@ import argparse import os import sys -from loguru import logger - if len(sys.argv) >= 2 and sys.argv[1] == "locusts": # monkey patch ssl at beginning to avoid RecursionError when running locust. from gevent import monkey monkey.patch_ssl() +from loguru import logger + from httprunner import __description__, __version__ from httprunner.api import HttpRunner from httprunner.ext.har2case import init_har2case_parser, main_har2case diff --git a/httprunner/ext/har2case/test_core.py b/httprunner/ext/har2case/core_test.py similarity index 100% rename from httprunner/ext/har2case/test_core.py rename to httprunner/ext/har2case/core_test.py diff --git a/httprunner/ext/har2case/test_utils.py b/httprunner/ext/har2case/utils_test.py similarity index 100% rename from httprunner/ext/har2case/test_utils.py rename to httprunner/ext/har2case/utils_test.py diff --git a/httprunner/ext/scaffold/test_scaffold.py b/httprunner/ext/scaffold/scaffold_test.py similarity index 100% rename from httprunner/ext/scaffold/test_scaffold.py rename to httprunner/ext/scaffold/scaffold_test.py From caa9503df83785ab16657497290732a2f2efb225 Mon Sep 17 00:00:00 2001 From: debugtalk Date: Sun, 12 Apr 2020 23:58:10 +0800 Subject: [PATCH 017/169] fix: check if locustio installed --- httprunner/ext/locusts/__init__.py | 11 +++++++++++ httprunner/ext/locusts/core.py | 14 ++------------ 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/httprunner/ext/locusts/__init__.py b/httprunner/ext/locusts/__init__.py index 7c05d6f2..747fbfc1 100644 --- a/httprunner/ext/locusts/__init__.py +++ b/httprunner/ext/locusts/__init__.py @@ -29,6 +29,17 @@ def init_parser_locusts(subparsers): def main_locusts(args, extra_args): """ Performance test with locust: parse command line options and run commands. """ + try: + from locust.main import main + except ImportError: + msg = """ + Locust is not installed, install first and try again. + install with pip: + $ pip install locustio + """ + logger.error(msg) + sys.exit(1) + logger.info(f"HttpRunner version: {__version__}") sys.argv = ["locust", *extra_args] diff --git a/httprunner/ext/locusts/core.py b/httprunner/ext/locusts/core.py index c03049aa..51823c85 100644 --- a/httprunner/ext/locusts/core.py +++ b/httprunner/ext/locusts/core.py @@ -5,17 +5,6 @@ import sys from loguru import logger -try: - from locust import main as locust_main -except ImportError: - msg = """ -Locust is not installed, install first and try again. -install with pip: -$ pip install locustio -""" - logger.error(msg) - sys.exit(0) - def parse_locustfile(file_path): """ parse testcase file and return locustfile path. @@ -59,7 +48,8 @@ def gen_locustfile(testcase_file_path): def start_locust_main(): logger.info(f"run command: {sys.argv}") - locust_main.main() + from locust.main import main + main() def start_master(sys_argv): From 7bf275b7cc686fe178b8c3afa02a9730383f5a0a Mon Sep 17 00:00:00 2001 From: debugtalk Date: Mon, 13 Apr 2020 00:02:13 +0800 Subject: [PATCH 018/169] fix: github action scripts --- .github/workflows/integration_test.yml | 5 ++++- tests/test_runner.py | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/integration_test.yml b/.github/workflows/integration_test.yml index 9e6c52c9..256cbfec 100644 --- a/.github/workflows/integration_test.yml +++ b/.github/workflows/integration_test.yml @@ -30,7 +30,10 @@ jobs: poetry build ls dist/*.whl | xargs pip install # test installation hrun -V - locusts -V + hrun run -h + hrun startproject -h + hrun har2case -h + hrun locusts -h - name: Run smoketest for hrun command run: | cd tests/httpbin && hrun run basic.yml --failfast && cd - diff --git a/tests/test_runner.py b/tests/test_runner.py index fe44ff2b..ab9f4805 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -83,7 +83,7 @@ class TestRunner(ApiServerUnittest): test_runner.run_test(parsed_testcase["teststeps"][0]) end_time = time.time() # testcase teardown hook has not been executed now - self.assertLess(end_time - start_time, 1) + self.assertLess(end_time - start_time, 2) def test_run_testcase_with_hooks_assignment(self): testcases = [ From 1e64171fb8705b5ad7ce5e1c2241628b7b95f959 Mon Sep 17 00:00:00 2001 From: debugtalk Date: Mon, 13 Apr 2020 00:33:11 +0800 Subject: [PATCH 019/169] fix: check if gevent installed when running locusts --- httprunner/cli.py | 14 ++++++++++++-- httprunner/ext/locusts/__init__.py | 14 +++----------- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/httprunner/cli.py b/httprunner/cli.py index a68cedc1..4aac1b87 100644 --- a/httprunner/cli.py +++ b/httprunner/cli.py @@ -4,8 +4,18 @@ import sys if len(sys.argv) >= 2 and sys.argv[1] == "locusts": # monkey patch ssl at beginning to avoid RecursionError when running locust. - from gevent import monkey - monkey.patch_ssl() + try: + from gevent import monkey + monkey.patch_ssl() + from locust.main import main + except ImportError: + msg = """ +Locust is not installed, install first and try again. +install with pip: +$ pip install locustio +""" + print(msg) + sys.exit(1) from loguru import logger diff --git a/httprunner/ext/locusts/__init__.py b/httprunner/ext/locusts/__init__.py index 747fbfc1..cdd6dfbb 100644 --- a/httprunner/ext/locusts/__init__.py +++ b/httprunner/ext/locusts/__init__.py @@ -29,17 +29,6 @@ def init_parser_locusts(subparsers): def main_locusts(args, extra_args): """ Performance test with locust: parse command line options and run commands. """ - try: - from locust.main import main - except ImportError: - msg = """ - Locust is not installed, install first and try again. - install with pip: - $ pip install locustio - """ - logger.error(msg) - sys.exit(1) - logger.info(f"HttpRunner version: {__version__}") sys.argv = ["locust", *extra_args] @@ -86,5 +75,8 @@ def main_locusts(args, extra_args): start_master(sys.argv) elif args.slaves: start_slaves(args.slaves) + else: + quick_run_locusts(CPU_COUNT) + except KeyboardInterrupt: manager.shutdown() From f15a27763c4e9d896da2941252dfbd4dd2df1fd9 Mon Sep 17 00:00:00 2001 From: debugtalk Date: Mon, 13 Apr 2020 00:42:55 +0800 Subject: [PATCH 020/169] change: add argument to specify yaml/json testcase file for locusts --- httprunner/ext/locusts/__init__.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/httprunner/ext/locusts/__init__.py b/httprunner/ext/locusts/__init__.py index cdd6dfbb..1aeb3388 100644 --- a/httprunner/ext/locusts/__init__.py +++ b/httprunner/ext/locusts/__init__.py @@ -16,6 +16,8 @@ def init_parser_locusts(subparsers): sub_parser_locusts.add_argument( '--locust-help', action='store_true', default=False, help="Show locust help.") + sub_parser_locusts.add_argument('test_file', nargs='?', + help="Specify YAML/JSON testcase file.") sub_parser_locusts.add_argument( "--master", action='store_true', default=False, help="Start locust master.") sub_parser_locusts.add_argument( @@ -57,16 +59,14 @@ def main_locusts(args, extra_args): logger.remove() logger.add(sys.stdout, level=loglevel) - # convert httprunner yaml/json case to locustfile.py - try: - testcase_index = get_arg_index("-f", "--locustfile") - assert testcase_index and testcase_index < len(sys.argv) - testcase_file_path = sys.argv[testcase_index] - sys.argv[testcase_index] = parse_locustfile(testcase_file_path) - except AssertionError: - print("Testcase file is not specified, exit.") + if not args.test_file: + logger.error("Testcase file is not specified, exit.") sys.exit(1) + # convert httprunner yaml/json case to locustfile.py + locustfile_path = parse_locustfile(args.test_file) + sys.argv.extend(["-f", locustfile_path]) + manager = multiprocessing.Manager() try: if args.quickstart: From ed42f1e9675808f6b2c2867ccc22b73756731ffa Mon Sep 17 00:00:00 2001 From: debugtalk Date: Mon, 13 Apr 2020 00:45:58 +0800 Subject: [PATCH 021/169] fix: github action scripts --- .github/workflows/integration_test.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/integration_test.yml b/.github/workflows/integration_test.yml index 256cbfec..076eeb42 100644 --- a/.github/workflows/integration_test.yml +++ b/.github/workflows/integration_test.yml @@ -33,6 +33,7 @@ jobs: hrun run -h hrun startproject -h hrun har2case -h + pip install locustio hrun locusts -h - name: Run smoketest for hrun command run: | From ec6b60052435ee5da7bc21be2a32ba85a3a52d06 Mon Sep 17 00:00:00 2001 From: debugtalk Date: Mon, 13 Apr 2020 21:59:35 +0800 Subject: [PATCH 022/169] fix: unittest along with code --- httprunner/ext/har2case/core_test.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/httprunner/ext/har2case/core_test.py b/httprunner/ext/har2case/core_test.py index d491b353..b90ce6f4 100644 --- a/httprunner/ext/har2case/core_test.py +++ b/httprunner/ext/har2case/core_test.py @@ -1,8 +1,8 @@ import os -from httprunner.ext.har2case.utils import load_har_log_entries from httprunner.ext.har2case.core import HarParser -from httprunner.ext.har2case.test_utils import TestUtils +from httprunner.ext.har2case.utils import load_har_log_entries +from httprunner.ext.har2case.utils_test import TestUtils class TestHar(TestUtils): From c537a50cc03e58c5dc281edff35f28a659506ba7 Mon Sep 17 00:00:00 2001 From: debugtalk Date: Fri, 17 Apr 2020 21:56:20 +0800 Subject: [PATCH 023/169] **Added** - feat: implement global hooks `setup_testcase/teardown_testcase`, called before/after each testcase - feat: implement global hooks `setup_teststep/teardown_teststep`, called before/after each teststep **Changed** - remove default header `HRUN-Request-ID`, implement the same function with global hooks --- docs/CHANGELOG.md | 8 +++++++- httprunner/api.py | 1 - httprunner/context.py | 4 +++- httprunner/parser.py | 19 ++++++++++++++++++- httprunner/runner.py | 19 +++++++------------ tests/debugtalk.py | 23 +++++++++++++++++++++++ tests/httpbin/hooks.yml | 2 ++ 7 files changed, 60 insertions(+), 16 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 981c1f59..b9441001 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,6 +1,11 @@ # Release History -## 3.0.2 (2020-04-12) +## 3.0.2 (2020-04-17) + +**Added** + +- feat: implement global hooks `setup_testcase/teardown_testcase`, called before/after each testcase +- feat: implement global hooks `setup_teststep/teardown_teststep`, called before/after each teststep **Changed** @@ -9,6 +14,7 @@ - make `startproject` as hrun sub-command, usage: `hrun startproject ` - make `har2case` as hrun sub-command, usage: `hrun har2case -h` - make `locusts` as hrun sub-command, usage: `hrun locusts -h` +- remove default header `HRUN-Request-ID`, implement the same function with global hooks ## 3.0.1 (2020-03-24) diff --git a/httprunner/api.py b/httprunner/api.py index ec4e482d..1d4162d5 100644 --- a/httprunner/api.py +++ b/httprunner/api.py @@ -200,7 +200,6 @@ class HttpRunner(object): ) testcase_summary["log"] = logs_file_abs_path - testcase_summary["HRUN-Request-ID"] = testcase.runner.hrun_request_id summary["details"].append(testcase_summary) return summary diff --git a/httprunner/context.py b/httprunner/context.py index c08af242..e19aea5a 100644 --- a/httprunner/context.py +++ b/httprunner/context.py @@ -1,3 +1,5 @@ +import copy + from httprunner import parser, utils @@ -34,7 +36,7 @@ class SessionContext(object): } """ - variables_mapping = variables_mapping or {} + variables_mapping = copy.deepcopy(variables_mapping or {}) variables_mapping = utils.ensure_mapping_format(variables_mapping) variables_mapping.update(self.session_variables_mapping) parsed_variables_mapping = parser.parse_variables_mapping(variables_mapping) diff --git a/httprunner/parser.py b/httprunner/parser.py index b61e6adc..23372cbd 100644 --- a/httprunner/parser.py +++ b/httprunner/parser.py @@ -1076,8 +1076,17 @@ def __prepare_config(config, project_mapping, session_variables_set=None): if raw_config_variables_mapping: config["variables"] = raw_config_variables_mapping + if "setup_testcase" in functions: + config.setdefault("setup_hooks", []) + config["setup_hooks"].insert(0, "${setup_testcase($variables)}") + + if "teardown_testcase" in functions: + config.setdefault("teardown_hooks", []) + config["teardown_hooks"].append("${teardown_testcase()}") + check_variables_set = set(raw_config_variables_mapping.keys()) check_variables_set |= (session_variables_set or set()) + check_variables_set.add("variables") prepared_config = prepare_lazy_data(config, functions, check_variables_set, cached=True) return prepared_config @@ -1110,7 +1119,7 @@ def __prepare_testcase_tests(tests, config, project_mapping, session_variables_s session_variables_set = set(config_variables.keys()) | (session_variables_set or set()) for test_dict in tests: - teststep_variables_set = {"request", "response"} + teststep_variables_set = {"request", "response", "variables"} # 1, testcase config => testcase tests # override test_dict variables @@ -1120,6 +1129,14 @@ def __prepare_testcase_tests(tests, config, project_mapping, session_variables_s ) test_dict["variables"] = test_dict_variables + if "setup_teststep" in functions: + test_dict.setdefault("setup_hooks", []) + test_dict["setup_hooks"].insert(0, "${setup_teststep($request, $variables)}") + + if "teardown_teststep" in functions: + test_dict.setdefault("teardown_hooks", []) + test_dict["teardown_hooks"].append("${teardown_teststep($response)}") + # base_url & verify: priority test_dict > config if (not test_dict.get("base_url")) and config_base_url: test_dict["base_url"] = config_base_url diff --git a/httprunner/runner.py b/httprunner/runner.py index 71e2f723..9617db4c 100644 --- a/httprunner/runner.py +++ b/httprunner/runner.py @@ -1,4 +1,3 @@ -import uuid from enum import Enum from unittest.case import SkipTest @@ -72,12 +71,6 @@ class Runner(object): self.export = config.get("export") or config.get("output", []) config_variables = config.get("variables", {}) - self.hrun_request_id = str(uuid.uuid4()) - if "HRUN-Request-ID" not in config_variables: - config_variables["HRUN-Request-ID"] = self.hrun_request_id - else: - self.hrun_request_id = config_variables["HRUN-Request-ID"] - # testcase setup hooks testcase_setup_hooks = config.get("setup_hooks", []) # testcase teardown hooks @@ -86,6 +79,10 @@ class Runner(object): self.http_client_session = http_client_session or HttpSession() self.session_context = SessionContext(config_variables) + self.session_context.update_session_variables({ + "variables": config_variables + }) + if testcase_setup_hooks: self.do_hook_actions(testcase_setup_hooks, HookTypeEnum.SETUP) @@ -217,6 +214,9 @@ class Runner(object): parsed_test_request = self.session_context.eval_content(raw_request) self.session_context.update_test_variables("request", parsed_test_request) + test_variables.update(self.session_context.session_variables_mapping["variables"]) + self.session_context.update_test_variables("variables", test_variables) + # setup hooks setup_hooks = test_dict.get("setup_hooks", []) if setup_hooks: @@ -227,11 +227,6 @@ class Runner(object): base_url = self.session_context.eval_content(test_dict.get("base_url", "")) parsed_url = utils.build_url(base_url, url) - request_headers = parsed_test_request.setdefault("headers", {}) - if "HRUN-Request-ID" not in request_headers: - parsed_test_request["headers"]["HRUN-Request-ID"] = \ - self.session_context.session_variables_mapping["HRUN-Request-ID"] - try: method = parsed_test_request.pop('method') parsed_test_request.setdefault("verify", self.verify) diff --git a/tests/debugtalk.py b/tests/debugtalk.py index 101d6308..70681202 100644 --- a/tests/debugtalk.py +++ b/tests/debugtalk.py @@ -2,6 +2,9 @@ import os import random import string import time +import uuid + +from loguru import logger from tests.api_server import HTTPBIN_SERVER, gen_md5, get_sign @@ -25,6 +28,26 @@ def get_default_request(): } +def setup_testcase(variables): + logger.info(f"setup_testcase, variables: {variables}") + variables["request_id_prefix"] = str(int(time.time())) + + +def teardown_testcase(): + logger.info(f"teardown_testcase.") + + +def setup_teststep(request, variables): + logger.info(f"setup_teststep, request: {request}, variables: {variables}") + request.setdefault("headers", {}) + request_id_prefix = variables["request_id_prefix"] + request["headers"]["HRUN-Request-ID"] = request_id_prefix + "-" + str(uuid.uuid4()) + + +def teardown_teststep(response): + logger.info(f"teardown_teststep, response status code: {response.status_code}") + + def sum_two(m, n): return m + n diff --git a/tests/httpbin/hooks.yml b/tests/httpbin/hooks.yml index 280803ce..30078928 100644 --- a/tests/httpbin/hooks.yml +++ b/tests/httpbin/hooks.yml @@ -9,6 +9,8 @@ config: teststeps: - name: headers + variables: + a: 123 request: url: /headers method: GET From e2b369b305e0fb9d9498c5c8cb95ea10459e52cf Mon Sep 17 00:00:00 2001 From: debugtalk Date: Sat, 18 Apr 2020 11:54:36 +0800 Subject: [PATCH 024/169] test: add testcase example, set & delete cookies --- .../set_delete_cookies.yml | 41 +++++++++++++++++++ examples/postman_echo/debugtalk.py | 5 +++ 2 files changed, 46 insertions(+) create mode 100644 examples/postman_echo/cookie_manipulation/set_delete_cookies.yml create mode 100644 examples/postman_echo/debugtalk.py diff --git a/examples/postman_echo/cookie_manipulation/set_delete_cookies.yml b/examples/postman_echo/cookie_manipulation/set_delete_cookies.yml new file mode 100644 index 00000000..f43116a9 --- /dev/null +++ b/examples/postman_echo/cookie_manipulation/set_delete_cookies.yml @@ -0,0 +1,41 @@ +config: + name: "set & delete cookies." + variables: + foo1: bar1 + foo2: bar2 + base_url: "https://postman-echo.com" + verify: False + export: ["cookie_foo1", "cookie_foo3"] + +teststeps: +- + name: set cookie foo1 & foo2 & foo3 + variables: + foo3: bar3 + request: + method: GET + url: /cookies/set + params: + foo1: bar111 + foo2: $foo2 + foo3: $foo3 + headers: + User-Agent: HttpRunner/${get_httprunner_version()} + extract: + cookie_foo1: $.cookies.foo1 + cookie_foo3: $.cookies.foo3 + validate: + - eq: ["status_code", 200] + - ne: ["$.cookies.foo3", "$foo3"] +- + name: delete cookie foo2 + request: + method: GET + url: /cookies/delete?foo2 + headers: + User-Agent: HttpRunner/${get_httprunner_version()} + validate: + - eq: ["status_code", 200] + - ne: ["$.cookies.foo1", "$foo1"] + - eq: ["$.cookies.foo1", "$cookie_foo1"] + - eq: ["$.cookies.foo3", "$cookie_foo3"] diff --git a/examples/postman_echo/debugtalk.py b/examples/postman_echo/debugtalk.py new file mode 100644 index 00000000..9ec3149b --- /dev/null +++ b/examples/postman_echo/debugtalk.py @@ -0,0 +1,5 @@ +from httprunner import __version__ + + +def get_httprunner_version(): + return __version__ From 5d8b09628ff150336106184e101c0b43713bfeea Mon Sep 17 00:00:00 2001 From: debugtalk Date: Sun, 19 Apr 2020 12:30:14 +0800 Subject: [PATCH 025/169] test: add testcase example, request methods testcase in hardcode --- .../postman_echo/request_methods/hardcode.yml | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 examples/postman_echo/request_methods/hardcode.yml diff --git a/examples/postman_echo/request_methods/hardcode.yml b/examples/postman_echo/request_methods/hardcode.yml new file mode 100644 index 00000000..6cb3fde1 --- /dev/null +++ b/examples/postman_echo/request_methods/hardcode.yml @@ -0,0 +1,51 @@ +config: + name: "request methods testcase in hardcode" + base_url: "https://postman-echo.com" + verify: False + +teststeps: +- + name: get with params + request: + method: GET + url: /get + params: + foo1: bar1 + foo2: bar2 + headers: + User-Agent: HttpRunner/3.0 + validate: + - eq: ["status_code", 200] +- + name: post raw text + request: + method: POST + url: /post + headers: + User-Agent: HttpRunner/3.0 + Content-Type: "text/plain" + data: "This is expected to be sent back as part of response body." + validate: + - eq: ["status_code", 200] +- + name: post form data + request: + method: POST + url: /post + headers: + User-Agent: HttpRunner/3.0 + Content-Type: "application/x-www-form-urlencoded" + data: "foo1=bar1&foo2=bar2" + validate: + - eq: ["status_code", 200] +- + name: put request + request: + method: PUT + url: /put + headers: + User-Agent: HttpRunner/3.0 + Content-Type: "text/plain" + data: "This is expected to be sent back as part of response body." + validate: + - eq: ["status_code", 200] \ No newline at end of file From f1fa62b2c456641179ac90451236709114474cdf Mon Sep 17 00:00:00 2001 From: debugtalk Date: Sun, 19 Apr 2020 12:47:28 +0800 Subject: [PATCH 026/169] init v3: make http request without validation --- examples/__init__.py | 0 examples/postman_echo/__init__.py | 0 .../postman_echo/request_methods/__init__.py | 0 .../postman_echo/request_methods/hardcode.py | 79 ++++++++ httprunner/v3/__init__.py | 0 httprunner/v3/exceptions/__init__.py | 81 ++++++++ httprunner/v3/parser/__init__.py | 178 ++++++++++++++++++ httprunner/v3/runner/__init__.py | 38 ++++ httprunner/v3/schema/__init__.py | 60 ++++++ 9 files changed, 436 insertions(+) create mode 100644 examples/__init__.py create mode 100644 examples/postman_echo/__init__.py create mode 100644 examples/postman_echo/request_methods/__init__.py create mode 100644 examples/postman_echo/request_methods/hardcode.py create mode 100644 httprunner/v3/__init__.py create mode 100644 httprunner/v3/exceptions/__init__.py create mode 100644 httprunner/v3/parser/__init__.py create mode 100644 httprunner/v3/runner/__init__.py create mode 100644 httprunner/v3/schema/__init__.py diff --git a/examples/__init__.py b/examples/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/postman_echo/__init__.py b/examples/postman_echo/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/postman_echo/request_methods/__init__.py b/examples/postman_echo/request_methods/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/postman_echo/request_methods/hardcode.py b/examples/postman_echo/request_methods/hardcode.py new file mode 100644 index 00000000..d1129ab6 --- /dev/null +++ b/examples/postman_echo/request_methods/hardcode.py @@ -0,0 +1,79 @@ +from httprunner.v3.runner import TestCaseRunner +from httprunner.v3.schema import TestsConfig, TestStep + + +class TestCaseRequestMethodsHardcode(TestCaseRunner): + config = TestsConfig(**{ + "name": "request methods testcase in hardcode", + "base_url": "https://postman-echo.com", + "verify": False + }) + + teststeps = [ + TestStep(**{ + "name": "get with params", + "request": { + "method": "GET", + "url": "/get", + "params": { + "foo1": "bar1", + "foo2": "bar2" + }, + "headers": { + "User-Agent": "HttpRunner/3.0" + } + }, + "validate": [ + {"eq": ["status_code", 200]} + ] + }), + TestStep(**{ + "name": "post raw text", + "request": { + "method": "POST", + "url": "/post", + "data": "This is expected to be sent back as part of response body.", + "headers": { + "User-Agent": "HttpRunner/3.0", + "Content-Type": "text/plain" + } + }, + "validate": [ + {"eq": ["status_code", 200]} + ] + }), + TestStep(**{ + "name": "post form data", + "request": { + "method": "POST", + "url": "/post", + "data": "foo1=bar1&foo2=bar2", + "headers": { + "User-Agent": "HttpRunner/3.0", + "Content-Type": "application/x-www-form-urlencoded" + } + }, + "validate": [ + {"eq": ["status_code", 200]} + ] + }), + TestStep(**{ + "name": "put request", + "request": { + "method": "PUT", + "url": "/put", + "data": "This is expected to be sent back as part of response body.", + "headers": { + "User-Agent": "HttpRunner/3.0", + "Content-Type": "text/plain" + } + }, + "validate": [ + {"eq": ["status_code", 200]} + ] + }) + ] + + +if __name__ == '__main__': + TestCaseRequestMethodsHardcode().run() diff --git a/httprunner/v3/__init__.py b/httprunner/v3/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/httprunner/v3/exceptions/__init__.py b/httprunner/v3/exceptions/__init__.py new file mode 100644 index 00000000..77d1be52 --- /dev/null +++ b/httprunner/v3/exceptions/__init__.py @@ -0,0 +1,81 @@ +""" failure type exceptions + these exceptions will mark test as failure +""" + + +class MyBaseFailure(Exception): + pass + + +class ParseTestsFailure(MyBaseFailure): + pass + + +class ValidationFailure(MyBaseFailure): + pass + + +class ExtractFailure(MyBaseFailure): + pass + + +class SetupHooksFailure(MyBaseFailure): + pass + + +class TeardownHooksFailure(MyBaseFailure): + pass + + +""" error type exceptions + these exceptions will mark test as error +""" + + +class MyBaseError(Exception): + pass + + +class FileFormatError(MyBaseError): + pass + + +class ParamsError(MyBaseError): + pass + + +class NotFoundError(MyBaseError): + pass + + +class FileNotFound(FileNotFoundError, NotFoundError): + pass + + +class FunctionNotFound(NotFoundError): + pass + + +class VariableNotFound(NotFoundError): + pass + + +class EnvNotFound(NotFoundError): + pass + + +class CSVNotFound(NotFoundError): + pass + + +class ApiNotFound(NotFoundError): + pass + + +class TestcaseNotFound(NotFoundError): + pass + + +class SummaryEmpty(MyBaseError): + """ test result summary data is empty + """ diff --git a/httprunner/v3/parser/__init__.py b/httprunner/v3/parser/__init__.py new file mode 100644 index 00000000..1a7803a1 --- /dev/null +++ b/httprunner/v3/parser/__init__.py @@ -0,0 +1,178 @@ +import re +from typing import Any, Set +from typing import Dict + +from httprunner.v3.exceptions import ParamsError + +absolute_http_url_regexp = re.compile(r"^https?://", re.I) + +# use $$ to escape $ notation +dolloar_regex_compile = re.compile(r"\$\$") +# variable notation, e.g. ${var} or $var +variable_regex_compile = re.compile(r"\$\{(\w+)\}|\$(\w+)") +# function notation, e.g. ${func1($var_1, $var_3)} +function_regex_compile = re.compile(r"\$\{(\w+)\(([\$\w\.\-/\s=,]*)\)\}") + + +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): + return path + elif base_url: + return "{}/{}".format(base_url.rstrip("/"), path.lstrip("/")) + else: + raise ParamsError("base url missed!") + + +def regex_findall_variables(content): + """ extract all variable names from content, which is in format $variable + + Args: + content (str): string content + + Returns: + list: variables list extracted from string content + + Examples: + >>> regex_findall_variables("$variable") + ["variable"] + + >>> regex_findall_variables("/blog/$postid") + ["postid"] + + >>> regex_findall_variables("/$var1/$var2") + ["var1", "var2"] + + >>> regex_findall_variables("abc") + [] + + """ + try: + vars_list = [] + for var_tuple in variable_regex_compile.findall(content): + vars_list.append( + var_tuple[0] or var_tuple[1] + ) + return vars_list + except TypeError: + return [] + + + +def extract_variables(content: Any) -> Set: + """ extract all variables in content recursively. + """ + if isinstance(content, (list, set, tuple)): + variables = set() + for item in content: + variables = variables | extract_variables(item) + return variables + + elif isinstance(content, dict): + variables = set() + for key, value in content.items(): + variables = variables | extract_variables(value) + return variables + + elif isinstance(content, str): + return set(regex_findall_variables(content)) + + return set() + + +def parse_string_variables(content, variables_mapping): + """ parse string content with variables mapping. + + Args: + content (str): string content to be parsed. + variables_mapping (dict): variables mapping. + + Returns: + str: parsed string content. + + Examples: + >>> content = "/api/users/$uid" + >>> variables_mapping = {"$uid": 1000} + >>> parse_string_variables(content, variables_mapping) + "/api/users/1000" + + """ + variables_list = extract_variables(content) + for variable_name in variables_list: + variable_value = variables_mapping[variable_name] + + # TODO: replace variable label from $var to {{var}} + if "${}".format(variable_name) == content: + # content is a variable + content = variable_value + else: + # content contains one or several variables + if not isinstance(variable_value, str): + variable_value = str(variable_value) + + content = content.replace( + "${}".format(variable_name), + variable_value, 1 + ) + + return content + + +def parse_content(content: Any, variables_mapping: Dict[str, Any] = None, functions_mapping=None): + """ parse content with evaluated variables mapping. + Notice: variables_mapping should not contain any variable or function. + """ + # TODO: refactor type check + if content is None or isinstance(content, (int, float, bool)): + return content + + elif isinstance(content, str): + # content is in string format here + variables_mapping = variables_mapping or {} + functions_mapping = functions_mapping or {} + 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) + + # replace variables with binding value + content = parse_string_variables(content, variables_mapping) + + return content + + elif isinstance(content, (list, set, tuple)): + return [ + parse_content(item, variables_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_content[parsed_key] = parsed_value + + return parsed_content + + return content + + +def parse_variables_mapping(variables_mapping: Dict[str, Any]): + + parsed_variables: Dict[str, Any] = {} + + while len(parsed_variables) != len(variables_mapping): + for var_name in variables_mapping: + + var_value = variables_mapping[var_name] + # variables = extract_variables(var_value) + + if var_name in parsed_variables: + continue + + parsed_value = parse_content(var_value, parsed_variables) + parsed_variables[var_name] = parsed_value + + return parsed_variables diff --git a/httprunner/v3/runner/__init__.py b/httprunner/v3/runner/__init__.py new file mode 100644 index 00000000..61638567 --- /dev/null +++ b/httprunner/v3/runner/__init__.py @@ -0,0 +1,38 @@ +from typing import List + +import requests + +from httprunner.v3.parser import build_url +from httprunner.v3.schema import TestsConfig, TestStep + + +class TestCaseRunner(object): + + config: TestsConfig = {} + teststeps: List[TestStep] = [] + session: requests.Session = None + + def with_session(self, s: requests.Session) -> "TestCaseRunner": + self.session = s + return self + + def with_variables(self, **variables) -> "TestCaseRunner": + self.config.variables.update(variables) + return self + + def run_step(self, step): + request_dict = step.request.dict() + + method = request_dict.pop("method") + url_path = request_dict.pop("url") + url = build_url(self.config.base_url, url_path) + + request_dict["json"] = request_dict.pop("req_json", {}) + + session = self.session or requests.Session() + resp = session.request(method, url, **request_dict) + + def run(self): + for step in self.teststeps: + step.variables.update(self.config.variables) + self.run_step(step) diff --git a/httprunner/v3/schema/__init__.py b/httprunner/v3/schema/__init__.py new file mode 100644 index 00000000..fcaac363 --- /dev/null +++ b/httprunner/v3/schema/__init__.py @@ -0,0 +1,60 @@ +from enum import Enum +from typing import Any +from typing import Dict, List, Text, Union + +from pydantic import BaseModel, Field +from pydantic import HttpUrl + +Name = Text +Url = Text +BaseUrl = Union[HttpUrl, Text] +Variables = Dict[Text, Any] +Headers = Dict[Text, Text] +Verify = bool +Hook = List[Text] +Export = List[Text] +Validate = List[Dict] +Env = Dict[Text, Any] + + +class MethodEnum(Text, Enum): + GET = 'GET' + POST = 'POST' + PUT = "PUT" + DELETE = "DELETE" + HEAD = "HEAD" + OPTIONS = "OPTIONS" + PATCH = "PATCH" + CONNECT = "CONNECT" + TRACE = "TRACE" + + +class TestsConfig(BaseModel): + name: Name + verify: Verify = False + base_url: BaseUrl = "" + variables: Variables = {} + setup_hooks: Hook = [] + teardown_hooks: Hook = [] + export: Export = [] + + +class Request(BaseModel): + method: MethodEnum = MethodEnum.GET + url: Url + params: Dict[Text, Text] = {} + headers: Headers = {} + req_json: Dict = Field({}, alias="json") + data: Union[Text, Dict[Text, Any]] = "" + cookies: Dict[Text, Text] = {} + timeout: int = 120 + allow_redirects: bool = True + verify: Verify = False + + +class TestStep(BaseModel): + name: Name + request: Request + variables: Variables = {} + extract: Union[Dict[Text, Text], List[Text]] = {} + validation: Validate = Field([], alias="validate") From 9aef959d7c55d07b4f9cd31d70858d5a3f667064 Mon Sep 17 00:00:00 2001 From: debugtalk Date: Sun, 19 Apr 2020 17:37:23 +0800 Subject: [PATCH 027/169] change v3: add basic validation --- .../{hardcode.py => hardcode_test.py} | 3 +- .../v3/{parser/__init__.py => parser.py} | 7 +- httprunner/v3/response.py | 13 ++ .../v3/{runner/__init__.py => runner.py} | 25 ++- httprunner/v3/validator.py | 145 ++++++++++++++++++ poetry.lock | 14 +- pyproject.toml | 1 + 7 files changed, 200 insertions(+), 8 deletions(-) rename examples/postman_echo/request_methods/{hardcode.py => hardcode_test.py} (95%) rename httprunner/v3/{parser/__init__.py => parser.py} (97%) create mode 100644 httprunner/v3/response.py rename httprunner/v3/{runner/__init__.py => runner.py} (62%) create mode 100644 httprunner/v3/validator.py diff --git a/examples/postman_echo/request_methods/hardcode.py b/examples/postman_echo/request_methods/hardcode_test.py similarity index 95% rename from examples/postman_echo/request_methods/hardcode.py rename to examples/postman_echo/request_methods/hardcode_test.py index d1129ab6..52f7899b 100644 --- a/examples/postman_echo/request_methods/hardcode.py +++ b/examples/postman_echo/request_methods/hardcode_test.py @@ -24,7 +24,8 @@ class TestCaseRequestMethodsHardcode(TestCaseRunner): } }, "validate": [ - {"eq": ["status_code", 200]} + {"eq": ["status_code", 200]}, + {"eq": ["headers.Server", "nginx"]} ] }), TestStep(**{ diff --git a/httprunner/v3/parser/__init__.py b/httprunner/v3/parser.py similarity index 97% rename from httprunner/v3/parser/__init__.py rename to httprunner/v3/parser.py index 1a7803a1..c1ed97a9 100644 --- a/httprunner/v3/parser/__init__.py +++ b/httprunner/v3/parser.py @@ -1,8 +1,8 @@ import re -from typing import Any, Set +from typing import Any, Set, Text from typing import Dict -from httprunner.v3.exceptions import ParamsError +from httprunner.v3 import exceptions absolute_http_url_regexp = re.compile(r"^https?://", re.I) @@ -21,7 +21,7 @@ def build_url(base_url, path): elif base_url: return "{}/{}".format(base_url.rstrip("/"), path.lstrip("/")) else: - raise ParamsError("base url missed!") + raise exceptions.ParamsError("base url missed!") def regex_findall_variables(content): @@ -58,7 +58,6 @@ def regex_findall_variables(content): return [] - def extract_variables(content: Any) -> Set: """ extract all variables in content recursively. """ diff --git a/httprunner/v3/response.py b/httprunner/v3/response.py new file mode 100644 index 00000000..422218bf --- /dev/null +++ b/httprunner/v3/response.py @@ -0,0 +1,13 @@ +import requests + + +class ResponseObject(object): + + def __init__(self, resp_obj: requests.Response): + """ initialize with a requests.Response object + + Args: + resp_obj (instance): requests.Response instance + + """ + self.obj = resp_obj diff --git a/httprunner/v3/runner/__init__.py b/httprunner/v3/runner.py similarity index 62% rename from httprunner/v3/runner/__init__.py rename to httprunner/v3/runner.py index 61638567..7d4a12f2 100644 --- a/httprunner/v3/runner/__init__.py +++ b/httprunner/v3/runner.py @@ -2,8 +2,12 @@ from typing import List import requests +from loguru import logger + from httprunner.v3.parser import build_url from httprunner.v3.schema import TestsConfig, TestStep +from httprunner.v3.validator import Validator +from httprunner.v3.response import ResponseObject class TestCaseRunner(object): @@ -21,18 +25,35 @@ class TestCaseRunner(object): return self def run_step(self, step): - request_dict = step.request.dict() + logger.info(f"run step: {step.name}") + # prepare arguments + request_dict = step.request.dict() method = request_dict.pop("method") url_path = request_dict.pop("url") url = build_url(self.config.base_url, url_path) request_dict["json"] = request_dict.pop("req_json", {}) + logger.info(f"{method} {url}") + logger.debug(f"request kwargs(raw): {request_dict}") + + # request session = self.session or requests.Session() resp = session.request(method, url, **request_dict) - def run(self): + # validate + resp_obj = ResponseObject(resp) + validator = Validator(resp_obj) + validators = step.validation + validator.validate(validators) + + def test_start(self): + """main entrance""" for step in self.teststeps: step.variables.update(self.config.variables) self.run_step(step) + + def run(self): + """main entrance alias for test_start""" + return self.test_start() diff --git a/httprunner/v3/validator.py b/httprunner/v3/validator.py new file mode 100644 index 00000000..04940b13 --- /dev/null +++ b/httprunner/v3/validator.py @@ -0,0 +1,145 @@ +from typing import Text + +import jmespath +from loguru import logger + +from httprunner.v3.exceptions import ParamsError, ValidationFailure +from httprunner.v3.response import ResponseObject + + +def get_uniform_comparator(comparator: Text): + """ convert comparator alias to uniform name + """ + if comparator in ["eq", "equals", "==", "is"]: + return "equals" + elif comparator in ["lt", "less_than"]: + return "less_than" + elif comparator in ["le", "less_than_or_equals"]: + return "less_than_or_equals" + elif comparator in ["gt", "greater_than"]: + return "greater_than" + elif comparator in ["ge", "greater_than_or_equals"]: + return "greater_than_or_equals" + elif comparator in ["ne", "not_equals"]: + return "not_equals" + elif comparator in ["str_eq", "string_equals"]: + return "string_equals" + elif comparator in ["len_eq", "length_equals", "count_eq"]: + return "length_equals" + elif comparator in ["len_gt", "count_gt", "length_greater_than", "count_greater_than"]: + return "length_greater_than" + elif comparator in ["len_ge", "count_ge", "length_greater_than_or_equals", + "count_greater_than_or_equals"]: + return "length_greater_than_or_equals" + elif comparator in ["len_lt", "count_lt", "length_less_than", "count_less_than"]: + return "length_less_than" + elif comparator in ["len_le", "count_le", "length_less_than_or_equals", + "count_less_than_or_equals"]: + return "length_less_than_or_equals" + else: + return comparator + + +def uniform_validator(validator): + """ unify validator + + Args: + validator (dict): validator maybe in two formats: + + format1: this is kept for compatiblity with the previous versions. + {"check": "status_code", "assert": "eq", "expect": 201} + {"check": "$resp_body_success", "assert": "eq", "expect": True} + format2: recommended new version, {assert: [check_item, expected_value]} + {'eq': ['status_code', 201]} + {'eq': ['$resp_body_success', True]} + + Returns + dict: validator info + + { + "check": "status_code", + "expect": 201, + "assert": "equals" + } + + """ + if not isinstance(validator, dict): + raise ParamsError(f"invalid validator: {validator}") + + if "check" in validator and "expect" in validator: + # format1 + check_item = validator["check"] + expect_value = validator["expect"] + comparator = validator.get("comparator", "eq") + + elif len(validator) == 1: + # format2 + comparator = list(validator.keys())[0] + compare_values = validator[comparator] + + if not isinstance(compare_values, list) or len(compare_values) != 2: + raise ParamsError(f"invalid validator: {validator}") + + check_item, expect_value = compare_values + + else: + raise ParamsError(f"invalid validator: {validator}") + + # uniform comparator, e.g. lt => less_than, eq => equals + assert_method = get_uniform_comparator(comparator) + + return { + "check": check_item, + "expect": expect_value, + "assert": assert_method + } + + +class AssertMethods(object): + + @staticmethod + def equals(actual_value, expect_value): + assert actual_value == expect_value + + @staticmethod + def less_than(actual_value, expect_value): + assert actual_value < expect_value + + @staticmethod + def greater_than(actual_value, expect_value): + assert actual_value > expect_value + + +class Validator(object): + + def __init__(self, resp_obj: ResponseObject): + self.resp_meta = { + "status_code": resp_obj.obj.status_code, + "headers": resp_obj.obj.headers, + "body": resp_obj.obj.json() + } + + def validate(self, validators): + + for v in validators: + u_validator = uniform_validator(v) + field = u_validator["check"] + assert_method = u_validator["assert"] + expect_value = u_validator["expect"] + actual_value = jmespath.search(field, self.resp_meta) + + msg = f"assert {field} {assert_method} {expect_value}" + + try: + assert_func = getattr(AssertMethods, assert_method) + except AttributeError: + raise ParamsError(f"Assert Method not supported: {assert_method}") + + try: + assert_func(actual_value, expect_value) + msg += " - success" + logger.info(msg) + except AssertionError: + msg += " - fail" + logger.error(msg) + raise ValidationFailure(f"assert {field}: {actual_value} {assert_method} {expect_value}") diff --git a/poetry.lock b/poetry.lock index 3dfab901..9d54bc93 100644 --- a/poetry.lock +++ b/poetry.lock @@ -173,6 +173,14 @@ MarkupSafe = ">=0.23" [package.extras] i18n = ["Babel (>=0.8)"] +[[package]] +category = "main" +description = "JSON Matching Expressions" +name = "jmespath" +optional = false +python-versions = "*" +version = "0.9.5" + [[package]] category = "main" description = "An XPath for JSON" @@ -345,7 +353,7 @@ version = "1.0.1" dev = ["pytest (>=4.6.2)", "black (>=19.3b0)"] [metadata] -content-hash = "e1204ede1ab227bc33783b362d866c2a0b1fb8faba283216b2973e2261b0b966" +content-hash = "85e388b2b80681f40a72f5ec0a1746a5a7e834c63fbfc9e2a07058bebb6f6c29" python-versions = "^3.6" [metadata.files] @@ -470,6 +478,10 @@ jinja2 = [ {file = "Jinja2-2.10.3-py2.py3-none-any.whl", hash = "sha256:74320bb91f31270f9551d46522e33af46a80c3d619f4a4bf42b3164d30b5911f"}, {file = "Jinja2-2.10.3.tar.gz", hash = "sha256:9fe95f19286cfefaa917656583d020be14e7859c6b0252588391e47db34527de"}, ] +jmespath = [ + {file = "jmespath-0.9.5-py2.py3-none-any.whl", hash = "sha256:695cb76fa78a10663425d5b73ddc5714eb711157e52704d69be03b1a02ba4fec"}, + {file = "jmespath-0.9.5.tar.gz", hash = "sha256:cca55c8d153173e21baa59983015ad0daf603f9cb799904ff057bfb8ff8dc2d9"}, +] jsonpath = [ {file = "jsonpath-0.82.tar.gz", hash = "sha256:46d3fd2016cd5b842283d547877a02c418a0fe9aa7a6b0ae344115a2c990fef4"}, ] diff --git a/pyproject.toml b/pyproject.toml index 3de0634c..9c47e387 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,7 @@ filetype = "^1.0.5" jsonpath = "^0.82" pydantic = "^1.4" loguru = "^0.4.1" +jmespath = "^0.9.5" [tool.poetry.dev-dependencies] flask = "<1.0.0" From 1ce09d223cbd2c04b28ad7c0989b639dd344232a Mon Sep 17 00:00:00 2001 From: debugtalk Date: Sun, 19 Apr 2020 18:19:01 +0800 Subject: [PATCH 028/169] change v3: add basic extract --- .../request_methods/hardcode_test.py | 3 ++ httprunner/v3/response.py | 50 ++++++++++++++++++- httprunner/v3/runner.py | 19 ++++--- httprunner/v3/schema/__init__.py | 2 +- httprunner/v3/validator.py | 41 +-------------- 5 files changed, 66 insertions(+), 49 deletions(-) diff --git a/examples/postman_echo/request_methods/hardcode_test.py b/examples/postman_echo/request_methods/hardcode_test.py index 52f7899b..0c269b0b 100644 --- a/examples/postman_echo/request_methods/hardcode_test.py +++ b/examples/postman_echo/request_methods/hardcode_test.py @@ -23,6 +23,9 @@ class TestCaseRequestMethodsHardcode(TestCaseRunner): "User-Agent": "HttpRunner/3.0" } }, + "extract": { + "server": "headers.Server" + }, "validate": [ {"eq": ["status_code", 200]}, {"eq": ["headers.Server", "nginx"]} diff --git a/httprunner/v3/response.py b/httprunner/v3/response.py index 422218bf..5dbf9577 100644 --- a/httprunner/v3/response.py +++ b/httprunner/v3/response.py @@ -1,4 +1,11 @@ +from typing import Dict, Text, Any + +import jmespath import requests +from loguru import logger + +from httprunner.v3.exceptions import ParamsError, ValidationFailure +from httprunner.v3.validator import uniform_validator, AssertMethods class ResponseObject(object): @@ -10,4 +17,45 @@ class ResponseObject(object): resp_obj (instance): requests.Response instance """ - self.obj = resp_obj + self.resp_obj_meta = { + "status_code": resp_obj.status_code, + "headers": resp_obj.headers, + "body": resp_obj.json() + } + + def validate(self, validators): + + for v in validators: + u_validator = uniform_validator(v) + field = u_validator["check"] + assert_method = u_validator["assert"] + expect_value = u_validator["expect"] + actual_value = jmespath.search(field, self.resp_obj_meta) + + msg = f"assert {field} {assert_method} {expect_value}" + + try: + assert_func = getattr(AssertMethods, assert_method) + except AttributeError: + raise ParamsError(f"Assert Method not supported: {assert_method}") + + try: + assert_func(actual_value, expect_value) + msg += " - success" + logger.info(msg) + except AssertionError: + msg += " - fail" + logger.error(msg) + raise ValidationFailure(f"assert {field}: {actual_value} {assert_method} {expect_value}") + + def extract(self, extractors: Dict[Text, Text]) -> Dict[Text, Any]: + if not extractors: + return {} + + extract_mapping = {} + for key, field in extractors.items(): + field_value = jmespath.search(field, self.resp_obj_meta) + extract_mapping[key] = field_value + + logger.info(f"extract mapping: {extract_mapping}") + return extract_mapping diff --git a/httprunner/v3/runner.py b/httprunner/v3/runner.py index 7d4a12f2..49fb6303 100644 --- a/httprunner/v3/runner.py +++ b/httprunner/v3/runner.py @@ -1,13 +1,11 @@ from typing import List import requests - from loguru import logger from httprunner.v3.parser import build_url -from httprunner.v3.schema import TestsConfig, TestStep -from httprunner.v3.validator import Validator from httprunner.v3.response import ResponseObject +from httprunner.v3.schema import TestsConfig, TestStep class TestCaseRunner(object): @@ -41,18 +39,25 @@ class TestCaseRunner(object): # request session = self.session or requests.Session() resp = session.request(method, url, **request_dict) + resp_obj = ResponseObject(resp) # validate - resp_obj = ResponseObject(resp) - validator = Validator(resp_obj) validators = step.validation - validator.validate(validators) + resp_obj.validate(validators) + + # extract + extractors = step.extract + extract_mapping = resp_obj.extract(extractors) + return extract_mapping def test_start(self): """main entrance""" + session_variables = {} for step in self.teststeps: step.variables.update(self.config.variables) - self.run_step(step) + step.variables.update(session_variables) + extract_mapping = self.run_step(step) + session_variables.update(extract_mapping) def run(self): """main entrance alias for test_start""" diff --git a/httprunner/v3/schema/__init__.py b/httprunner/v3/schema/__init__.py index fcaac363..778a217a 100644 --- a/httprunner/v3/schema/__init__.py +++ b/httprunner/v3/schema/__init__.py @@ -56,5 +56,5 @@ class TestStep(BaseModel): name: Name request: Request variables: Variables = {} - extract: Union[Dict[Text, Text], List[Text]] = {} + extract: Dict[Text, Text] = {} validation: Validate = Field([], alias="validate") diff --git a/httprunner/v3/validator.py b/httprunner/v3/validator.py index 04940b13..cc0b39b9 100644 --- a/httprunner/v3/validator.py +++ b/httprunner/v3/validator.py @@ -1,10 +1,6 @@ from typing import Text -import jmespath -from loguru import logger - -from httprunner.v3.exceptions import ParamsError, ValidationFailure -from httprunner.v3.response import ResponseObject +from httprunner.v3.exceptions import ParamsError def get_uniform_comparator(comparator: Text): @@ -108,38 +104,3 @@ class AssertMethods(object): @staticmethod def greater_than(actual_value, expect_value): assert actual_value > expect_value - - -class Validator(object): - - def __init__(self, resp_obj: ResponseObject): - self.resp_meta = { - "status_code": resp_obj.obj.status_code, - "headers": resp_obj.obj.headers, - "body": resp_obj.obj.json() - } - - def validate(self, validators): - - for v in validators: - u_validator = uniform_validator(v) - field = u_validator["check"] - assert_method = u_validator["assert"] - expect_value = u_validator["expect"] - actual_value = jmespath.search(field, self.resp_meta) - - msg = f"assert {field} {assert_method} {expect_value}" - - try: - assert_func = getattr(AssertMethods, assert_method) - except AttributeError: - raise ParamsError(f"Assert Method not supported: {assert_method}") - - try: - assert_func(actual_value, expect_value) - msg += " - success" - logger.info(msg) - except AssertionError: - msg += " - fail" - logger.error(msg) - raise ValidationFailure(f"assert {field}: {actual_value} {assert_method} {expect_value}") From 3051a60731834bed093914fa9c7a47dbf2a2d195 Mon Sep 17 00:00:00 2001 From: debugtalk Date: Mon, 20 Apr 2020 12:03:08 +0800 Subject: [PATCH 029/169] v3 feat: support teststep variables --- .../request_methods/with_functions.yml | 0 .../request_methods/with_variables.yml | 53 ++++++++++++ .../request_methods/with_variables_test.py | 80 +++++++++++++++++++ httprunner/v3/parser.py | 4 +- httprunner/v3/runner.py | 17 ++-- 5 files changed, 145 insertions(+), 9 deletions(-) create mode 100644 examples/postman_echo/request_methods/with_functions.yml create mode 100644 examples/postman_echo/request_methods/with_variables.yml create mode 100644 examples/postman_echo/request_methods/with_variables_test.py diff --git a/examples/postman_echo/request_methods/with_functions.yml b/examples/postman_echo/request_methods/with_functions.yml new file mode 100644 index 00000000..e69de29b diff --git a/examples/postman_echo/request_methods/with_variables.yml b/examples/postman_echo/request_methods/with_variables.yml new file mode 100644 index 00000000..050d7910 --- /dev/null +++ b/examples/postman_echo/request_methods/with_variables.yml @@ -0,0 +1,53 @@ +config: + name: "request methods testcase with variables" + base_url: "https://postman-echo.com" + verify: False + +teststeps: +- + name: get with params + variables: + foo1: bar1 + foo2: bar2 + request: + method: GET + url: /get + params: + foo1: $foo1 + foo2: $foo2 + headers: + User-Agent: HttpRunner/3.0 + validate: + - eq: ["status_code", 200] + - eq: ["body.args.foo1", "bar1"] + - eq: ["body.args.foo2", "bar2"] +- + name: post raw text + variables: + foo1: "hello world" + request: + method: POST + url: /post + headers: + User-Agent: HttpRunner/3.0 + Content-Type: "text/plain" + data: "This is expected to be sent back as part of response body: $foo1." + validate: + - eq: ["status_code", 200] + - eq: ["body.data", "This is expected to be sent back as part of response body: hello world."] +- + name: post form data + variables: + foo1: bar1 + foo2: bar2 + request: + method: POST + url: /post + headers: + User-Agent: HttpRunner/3.0 + Content-Type: "application/x-www-form-urlencoded" + data: "foo1=$foo1&foo2=$foo2" + validate: + - eq: ["status_code", 200] + - eq: ["body.form.foo1", "bar1"] + - eq: ["body.form.foo2", "bar2"] diff --git a/examples/postman_echo/request_methods/with_variables_test.py b/examples/postman_echo/request_methods/with_variables_test.py new file mode 100644 index 00000000..cdec850e --- /dev/null +++ b/examples/postman_echo/request_methods/with_variables_test.py @@ -0,0 +1,80 @@ +from httprunner.v3.runner import TestCaseRunner +from httprunner.v3.schema import TestsConfig, TestStep + + +class TestCaseRequestMethodsWithVariables(TestCaseRunner): + config = TestsConfig(**{ + "name": "request methods testcase with variables", + "base_url": "https://postman-echo.com", + "verify": False + }) + + teststeps = [ + TestStep(**{ + "name": "get with params", + "variables": { + "foo1": "bar1", + "foo2": "bar2" + }, + "request": { + "method": "GET", + "url": "/get", + "params": { + "foo1": "$foo1", + "foo2": "$foo2" + }, + "headers": { + "User-Agent": "HttpRunner/3.0" + } + }, + "validate": [ + {"eq": ["status_code", 200]}, + {"eq": ["body.args.foo1", "bar1"]}, + {"eq": ["body.args.foo2", "bar2"]} + ] + }), + TestStep(**{ + "name": "post raw text", + "variables": { + "foo1": "hello world" + }, + "request": { + "method": "POST", + "url": "/post", + "data": "This is expected to be sent back as part of response body: $foo1.", + "headers": { + "User-Agent": "HttpRunner/3.0", + "Content-Type": "text/plain" + } + }, + "validate": [ + {"eq": ["status_code", 200]}, + {"eq": ["body.data", "This is expected to be sent back as part of response body: hello world."]}, + ] + }), + TestStep(**{ + "name": "post form data", + "variables": { + "foo1": "bar1", + "foo2": "bar2" + }, + "request": { + "method": "POST", + "url": "/post", + "data": "foo1=$foo1&foo2=$foo2", + "headers": { + "User-Agent": "HttpRunner/3.0", + "Content-Type": "application/x-www-form-urlencoded" + } + }, + "validate": [ + {"eq": ["status_code", 200]}, + {"eq": ["body.form.foo1", "bar1"]}, + {"eq": ["body.form.foo2", "bar2"]} + ] + }) + ] + + +if __name__ == '__main__': + TestCaseRequestMethodsWithVariables().run() diff --git a/httprunner/v3/parser.py b/httprunner/v3/parser.py index c1ed97a9..c3365490 100644 --- a/httprunner/v3/parser.py +++ b/httprunner/v3/parser.py @@ -101,7 +101,7 @@ def parse_string_variables(content, variables_mapping): variable_value = variables_mapping[variable_name] # TODO: replace variable label from $var to {{var}} - if "${}".format(variable_name) == content: + if f"${variable_name}" == content: # content is a variable content = variable_value else: @@ -110,7 +110,7 @@ def parse_string_variables(content, variables_mapping): variable_value = str(variable_value) content = content.replace( - "${}".format(variable_name), + f"${variable_name}", variable_value, 1 ) diff --git a/httprunner/v3/runner.py b/httprunner/v3/runner.py index 49fb6303..fc237f71 100644 --- a/httprunner/v3/runner.py +++ b/httprunner/v3/runner.py @@ -3,7 +3,7 @@ from typing import List import requests from loguru import logger -from httprunner.v3.parser import build_url +from httprunner.v3.parser import build_url, parse_content from httprunner.v3.response import ResponseObject from httprunner.v3.schema import TestsConfig, TestStep @@ -25,20 +25,23 @@ class TestCaseRunner(object): def run_step(self, step): logger.info(f"run step: {step.name}") - # prepare arguments + # parse request_dict = step.request.dict() - method = request_dict.pop("method") - url_path = request_dict.pop("url") + parsed_request_dict = parse_content(request_dict, step.variables) + + # prepare arguments + method = parsed_request_dict.pop("method") + url_path = parsed_request_dict.pop("url") url = build_url(self.config.base_url, url_path) - request_dict["json"] = 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): {request_dict}") + logger.debug(f"request kwargs(raw): {parsed_request_dict}") # request session = self.session or requests.Session() - resp = session.request(method, url, **request_dict) + resp = session.request(method, url, **parsed_request_dict) resp_obj = ResponseObject(resp) # validate From 39b8f74b2745d4cdbd47a2bc01494d1f43015dd2 Mon Sep 17 00:00:00 2001 From: debugtalk Date: Mon, 20 Apr 2020 12:49:53 +0800 Subject: [PATCH 030/169] v3 test example: support session variables --- .../postman_echo/request_methods/with_functions.yml | 0 .../postman_echo/request_methods/with_variables.yml | 8 +++++--- .../request_methods/with_variables_test.py | 11 +++++++---- 3 files changed, 12 insertions(+), 7 deletions(-) delete mode 100644 examples/postman_echo/request_methods/with_functions.yml diff --git a/examples/postman_echo/request_methods/with_functions.yml b/examples/postman_echo/request_methods/with_functions.yml deleted file mode 100644 index e69de29b..00000000 diff --git a/examples/postman_echo/request_methods/with_variables.yml b/examples/postman_echo/request_methods/with_variables.yml index 050d7910..008dd007 100644 --- a/examples/postman_echo/request_methods/with_variables.yml +++ b/examples/postman_echo/request_methods/with_variables.yml @@ -1,5 +1,7 @@ config: name: "request methods testcase with variables" + variables: + foo1: session_bar1 base_url: "https://postman-echo.com" verify: False @@ -19,7 +21,7 @@ teststeps: User-Agent: HttpRunner/3.0 validate: - eq: ["status_code", 200] - - eq: ["body.args.foo1", "bar1"] + - eq: ["body.args.foo1", "session_bar1"] - eq: ["body.args.foo2", "bar2"] - name: post raw text @@ -34,7 +36,7 @@ teststeps: data: "This is expected to be sent back as part of response body: $foo1." validate: - eq: ["status_code", 200] - - eq: ["body.data", "This is expected to be sent back as part of response body: hello world."] + - eq: ["body.data", "This is expected to be sent back as part of response body: session_bar1."] - name: post form data variables: @@ -49,5 +51,5 @@ teststeps: data: "foo1=$foo1&foo2=$foo2" validate: - eq: ["status_code", 200] - - eq: ["body.form.foo1", "bar1"] + - eq: ["body.form.foo1", "session_bar1"] - eq: ["body.form.foo2", "bar2"] diff --git a/examples/postman_echo/request_methods/with_variables_test.py b/examples/postman_echo/request_methods/with_variables_test.py index cdec850e..c68c99fa 100644 --- a/examples/postman_echo/request_methods/with_variables_test.py +++ b/examples/postman_echo/request_methods/with_variables_test.py @@ -5,6 +5,9 @@ from httprunner.v3.schema import TestsConfig, TestStep class TestCaseRequestMethodsWithVariables(TestCaseRunner): config = TestsConfig(**{ "name": "request methods testcase with variables", + "variables": { + "foo1": "session_bar1" + }, "base_url": "https://postman-echo.com", "verify": False }) @@ -29,7 +32,7 @@ class TestCaseRequestMethodsWithVariables(TestCaseRunner): }, "validate": [ {"eq": ["status_code", 200]}, - {"eq": ["body.args.foo1", "bar1"]}, + {"eq": ["body.args.foo1", "session_bar1"]}, {"eq": ["body.args.foo2", "bar2"]} ] }), @@ -49,13 +52,13 @@ class TestCaseRequestMethodsWithVariables(TestCaseRunner): }, "validate": [ {"eq": ["status_code", 200]}, - {"eq": ["body.data", "This is expected to be sent back as part of response body: hello world."]}, + {"eq": ["body.data", "This is expected to be sent back as part of response body: session_bar1."]}, ] }), TestStep(**{ "name": "post form data", "variables": { - "foo1": "bar1", + "foo1": "session_bar1", "foo2": "bar2" }, "request": { @@ -69,7 +72,7 @@ class TestCaseRequestMethodsWithVariables(TestCaseRunner): }, "validate": [ {"eq": ["status_code", 200]}, - {"eq": ["body.form.foo1", "bar1"]}, + {"eq": ["body.form.foo1", "session_bar1"]}, {"eq": ["body.form.foo2", "bar2"]} ] }) From bd7b84d42cb940d315896476cc836e5aed70a142 Mon Sep 17 00:00:00 2001 From: debugtalk Date: Mon, 20 Apr 2020 13:26:40 +0800 Subject: [PATCH 031/169] v3 feat: support extract session variables --- .../request_methods/with_variables.yml | 11 ++++++---- .../request_methods/with_variables_test.py | 17 +++++++++----- httprunner/v3/parser.py | 22 +++++++++++++------ httprunner/v3/runner.py | 8 ++++++- 4 files changed, 41 insertions(+), 17 deletions(-) diff --git a/examples/postman_echo/request_methods/with_variables.yml b/examples/postman_echo/request_methods/with_variables.yml index 008dd007..625e240f 100644 --- a/examples/postman_echo/request_methods/with_variables.yml +++ b/examples/postman_echo/request_methods/with_variables.yml @@ -10,7 +10,7 @@ teststeps: name: get with params variables: foo1: bar1 - foo2: bar2 + foo2: session_bar2 request: method: GET url: /get @@ -19,24 +19,27 @@ teststeps: foo2: $foo2 headers: User-Agent: HttpRunner/3.0 + extract: + session_foo2: "body.args.foo2" validate: - eq: ["status_code", 200] - eq: ["body.args.foo1", "session_bar1"] - - eq: ["body.args.foo2", "bar2"] + - eq: ["body.args.foo2", "session_bar2"] - name: post raw text variables: foo1: "hello world" + foo3: "$session_foo2" request: method: POST url: /post headers: User-Agent: HttpRunner/3.0 Content-Type: "text/plain" - data: "This is expected to be sent back as part of response body: $foo1." + 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."] + - eq: ["body.data", "This is expected to be sent back as part of response body: session_bar1-session_bar2."] - name: post form data variables: diff --git a/examples/postman_echo/request_methods/with_variables_test.py b/examples/postman_echo/request_methods/with_variables_test.py index c68c99fa..df29953f 100644 --- a/examples/postman_echo/request_methods/with_variables_test.py +++ b/examples/postman_echo/request_methods/with_variables_test.py @@ -17,7 +17,7 @@ class TestCaseRequestMethodsWithVariables(TestCaseRunner): "name": "get with params", "variables": { "foo1": "bar1", - "foo2": "bar2" + "foo2": "session_bar2" }, "request": { "method": "GET", @@ -30,21 +30,25 @@ class TestCaseRequestMethodsWithVariables(TestCaseRunner): "User-Agent": "HttpRunner/3.0" } }, + "extract": { + "session_foo2": "body.args.foo2" + }, "validate": [ {"eq": ["status_code", 200]}, {"eq": ["body.args.foo1", "session_bar1"]}, - {"eq": ["body.args.foo2", "bar2"]} + {"eq": ["body.args.foo2", "session_bar2"]} ] }), TestStep(**{ "name": "post raw text", "variables": { - "foo1": "hello world" + "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.", + "data": "This is expected to be sent back as part of response body: $foo1-$foo3.", "headers": { "User-Agent": "HttpRunner/3.0", "Content-Type": "text/plain" @@ -52,7 +56,10 @@ class TestCaseRequestMethodsWithVariables(TestCaseRunner): }, "validate": [ {"eq": ["status_code", 200]}, - {"eq": ["body.data", "This is expected to be sent back as part of response body: session_bar1."]}, + {"eq": [ + "body.data", + "This is expected to be sent back as part of response body: session_bar1-session_bar2." + ]}, ] }), TestStep(**{ diff --git a/httprunner/v3/parser.py b/httprunner/v3/parser.py index c3365490..9e1b2ad3 100644 --- a/httprunner/v3/parser.py +++ b/httprunner/v3/parser.py @@ -3,6 +3,7 @@ from typing import Any, Set, Text from typing import Dict from httprunner.v3 import exceptions +from httprunner.v3.exceptions import VariableNotFound absolute_http_url_regexp = re.compile(r"^https?://", re.I) @@ -98,7 +99,10 @@ def parse_string_variables(content, variables_mapping): """ variables_list = extract_variables(content) for variable_name in variables_list: - variable_value = variables_mapping[variable_name] + try: + variable_value = variables_mapping[variable_name] + except KeyError: + raise VariableNotFound(f"{variable_name} not in {variables_mapping}") # TODO: replace variable label from $var to {{var}} if f"${variable_name}" == content: @@ -158,20 +162,24 @@ def parse_content(content: Any, variables_mapping: Dict[str, Any] = None, functi return content -def parse_variables_mapping(variables_mapping: Dict[str, Any]): +def parse_variables_mapping(variables_mapping: Dict[Text, Any]) -> Dict[Text, Any]: - parsed_variables: Dict[str, Any] = {} + parsed_variables: Dict[Text, Any] = {} while len(parsed_variables) != len(variables_mapping): for var_name in variables_mapping: - var_value = variables_mapping[var_name] - # variables = extract_variables(var_value) - if var_name in parsed_variables: continue - parsed_value = parse_content(var_value, parsed_variables) + var_value = variables_mapping[var_name] + # variables = extract_variables(var_value) + + try: + parsed_value = parse_content(var_value, parsed_variables) + except VariableNotFound: + continue + parsed_variables[var_name] = parsed_value return parsed_variables diff --git a/httprunner/v3/runner.py b/httprunner/v3/runner.py index fc237f71..12d28059 100644 --- a/httprunner/v3/runner.py +++ b/httprunner/v3/runner.py @@ -3,7 +3,7 @@ from typing import List import requests from loguru import logger -from httprunner.v3.parser import build_url, parse_content +from httprunner.v3.parser import build_url, parse_content, parse_variables_mapping from httprunner.v3.response import ResponseObject from httprunner.v3.schema import TestsConfig, TestStep @@ -57,9 +57,15 @@ class TestCaseRunner(object): """main entrance""" session_variables = {} for step in self.teststeps: + # update with config variables step.variables.update(self.config.variables) + # update with session variables extracted from former step step.variables.update(session_variables) + # parse variables + step.variables = parse_variables_mapping(step.variables) + # run step extract_mapping = self.run_step(step) + # save extracted variables to session variables session_variables.update(extract_mapping) def run(self): From 4d69e0c8588e71f1739e2dd1183ab3438d4b8dae Mon Sep 17 00:00:00 2001 From: debugtalk Date: Mon, 20 Apr 2020 15:44:26 +0800 Subject: [PATCH 032/169] fix parser: check if reference variable itself check if reference variable not in variables_mapping --- httprunner/v3/parser.py | 20 +++++++++++++++++++- httprunner/v3/parser_test.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 httprunner/v3/parser_test.py diff --git a/httprunner/v3/parser.py b/httprunner/v3/parser.py index 9e1b2ad3..f6ff3a58 100644 --- a/httprunner/v3/parser.py +++ b/httprunner/v3/parser.py @@ -173,7 +173,25 @@ def parse_variables_mapping(variables_mapping: Dict[Text, Any]) -> Dict[Text, An continue var_value = variables_mapping[var_name] - # variables = extract_variables(var_value) + variables = extract_variables(var_value) + + # check if reference variable itself + if var_name in variables: + # e.g. + # variables_mapping = {"token": "abc$token"} + # variables_mapping = {"key": ["$key", 2]} + raise exceptions.VariableNotFound(var_name) + + # check if reference variable not in variables_mapping + not_defined_variables = [ + v_name + for v_name in variables + if v_name not in variables_mapping + ] + if not_defined_variables: + # e.g. {"varA": "123$varB", "varB": "456$varC"} + # e.g. {"varC": "${sum_two($a, $b)}"} + raise VariableNotFound(not_defined_variables) try: parsed_value = parse_content(var_value, parsed_variables) diff --git a/httprunner/v3/parser_test.py b/httprunner/v3/parser_test.py new file mode 100644 index 00000000..f4a02068 --- /dev/null +++ b/httprunner/v3/parser_test.py @@ -0,0 +1,30 @@ +import unittest + +from httprunner.v3.parser import parse_variables_mapping +from httprunner.v3.exceptions import VariableNotFound + + +class TestParserBasic(unittest.TestCase): + + def test_parse_variables_mapping(self): + variables = { + "varA": "$varB", + "varB": "$varC", + "varC": "123", + "a": 1, + "b": 2 + } + parsed_variables = parse_variables_mapping(variables) + print(parsed_variables) + self.assertEqual(parsed_variables["varA"], "123") + self.assertEqual(parsed_variables["varB"], "123") + + def test_parse_variables_mapping_exception(self): + variables = { + "varA": "$varB", + "varB": "$varC", + "a": 1, + "b": 2 + } + with self.assertRaises(VariableNotFound): + parse_variables_mapping(variables) From 36f9af441cccdd1a4499cb83c03167a622ede320 Mon Sep 17 00:00:00 2001 From: debugtalk Date: Mon, 20 Apr 2020 17:06:21 +0800 Subject: [PATCH 033/169] v3 feat: support function calls --- examples/postman_echo/debugtalk.py | 4 + .../request_methods/with_functions.yml | 61 ++++++ .../request_methods/with_functions_test.py | 98 +++++++++ httprunner/v3/parser.py | 186 ++++++++++++++++-- httprunner/v3/runner.py | 4 +- httprunner/v3/schema/__init__.py | 3 +- 6 files changed, 338 insertions(+), 18 deletions(-) create mode 100644 examples/postman_echo/request_methods/with_functions.yml create mode 100644 examples/postman_echo/request_methods/with_functions_test.py diff --git a/examples/postman_echo/debugtalk.py b/examples/postman_echo/debugtalk.py index 9ec3149b..849bd537 100644 --- a/examples/postman_echo/debugtalk.py +++ b/examples/postman_echo/debugtalk.py @@ -3,3 +3,7 @@ from httprunner import __version__ def get_httprunner_version(): return __version__ + + +def sum_two(m, n): + return m + n diff --git a/examples/postman_echo/request_methods/with_functions.yml b/examples/postman_echo/request_methods/with_functions.yml new file mode 100644 index 00000000..5391a785 --- /dev/null +++ b/examples/postman_echo/request_methods/with_functions.yml @@ -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"] diff --git a/examples/postman_echo/request_methods/with_functions_test.py b/examples/postman_echo/request_methods/with_functions_test.py new file mode 100644 index 00000000..b8bd9126 --- /dev/null +++ b/examples/postman_echo/request_methods/with_functions_test.py @@ -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() diff --git a/httprunner/v3/parser.py b/httprunner/v3/parser.py index f6ff3a58..54a7f349 100644 --- a/httprunner/v3/parser.py +++ b/httprunner/v3/parser.py @@ -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 diff --git a/httprunner/v3/runner.py b/httprunner/v3/runner.py index 12d28059..76277225 100644 --- a/httprunner/v3/runner.py +++ b/httprunner/v3/runner.py @@ -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 diff --git a/httprunner/v3/schema/__init__.py b/httprunner/v3/schema/__init__.py index 778a217a..ea1220d2 100644 --- a/httprunner/v3/schema/__init__.py +++ b/httprunner/v3/schema/__init__.py @@ -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 = [] From 12148a13063fe81b1c5122d8b1c162f01ba7bc92 Mon Sep 17 00:00:00 2001 From: debugtalk Date: Mon, 20 Apr 2020 18:40:36 +0800 Subject: [PATCH 034/169] test: add unittest for v3 parser --- httprunner/v3/parser_test.py | 308 ++++++++++++++++++++++++++++++++++- 1 file changed, 304 insertions(+), 4 deletions(-) diff --git a/httprunner/v3/parser_test.py b/httprunner/v3/parser_test.py index f4a02068..51b067f4 100644 --- a/httprunner/v3/parser_test.py +++ b/httprunner/v3/parser_test.py @@ -1,7 +1,8 @@ +import time import unittest -from httprunner.v3.parser import parse_variables_mapping -from httprunner.v3.exceptions import VariableNotFound +from httprunner.v3 import parser +from httprunner.v3.exceptions import VariableNotFound, FunctionNotFound class TestParserBasic(unittest.TestCase): @@ -14,7 +15,7 @@ class TestParserBasic(unittest.TestCase): "a": 1, "b": 2 } - parsed_variables = parse_variables_mapping(variables) + parsed_variables = parser.parse_variables_mapping(variables) print(parsed_variables) self.assertEqual(parsed_variables["varA"], "123") self.assertEqual(parsed_variables["varB"], "123") @@ -27,4 +28,303 @@ class TestParserBasic(unittest.TestCase): "b": 2 } with self.assertRaises(VariableNotFound): - parse_variables_mapping(variables) + parser.parse_variables_mapping(variables) + + def test_parse_string_value(self): + self.assertEqual(parser.parse_string_value("123"), 123) + self.assertEqual(parser.parse_string_value("12.3"), 12.3) + self.assertEqual(parser.parse_string_value("a123"), "a123") + self.assertEqual(parser.parse_string_value("$var"), "$var") + self.assertEqual(parser.parse_string_value("${func}"), "${func}") + + def test_extract_variables(self): + self.assertEqual( + parser.extract_variables("$var"), + {"var"} + ) + self.assertEqual( + parser.extract_variables("$var123"), + {"var123"} + ) + self.assertEqual( + parser.extract_variables("$var_name"), + {"var_name"} + ) + self.assertEqual( + parser.extract_variables("var"), + set() + ) + self.assertEqual( + parser.extract_variables("a$var"), + {"var"} + ) + self.assertEqual( + parser.extract_variables("$v ar"), + {"v"} + ) + self.assertEqual( + parser.extract_variables(" "), + set() + ) + self.assertEqual( + parser.extract_variables("$abc*"), + {"abc"} + ) + self.assertEqual( + parser.extract_variables("${func()}"), + set() + ) + self.assertEqual( + parser.extract_variables("${func(1,2)}"), + set() + ) + self.assertEqual( + parser.extract_variables("${gen_md5($TOKEN, $data, $random)}"), + {"TOKEN", "data", "random"} + ) + + def test_parse_function(self): + self.assertEqual( + parser.parse_args_str(""), + ([], {}) + ) + self.assertEqual( + parser.parse_args_str("5"), + ([5], {}) + ) + self.assertEqual( + parser.parse_args_str("1, 2"), + ([1, 2], {}) + ) + self.assertEqual( + parser.parse_args_str("a=1, b=2"), + ([], {'a': 1, 'b': 2}) + ) + self.assertEqual( + parser.parse_args_str("a= 1, b =2"), + ([], {'a': 1, 'b': 2}) + ) + self.assertEqual( + parser.parse_args_str("1, 2, a=3, b=4"), + ([1, 2], {'a': 3, 'b': 4}) + ) + self.assertEqual( + parser.parse_args_str("$request, 123"), + (["$request", 123], {}) + ) + self.assertEqual( + parser.parse_args_str(" "), + ([], {}) + ) + self.assertEqual( + parser.parse_args_str("hello world, a=3, b=4"), + (["hello world"], {'a': 3, 'b': 4}) + ) + self.assertEqual( + parser.parse_args_str("$request, 12 3"), + (["$request", '12 3'], {}) + ) + + def test_extract_functions(self): + self.assertEqual( + parser.regex_findall_functions("${func()}"), + [("func", "")] + ) + self.assertEqual( + parser.regex_findall_functions("${func(5)}"), + [("func", "5")] + ) + self.assertEqual( + parser.regex_findall_functions("${func(a=1, b=2)}"), + [("func", "a=1, b=2")] + ) + self.assertEqual( + parser.regex_findall_functions("${func(1, $b, c=$x, d=4)}"), + [("func", "1, $b, c=$x, d=4")] + ) + self.assertEqual( + parser.regex_findall_functions("/api/1000?_t=${get_timestamp()}"), + [("get_timestamp", "")] + ) + self.assertEqual( + parser.regex_findall_functions("/api/${add(1, 2)}"), + [("add", "1, 2")] + ) + self.assertEqual( + parser.regex_findall_functions("/api/${add(1, 2)}?_t=${get_timestamp()}"), + [('add', '1, 2'), ('get_timestamp', '')] + ) + self.assertEqual( + parser.regex_findall_functions("abc${func(1, 2, a=3, b=4)}def"), + [('func', '1, 2, a=3, b=4')] + ) + + def test_parse_content(self): + content = { + 'request': { + 'url': '/api/users/$uid', + 'method': "$method", + 'headers': {'token': '$token'}, + 'data': { + "null": None, + "true": True, + "false": False, + "empty_str": "", + "value": "abc${add_one(3)}def" + } + } + } + variables_mapping = { + "uid": 1000, + "method": "POST", + "token": "abc123" + } + functions_mapping = { + "add_one": lambda x: x + 1 + } + result = parser.parse_content(content, variables_mapping, functions_mapping) + self.assertEqual("/api/users/1000", result["request"]["url"]) + self.assertEqual("abc123", result["request"]["headers"]["token"]) + self.assertEqual("POST", result["request"]["method"]) + self.assertIsNone(result["request"]["data"]["null"]) + self.assertTrue(result["request"]["data"]["true"]) + self.assertFalse(result["request"]["data"]["false"]) + self.assertEqual("", result["request"]["data"]["empty_str"]) + self.assertEqual("abc4def", result["request"]["data"]["value"]) + + def test_parse_data_variables(self): + variables_mapping = { + "var_1": "abc", + "var_2": "def", + "var_3": 123, + "var_4": {"a": 1}, + "var_5": True, + "var_6": None + } + self.assertEqual( + parser.parse_content("$var_1", variables_mapping), + "abc" + ) + self.assertEqual( + parser.parse_content("var_1", variables_mapping), + "var_1" + ) + self.assertEqual( + parser.parse_content("$var_1#XYZ", variables_mapping), + "abc#XYZ" + ) + self.assertEqual( + parser.parse_content("/$var_1/$var_2/var3", variables_mapping), + "/abc/def/var3" + ) + self.assertEqual( + parser.parse_string_variables("${func($var_1, $var_2, xyz)}", variables_mapping), + "${func(abc, def, xyz)}" + ) + self.assertEqual( + parser.parse_content("$var_3", variables_mapping), + 123 + ) + self.assertEqual( + parser.parse_content("$var_4", variables_mapping), + {"a": 1} + ) + self.assertEqual( + parser.parse_content("$var_5", variables_mapping), + True + ) + self.assertEqual( + parser.parse_content("abc$var_5", variables_mapping), + "abcTrue" + ) + self.assertEqual( + parser.parse_content("abc$var_4", variables_mapping), + "abc{'a': 1}" + ) + self.assertEqual( + parser.parse_content("$var_6", variables_mapping), + None + ) + + with self.assertRaises(VariableNotFound): + parser.parse_content("/api/$SECRET_KEY", variables_mapping) + + self.assertEqual( + parser.parse_content(["$var_1", "$var_2"], variables_mapping), + ["abc", "def"] + ) + self.assertEqual( + parser.parse_content({"$var_1": "$var_2"}, variables_mapping), + {"abc": "def"} + ) + + def test_parse_data_functions(self): + import random, string + functions_mapping = { + "gen_random_string": lambda str_len: ''.join(random.choice(string.ascii_letters + string.digits) \ + for _ in range(str_len)) + } + result = parser.parse_content("${gen_random_string(5)}", functions_mapping=functions_mapping) + self.assertEqual(len(result), 5) + + add_two_nums = lambda a, b=1: a + b + functions_mapping["add_two_nums"] = add_two_nums + self.assertEqual( + parser.parse_content("${add_two_nums(1)}", functions_mapping=functions_mapping), + 2 + ) + self.assertEqual( + parser.parse_content("${add_two_nums(1, 2)}", functions_mapping=functions_mapping), + 3 + ) + self.assertEqual( + parser.parse_content("/api/${add_two_nums(1, 2)}", functions_mapping=functions_mapping), + "/api/3" + ) + + with self.assertRaises(FunctionNotFound): + parser.parse_content("/api/${gen_md5(abc)}") + + def test_parse_data_testcase(self): + variables = { + "uid": "1000", + "random": "A2dEx", + "authorization": "a83de0ff8d2e896dbd8efb81ba14e17d", + "data": {"name": "user", "password": "123456"} + } + functions = { + "add_two_nums": lambda a, b=1: a + b, + "get_timestamp": lambda: int(time.time() * 1000) + } + testcase_template = { + "url": "http://127.0.0.1:5000/api/users/$uid/${add_two_nums(1,2)}", + "method": "POST", + "headers": { + "Content-Type": "application/json", + "authorization": "$authorization", + "random": "$random", + "sum": "${add_two_nums(1, 2)}" + }, + "body": "$data" + } + parsed_testcase = parser.parse_content(testcase_template, variables, functions) + self.assertEqual( + parsed_testcase["url"], + "http://127.0.0.1:5000/api/users/1000/3" + ) + self.assertEqual( + parsed_testcase["headers"]["authorization"], + variables["authorization"] + ) + self.assertEqual( + parsed_testcase["headers"]["random"], + variables["random"] + ) + self.assertEqual( + parsed_testcase["body"], + variables["data"] + ) + self.assertEqual( + parsed_testcase["headers"]["sum"], + 3 + ) From 8c625f08f348e5f411728d880e61b8a419c17933 Mon Sep 17 00:00:00 2001 From: debugtalk Date: Tue, 21 Apr 2020 11:38:33 +0800 Subject: [PATCH 035/169] refactor: implement parse_string to eval string with both variables and functions --- httprunner/v3/parser.py | 277 ++++++++++++++++++----------------- httprunner/v3/parser_test.py | 87 +++++++---- 2 files changed, 203 insertions(+), 161 deletions(-) diff --git a/httprunner/v3/parser.py b/httprunner/v3/parser.py index 54a7f349..965d6f22 100644 --- a/httprunner/v3/parser.py +++ b/httprunner/v3/parser.py @@ -107,56 +107,6 @@ def regex_findall_functions(content: Text) -> List[Text]: 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. """ @@ -178,97 +128,156 @@ def extract_variables(content: Any) -> Set: return set() -def parse_string_functions( - content: Text, - variables_mapping: Dict[Text, Any], - functions_mapping: Dict[Text, Callable]) -> Text: - """ parse string content with functions mapping. +def parse_function_params(params): + """ parse function params to args and kwargs. Args: - content (str): string content to be parsed. - variables_mapping (dict): variables mapping. - functions_mapping (dict): functions mapping. + params (str): function param in string + + Returns: + dict: function meta dict + + { + "args": [], + "kwargs": {} + } + + Examples: + >>> parse_function_params("") + {'args': [], 'kwargs': {}} + + >>> parse_function_params("5") + {'args': [5], 'kwargs': {}} + + >>> parse_function_params("1, 2") + {'args': [1, 2], 'kwargs': {}} + + >>> parse_function_params("a=1, b=2") + {'args': [], 'kwargs': {'a': 1, 'b': 2}} + + >>> parse_function_params("1, 2, a=3, b=4") + {'args': [1, 2], 'kwargs': {'a':3, 'b':4}} + + """ + function_meta = { + "args": [], + "kwargs": {} + } + + params_str = params.strip() + if params_str == "": + return function_meta + + args_list = params_str.split(',') + for arg in args_list: + arg = arg.strip() + if '=' in arg: + key, value = arg.split('=') + function_meta["kwargs"][key.strip()] = parse_string_value(value.strip()) + else: + function_meta["args"].append(parse_string_value(arg)) + + return function_meta + + +def parse_string( + raw_string: Text, + variables_mapping: Dict[Text, Any], + functions_mapping: Dict[Text, Callable]) -> Text: + """ parse string content with variables and functions mapping. + + Args: + raw_string: raw string content to be parsed. + variables_mapping: variables mapping. + functions_mapping: functions mapping. Returns: str: parsed string content. Examples: - >>> content = "abc${add_one(3)}def" + >>> raw_string = "abc${add_one($num)}def" + >>> variables_mapping = {"num": 3} >>> functions_mapping = {"add_one": lambda x: x + 1} - >>> parse_string_functions(content, {}, functions_mapping) + >>> parse_string(raw_string, variables_mapping, 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) + try: + match_start_position = raw_string.index("$", 0) + parsed_string = raw_string[0:match_start_position] + except ValueError: + parsed_string = raw_string + return parsed_string - args = parse_content(args, variables_mapping, functions_mapping) - kwargs = parse_content(kwargs, variables_mapping, functions_mapping) + while match_start_position < len(raw_string): + # Notice: notation priority + # $$ > ${func($a, $b)} > $var + + # search $$ + dollar_match = dolloar_regex_compile.match(raw_string, match_start_position) + if dollar_match: + match_start_position = dollar_match.end() + parsed_string += "$" + continue + + # search function like ${func($a, $b)} + func_match = function_regex_compile.match(raw_string, match_start_position) + if func_match: + func_name = func_match.group(1) + try: + func = functions_mapping[func_name] + except KeyError: + raise FunctionNotFound(f"{func_name} not found in {functions_mapping}") + + func_params_str = func_match.group(2) + function_meta = parse_function_params(func_params_str) + args = function_meta["args"] + kwargs = function_meta["kwargs"] + func_eval_value = func(*args, **kwargs) + + func_raw_str = "${" + func_name + f"({func_params_str})" + "}" + if func_raw_str == raw_string: + # raw_string is a function, e.g. "${add_one(3)}", return its eval value directly + return func_eval_value + + # raw_string contains one or many functions, e.g. "abc${add_one(3)}def" + parsed_string += str(func_eval_value) + match_start_position = func_match.end() + continue + + # search variable like ${var} or $var + var_match = variable_regex_compile.match(raw_string, match_start_position) + if var_match: + var_name = var_match.group(1) or var_match.group(2) + # check if any variable undefined in variables_mapping + try: + var_value = variables_mapping[var_name] + except KeyError: + raise VariableNotFound(f"{var_name} not found in {variables_mapping}") + + if f"${var_name}" == raw_string or "${" + var_name + "}" == raw_string: + # raw_string is a variable, $var or ${var}, return its value directly + return var_value + + # raw_string contains one or many variables, e.g. "abc${var}def" + parsed_string += str(var_value) + match_start_position = var_match.end() + continue + + curr_position = match_start_position try: - func = functions_mapping[func_name] - except KeyError: - raise FunctionNotFound(f"{func_name} not found in {functions_mapping}") + # find next $ location + match_start_position = raw_string.index("$", curr_position + 1) + remain_string = raw_string[curr_position:match_start_position] + except ValueError: + remain_string = raw_string[curr_position:] + # break while loop + match_start_position = len(raw_string) - eval_value = func(*args, **kwargs) + parsed_string += remain_string - 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: - content (str): string content to be parsed. - variables_mapping (dict): variables mapping. - - Returns: - str: parsed string content. - - Examples: - >>> content = "/api/users/$uid" - >>> variables_mapping = {"uid": 1000} - >>> parse_string_variables(content, variables_mapping) - "/api/users/1000" - - """ - variables_list = extract_variables(content) - for variable_name in variables_list: - try: - variable_value = variables_mapping[variable_name] - except KeyError: - raise VariableNotFound(f"{variable_name} not found in {variables_mapping}") - - # TODO: replace variable label from $var to {{var}} - if f"${variable_name}" == content: - # content is a variable - content = variable_value - else: - # content contains one or several variables - if not isinstance(variable_value, str): - variable_value = str(variable_value) - - content = content.replace( - f"${variable_name}", - variable_value, 1 - ) - - return content + return parsed_string def parse_content( @@ -278,24 +287,20 @@ def parse_content( """ parse content with evaluated variables mapping. Notice: variables_mapping should not contain any variable or function. """ - # TODO: refactor type check - if content is None or isinstance(content, (int, float, bool)): - return content - - elif isinstance(content, str): - # content is in string format here + if isinstance(content, str): + # content in string format may contains variables and functions variables_mapping = variables_mapping or {} functions_mapping = functions_mapping or {} content = content.strip() # replace functions with evaluated value # Notice: parse_string_functions must be called before parse_string_variables - content = parse_string_functions(content, variables_mapping, functions_mapping) + # content = parse_string_functions(content, variables_mapping, functions_mapping) # replace variables with binding value - content = parse_string_variables(content, variables_mapping) + # content = parse_string_variables(content, variables_mapping) - return content + return parse_string(content, variables_mapping, functions_mapping) elif isinstance(content, (list, set, tuple)): return [ @@ -312,7 +317,9 @@ def parse_content( return parsed_content - return content + else: + # other types, e.g. None, int, float, bool + return content def parse_variables_mapping( diff --git a/httprunner/v3/parser_test.py b/httprunner/v3/parser_test.py index 51b067f4..32546c2b 100644 --- a/httprunner/v3/parser_test.py +++ b/httprunner/v3/parser_test.py @@ -83,46 +83,46 @@ class TestParserBasic(unittest.TestCase): {"TOKEN", "data", "random"} ) - def test_parse_function(self): + def test_parse_function_params(self): self.assertEqual( - parser.parse_args_str(""), - ([], {}) + parser.parse_function_params(""), + {'args': [], 'kwargs': {}} ) self.assertEqual( - parser.parse_args_str("5"), - ([5], {}) + parser.parse_function_params("5"), + {'args': [5], 'kwargs': {}} ) self.assertEqual( - parser.parse_args_str("1, 2"), - ([1, 2], {}) + parser.parse_function_params("1, 2"), + {'args': [1, 2], 'kwargs': {}} ) self.assertEqual( - parser.parse_args_str("a=1, b=2"), - ([], {'a': 1, 'b': 2}) + parser.parse_function_params("a=1, b=2"), + {'args': [], 'kwargs': {'a': 1, 'b': 2}} ) self.assertEqual( - parser.parse_args_str("a= 1, b =2"), - ([], {'a': 1, 'b': 2}) + parser.parse_function_params("a= 1, b =2"), + {'args': [], 'kwargs': {'a': 1, 'b': 2}} ) self.assertEqual( - parser.parse_args_str("1, 2, a=3, b=4"), - ([1, 2], {'a': 3, 'b': 4}) + parser.parse_function_params("1, 2, a=3, b=4"), + {'args': [1, 2], 'kwargs': {'a': 3, 'b': 4}} ) self.assertEqual( - parser.parse_args_str("$request, 123"), - (["$request", 123], {}) + parser.parse_function_params("$request, 123"), + {'args': ["$request", 123], 'kwargs': {}} ) self.assertEqual( - parser.parse_args_str(" "), - ([], {}) + parser.parse_function_params(" "), + {'args': [], 'kwargs': {}} ) self.assertEqual( - parser.parse_args_str("hello world, a=3, b=4"), - (["hello world"], {'a': 3, 'b': 4}) + parser.parse_function_params("hello world, a=3, b=4"), + {'args': ["hello world"], 'kwargs': {'a': 3, 'b': 4}} ) self.assertEqual( - parser.parse_args_str("$request, 12 3"), - (["$request", '12 3'], {}) + parser.parse_function_params("$request, 12 3"), + {'args': ["$request", '12 3'], 'kwargs': {}} ) def test_extract_functions(self): @@ -192,7 +192,7 @@ class TestParserBasic(unittest.TestCase): self.assertEqual("", result["request"]["data"]["empty_str"]) self.assertEqual("abc4def", result["request"]["data"]["value"]) - def test_parse_data_variables(self): + def test_parse_content_with_variables(self): variables_mapping = { "var_1": "abc", "var_2": "def", @@ -205,6 +205,10 @@ class TestParserBasic(unittest.TestCase): parser.parse_content("$var_1", variables_mapping), "abc" ) + self.assertEqual( + parser.parse_content("${var_1}", variables_mapping), + "abc" + ) self.assertEqual( parser.parse_content("var_1", variables_mapping), "var_1" @@ -214,12 +218,12 @@ class TestParserBasic(unittest.TestCase): "abc#XYZ" ) self.assertEqual( - parser.parse_content("/$var_1/$var_2/var3", variables_mapping), - "/abc/def/var3" + parser.parse_content("${var_1}#XYZ", variables_mapping), + "abc#XYZ" ) self.assertEqual( - parser.parse_string_variables("${func($var_1, $var_2, xyz)}", variables_mapping), - "${func(abc, def, xyz)}" + parser.parse_content("/$var_1/$var_2/var3", variables_mapping), + "/abc/def/var3" ) self.assertEqual( parser.parse_content("$var_3", variables_mapping), @@ -258,6 +262,37 @@ class TestParserBasic(unittest.TestCase): {"abc": "def"} ) + def test_parse_data_multiple_identical_variables(self): + variables_mapping = { + "var_1": "abc", + "var_2": "def", + } + self.assertEqual( + parser.parse_content("/$var_1/$var_2/$var_1", variables_mapping), + "/abc/def/abc" + ) + + variables_mapping = { + "userid": 100, + "data": 1498 + } + content = "/users/$userid/training/$data?userId=$userid&data=$data" + self.assertEqual( + parser.parse_content(content, variables_mapping), + "/users/100/training/1498?userId=100&data=1498" + ) + + variables_mapping = { + "user": 100, + "userid": 1000, + "data": 1498 + } + content = "/users/$user/$userid/$data?userId=$userid&data=$data" + self.assertEqual( + parser.parse_content(content, variables_mapping), + "/users/100/1000/1498?userId=1000&data=1498" + ) + def test_parse_data_functions(self): import random, string functions_mapping = { From cf3653cf18b67b6af9cc01de9420c763e3889abe Mon Sep 17 00:00:00 2001 From: debugtalk Date: Tue, 21 Apr 2020 11:44:00 +0800 Subject: [PATCH 036/169] refactor: change function name from parse_content to parse_data --- httprunner/v3/parser.py | 44 +++++++++++++------------------ httprunner/v3/parser_test.py | 50 ++++++++++++++++++------------------ httprunner/v3/runner.py | 4 +-- 3 files changed, 45 insertions(+), 53 deletions(-) diff --git a/httprunner/v3/parser.py b/httprunner/v3/parser.py index 965d6f22..8292bb36 100644 --- a/httprunner/v3/parser.py +++ b/httprunner/v3/parser.py @@ -280,46 +280,38 @@ def parse_string( return parsed_string -def parse_content( - content: Any, +def parse_data( + raw_data: Any, variables_mapping: Dict[Text, Any] = None, functions_mapping: Dict[Text, Callable] = None) -> Any: - """ parse content with evaluated variables mapping. + """ parse raw data with evaluated variables mapping. Notice: variables_mapping should not contain any variable or function. """ - if isinstance(content, str): + if isinstance(raw_data, str): # content in string format may contains variables and functions variables_mapping = variables_mapping or {} functions_mapping = functions_mapping or {} - content = content.strip() + raw_data = raw_data.strip() + return parse_string(raw_data, variables_mapping, functions_mapping) - # replace functions with evaluated value - # 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) - - return parse_string(content, variables_mapping, functions_mapping) - - elif isinstance(content, (list, set, tuple)): + elif isinstance(raw_data, (list, set, tuple)): return [ - parse_content(item, variables_mapping, functions_mapping) - for item in content + parse_data(item, variables_mapping, functions_mapping) + for item in raw_data ] - elif isinstance(content, dict): - parsed_content = {} - for key, value in content.items(): - parsed_key = parse_content(key, variables_mapping, functions_mapping) - parsed_value = parse_content(value, variables_mapping, functions_mapping) - parsed_content[parsed_key] = parsed_value + elif isinstance(raw_data, dict): + parsed_data = {} + for key, value in raw_data.items(): + parsed_key = parse_data(key, variables_mapping, functions_mapping) + parsed_value = parse_data(value, variables_mapping, functions_mapping) + parsed_data[parsed_key] = parsed_value - return parsed_content + return parsed_data else: # other types, e.g. None, int, float, bool - return content + return raw_data def parse_variables_mapping( @@ -356,7 +348,7 @@ def parse_variables_mapping( raise VariableNotFound(not_defined_variables) try: - parsed_value = parse_content( + parsed_value = parse_data( var_value, parsed_variables, functions_mapping) except VariableNotFound: continue diff --git a/httprunner/v3/parser_test.py b/httprunner/v3/parser_test.py index 32546c2b..c63cab93 100644 --- a/httprunner/v3/parser_test.py +++ b/httprunner/v3/parser_test.py @@ -182,7 +182,7 @@ class TestParserBasic(unittest.TestCase): functions_mapping = { "add_one": lambda x: x + 1 } - result = parser.parse_content(content, variables_mapping, functions_mapping) + result = parser.parse_data(content, variables_mapping, functions_mapping) self.assertEqual("/api/users/1000", result["request"]["url"]) self.assertEqual("abc123", result["request"]["headers"]["token"]) self.assertEqual("POST", result["request"]["method"]) @@ -202,63 +202,63 @@ class TestParserBasic(unittest.TestCase): "var_6": None } self.assertEqual( - parser.parse_content("$var_1", variables_mapping), + parser.parse_data("$var_1", variables_mapping), "abc" ) self.assertEqual( - parser.parse_content("${var_1}", variables_mapping), + parser.parse_data("${var_1}", variables_mapping), "abc" ) self.assertEqual( - parser.parse_content("var_1", variables_mapping), + parser.parse_data("var_1", variables_mapping), "var_1" ) self.assertEqual( - parser.parse_content("$var_1#XYZ", variables_mapping), + parser.parse_data("$var_1#XYZ", variables_mapping), "abc#XYZ" ) self.assertEqual( - parser.parse_content("${var_1}#XYZ", variables_mapping), + parser.parse_data("${var_1}#XYZ", variables_mapping), "abc#XYZ" ) self.assertEqual( - parser.parse_content("/$var_1/$var_2/var3", variables_mapping), + parser.parse_data("/$var_1/$var_2/var3", variables_mapping), "/abc/def/var3" ) self.assertEqual( - parser.parse_content("$var_3", variables_mapping), + parser.parse_data("$var_3", variables_mapping), 123 ) self.assertEqual( - parser.parse_content("$var_4", variables_mapping), + parser.parse_data("$var_4", variables_mapping), {"a": 1} ) self.assertEqual( - parser.parse_content("$var_5", variables_mapping), + parser.parse_data("$var_5", variables_mapping), True ) self.assertEqual( - parser.parse_content("abc$var_5", variables_mapping), + parser.parse_data("abc$var_5", variables_mapping), "abcTrue" ) self.assertEqual( - parser.parse_content("abc$var_4", variables_mapping), + parser.parse_data("abc$var_4", variables_mapping), "abc{'a': 1}" ) self.assertEqual( - parser.parse_content("$var_6", variables_mapping), + parser.parse_data("$var_6", variables_mapping), None ) with self.assertRaises(VariableNotFound): - parser.parse_content("/api/$SECRET_KEY", variables_mapping) + parser.parse_data("/api/$SECRET_KEY", variables_mapping) self.assertEqual( - parser.parse_content(["$var_1", "$var_2"], variables_mapping), + parser.parse_data(["$var_1", "$var_2"], variables_mapping), ["abc", "def"] ) self.assertEqual( - parser.parse_content({"$var_1": "$var_2"}, variables_mapping), + parser.parse_data({"$var_1": "$var_2"}, variables_mapping), {"abc": "def"} ) @@ -268,7 +268,7 @@ class TestParserBasic(unittest.TestCase): "var_2": "def", } self.assertEqual( - parser.parse_content("/$var_1/$var_2/$var_1", variables_mapping), + parser.parse_data("/$var_1/$var_2/$var_1", variables_mapping), "/abc/def/abc" ) @@ -278,7 +278,7 @@ class TestParserBasic(unittest.TestCase): } content = "/users/$userid/training/$data?userId=$userid&data=$data" self.assertEqual( - parser.parse_content(content, variables_mapping), + parser.parse_data(content, variables_mapping), "/users/100/training/1498?userId=100&data=1498" ) @@ -289,7 +289,7 @@ class TestParserBasic(unittest.TestCase): } content = "/users/$user/$userid/$data?userId=$userid&data=$data" self.assertEqual( - parser.parse_content(content, variables_mapping), + parser.parse_data(content, variables_mapping), "/users/100/1000/1498?userId=1000&data=1498" ) @@ -299,26 +299,26 @@ class TestParserBasic(unittest.TestCase): "gen_random_string": lambda str_len: ''.join(random.choice(string.ascii_letters + string.digits) \ for _ in range(str_len)) } - result = parser.parse_content("${gen_random_string(5)}", functions_mapping=functions_mapping) + result = parser.parse_data("${gen_random_string(5)}", functions_mapping=functions_mapping) self.assertEqual(len(result), 5) add_two_nums = lambda a, b=1: a + b functions_mapping["add_two_nums"] = add_two_nums self.assertEqual( - parser.parse_content("${add_two_nums(1)}", functions_mapping=functions_mapping), + parser.parse_data("${add_two_nums(1)}", functions_mapping=functions_mapping), 2 ) self.assertEqual( - parser.parse_content("${add_two_nums(1, 2)}", functions_mapping=functions_mapping), + parser.parse_data("${add_two_nums(1, 2)}", functions_mapping=functions_mapping), 3 ) self.assertEqual( - parser.parse_content("/api/${add_two_nums(1, 2)}", functions_mapping=functions_mapping), + parser.parse_data("/api/${add_two_nums(1, 2)}", functions_mapping=functions_mapping), "/api/3" ) with self.assertRaises(FunctionNotFound): - parser.parse_content("/api/${gen_md5(abc)}") + parser.parse_data("/api/${gen_md5(abc)}") def test_parse_data_testcase(self): variables = { @@ -342,7 +342,7 @@ class TestParserBasic(unittest.TestCase): }, "body": "$data" } - parsed_testcase = parser.parse_content(testcase_template, variables, functions) + parsed_testcase = parser.parse_data(testcase_template, variables, functions) self.assertEqual( parsed_testcase["url"], "http://127.0.0.1:5000/api/users/1000/3" diff --git a/httprunner/v3/runner.py b/httprunner/v3/runner.py index 76277225..3e3c3489 100644 --- a/httprunner/v3/runner.py +++ b/httprunner/v3/runner.py @@ -3,7 +3,7 @@ from typing import List import requests from loguru import logger -from httprunner.v3.parser import build_url, parse_content, parse_variables_mapping +from httprunner.v3.parser import build_url, parse_data, parse_variables_mapping from httprunner.v3.response import ResponseObject from httprunner.v3.schema import TestsConfig, TestStep @@ -27,7 +27,7 @@ class TestCaseRunner(object): # parse request_dict = step.request.dict() - parsed_request_dict = parse_content(request_dict, step.variables, self.config.functions) + parsed_request_dict = parse_data(request_dict, step.variables, self.config.functions) # prepare arguments method = parsed_request_dict.pop("method") From faa920d339d5d624143779fb0e7441695721db6a Mon Sep 17 00:00:00 2001 From: debugtalk Date: Tue, 21 Apr 2020 11:59:55 +0800 Subject: [PATCH 037/169] fix parser: parse function args and kwargs before eval function --- httprunner/v3/parser.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/httprunner/v3/parser.py b/httprunner/v3/parser.py index 8292bb36..1cfe8e98 100644 --- a/httprunner/v3/parser.py +++ b/httprunner/v3/parser.py @@ -234,7 +234,10 @@ def parse_string( function_meta = parse_function_params(func_params_str) args = function_meta["args"] kwargs = function_meta["kwargs"] - func_eval_value = func(*args, **kwargs) + + parsed_args = parse_data(args, variables_mapping, functions_mapping) + parsed_kwargs = parse_data(kwargs, variables_mapping, functions_mapping) + func_eval_value = func(*parsed_args, **parsed_kwargs) func_raw_str = "${" + func_name + f"({func_params_str})" + "}" if func_raw_str == raw_string: From 836e5fd64f993a9654a3600947827ec34b19a319 Mon Sep 17 00:00:00 2001 From: debugtalk Date: Tue, 21 Apr 2020 12:08:21 +0800 Subject: [PATCH 038/169] test: add unittests for parser --- httprunner/v3/parser_test.py | 236 +++++++++++++++++++++++++++++------ 1 file changed, 200 insertions(+), 36 deletions(-) diff --git a/httprunner/v3/parser_test.py b/httprunner/v3/parser_test.py index c63cab93..e7957614 100644 --- a/httprunner/v3/parser_test.py +++ b/httprunner/v3/parser_test.py @@ -159,40 +159,7 @@ class TestParserBasic(unittest.TestCase): [('func', '1, 2, a=3, b=4')] ) - def test_parse_content(self): - content = { - 'request': { - 'url': '/api/users/$uid', - 'method': "$method", - 'headers': {'token': '$token'}, - 'data': { - "null": None, - "true": True, - "false": False, - "empty_str": "", - "value": "abc${add_one(3)}def" - } - } - } - variables_mapping = { - "uid": 1000, - "method": "POST", - "token": "abc123" - } - functions_mapping = { - "add_one": lambda x: x + 1 - } - result = parser.parse_data(content, variables_mapping, functions_mapping) - self.assertEqual("/api/users/1000", result["request"]["url"]) - self.assertEqual("abc123", result["request"]["headers"]["token"]) - self.assertEqual("POST", result["request"]["method"]) - self.assertIsNone(result["request"]["data"]["null"]) - self.assertTrue(result["request"]["data"]["true"]) - self.assertFalse(result["request"]["data"]["false"]) - self.assertEqual("", result["request"]["data"]["empty_str"]) - self.assertEqual("abc4def", result["request"]["data"]["value"]) - - def test_parse_content_with_variables(self): + def test_parse_data_string_with_variables(self): variables_mapping = { "var_1": "abc", "var_2": "def", @@ -262,6 +229,56 @@ class TestParserBasic(unittest.TestCase): {"abc": "def"} ) + # format: $var + value = parser.parse_data("ABC$var_1", variables_mapping) + self.assertEqual(value, "ABCabc") + + value = parser.parse_data("ABC$var_1$var_3", variables_mapping) + self.assertEqual(value, "ABCabc123") + + value = parser.parse_data("ABC$var_1/$var_3", variables_mapping) + self.assertEqual(value, "ABCabc/123") + + value = parser.parse_data("ABC$var_1/", variables_mapping) + self.assertEqual(value, "ABCabc/") + + value = parser.parse_data("ABC$var_1$", variables_mapping) + self.assertEqual(value, "ABCabc$") + + value = parser.parse_data("ABC$var_1/123$var_1/456", variables_mapping) + self.assertEqual(value, "ABCabc/123abc/456") + + value = parser.parse_data("ABC$var_1/$var_2/$var_1", variables_mapping) + self.assertEqual(value, "ABCabc/def/abc") + + value = parser.parse_data("func1($var_1, $var_3)", variables_mapping) + self.assertEqual(value, "func1(abc, 123)") + + # format: ${var} + value = parser.parse_data("ABC${var_1}", variables_mapping) + self.assertEqual(value, "ABCabc") + + value = parser.parse_data("ABC${var_1}${var_3}", variables_mapping) + self.assertEqual(value, "ABCabc123") + + value = parser.parse_data("ABC${var_1}/${var_3}", variables_mapping) + self.assertEqual(value, "ABCabc/123") + + value = parser.parse_data("ABC${var_1}/", variables_mapping) + self.assertEqual(value, "ABCabc/") + + value = parser.parse_data("ABC${var_1}123", variables_mapping) + self.assertEqual(value, "ABCabc123") + + value = parser.parse_data("ABC${var_1}/123${var_1}/456", variables_mapping) + self.assertEqual(value, "ABCabc/123abc/456") + + value = parser.parse_data("ABC${var_1}/${var_2}/${var_1}", variables_mapping) + self.assertEqual(value, "ABCabc/def/abc") + + value = parser.parse_data("func1(${var_1}, ${var_3})", variables_mapping) + self.assertEqual(value, "func1(abc, 123)") + def test_parse_data_multiple_identical_variables(self): variables_mapping = { "var_1": "abc", @@ -293,11 +310,11 @@ class TestParserBasic(unittest.TestCase): "/users/100/1000/1498?userId=1000&data=1498" ) - def test_parse_data_functions(self): + def test_parse_data_string_with_functions(self): import random, string functions_mapping = { "gen_random_string": lambda str_len: ''.join(random.choice(string.ascii_letters + string.digits) \ - for _ in range(str_len)) + for _ in range(str_len)) } result = parser.parse_data("${gen_random_string(5)}", functions_mapping=functions_mapping) self.assertEqual(len(result), 5) @@ -320,6 +337,153 @@ class TestParserBasic(unittest.TestCase): with self.assertRaises(FunctionNotFound): parser.parse_data("/api/${gen_md5(abc)}") + variables_mapping = { + "var_1": "abc", + "var_2": "def", + "var_3": 123, + "var_4": {"a": 1}, + "var_5": True, + "var_6": None + } + functions_mapping = { + "func1": lambda x, y: str(x) + str(y) + } + + value = parser.parse_data("${func1($var_1, $var_3)}", variables_mapping, functions_mapping) + self.assertEqual(value, "abc123") + + value = parser.parse_data("ABC${func1($var_1, $var_3)}DE", variables_mapping, functions_mapping) + self.assertEqual(value, "ABCabc123DE") + + value = parser.parse_data("ABC${func1($var_1, $var_3)}$var_5", variables_mapping, functions_mapping) + self.assertEqual(value, "ABCabc123True") + + value = parser.parse_data("ABC${func1($var_1, $var_3)}DE$var_4", variables_mapping, functions_mapping) + self.assertEqual(value, "ABCabc123DE{'a': 1}") + + value = parser.parse_data("ABC$var_5${func1($var_1, $var_3)}", variables_mapping, functions_mapping) + self.assertEqual(value, "ABCTrueabc123") + + # TODO: Python builtin functions + # value = parser.parse_data("ABC${ord(a)}DEF${len(abcd)}", variables_mapping, functions_mapping) + # self.assertEqual(value, "ABC97DEF4") + + def test_parse_data_func_var_duplicate(self): + variables_mapping = { + "var_1": "abc", + "var_2": "def", + "var_3": 123, + "var_4": {"a": 1}, + "var_5": True, + "var_6": None + } + functions_mapping = { + "func1": lambda x, y: str(x) + str(y) + } + value = parser.parse_data( + "ABC${func1($var_1, $var_3)}--${func1($var_1, $var_3)}", + variables_mapping, functions_mapping) + self.assertEqual(value, "ABCabc123--abc123") + + value = parser.parse_data("ABC${func1($var_1, $var_3)}$var_1", variables_mapping, functions_mapping) + self.assertEqual(value, "ABCabc123abc") + + value = parser.parse_data( + "ABC${func1($var_1, $var_3)}$var_1--${func1($var_1, $var_3)}$var_1", + variables_mapping, functions_mapping) + self.assertEqual(value, "ABCabc123abc--abc123abc") + + def test_parse_data_func_abnormal(self): + variables_mapping = { + "var_1": "abc", + "var_2": "def", + "var_3": 123, + "var_4": {"a": 1}, + "var_5": True, + "var_6": None + } + functions_mapping = { + "func1": lambda x, y: str(x) + str(y) + } + + # { + value = parser.parse_data("ABC$var_1{", variables_mapping, functions_mapping) + self.assertEqual(value, "ABCabc{") + + value = parser.parse_data("{ABC$var_1{}a}", variables_mapping, functions_mapping) + self.assertEqual(value, "{ABCabc{}a}") + + value = parser.parse_data("AB{C$var_1{}a}", variables_mapping, functions_mapping) + self.assertEqual(value, "AB{Cabc{}a}") + + # } + value = parser.parse_data("ABC$var_1}", variables_mapping, functions_mapping) + self.assertEqual(value, "ABCabc}") + + # $$ + value = parser.parse_data("ABC$$var_1{", variables_mapping, functions_mapping) + self.assertEqual(value, "ABC$var_1{") + + # $$$ + value = parser.parse_data("ABC$$$var_1{", variables_mapping, functions_mapping) + self.assertEqual(value, "ABC$abc{") + + # $$$$ + value = parser.parse_data("ABC$$$$var_1{", variables_mapping, functions_mapping) + self.assertEqual(value, "ABC$$var_1{") + + # ${ + value = parser.parse_data("ABC$var_1${", variables_mapping, functions_mapping) + self.assertEqual(value, "ABCabc${") + + value = parser.parse_data("ABC$var_1${a", variables_mapping, functions_mapping) + self.assertEqual(value, "ABCabc${a") + + # $} + value = parser.parse_data("ABC$var_1$}a", variables_mapping, functions_mapping) + self.assertEqual(value, "ABCabc$}a") + + # }{ + value = parser.parse_data("ABC$var_1}{a", variables_mapping, functions_mapping) + self.assertEqual(value, "ABCabc}{a") + + # {} + value = parser.parse_data("ABC$var_1{}a", variables_mapping, functions_mapping) + self.assertEqual(value, "ABCabc{}a") + + def test_parse_data_request(self): + content = { + 'request': { + 'url': '/api/users/$uid', + 'method': "$method", + 'headers': {'token': '$token'}, + 'data': { + "null": None, + "true": True, + "false": False, + "empty_str": "", + "value": "abc${add_one(3)}def" + } + } + } + variables_mapping = { + "uid": 1000, + "method": "POST", + "token": "abc123" + } + functions_mapping = { + "add_one": lambda x: x + 1 + } + result = parser.parse_data(content, variables_mapping, functions_mapping) + self.assertEqual("/api/users/1000", result["request"]["url"]) + self.assertEqual("abc123", result["request"]["headers"]["token"]) + self.assertEqual("POST", result["request"]["method"]) + self.assertIsNone(result["request"]["data"]["null"]) + self.assertTrue(result["request"]["data"]["true"]) + self.assertFalse(result["request"]["data"]["false"]) + self.assertEqual("", result["request"]["data"]["empty_str"]) + self.assertEqual("abc4def", result["request"]["data"]["value"]) + def test_parse_data_testcase(self): variables = { "uid": "1000", From 01e0b278b36a88883739a58fc5f6bf2d410e4fbf Mon Sep 17 00:00:00 2001 From: debugtalk Date: Tue, 21 Apr 2020 12:54:57 +0800 Subject: [PATCH 039/169] feat: call with python builtin functions --- httprunner/v3/parser.py | 52 +++++++++++++++++++++++++++++++++--- httprunner/v3/parser_test.py | 5 ++-- 2 files changed, 50 insertions(+), 7 deletions(-) diff --git a/httprunner/v3/parser.py b/httprunner/v3/parser.py index 1cfe8e98..617b0b02 100644 --- a/httprunner/v3/parser.py +++ b/httprunner/v3/parser.py @@ -1,7 +1,9 @@ import ast +import builtins import re from typing import Any, Set, Text, Callable, Tuple, List, Dict, Union +from httprunner import loader, utils from httprunner.v3 import exceptions from httprunner.v3.exceptions import VariableNotFound, FunctionNotFound @@ -180,6 +182,51 @@ def parse_function_params(params): return function_meta +def get_mapping_function(function_name: Text, functions_mapping: Dict[Text, Callable]) -> Callable: + """ get function from functions_mapping, + if not found, then try to check if builtin function. + + Args: + function_name (str): function name + functions_mapping (dict): functions mapping + + Returns: + mapping function object. + + Raises: + exceptions.FunctionNotFound: function is neither defined in debugtalk.py nor builtin. + + """ + if function_name in functions_mapping: + return functions_mapping[function_name] + + elif function_name in ["parameterize", "P"]: + return loader.load_csv_file + + elif function_name in ["environ", "ENV"]: + return utils.get_os_environ + + elif function_name in ["multipart_encoder", "multipart_content_type"]: + # extension for upload test + from httprunner.ext import uploader + return getattr(uploader, function_name) + + try: + # check if HttpRunner builtin functions + built_in_functions = loader.load_builtin_functions() + return built_in_functions[function_name] + except KeyError: + pass + + try: + # check if Python builtin functions + return getattr(builtins, function_name) + except AttributeError: + pass + + raise exceptions.FunctionNotFound(f"{function_name} is not found.") + + def parse_string( raw_string: Text, variables_mapping: Dict[Text, Any], @@ -225,10 +272,7 @@ def parse_string( func_match = function_regex_compile.match(raw_string, match_start_position) if func_match: func_name = func_match.group(1) - try: - func = functions_mapping[func_name] - except KeyError: - raise FunctionNotFound(f"{func_name} not found in {functions_mapping}") + func = get_mapping_function(func_name, functions_mapping) func_params_str = func_match.group(2) function_meta = parse_function_params(func_params_str) diff --git a/httprunner/v3/parser_test.py b/httprunner/v3/parser_test.py index e7957614..ee42c525 100644 --- a/httprunner/v3/parser_test.py +++ b/httprunner/v3/parser_test.py @@ -364,9 +364,8 @@ class TestParserBasic(unittest.TestCase): value = parser.parse_data("ABC$var_5${func1($var_1, $var_3)}", variables_mapping, functions_mapping) self.assertEqual(value, "ABCTrueabc123") - # TODO: Python builtin functions - # value = parser.parse_data("ABC${ord(a)}DEF${len(abcd)}", variables_mapping, functions_mapping) - # self.assertEqual(value, "ABC97DEF4") + value = parser.parse_data("ABC${ord(a)}DEF${len(abcd)}", variables_mapping, functions_mapping) + self.assertEqual(value, "ABC97DEF4") def test_parse_data_func_var_duplicate(self): variables_mapping = { From 5dc580e93fe4371096c98405b8e745e989fdc99a Mon Sep 17 00:00:00 2001 From: debugtalk Date: Tue, 21 Apr 2020 13:02:14 +0800 Subject: [PATCH 040/169] refactor: replace with get_mapping_variable --- httprunner/v3/parser.py | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/httprunner/v3/parser.py b/httprunner/v3/parser.py index 617b0b02..c80c2a2e 100644 --- a/httprunner/v3/parser.py +++ b/httprunner/v3/parser.py @@ -182,6 +182,27 @@ def parse_function_params(params): return function_meta +def get_mapping_variable(variable_name: Text, variables_mapping: Dict[Text, Any]) -> Any: + """ get variable from variables_mapping. + + Args: + variable_name (str): variable name + variables_mapping (dict): variables mapping + + Returns: + mapping variable value. + + Raises: + exceptions.VariableNotFound: variable is not found. + + """ + # TODO: get variable from debugtalk module and environ + try: + return variables_mapping[variable_name] + except KeyError: + raise exceptions.VariableNotFound(f"{variable_name} not found in {variables_mapping}") + + def get_mapping_function(function_name: Text, functions_mapping: Dict[Text, Callable]) -> Callable: """ get function from functions_mapping, if not found, then try to check if builtin function. @@ -230,7 +251,7 @@ def get_mapping_function(function_name: Text, functions_mapping: Dict[Text, Call def parse_string( raw_string: Text, variables_mapping: Dict[Text, Any], - functions_mapping: Dict[Text, Callable]) -> Text: + functions_mapping: Dict[Text, Callable]) -> Any: """ parse string content with variables and functions mapping. Args: @@ -297,11 +318,7 @@ def parse_string( var_match = variable_regex_compile.match(raw_string, match_start_position) if var_match: var_name = var_match.group(1) or var_match.group(2) - # check if any variable undefined in variables_mapping - try: - var_value = variables_mapping[var_name] - except KeyError: - raise VariableNotFound(f"{var_name} not found in {variables_mapping}") + var_value = get_mapping_variable(var_name, variables_mapping) if f"${var_name}" == raw_string or "${" + var_name + "}" == raw_string: # raw_string is a variable, $var or ${var}, return its value directly From 58cb3b069162f661fe47f24d2496a51257439437 Mon Sep 17 00:00:00 2001 From: debugtalk Date: Tue, 21 Apr 2020 13:09:03 +0800 Subject: [PATCH 041/169] refactor: add argument typing --- httprunner/v3/parser.py | 28 ++++++++++++++-------------- httprunner/v3/runner.py | 6 +++--- httprunner/v3/schema/__init__.py | 9 +++++---- 3 files changed, 22 insertions(+), 21 deletions(-) diff --git a/httprunner/v3/parser.py b/httprunner/v3/parser.py index c80c2a2e..c0e12c7a 100644 --- a/httprunner/v3/parser.py +++ b/httprunner/v3/parser.py @@ -1,11 +1,11 @@ import ast import builtins import re -from typing import Any, Set, Text, Callable, Tuple, List, Dict, Union +from typing import Any, Set, Text, Callable, List, Dict from httprunner import loader, utils from httprunner.v3 import exceptions -from httprunner.v3.exceptions import VariableNotFound, FunctionNotFound +from httprunner.v3.schema import VariablesMapping, FunctionsMapping absolute_http_url_regexp = re.compile(r"^https?://", re.I) @@ -130,7 +130,7 @@ def extract_variables(content: Any) -> Set: return set() -def parse_function_params(params): +def parse_function_params(params: Text) -> Dict: """ parse function params to args and kwargs. Args: @@ -182,7 +182,7 @@ def parse_function_params(params): return function_meta -def get_mapping_variable(variable_name: Text, variables_mapping: Dict[Text, Any]) -> Any: +def get_mapping_variable(variable_name: Text, variables_mapping: VariablesMapping) -> Any: """ get variable from variables_mapping. Args: @@ -203,7 +203,7 @@ def get_mapping_variable(variable_name: Text, variables_mapping: Dict[Text, Any] raise exceptions.VariableNotFound(f"{variable_name} not found in {variables_mapping}") -def get_mapping_function(function_name: Text, functions_mapping: Dict[Text, Callable]) -> Callable: +def get_mapping_function(function_name: Text, functions_mapping: FunctionsMapping) -> Callable: """ get function from functions_mapping, if not found, then try to check if builtin function. @@ -250,8 +250,8 @@ def get_mapping_function(function_name: Text, functions_mapping: Dict[Text, Call def parse_string( raw_string: Text, - variables_mapping: Dict[Text, Any], - functions_mapping: Dict[Text, Callable]) -> Any: + variables_mapping: VariablesMapping, + functions_mapping: FunctionsMapping) -> Any: """ parse string content with variables and functions mapping. Args: @@ -346,8 +346,8 @@ def parse_string( def parse_data( raw_data: Any, - variables_mapping: Dict[Text, Any] = None, - functions_mapping: Dict[Text, Callable] = None) -> Any: + variables_mapping: VariablesMapping = None, + functions_mapping: FunctionsMapping = None) -> Any: """ parse raw data with evaluated variables mapping. Notice: variables_mapping should not contain any variable or function. """ @@ -379,10 +379,10 @@ def parse_data( def parse_variables_mapping( - variables_mapping: Dict[Text, Any], - functions_mapping: Dict[Text, Callable] = None) -> Dict[Text, Any]: + variables_mapping: VariablesMapping, + functions_mapping: FunctionsMapping = None) -> VariablesMapping: - parsed_variables: Dict[Text, Any] = {} + parsed_variables: VariablesMapping = {} while len(parsed_variables) != len(variables_mapping): for var_name in variables_mapping: @@ -409,12 +409,12 @@ def parse_variables_mapping( if not_defined_variables: # e.g. {"varA": "123$varB", "varB": "456$varC"} # e.g. {"varC": "${sum_two($a, $b)}"} - raise VariableNotFound(not_defined_variables) + raise exceptions.VariableNotFound(not_defined_variables) try: parsed_value = parse_data( var_value, parsed_variables, functions_mapping) - except VariableNotFound: + except exceptions.VariableNotFound: continue parsed_variables[var_name] = parsed_value diff --git a/httprunner/v3/runner.py b/httprunner/v3/runner.py index 3e3c3489..8ab7113b 100644 --- a/httprunner/v3/runner.py +++ b/httprunner/v3/runner.py @@ -5,7 +5,7 @@ from loguru import logger from httprunner.v3.parser import build_url, parse_data, parse_variables_mapping from httprunner.v3.response import ResponseObject -from httprunner.v3.schema import TestsConfig, TestStep +from httprunner.v3.schema import TestsConfig, TestStep, VariablesMapping class TestCaseRunner(object): @@ -18,11 +18,11 @@ class TestCaseRunner(object): self.session = s return self - def with_variables(self, **variables) -> "TestCaseRunner": + def with_variables(self, **variables: VariablesMapping) -> "TestCaseRunner": self.config.variables.update(variables) return self - def run_step(self, step): + def run_step(self, step: TestStep): logger.info(f"run step: {step.name}") # parse diff --git a/httprunner/v3/schema/__init__.py b/httprunner/v3/schema/__init__.py index ea1220d2..198e4b57 100644 --- a/httprunner/v3/schema/__init__.py +++ b/httprunner/v3/schema/__init__.py @@ -8,7 +8,8 @@ from pydantic import HttpUrl Name = Text Url = Text BaseUrl = Union[HttpUrl, Text] -Variables = Dict[Text, Any] +VariablesMapping = Dict[Text, Any] +FunctionsMapping = Dict[Text, Callable] Headers = Dict[Text, Text] Verify = bool Hook = List[Text] @@ -33,8 +34,8 @@ class TestsConfig(BaseModel): name: Name verify: Verify = False base_url: BaseUrl = "" - variables: Variables = {} - functions: Dict[Text, Callable] + variables: VariablesMapping = {} + functions: FunctionsMapping = {} setup_hooks: Hook = [] teardown_hooks: Hook = [] export: Export = [] @@ -56,6 +57,6 @@ class Request(BaseModel): class TestStep(BaseModel): name: Name request: Request - variables: Variables = {} + variables: VariablesMapping = {} extract: Dict[Text, Text] = {} validation: Validate = Field([], alias="validate") From 6a5e271acd64fc442995a3ff9af4e148fb67b6a6 Mon Sep 17 00:00:00 2001 From: debugtalk Date: Tue, 21 Apr 2020 13:30:36 +0800 Subject: [PATCH 042/169] refactor: change example file name --- .../{with_functions.yml => request_with_functions.yml} | 0 .../{with_functions_test.py => request_with_functions_test.py} | 0 .../{with_variables.yml => request_with_variables.yml} | 0 .../{with_variables_test.py => request_with_variables_test.py} | 0 4 files changed, 0 insertions(+), 0 deletions(-) rename examples/postman_echo/request_methods/{with_functions.yml => request_with_functions.yml} (100%) rename examples/postman_echo/request_methods/{with_functions_test.py => request_with_functions_test.py} (100%) rename examples/postman_echo/request_methods/{with_variables.yml => request_with_variables.yml} (100%) rename examples/postman_echo/request_methods/{with_variables_test.py => request_with_variables_test.py} (100%) diff --git a/examples/postman_echo/request_methods/with_functions.yml b/examples/postman_echo/request_methods/request_with_functions.yml similarity index 100% rename from examples/postman_echo/request_methods/with_functions.yml rename to examples/postman_echo/request_methods/request_with_functions.yml diff --git a/examples/postman_echo/request_methods/with_functions_test.py b/examples/postman_echo/request_methods/request_with_functions_test.py similarity index 100% rename from examples/postman_echo/request_methods/with_functions_test.py rename to examples/postman_echo/request_methods/request_with_functions_test.py diff --git a/examples/postman_echo/request_methods/with_variables.yml b/examples/postman_echo/request_methods/request_with_variables.yml similarity index 100% rename from examples/postman_echo/request_methods/with_variables.yml rename to examples/postman_echo/request_methods/request_with_variables.yml diff --git a/examples/postman_echo/request_methods/with_variables_test.py b/examples/postman_echo/request_methods/request_with_variables_test.py similarity index 100% rename from examples/postman_echo/request_methods/with_variables_test.py rename to examples/postman_echo/request_methods/request_with_variables_test.py From 0532e0d35dfa7806e9d1066e45d227709581973c Mon Sep 17 00:00:00 2001 From: debugtalk Date: Tue, 21 Apr 2020 14:26:57 +0800 Subject: [PATCH 043/169] feat: validate with config/teststep/extracted variables --- .../validate_with_variables.yml | 58 +++++++++++ .../validate_with_variables_test.py | 98 +++++++++++++++++++ httprunner/v3/response.py | 9 +- httprunner/v3/runner.py | 12 ++- httprunner/v3/schema/__init__.py | 4 +- 5 files changed, 173 insertions(+), 8 deletions(-) create mode 100644 examples/postman_echo/request_methods/validate_with_variables.yml create mode 100644 examples/postman_echo/request_methods/validate_with_variables_test.py diff --git a/examples/postman_echo/request_methods/validate_with_variables.yml b/examples/postman_echo/request_methods/validate_with_variables.yml new file mode 100644 index 00000000..7f77219f --- /dev/null +++ b/examples/postman_echo/request_methods/validate_with_variables.yml @@ -0,0 +1,58 @@ +config: + name: "request methods testcase: validate with variables" + variables: + foo1: session_bar1 + base_url: "https://postman-echo.com" + verify: False + +teststeps: +- + name: get with params + variables: + foo1: bar1 + foo2: session_bar2 + request: + method: GET + url: /get + params: + foo1: $foo1 + foo2: $foo2 + headers: + User-Agent: HttpRunner/3.0 + extract: + session_foo2: "body.args.foo2" + validate: + - eq: ["status_code", 200] + - eq: ["body.args.foo1", "$foo1"] + - eq: ["body.args.foo2", "$foo2"] +- + name: post raw text + variables: + foo1: "hello world" + foo3: "$session_foo2" + request: + method: POST + url: /post + headers: + User-Agent: HttpRunner/3.0 + 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-$foo3."] +- + name: post form data + variables: + foo1: bar1 + foo2: bar2 + request: + method: POST + url: /post + headers: + User-Agent: HttpRunner/3.0 + Content-Type: "application/x-www-form-urlencoded" + data: "foo1=$foo1&foo2=$foo2" + validate: + - eq: ["status_code", 200] + - eq: ["body.form.foo1", "$foo1"] + - eq: ["body.form.foo2", "$foo2"] diff --git a/examples/postman_echo/request_methods/validate_with_variables_test.py b/examples/postman_echo/request_methods/validate_with_variables_test.py new file mode 100644 index 00000000..14cd2e71 --- /dev/null +++ b/examples/postman_echo/request_methods/validate_with_variables_test.py @@ -0,0 +1,98 @@ +from httprunner.v3.runner import TestCaseRunner +from httprunner.v3.schema import TestsConfig, TestStep + + +class TestCaseRequestMethodsValidateWithVariables(TestCaseRunner): + config = TestsConfig(**{ + "name": "request methods testcase: validate with variables", + "variables": { + "foo1": "session_bar1" + }, + "base_url": "https://postman-echo.com", + "verify": False + }) + + teststeps = [ + TestStep(**{ + "name": "get with params", + "variables": { + "foo1": "bar1", + "foo2": "session_bar2" + }, + "request": { + "method": "GET", + "url": "/get", + "params": { + "foo1": "$foo1", + "foo2": "$foo2" + }, + "headers": { + "User-Agent": "HttpRunner/3.0" + } + }, + "extract": { + "session_foo2": "body.args.foo2" + }, + "validate": [ + {"eq": ["status_code", 200]}, + {"eq": ["body.args.foo1", "session_bar1"]}, + {"eq": ["body.args.foo1", "$foo1"]}, + {"eq": ["body.args.foo2", "session_bar2"]}, + {"eq": ["body.args.foo2", "$foo2"]} + ] + }), + 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/3.0", + "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." + ]}, + {"eq": [ + "body.data", + "This is expected to be sent back as part of response body: $foo1-$foo3." + ]}, + ] + }), + 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/3.0", + "Content-Type": "application/x-www-form-urlencoded" + } + }, + "validate": [ + {"eq": ["status_code", 200]}, + {"eq": ["body.form.foo1", "session_bar1"]}, + {"eq": ["body.form.foo1", "$foo1"]}, + {"eq": ["body.form.foo2", "bar2"]}, + {"eq": ["body.form.foo2", "$foo2"]} + ] + }) + ] + + +if __name__ == '__main__': + TestCaseRequestMethodsValidateWithVariables().run() diff --git a/httprunner/v3/response.py b/httprunner/v3/response.py index 5dbf9577..222e63b7 100644 --- a/httprunner/v3/response.py +++ b/httprunner/v3/response.py @@ -1,10 +1,12 @@ -from typing import Dict, Text, Any +from typing import Dict, Text, Any, NoReturn import jmespath import requests from loguru import logger from httprunner.v3.exceptions import ParamsError, ValidationFailure +from httprunner.v3.parser import parse_data +from httprunner.v3.schema import VariablesMapping, Validators from httprunner.v3.validator import uniform_validator, AssertMethods @@ -23,7 +25,7 @@ class ResponseObject(object): "body": resp_obj.json() } - def validate(self, validators): + def validate(self, validators: Validators, variables_mapping: VariablesMapping = None) -> NoReturn: for v in validators: u_validator = uniform_validator(v) @@ -39,6 +41,9 @@ class ResponseObject(object): except AttributeError: raise ParamsError(f"Assert Method not supported: {assert_method}") + # parse expected value with config/teststep/extracted variables + expect_value = parse_data(expect_value, variables_mapping) + try: assert_func(actual_value, expect_value) msg += " - success" diff --git a/httprunner/v3/runner.py b/httprunner/v3/runner.py index 8ab7113b..45c92ad3 100644 --- a/httprunner/v3/runner.py +++ b/httprunner/v3/runner.py @@ -44,13 +44,17 @@ class TestCaseRunner(object): resp = session.request(method, url, **parsed_request_dict) resp_obj = ResponseObject(resp) - # validate - validators = step.validation - resp_obj.validate(validators) - # extract extractors = step.extract extract_mapping = resp_obj.extract(extractors) + + variables_mapping = step.variables + variables_mapping.update(extract_mapping) + + # validate + validators = step.validation + resp_obj.validate(validators, variables_mapping) + return extract_mapping def test_start(self): diff --git a/httprunner/v3/schema/__init__.py b/httprunner/v3/schema/__init__.py index 198e4b57..00577a4d 100644 --- a/httprunner/v3/schema/__init__.py +++ b/httprunner/v3/schema/__init__.py @@ -14,7 +14,7 @@ Headers = Dict[Text, Text] Verify = bool Hook = List[Text] Export = List[Text] -Validate = List[Dict] +Validators = List[Dict] Env = Dict[Text, Any] @@ -59,4 +59,4 @@ class TestStep(BaseModel): request: Request variables: VariablesMapping = {} extract: Dict[Text, Text] = {} - validation: Validate = Field([], alias="validate") + validation: Validators = Field([], alias="validate") From 96b5a127cd6433559e77c74657ef78c18cd5eef2 Mon Sep 17 00:00:00 2001 From: debugtalk Date: Tue, 21 Apr 2020 14:33:01 +0800 Subject: [PATCH 044/169] refactor: rename TestStep model field, validators --- httprunner/v3/runner.py | 2 +- httprunner/v3/schema/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/httprunner/v3/runner.py b/httprunner/v3/runner.py index 45c92ad3..0048de7e 100644 --- a/httprunner/v3/runner.py +++ b/httprunner/v3/runner.py @@ -52,7 +52,7 @@ class TestCaseRunner(object): variables_mapping.update(extract_mapping) # validate - validators = step.validation + validators = step.validators resp_obj.validate(validators, variables_mapping) return extract_mapping diff --git a/httprunner/v3/schema/__init__.py b/httprunner/v3/schema/__init__.py index 00577a4d..ae426451 100644 --- a/httprunner/v3/schema/__init__.py +++ b/httprunner/v3/schema/__init__.py @@ -59,4 +59,4 @@ class TestStep(BaseModel): request: Request variables: VariablesMapping = {} extract: Dict[Text, Text] = {} - validation: Validators = Field([], alias="validate") + validators: Validators = Field([], alias="validate") From 41f819b9cb359062798e17036e50f9f2d3feb018 Mon Sep 17 00:00:00 2001 From: debugtalk Date: Tue, 21 Apr 2020 14:45:24 +0800 Subject: [PATCH 045/169] feat: validate with functions --- .../validate_with_functions.yml | 60 +++++++++++++++++++ .../validate_with_functions_test.py | 53 ++++++++++++++++ httprunner/v3/response.py | 12 ++-- httprunner/v3/runner.py | 2 +- 4 files changed, 122 insertions(+), 5 deletions(-) create mode 100644 examples/postman_echo/request_methods/validate_with_functions.yml create mode 100644 examples/postman_echo/request_methods/validate_with_functions_test.py diff --git a/examples/postman_echo/request_methods/validate_with_functions.yml b/examples/postman_echo/request_methods/validate_with_functions.yml new file mode 100644 index 00000000..ea2c6a40 --- /dev/null +++ b/examples/postman_echo/request_methods/validate_with_functions.yml @@ -0,0 +1,60 @@ +config: + name: "request methods testcase: validate 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.sum_v", "3"] + - less_than: ["body.args.sum_v", "${sum_two(2, 2)}"] +- + 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"] diff --git a/examples/postman_echo/request_methods/validate_with_functions_test.py b/examples/postman_echo/request_methods/validate_with_functions_test.py new file mode 100644 index 00000000..8f600dfb --- /dev/null +++ b/examples/postman_echo/request_methods/validate_with_functions_test.py @@ -0,0 +1,53 @@ +from httprunner.v3.runner import TestCaseRunner +from httprunner.v3.schema import TestsConfig, TestStep +from examples.postman_echo import debugtalk + + +class TestCaseRequestMethodsValidateWithFunctions(TestCaseRunner): + config = TestsConfig(**{ + "name": "request methods testcase: validate 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.sum_v", 3]}, + {"less_than": ["body.args.sum_v", "${sum_two(2, 2)}"]} + ] + }) + ] + + +if __name__ == '__main__': + TestCaseRequestMethodsValidateWithFunctions().run() diff --git a/httprunner/v3/response.py b/httprunner/v3/response.py index 222e63b7..8523a688 100644 --- a/httprunner/v3/response.py +++ b/httprunner/v3/response.py @@ -5,8 +5,8 @@ import requests from loguru import logger from httprunner.v3.exceptions import ParamsError, ValidationFailure -from httprunner.v3.parser import parse_data -from httprunner.v3.schema import VariablesMapping, Validators +from httprunner.v3.parser import parse_data, parse_string_value +from httprunner.v3.schema import VariablesMapping, Validators, FunctionsMapping from httprunner.v3.validator import uniform_validator, AssertMethods @@ -25,7 +25,10 @@ class ResponseObject(object): "body": resp_obj.json() } - def validate(self, validators: Validators, variables_mapping: VariablesMapping = None) -> NoReturn: + def validate(self, + validators: Validators, + variables_mapping: VariablesMapping = None, + functions_mapping: FunctionsMapping = None) -> NoReturn: for v in validators: u_validator = uniform_validator(v) @@ -41,8 +44,9 @@ class ResponseObject(object): except AttributeError: raise ParamsError(f"Assert Method not supported: {assert_method}") + actual_value = parse_string_value(actual_value) # parse expected value with config/teststep/extracted variables - expect_value = parse_data(expect_value, variables_mapping) + expect_value = parse_data(expect_value, variables_mapping, functions_mapping) try: assert_func(actual_value, expect_value) diff --git a/httprunner/v3/runner.py b/httprunner/v3/runner.py index 0048de7e..53b27d11 100644 --- a/httprunner/v3/runner.py +++ b/httprunner/v3/runner.py @@ -53,7 +53,7 @@ class TestCaseRunner(object): # validate validators = step.validators - resp_obj.validate(validators, variables_mapping) + resp_obj.validate(validators, variables_mapping, self.config.functions) return extract_mapping From 94cf2b074fa6c0250e807e068b770ea7b2e095ef Mon Sep 17 00:00:00 2001 From: debugtalk Date: Tue, 21 Apr 2020 14:54:28 +0800 Subject: [PATCH 046/169] refactor: validate with builtin assert methods --- httprunner/v3/response.py | 12 ++++-------- httprunner/v3/validator.py | 15 --------------- 2 files changed, 4 insertions(+), 23 deletions(-) diff --git a/httprunner/v3/response.py b/httprunner/v3/response.py index 8523a688..9c35ae4d 100644 --- a/httprunner/v3/response.py +++ b/httprunner/v3/response.py @@ -4,10 +4,10 @@ import jmespath import requests from loguru import logger -from httprunner.v3.exceptions import ParamsError, ValidationFailure -from httprunner.v3.parser import parse_data, parse_string_value +from httprunner.v3.exceptions import ValidationFailure +from httprunner.v3.parser import parse_data, parse_string_value, get_mapping_function from httprunner.v3.schema import VariablesMapping, Validators, FunctionsMapping -from httprunner.v3.validator import uniform_validator, AssertMethods +from httprunner.v3.validator import uniform_validator class ResponseObject(object): @@ -39,11 +39,7 @@ class ResponseObject(object): msg = f"assert {field} {assert_method} {expect_value}" - try: - assert_func = getattr(AssertMethods, assert_method) - except AttributeError: - raise ParamsError(f"Assert Method not supported: {assert_method}") - + assert_func = get_mapping_function(assert_method, functions_mapping) actual_value = parse_string_value(actual_value) # parse expected value with config/teststep/extracted variables expect_value = parse_data(expect_value, variables_mapping, functions_mapping) diff --git a/httprunner/v3/validator.py b/httprunner/v3/validator.py index cc0b39b9..c1278c2e 100644 --- a/httprunner/v3/validator.py +++ b/httprunner/v3/validator.py @@ -89,18 +89,3 @@ def uniform_validator(validator): "expect": expect_value, "assert": assert_method } - - -class AssertMethods(object): - - @staticmethod - def equals(actual_value, expect_value): - assert actual_value == expect_value - - @staticmethod - def less_than(actual_value, expect_value): - assert actual_value < expect_value - - @staticmethod - def greater_than(actual_value, expect_value): - assert actual_value > expect_value From a4328b7586976005621b11ef08dcb383ee61b82f Mon Sep 17 00:00:00 2001 From: debugtalk Date: Tue, 21 Apr 2020 15:13:48 +0800 Subject: [PATCH 047/169] fix: show assert value type when validation failed --- .../request_with_functions_test.py | 2 +- .../validate_with_functions.yml | 31 ------------------- httprunner/v3/response.py | 4 ++- 3 files changed, 4 insertions(+), 33 deletions(-) diff --git a/examples/postman_echo/request_methods/request_with_functions_test.py b/examples/postman_echo/request_methods/request_with_functions_test.py index b8bd9126..3de4c44a 100644 --- a/examples/postman_echo/request_methods/request_with_functions_test.py +++ b/examples/postman_echo/request_methods/request_with_functions_test.py @@ -44,7 +44,7 @@ class TestCaseRequestMethodsWithFunctions(TestCaseRunner): {"eq": ["status_code", 200]}, {"eq": ["body.args.foo1", "session_bar1"]}, {"eq": ["body.args.foo2", "session_bar2"]}, - {"eq": ["body.args.sum_v", "3"]} + {"eq": ["body.args.sum_v", 3]} ] }), TestStep(**{ diff --git a/examples/postman_echo/request_methods/validate_with_functions.yml b/examples/postman_echo/request_methods/validate_with_functions.yml index ea2c6a40..8de9242b 100644 --- a/examples/postman_echo/request_methods/validate_with_functions.yml +++ b/examples/postman_echo/request_methods/validate_with_functions.yml @@ -27,34 +27,3 @@ teststeps: - eq: ["status_code", 200] - eq: ["body.args.sum_v", "3"] - less_than: ["body.args.sum_v", "${sum_two(2, 2)}"] -- - 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"] diff --git a/httprunner/v3/response.py b/httprunner/v3/response.py index 9c35ae4d..d4546962 100644 --- a/httprunner/v3/response.py +++ b/httprunner/v3/response.py @@ -51,7 +51,9 @@ class ResponseObject(object): except AssertionError: msg += " - fail" logger.error(msg) - raise ValidationFailure(f"assert {field}: {actual_value} {assert_method} {expect_value}") + actual_type = type(actual_value).__name__ + expect_type = type(expect_value).__name__ + raise ValidationFailure(f"assert {field}: {actual_value}({actual_type}) {assert_method} {expect_value}({expect_type})") def extract(self, extractors: Dict[Text, Text]) -> Dict[Text, Any]: if not extractors: From a98d4113340029e49664f869c105f467e0c32709 Mon Sep 17 00:00:00 2001 From: debugtalk Date: Tue, 21 Apr 2020 18:55:31 +0800 Subject: [PATCH 048/169] feat: get request & response meta datas --- .../validate_with_functions.yml | 2 +- .../validate_with_variables_test.py | 3 ++- httprunner/v3/runner.py | 23 ++++++++++++++----- httprunner/v3/schema/__init__.py | 23 ++++++++++++++++++- 4 files changed, 42 insertions(+), 9 deletions(-) diff --git a/examples/postman_echo/request_methods/validate_with_functions.yml b/examples/postman_echo/request_methods/validate_with_functions.yml index 8de9242b..41aca935 100644 --- a/examples/postman_echo/request_methods/validate_with_functions.yml +++ b/examples/postman_echo/request_methods/validate_with_functions.yml @@ -25,5 +25,5 @@ teststeps: session_foo2: "body.args.foo2" validate: - eq: ["status_code", 200] - - eq: ["body.args.sum_v", "3"] + - eq: ["body.args.sum_v", 3] - less_than: ["body.args.sum_v", "${sum_two(2, 2)}"] diff --git a/examples/postman_echo/request_methods/validate_with_variables_test.py b/examples/postman_echo/request_methods/validate_with_variables_test.py index 14cd2e71..840e5ce1 100644 --- a/examples/postman_echo/request_methods/validate_with_variables_test.py +++ b/examples/postman_echo/request_methods/validate_with_variables_test.py @@ -95,4 +95,5 @@ class TestCaseRequestMethodsValidateWithVariables(TestCaseRunner): if __name__ == '__main__': - TestCaseRequestMethodsValidateWithVariables().run() + runner = TestCaseRequestMethodsValidateWithVariables().run() + print(runner.meta_datas) diff --git a/httprunner/v3/runner.py b/httprunner/v3/runner.py index 53b27d11..297aac63 100644 --- a/httprunner/v3/runner.py +++ b/httprunner/v3/runner.py @@ -1,20 +1,26 @@ from typing import List -import requests from loguru import logger +from httprunner.client import HttpSession from httprunner.v3.parser import build_url, parse_data, parse_variables_mapping from httprunner.v3.response import ResponseObject -from httprunner.v3.schema import TestsConfig, TestStep, VariablesMapping +from httprunner.v3.schema import TestsConfig, TestStep, VariablesMapping, TestCase class TestCaseRunner(object): config: TestsConfig = {} teststeps: List[TestStep] = [] - session: requests.Session = None + session: HttpSession = None + meta_datas: List = [] - def with_session(self, s: requests.Session) -> "TestCaseRunner": + def init(self, testcase: TestCase) -> "TestCaseRunner": + self.config = testcase.config + self.teststeps = testcase.teststeps + return self + + def with_session(self, s: HttpSession) -> "TestCaseRunner": self.session = s return self @@ -40,8 +46,8 @@ class TestCaseRunner(object): logger.debug(f"request kwargs(raw): {parsed_request_dict}") # request - session = self.session or requests.Session() - resp = session.request(method, url, **parsed_request_dict) + self.session = self.session or HttpSession() + resp = self.session.request(method, url, **parsed_request_dict) resp_obj = ResponseObject(resp) # extract @@ -59,6 +65,7 @@ class TestCaseRunner(object): def test_start(self): """main entrance""" + self.meta_datas.clear() session_variables = {} for step in self.teststeps: # update with config variables @@ -71,6 +78,10 @@ class TestCaseRunner(object): extract_mapping = self.run_step(step) # save extracted variables to session variables session_variables.update(extract_mapping) + # save request & response meta data + self.meta_datas.append(self.session.meta_data) + + return self def run(self): """main entrance alias for test_start""" diff --git a/httprunner/v3/schema/__init__.py b/httprunner/v3/schema/__init__.py index ae426451..5d75ac71 100644 --- a/httprunner/v3/schema/__init__.py +++ b/httprunner/v3/schema/__init__.py @@ -1,6 +1,7 @@ from enum import Enum from typing import Any -from typing import Dict, List, Text, Union, Callable +from typing import Dict, Text, Union, Callable +from typing import List from pydantic import BaseModel, Field from pydantic import HttpUrl @@ -56,7 +57,27 @@ class Request(BaseModel): class TestStep(BaseModel): name: Name + times: int = 1 request: Request variables: VariablesMapping = {} extract: Dict[Text, Text] = {} validators: Validators = Field([], alias="validate") + + +class TestCase(BaseModel): + config: TestsConfig + teststeps: List[TestStep] + + +class ProjectMeta(BaseModel): + debugtalk_py: Text = "" + variables: VariablesMapping = {} + functions: FunctionsMapping = {} + env: Env = {} + PWD: Text + test_path: Text + + +class TestsMapping(BaseModel): + project_mapping: ProjectMeta # TODO: rename to project_meta + testcases: List[TestCase] From f6a6e91c864126b140155ba3d302d62ddaee3dd1 Mon Sep 17 00:00:00 2001 From: debugtalk Date: Tue, 21 Apr 2020 21:19:05 +0800 Subject: [PATCH 049/169] fix: record teststep name --- httprunner/v3/runner.py | 1 + 1 file changed, 1 insertion(+) diff --git a/httprunner/v3/runner.py b/httprunner/v3/runner.py index 297aac63..cf94d148 100644 --- a/httprunner/v3/runner.py +++ b/httprunner/v3/runner.py @@ -79,6 +79,7 @@ class TestCaseRunner(object): # save extracted variables to session variables session_variables.update(extract_mapping) # save request & response meta data + self.session.meta_data["name"] = step.name self.meta_datas.append(self.session.meta_data) return self From 8620c2e1fafb80391ba8988d97977476494048ea Mon Sep 17 00:00:00 2001 From: debugtalk Date: Tue, 21 Apr 2020 21:22:00 +0800 Subject: [PATCH 050/169] refactor: make run_step as private method --- httprunner/v3/runner.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/httprunner/v3/runner.py b/httprunner/v3/runner.py index cf94d148..bbf2a30f 100644 --- a/httprunner/v3/runner.py +++ b/httprunner/v3/runner.py @@ -28,7 +28,7 @@ class TestCaseRunner(object): self.config.variables.update(variables) return self - def run_step(self, step: TestStep): + def __run_step(self, step: TestStep): logger.info(f"run step: {step.name}") # parse @@ -75,7 +75,7 @@ class TestCaseRunner(object): # parse variables step.variables = parse_variables_mapping(step.variables, self.config.functions) # run step - extract_mapping = self.run_step(step) + extract_mapping = self.__run_step(step) # save extracted variables to session variables session_variables.update(extract_mapping) # save request & response meta data From 71e65e02d52cb82ff671e4e5461d00022f4db030 Mon Sep 17 00:00:00 2001 From: debugtalk Date: Tue, 21 Apr 2020 21:31:09 +0800 Subject: [PATCH 051/169] feat: implement HttpRunner main interface v3 --- httprunner/v3/api.py | 256 ++++++++++++++++++++++++++++++++++++++ httprunner/v3/api_test.py | 17 +++ 2 files changed, 273 insertions(+) create mode 100644 httprunner/v3/api.py create mode 100644 httprunner/v3/api_test.py diff --git a/httprunner/v3/api.py b/httprunner/v3/api.py new file mode 100644 index 00000000..e77f8a30 --- /dev/null +++ b/httprunner/v3/api.py @@ -0,0 +1,256 @@ +import os +import sys +import unittest +from typing import List, Tuple + +from loguru import logger + +from httprunner import report, loader, utils, exceptions, __version__ +from httprunner.v3.runner import TestCaseRunner +from httprunner.v3.schema import TestsMapping + + +class HttpRunner(object): + """ Developer Interface: Main Interface + Usage: + + from httprunner.api import HttpRunner + runner = HttpRunner( + failfast=True, + save_tests=True, + log_level="INFO", + log_file="test.log" + ) + summary = runner.run(path_or_tests) + + """ + + def __init__(self, failfast=False, save_tests=False, log_level="WARNING", log_file=None): + """ initialize HttpRunner. + + Args: + failfast (bool): stop the test run on the first error or failure. + save_tests (bool): save loaded/parsed tests to JSON file. + log_level (str): logging level. + log_file (str): log file path. + + """ + self.exception_stage = "initialize HttpRunner()" + kwargs = { + "failfast": failfast, + "resultclass": report.HtmlTestResult + } + + logger.remove() + log_level = log_level.upper() + logger.add(sys.stdout, level=log_level) + if log_file: + logger.add(log_file, level=log_level) + + self.unittest_runner = unittest.TextTestRunner(**kwargs) + self.test_loader = unittest.TestLoader() + self.save_tests = save_tests + self._summary = None + self.test_path = None + + def _prepare_tests(self, tests: TestsMapping) -> List[unittest.TestSuite]: + def _add_test(test_runner: TestCaseRunner): + """ add test to testcase. + """ + def test(self): + try: + test_runner.run() + except exceptions.MyBaseFailure as ex: + self.fail(str(ex)) + finally: + self.meta_datas = test_runner.meta_datas + + test.__doc__ = test_runner.config.name + return test + + project_meta = tests.project_mapping + testcases = tests.testcases + + prepared_testcases: List[unittest.TestSuite] = [] + + for testcase in testcases: + testcase.config.variables.update(project_meta.variables) + testcase.config.functions.update(project_meta.functions) + + test_runner = TestCaseRunner().init(testcase) + + TestSequense = type('TestSequense', (unittest.TestCase,), {}) + test_method = _add_test(test_runner) + setattr(TestSequense, "test_method_name", test_method) + + loaded_testcase = self.test_loader.loadTestsFromTestCase(TestSequense) + setattr(loaded_testcase, "config", testcase.config) + # setattr(loaded_testcase, "teststeps", testcase.teststeps) + # setattr(loaded_testcase, "runner", test_runner) + prepared_testcases.append(loaded_testcase) + + return prepared_testcases + + def _run_suite(self, prepared_testcases: List[unittest.TestSuite]) -> List[Tuple]: + """ run prepared testcases + """ + tests_results: List[Tuple] = [] + + for index, testcase in enumerate(prepared_testcases): + log_handler = None + if self.save_tests: + logs_file_abs_path = utils.prepare_log_file_abs_path( + self.test_path, f"testcase_{index+1}.log" + ) + log_handler = logger.add(logs_file_abs_path, level="DEBUG") + + logger.info(f"Start to run testcase: {testcase.config.name}") + + result = self.unittest_runner.run(testcase) + if result.wasSuccessful(): + tests_results.append((testcase, result)) + else: + tests_results.insert(0, (testcase, result)) + + if self.save_tests and log_handler: + logger.remove(log_handler) + + return tests_results + + def _aggregate(self, tests_results: List[Tuple]): + """ aggregate results + + Args: + tests_results (list): list of (testcase, result) + + """ + summary = { + "success": True, + "stat": { + "testcases": { + "total": len(tests_results), + "success": 0, + "fail": 0 + }, + "teststeps": {} + }, + "time": {}, + "platform": report.get_platform(), + "details": [] + } + + for index, tests_result in enumerate(tests_results): + testcase, result = tests_result + testcase_summary = report.get_summary(result) + + if testcase_summary["success"]: + summary["stat"]["testcases"]["success"] += 1 + else: + summary["stat"]["testcases"]["fail"] += 1 + + summary["success"] &= testcase_summary["success"] + testcase_summary["name"] = testcase.config.name + testcase_summary["in_out"] = { + "in": testcase.config.variables, + "out": testcase.config.export + } + + report.aggregate_stat(summary["stat"]["teststeps"], testcase_summary["stat"]) + report.aggregate_stat(summary["time"], testcase_summary["time"]) + + if self.save_tests: + logs_file_abs_path = utils.prepare_log_file_abs_path( + self.test_path, f"testcase_{index+1}.log" + ) + testcase_summary["log"] = logs_file_abs_path + + summary["details"].append(testcase_summary) + + return summary + + def run_tests(self, tests_mapping): + """ run testcase/testsuite data + """ + tests = TestsMapping.parse_obj(tests_mapping) + self.test_path = tests.project_mapping.test_path + + if self.save_tests: + utils.dump_json_file( + tests_mapping, + utils.prepare_log_file_abs_path(self.test_path, "loaded.json") + ) + + # prepare testcases + self.exception_stage = "prepare testcases" + prepared_testcases = self._prepare_tests(tests) + + # run prepared testcases + self.exception_stage = "run prepared testcases" + results = self._run_suite(prepared_testcases) + + # aggregate results + self.exception_stage = "aggregate results" + self._summary = self._aggregate(results) + + # generate html report + self.exception_stage = "generate html report" + report.stringify_summary(self._summary) + + if self.save_tests: + utils.dump_json_file( + self._summary, + utils.prepare_log_file_abs_path(self.test_path, "summary.json") + ) + # save variables and export data + vars_out = self.get_vars_out() # TODO + utils.dump_json_file( + vars_out, + utils.prepare_log_file_abs_path(self.test_path, "io.json") + ) + + return self._summary + + def run_path(self, path, dot_env_path=None, mapping=None): + """ run testcase/testsuite file or folder. + + Args: + path (str): testcase/testsuite file/foler path. + dot_env_path (str): specified .env file path. + mapping (dict): if mapping is specified, it will override variables in config block. + + Returns: + dict: result summary + + """ + # load tests + self.exception_stage = "load tests" + tests_mapping = loader.load_cases(path, dot_env_path) + + if mapping: + tests_mapping["project_mapping"]["variables"] = mapping + + return self.run_tests(tests_mapping) + + def run(self, path_or_tests, dot_env_path=None, mapping=None): + """ main interface. + + Args: + path_or_tests: + str: testcase/testsuite file/foler path + dict: valid testcase/testsuite data + dot_env_path (str): specified .env file path. + mapping (dict): if mapping is specified, it will override variables in config block. + + Returns: + dict: result summary + + """ + logger.info(f"HttpRunner version: {__version__}") + if loader.is_test_path(path_or_tests): + return self.run_path(path_or_tests, dot_env_path, mapping) + elif loader.is_test_content(path_or_tests): + project_working_directory = path_or_tests.get("project_mapping", {}).get("PWD", os.getcwd()) + loader.init_pwd(project_working_directory) + return self.run_tests(path_or_tests) + else: + raise exceptions.ParamsError(f"Invalid testcase path or testcases: {path_or_tests}") diff --git a/httprunner/v3/api_test.py b/httprunner/v3/api_test.py new file mode 100644 index 00000000..418c6f15 --- /dev/null +++ b/httprunner/v3/api_test.py @@ -0,0 +1,17 @@ +import unittest + +from httprunner.v3.api import HttpRunner + + +class TestHttpRunner(unittest.TestCase): + + def setUp(self): + self.runner = HttpRunner(failfast=True) + + def test_run_testcase_by_path(self): + summary = self.runner.run_path("examples/postman_echo/request_methods/request_with_variables.yml") + self.assertTrue(summary["success"]) + self.assertEqual(summary["details"][0]["name"], "request methods testcase with variables") + self.assertEqual(summary["details"][0]["records"][0]["name"], "request methods testcase with variables") + self.assertEqual(summary["stat"]["testcases"]["total"], 1) + # self.assertEqual(summary["stat"]["teststeps"]["total"], 2) From 8f3cb320a4772e075ae76880dcfa8015f85ae655 Mon Sep 17 00:00:00 2001 From: debugtalk Date: Tue, 21 Apr 2020 22:25:03 +0800 Subject: [PATCH 052/169] feat: html report for v3 --- httprunner/cli.py | 4 +- httprunner/v3/response.py | 99 +++++++++++++++++++++++++++------------ httprunner/v3/runner.py | 10 +++- 3 files changed, 80 insertions(+), 33 deletions(-) diff --git a/httprunner/cli.py b/httprunner/cli.py index 4aac1b87..f828e431 100644 --- a/httprunner/cli.py +++ b/httprunner/cli.py @@ -7,7 +7,7 @@ if len(sys.argv) >= 2 and sys.argv[1] == "locusts": try: from gevent import monkey monkey.patch_ssl() - from locust.main import main + from locust.main import main as _ except ImportError: msg = """ Locust is not installed, install first and try again. @@ -20,7 +20,7 @@ $ pip install locustio from loguru import logger from httprunner import __description__, __version__ -from httprunner.api import HttpRunner +from httprunner.v3.api import HttpRunner from httprunner.ext.har2case import init_har2case_parser, main_har2case from httprunner.ext.scaffold import init_parser_scaffold, main_scaffold from httprunner.ext.locusts import init_parser_locusts, main_locusts diff --git a/httprunner/v3/response.py b/httprunner/v3/response.py index d4546962..81404b80 100644 --- a/httprunner/v3/response.py +++ b/httprunner/v3/response.py @@ -24,36 +24,7 @@ class ResponseObject(object): "headers": resp_obj.headers, "body": resp_obj.json() } - - def validate(self, - validators: Validators, - variables_mapping: VariablesMapping = None, - functions_mapping: FunctionsMapping = None) -> NoReturn: - - for v in validators: - u_validator = uniform_validator(v) - field = u_validator["check"] - assert_method = u_validator["assert"] - expect_value = u_validator["expect"] - actual_value = jmespath.search(field, self.resp_obj_meta) - - msg = f"assert {field} {assert_method} {expect_value}" - - assert_func = get_mapping_function(assert_method, functions_mapping) - actual_value = parse_string_value(actual_value) - # parse expected value with config/teststep/extracted variables - expect_value = parse_data(expect_value, variables_mapping, functions_mapping) - - try: - assert_func(actual_value, expect_value) - msg += " - success" - logger.info(msg) - except AssertionError: - msg += " - fail" - logger.error(msg) - actual_type = type(actual_value).__name__ - expect_type = type(expect_value).__name__ - raise ValidationFailure(f"assert {field}: {actual_value}({actual_type}) {assert_method} {expect_value}({expect_type})") + self.validation_results = {} def extract(self, extractors: Dict[Text, Text]) -> Dict[Text, Any]: if not extractors: @@ -66,3 +37,71 @@ class ResponseObject(object): logger.info(f"extract mapping: {extract_mapping}") return extract_mapping + + def validate(self, + validators: Validators, + variables_mapping: VariablesMapping = None, + functions_mapping: FunctionsMapping = None) -> NoReturn: + + self.validation_results = {} + if not validators: + return + + validate_pass = True + failures = [] + + for v in validators: + + if "validate_extractor" not in self.validation_results: + self.validation_results["validate_extractor"] = [] + + u_validator = uniform_validator(v) + + # check item + check_item = u_validator["check"] + check_value = jmespath.search(check_item, self.resp_obj_meta) + check_value = parse_string_value(check_value) + + # comparator + assert_method = u_validator["assert"] + assert_func = get_mapping_function(assert_method, functions_mapping) + + # expect item + expect_item = u_validator["expect"] + # parse expected value with config/teststep/extracted variables + expect_value = parse_data(expect_item, variables_mapping, functions_mapping) + + validate_msg = f"assert {check_item} {assert_method} {expect_value}({type(expect_value).__name__})" + + validator_dict = { + "comparator": assert_method, + "check": check_item, + "check_value": check_value, + "expect": expect_item, + "expect_value": expect_value + } + + try: + assert_func(check_value, expect_value) + validate_msg += "\t==> pass" + logger.info(validate_msg) + validator_dict["check_result"] = "pass" + except AssertionError: + validate_pass = False + validator_dict["check_result"] = "fail" + validate_msg += "\t==> fail" + validate_msg += "\n{}({}) {} {}({})".format( + check_value, + type(check_value).__name__, + assert_method, + expect_value, + type(expect_value).__name__ + ) + logger.error(validate_msg) + failures.append(validate_msg) + + self.validation_results["validate_extractor"].append(validator_dict) + + if not validate_pass: + failures_string = "\n".join([failure for failure in failures]) + raise ValidationFailure(failures_string) diff --git a/httprunner/v3/runner.py b/httprunner/v3/runner.py index bbf2a30f..1225e795 100644 --- a/httprunner/v3/runner.py +++ b/httprunner/v3/runner.py @@ -3,6 +3,7 @@ from typing import List from loguru import logger from httprunner.client import HttpSession +from httprunner.v3.exceptions import ValidationFailure from httprunner.v3.parser import build_url, parse_data, parse_variables_mapping from httprunner.v3.response import ResponseObject from httprunner.v3.schema import TestsConfig, TestStep, VariablesMapping, TestCase @@ -14,6 +15,7 @@ class TestCaseRunner(object): teststeps: List[TestStep] = [] session: HttpSession = None meta_datas: List = [] + validation_results: List = [] def init(self, testcase: TestCase) -> "TestCaseRunner": self.config = testcase.config @@ -59,7 +61,12 @@ class TestCaseRunner(object): # validate validators = step.validators - resp_obj.validate(validators, variables_mapping, self.config.functions) + try: + resp_obj.validate(validators, variables_mapping, self.config.functions) + except ValidationFailure: + raise + finally: + self.validation_results = resp_obj.validation_results return extract_mapping @@ -79,6 +86,7 @@ class TestCaseRunner(object): # save extracted variables to session variables session_variables.update(extract_mapping) # save request & response meta data + self.session.meta_data["validators"] = self.validation_results self.session.meta_data["name"] = step.name self.meta_datas.append(self.session.meta_data) From 3a012f012e8924abb4a4e6cea66d5530b48163e6 Mon Sep 17 00:00:00 2001 From: debugtalk Date: Tue, 21 Apr 2020 22:42:14 +0800 Subject: [PATCH 053/169] feat: log detailed request & response when failed --- httprunner/v3/response.py | 31 +++++++++++++++++++++++-------- httprunner/v3/runner.py | 24 ++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 8 deletions(-) diff --git a/httprunner/v3/response.py b/httprunner/v3/response.py index 81404b80..ce4dde7c 100644 --- a/httprunner/v3/response.py +++ b/httprunner/v3/response.py @@ -4,7 +4,7 @@ import jmespath import requests from loguru import logger -from httprunner.v3.exceptions import ValidationFailure +from httprunner.v3.exceptions import ValidationFailure, ParamsError from httprunner.v3.parser import parse_data, parse_string_value, get_mapping_function from httprunner.v3.schema import VariablesMapping, Validators, FunctionsMapping from httprunner.v3.validator import uniform_validator @@ -19,6 +19,7 @@ class ResponseObject(object): resp_obj (instance): requests.Response instance """ + self.resp_obj = resp_obj self.resp_obj_meta = { "status_code": resp_obj.status_code, "headers": resp_obj.headers, @@ -26,6 +27,22 @@ class ResponseObject(object): } self.validation_results = {} + 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 {} @@ -90,13 +107,11 @@ class ResponseObject(object): validate_pass = False validator_dict["check_result"] = "fail" validate_msg += "\t==> fail" - validate_msg += "\n{}({}) {} {}({})".format( - check_value, - type(check_value).__name__, - assert_method, - expect_value, - type(expect_value).__name__ - ) + validate_msg += f"\n" \ + f"check_item: {check_item}\n" \ + f"check_value: {check_value}({type(check_value).__name__})\n" \ + f"assert_method: {assert_method}\n" \ + f"expect_value: {expect_value}({type(expect_value).__name__})" logger.error(validate_msg) failures.append(validate_msg) diff --git a/httprunner/v3/runner.py b/httprunner/v3/runner.py index 1225e795..2b0be8c9 100644 --- a/httprunner/v3/runner.py +++ b/httprunner/v3/runner.py @@ -2,6 +2,7 @@ from typing import List from loguru import logger +from httprunner import utils from httprunner.client import HttpSession from httprunner.v3.exceptions import ValidationFailure from httprunner.v3.parser import build_url, parse_data, parse_variables_mapping @@ -52,6 +53,28 @@ class TestCaseRunner(object): resp = self.session.request(method, url, **parsed_request_dict) resp_obj = ResponseObject(resp) + def log_req_resp_details(): + err_msg = "\n{} DETAILED REQUEST & RESPONSE {}\n".format("*" * 32, "*" * 32) + + # log request + err_msg += "====== request details ======\n" + err_msg += f"url: {url}\n" + err_msg += f"method: {method}\n" + headers = parsed_request_dict.pop("headers", {}) + err_msg += f"headers: {headers}\n" + for k, v in parsed_request_dict.items(): + v = utils.omit_long_data(v) + err_msg += f"{k}: {repr(v)}\n" + + err_msg += "\n" + + # 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" + logger.error(err_msg) + # extract extractors = step.extract extract_mapping = resp_obj.extract(extractors) @@ -64,6 +87,7 @@ class TestCaseRunner(object): try: resp_obj.validate(validators, variables_mapping, self.config.functions) except ValidationFailure: + log_req_resp_details() raise finally: self.validation_results = resp_obj.validation_results From d92c17abd45eef0f1dfc1c096fca0fc9d0bd970e Mon Sep 17 00:00:00 2001 From: debugtalk Date: Tue, 21 Apr 2020 22:51:38 +0800 Subject: [PATCH 054/169] fix: log request & response meta data when test failed --- httprunner/v3/runner.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/httprunner/v3/runner.py b/httprunner/v3/runner.py index 2b0be8c9..33b2e1f5 100644 --- a/httprunner/v3/runner.py +++ b/httprunner/v3/runner.py @@ -91,6 +91,10 @@ class TestCaseRunner(object): raise finally: self.validation_results = resp_obj.validation_results + # save request & response meta data + self.session.meta_data["validators"] = self.validation_results + self.session.meta_data["name"] = step.name + self.meta_datas.append(self.session.meta_data) return extract_mapping @@ -109,10 +113,6 @@ class TestCaseRunner(object): extract_mapping = self.__run_step(step) # save extracted variables to session variables session_variables.update(extract_mapping) - # save request & response meta data - self.session.meta_data["validators"] = self.validation_results - self.session.meta_data["name"] = step.name - self.meta_datas.append(self.session.meta_data) return self From 499ec50886fd7eebe36e4fae14f0790e14cd4c98 Mon Sep 17 00:00:00 2001 From: debugtalk Date: Tue, 21 Apr 2020 23:01:13 +0800 Subject: [PATCH 055/169] change: update schema --- httprunner/v3/{schema/__init__.py => schema.py} | 1 - 1 file changed, 1 deletion(-) rename httprunner/v3/{schema/__init__.py => schema.py} (98%) diff --git a/httprunner/v3/schema/__init__.py b/httprunner/v3/schema.py similarity index 98% rename from httprunner/v3/schema/__init__.py rename to httprunner/v3/schema.py index 5d75ac71..41721a60 100644 --- a/httprunner/v3/schema/__init__.py +++ b/httprunner/v3/schema.py @@ -57,7 +57,6 @@ class Request(BaseModel): class TestStep(BaseModel): name: Name - times: int = 1 request: Request variables: VariablesMapping = {} extract: Dict[Text, Text] = {} From 47ed8c086dd1060e68c2face709093c5b6efc9a8 Mon Sep 17 00:00:00 2001 From: debugtalk Date: Tue, 21 Apr 2020 23:08:26 +0800 Subject: [PATCH 056/169] change cli: run_path --- httprunner/cli.py | 2 +- httprunner/v3/api.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/httprunner/cli.py b/httprunner/cli.py index f828e431..671969a3 100644 --- a/httprunner/cli.py +++ b/httprunner/cli.py @@ -73,7 +73,7 @@ def main_run(args): err_code = 0 try: for path in args.testfile_paths: - summary = runner.run(path, dot_env_path=args.dot_env_path) + summary = runner.run_path(path, dot_env_path=args.dot_env_path) report_dir = args.report_dir or os.path.join(os.getcwd(), "reports") gen_html_report( summary, diff --git a/httprunner/v3/api.py b/httprunner/v3/api.py index e77f8a30..725dffc0 100644 --- a/httprunner/v3/api.py +++ b/httprunner/v3/api.py @@ -223,6 +223,7 @@ class HttpRunner(object): """ # load tests + logger.info(f"HttpRunner version: {__version__}") self.exception_stage = "load tests" tests_mapping = loader.load_cases(path, dot_env_path) @@ -245,7 +246,6 @@ class HttpRunner(object): dict: result summary """ - logger.info(f"HttpRunner version: {__version__}") if loader.is_test_path(path_or_tests): return self.run_path(path_or_tests, dot_env_path, mapping) elif loader.is_test_content(path_or_tests): From 5478fbe9b2937ab83bb49053c727d5016d8c3607 Mon Sep 17 00:00:00 2001 From: debugtalk Date: Tue, 21 Apr 2020 23:27:18 +0800 Subject: [PATCH 057/169] change: remove unused code --- httprunner/v3/exceptions/__init__.py | 81 ---------------------------- httprunner/v3/parser.py | 2 +- httprunner/v3/parser_test.py | 2 +- httprunner/v3/response.py | 2 +- httprunner/v3/runner.py | 2 +- httprunner/v3/validator.py | 2 +- 6 files changed, 5 insertions(+), 86 deletions(-) delete mode 100644 httprunner/v3/exceptions/__init__.py diff --git a/httprunner/v3/exceptions/__init__.py b/httprunner/v3/exceptions/__init__.py deleted file mode 100644 index 77d1be52..00000000 --- a/httprunner/v3/exceptions/__init__.py +++ /dev/null @@ -1,81 +0,0 @@ -""" failure type exceptions - these exceptions will mark test as failure -""" - - -class MyBaseFailure(Exception): - pass - - -class ParseTestsFailure(MyBaseFailure): - pass - - -class ValidationFailure(MyBaseFailure): - pass - - -class ExtractFailure(MyBaseFailure): - pass - - -class SetupHooksFailure(MyBaseFailure): - pass - - -class TeardownHooksFailure(MyBaseFailure): - pass - - -""" error type exceptions - these exceptions will mark test as error -""" - - -class MyBaseError(Exception): - pass - - -class FileFormatError(MyBaseError): - pass - - -class ParamsError(MyBaseError): - pass - - -class NotFoundError(MyBaseError): - pass - - -class FileNotFound(FileNotFoundError, NotFoundError): - pass - - -class FunctionNotFound(NotFoundError): - pass - - -class VariableNotFound(NotFoundError): - pass - - -class EnvNotFound(NotFoundError): - pass - - -class CSVNotFound(NotFoundError): - pass - - -class ApiNotFound(NotFoundError): - pass - - -class TestcaseNotFound(NotFoundError): - pass - - -class SummaryEmpty(MyBaseError): - """ test result summary data is empty - """ diff --git a/httprunner/v3/parser.py b/httprunner/v3/parser.py index c0e12c7a..12aa9e7b 100644 --- a/httprunner/v3/parser.py +++ b/httprunner/v3/parser.py @@ -4,7 +4,7 @@ import re from typing import Any, Set, Text, Callable, List, Dict from httprunner import loader, utils -from httprunner.v3 import exceptions +from httprunner import exceptions from httprunner.v3.schema import VariablesMapping, FunctionsMapping absolute_http_url_regexp = re.compile(r"^https?://", re.I) diff --git a/httprunner/v3/parser_test.py b/httprunner/v3/parser_test.py index ee42c525..9d9ec9e6 100644 --- a/httprunner/v3/parser_test.py +++ b/httprunner/v3/parser_test.py @@ -2,7 +2,7 @@ import time import unittest from httprunner.v3 import parser -from httprunner.v3.exceptions import VariableNotFound, FunctionNotFound +from httprunner.exceptions import VariableNotFound, FunctionNotFound class TestParserBasic(unittest.TestCase): diff --git a/httprunner/v3/response.py b/httprunner/v3/response.py index ce4dde7c..57f47eae 100644 --- a/httprunner/v3/response.py +++ b/httprunner/v3/response.py @@ -4,7 +4,7 @@ import jmespath import requests from loguru import logger -from httprunner.v3.exceptions import ValidationFailure, ParamsError +from httprunner.exceptions import ValidationFailure, ParamsError from httprunner.v3.parser import parse_data, parse_string_value, get_mapping_function from httprunner.v3.schema import VariablesMapping, Validators, FunctionsMapping from httprunner.v3.validator import uniform_validator diff --git a/httprunner/v3/runner.py b/httprunner/v3/runner.py index 33b2e1f5..4de890d8 100644 --- a/httprunner/v3/runner.py +++ b/httprunner/v3/runner.py @@ -4,7 +4,7 @@ from loguru import logger from httprunner import utils from httprunner.client import HttpSession -from httprunner.v3.exceptions import ValidationFailure +from httprunner.exceptions import ValidationFailure from httprunner.v3.parser import build_url, parse_data, parse_variables_mapping from httprunner.v3.response import ResponseObject from httprunner.v3.schema import TestsConfig, TestStep, VariablesMapping, TestCase diff --git a/httprunner/v3/validator.py b/httprunner/v3/validator.py index c1278c2e..3df458f3 100644 --- a/httprunner/v3/validator.py +++ b/httprunner/v3/validator.py @@ -1,6 +1,6 @@ from typing import Text -from httprunner.v3.exceptions import ParamsError +from httprunner.exceptions import ParamsError def get_uniform_comparator(comparator: Text): From 85a1ef27d1548472cf1a2f3254317a65b8e942f1 Mon Sep 17 00:00:00 2001 From: debugtalk Date: Wed, 22 Apr 2020 11:58:33 +0800 Subject: [PATCH 058/169] fix --- httprunner/v3/parser.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/httprunner/v3/parser.py b/httprunner/v3/parser.py index 12aa9e7b..d44b6bcd 100644 --- a/httprunner/v3/parser.py +++ b/httprunner/v3/parser.py @@ -3,8 +3,7 @@ import builtins import re from typing import Any, Set, Text, Callable, List, Dict -from httprunner import loader, utils -from httprunner import exceptions +from httprunner import loader, utils, exceptions from httprunner.v3.schema import VariablesMapping, FunctionsMapping absolute_http_url_regexp = re.compile(r"^https?://", re.I) From 0ba3bbb84ffa7a3eef61db94c631fe0a4fdd0ecd Mon Sep 17 00:00:00 2001 From: debugtalk Date: Wed, 22 Apr 2020 19:04:51 +0800 Subject: [PATCH 059/169] refactor: get testcase summary when run suite --- httprunner/v3/api.py | 47 +++++++++++++++++++++----------------------- 1 file changed, 22 insertions(+), 25 deletions(-) diff --git a/httprunner/v3/api.py b/httprunner/v3/api.py index 725dffc0..fb1708c9 100644 --- a/httprunner/v3/api.py +++ b/httprunner/v3/api.py @@ -1,7 +1,7 @@ import os import sys import unittest -from typing import List, Tuple +from typing import List, Dict from loguru import logger @@ -91,10 +91,10 @@ class HttpRunner(object): return prepared_testcases - def _run_suite(self, prepared_testcases: List[unittest.TestSuite]) -> List[Tuple]: + def _run_suite(self, prepared_testcases: List[unittest.TestSuite]) -> List[Dict]: """ run prepared testcases """ - tests_results: List[Tuple] = [] + tests_results: List[Dict] = [] for index, testcase in enumerate(prepared_testcases): log_handler = None @@ -107,21 +107,32 @@ class HttpRunner(object): logger.info(f"Start to run testcase: {testcase.config.name}") result = self.unittest_runner.run(testcase) - if result.wasSuccessful(): - tests_results.append((testcase, result)) - else: - tests_results.insert(0, (testcase, result)) + testcase_summary = report.get_summary(result) + testcase_summary["name"] = testcase.config.name + testcase_summary["in_out"] = { + "in": testcase.config.variables, + "out": testcase.config.export + } if self.save_tests and log_handler: logger.remove(log_handler) + logs_file_abs_path = utils.prepare_log_file_abs_path( + self.test_path, f"testcase_{index+1}.log" + ) + testcase_summary["log"] = logs_file_abs_path + + if result.wasSuccessful(): + tests_results.append(testcase_summary) + else: + tests_results.insert(0, testcase_summary) return tests_results - def _aggregate(self, tests_results: List[Tuple]): - """ aggregate results + def _aggregate(self, tests_results: List[Dict]): + """ aggregate multiple testcase results Args: - tests_results (list): list of (testcase, result) + tests_results (list): list of testcase summary """ summary = { @@ -139,31 +150,17 @@ class HttpRunner(object): "details": [] } - for index, tests_result in enumerate(tests_results): - testcase, result = tests_result - testcase_summary = report.get_summary(result) - + for testcase_summary in tests_results: if testcase_summary["success"]: summary["stat"]["testcases"]["success"] += 1 else: summary["stat"]["testcases"]["fail"] += 1 summary["success"] &= testcase_summary["success"] - testcase_summary["name"] = testcase.config.name - testcase_summary["in_out"] = { - "in": testcase.config.variables, - "out": testcase.config.export - } report.aggregate_stat(summary["stat"]["teststeps"], testcase_summary["stat"]) report.aggregate_stat(summary["time"], testcase_summary["time"]) - if self.save_tests: - logs_file_abs_path = utils.prepare_log_file_abs_path( - self.test_path, f"testcase_{index+1}.log" - ) - testcase_summary["log"] = logs_file_abs_path - summary["details"].append(testcase_summary) return summary From 310f1ea30ce5b92b7e0da1e1e28bd02c0e95fb50 Mon Sep 17 00:00:00 2001 From: debugtalk Date: Wed, 22 Apr 2020 21:16:58 +0800 Subject: [PATCH 060/169] refactor: add typing with pydantic --- httprunner/cli.py | 6 +-- httprunner/client.py | 36 ++++++-------- httprunner/report/html/gen_report.py | 15 +++--- httprunner/report/html/result.py | 16 ++++--- httprunner/report/html/template.html | 2 +- httprunner/report/stringify.py | 37 +++++++-------- httprunner/report/summarize.py | 46 ++++++++---------- httprunner/v3/api.py | 42 ++++++++-------- httprunner/v3/response.py | 2 +- httprunner/v3/runner.py | 12 ++--- httprunner/v3/schema.py | 71 ++++++++++++++++++++++++++++ 11 files changed, 171 insertions(+), 114 deletions(-) diff --git a/httprunner/cli.py b/httprunner/cli.py index 671969a3..2523c17e 100644 --- a/httprunner/cli.py +++ b/httprunner/cli.py @@ -73,15 +73,15 @@ def main_run(args): err_code = 0 try: for path in args.testfile_paths: - summary = runner.run_path(path, dot_env_path=args.dot_env_path) + testsuite_summary = runner.run_path(path, dot_env_path=args.dot_env_path) report_dir = args.report_dir or os.path.join(os.getcwd(), "reports") gen_html_report( - summary, + testsuite_summary, report_template=args.report_template, report_dir=report_dir, report_file=args.report_file ) - err_code |= (0 if summary and summary["success"] else 1) + err_code |= (0 if testsuite_summary and testsuite_summary.success else 1) except Exception as ex: logger.error(f"!!!!!!!!!! exception stage: {runner.exception_stage} !!!!!!!!!!\n{str(ex)}") err_code = 1 diff --git a/httprunner/client.py b/httprunner/client.py index 4577a40a..41e12129 100644 --- a/httprunner/client.py +++ b/httprunner/client.py @@ -11,6 +11,7 @@ from requests.exceptions import (InvalidSchema, InvalidURL, MissingSchema, from httprunner import response from httprunner.utils import lower_dict_keys, omit_long_data +from httprunner.v3.schema import MetaData, RequestStat urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) @@ -107,9 +108,8 @@ class HttpSession(requests.Session): def init_meta_data(self): """ initialize meta_data, it will store detail data of request and response """ - self.meta_data = { - "name": "", - "data": [ + self.meta_data = MetaData( + data=[ { "request": { "url": "N/A", @@ -124,19 +124,15 @@ class HttpSession(requests.Session): } } ], - "stat": { - "content_size": "N/A", - "response_time_ms": "N/A", - "elapsed_ms": "N/A", - } - } + stat=RequestStat() + ) def update_last_req_resp_record(self, resp_obj): """ update request and response info from Response() object. """ - self.meta_data["data"].pop() - self.meta_data["data"].append(get_req_resp_record(resp_obj)) + self.meta_data.data.pop() + self.meta_data.data.append(get_req_resp_record(resp_obj)) def request(self, method, url, name=None, **kwargs): """ @@ -180,13 +176,13 @@ class HttpSession(requests.Session): self.init_meta_data() # record test name - self.meta_data["name"] = name + self.meta_data.name = name # record original request info - self.meta_data["data"][0]["request"]["method"] = method - self.meta_data["data"][0]["request"]["url"] = url + self.meta_data.data[0]["request"]["method"] = method + self.meta_data.data[0]["request"]["url"] = url kwargs.setdefault("timeout", 120) - self.meta_data["data"][0]["request"].update(kwargs) + self.meta_data.data[0]["request"].update(kwargs) start_timestamp = time.time() response = self._send_request_safe_mode(method, url, **kwargs) @@ -200,15 +196,13 @@ class HttpSession(requests.Session): content_size = len(response.content or "") # record the consumed time - self.meta_data["stat"] = { - "response_time_ms": response_time_ms, - "elapsed_ms": response.elapsed.microseconds / 1000.0, - "content_size": content_size - } + self.meta_data.stat.response_time_ms = response_time_ms + self.meta_data.stat.elapsed_ms = response.elapsed.microseconds / 1000.0 + self.meta_data.stat.content_size = content_size # record request and response histories, include 30X redirection response_list = response.history + [response] - self.meta_data["data"] = [ + self.meta_data.data = [ get_req_resp_record(resp_obj) for resp_obj in response_list ] diff --git a/httprunner/report/html/gen_report.py b/httprunner/report/html/gen_report.py index c7791183..95772079 100644 --- a/httprunner/report/html/gen_report.py +++ b/httprunner/report/html/gen_report.py @@ -6,20 +6,21 @@ from jinja2 import Template from loguru import logger from httprunner.exceptions import SummaryEmpty +from httprunner.v3.schema import TestSuiteSummary -def gen_html_report(summary, report_template=None, report_dir=None, report_file=None): +def gen_html_report(testsuite_summary: TestSuiteSummary, report_template=None, report_dir=None, report_file=None): """ render html report with specified report name and template Args: - summary (dict): test result summary data + testsuite_summary (dict): testsuite result summary data report_template (str): specify html report template path, template should be in Jinja2 format. report_dir (str): specify html report save directory report_file (str): specify html report file path, this has higher priority than specifying report dir. """ - if not summary["time"] or summary["stat"]["testcases"]["total"] == 0: - logger.error(f"test result summary is empty ! {summary}") + if not testsuite_summary.time or testsuite_summary.stat.testcases["total"] == 0: + logger.error(f"test result testsuite_summary is empty ! {testsuite_summary}") raise SummaryEmpty if not report_template: @@ -33,9 +34,9 @@ def gen_html_report(summary, report_template=None, report_dir=None, report_file= logger.info("Start to render Html report ...") - start_at_timestamp = summary["time"]["start_at"] + start_at_timestamp = testsuite_summary.time.start_at utc_time_iso_8601_str = datetime.utcfromtimestamp(start_at_timestamp).isoformat() - summary["time"]["start_datetime"] = utc_time_iso_8601_str + testsuite_summary.time.start_datetime = utc_time_iso_8601_str if report_file: report_dir = os.path.dirname(report_file) @@ -55,7 +56,7 @@ def gen_html_report(summary, report_template=None, report_dir=None, report_file= rendered_content = Template( template_content, extensions=["jinja2.ext.loopcontrols"] - ).render(summary) + ).render(testsuite_summary.dict()) fp_w.write(rendered_content) logger.info(f"Generated Html report: {report_path}") diff --git a/httprunner/report/html/result.py b/httprunner/report/html/result.py index 762d0bb1..e9d88f2f 100644 --- a/httprunner/report/html/result.py +++ b/httprunner/report/html/result.py @@ -3,6 +3,8 @@ import unittest from loguru import logger +from httprunner.v3.schema import Record + class HtmlTestResult(unittest.TextTestResult): """ A html result class that can generate formatted html results. @@ -13,13 +15,13 @@ class HtmlTestResult(unittest.TextTestResult): self.records = [] def _record_test(self, test, status, attachment=''): - data = { - 'name': test.shortDescription(), - 'status': status, - 'attachment': attachment, - "meta_datas": test.meta_datas - } - self.records.append(data) + record = Record( + name=test.shortDescription(), + status=status, + attachment=attachment, + meta_datas=test.meta_datas + ) + self.records.append(record) def startTestRun(self): self.start_at = time.time() diff --git a/httprunner/report/html/template.html b/httprunner/report/html/template.html index 8bbfc1bf..a205a73f 100644 --- a/httprunner/report/html/template.html +++ b/httprunner/report/html/template.html @@ -201,7 +201,7 @@ {% for record in test_suite_summary.records %} {% set record_index = "{}_{}".format(suite_index, loop.index) %} - {% set record_meta_datas = record.meta_datas_expanded %} + {% set record_meta_datas = record.meta_datas %} {{record.status}} {{record.name}} diff --git a/httprunner/report/stringify.py b/httprunner/report/stringify.py index c6b9cf11..d13e2062 100644 --- a/httprunner/report/stringify.py +++ b/httprunner/report/stringify.py @@ -1,10 +1,13 @@ import json from base64 import b64encode from collections import Iterable +from typing import List from jinja2 import escape from requests.cookies import RequestsCookieJar +from httprunner.v3.schema import TestSuiteSummary, MetaData + def dumps_json(value): """ dumps json value to indented string @@ -164,20 +167,20 @@ def __expand_meta_datas(meta_datas, meta_datas_expanded): [dict1, dict2, dict3] """ - if isinstance(meta_datas, dict): + if isinstance(meta_datas, MetaData): meta_datas_expanded.append(meta_datas) elif isinstance(meta_datas, list): for meta_data in meta_datas: __expand_meta_datas(meta_data, meta_datas_expanded) -def __get_total_response_time(meta_datas_expanded): +def __get_total_response_time(meta_datas: List[MetaData]): """ caculate total response time of all meta_datas """ try: response_time = 0 - for meta_data in meta_datas_expanded: - response_time += meta_data["stat"]["response_time_ms"] + for meta_data in meta_datas: + response_time += meta_data.stat.response_time_ms return "{:.2f}".format(response_time) @@ -186,30 +189,24 @@ def __get_total_response_time(meta_datas_expanded): return "N/A" -def __stringify_meta_datas(meta_datas): +def __stringify_meta_datas(meta_datas: List[MetaData]): - if isinstance(meta_datas, list): - for _meta_data in meta_datas: - __stringify_meta_datas(_meta_data) - elif isinstance(meta_datas, dict): - data_list = meta_datas["data"] + for meta_data in meta_datas: + data_list = meta_data.data for data in data_list: __stringify_request(data["request"]) __stringify_response(data["response"]) -def stringify_summary(summary): +def stringify_summary(testsuite_summary: TestSuiteSummary): """ stringify summary, in order to dump json file and generate html report. """ - for index, suite_summary in enumerate(summary["details"]): + for index, testcase_summary in enumerate(testsuite_summary.details): - if not suite_summary.get("name"): - suite_summary["name"] = f"testcase {index}" + if not testcase_summary.name: + testcase_summary.name = f"testcase {index}" - for record in suite_summary.get("records"): - meta_datas = record['meta_datas'] + for record in testcase_summary.records: + meta_datas = record.meta_datas __stringify_meta_datas(meta_datas) - meta_datas_expanded = [] - __expand_meta_datas(meta_datas, meta_datas_expanded) - record["meta_datas_expanded"] = meta_datas_expanded - record["response_time"] = __get_total_response_time(meta_datas_expanded) + record.response_time = __get_total_response_time(meta_datas) diff --git a/httprunner/report/summarize.py b/httprunner/report/summarize.py index 93c7145f..404d8cb4 100644 --- a/httprunner/report/summarize.py +++ b/httprunner/report/summarize.py @@ -1,6 +1,8 @@ import platform from httprunner import __version__ +from httprunner.report.html.result import HtmlTestResult +from httprunner.v3.schema import TestCaseSummary, TestCaseStat, TestCaseTime, TestCaseInOut def get_platform(): @@ -38,7 +40,7 @@ def aggregate_stat(origin_stat, new_stat): origin_stat[key] += new_stat[key] -def get_summary(result): +def get_summary(result: HtmlTestResult) -> TestCaseSummary: """ get summary from test result Args: @@ -55,28 +57,20 @@ def get_summary(result): } """ - summary = { - "success": result.wasSuccessful(), - "stat": { - 'total': result.testsRun, - 'failures': len(result.failures), - 'errors': len(result.errors), - 'skipped': len(result.skipped), - 'expectedFailures': len(result.expectedFailures), - 'unexpectedSuccesses': len(result.unexpectedSuccesses) - } - } - summary["stat"]["successes"] = summary["stat"]["total"] \ - - summary["stat"]["failures"] \ - - summary["stat"]["errors"] \ - - summary["stat"]["skipped"] \ - - summary["stat"]["expectedFailures"] \ - - summary["stat"]["unexpectedSuccesses"] - - summary["time"] = { - 'start_at': result.start_at, - 'duration': result.duration - } - summary["records"] = result.records - - return summary + return TestCaseSummary( + success=result.wasSuccessful(), + stat=TestCaseStat( + total=result.testsRun, + failures=len(result.failures), + errors=len(result.errors), + skipped=len(result.skipped), + expectedFailures=len(result.expectedFailures), + unexpectedSuccesses=len(result.unexpectedSuccesses) + ), + time=TestCaseTime( + start_at=result.start_at, + duration=result.duration + ), + records=result.records, + in_out=TestCaseInOut() + ) diff --git a/httprunner/v3/api.py b/httprunner/v3/api.py index fb1708c9..b7a11d63 100644 --- a/httprunner/v3/api.py +++ b/httprunner/v3/api.py @@ -7,7 +7,7 @@ from loguru import logger from httprunner import report, loader, utils, exceptions, __version__ from httprunner.v3.runner import TestCaseRunner -from httprunner.v3.schema import TestsMapping +from httprunner.v3.schema import TestsMapping, TestCaseSummary, TestSuiteSummary class HttpRunner(object): @@ -91,10 +91,10 @@ class HttpRunner(object): return prepared_testcases - def _run_suite(self, prepared_testcases: List[unittest.TestSuite]) -> List[Dict]: + def _run_suite(self, prepared_testcases: List[unittest.TestSuite]) -> List[TestCaseSummary]: """ run prepared testcases """ - tests_results: List[Dict] = [] + tests_results: List[TestCaseSummary] = [] for index, testcase in enumerate(prepared_testcases): log_handler = None @@ -108,18 +108,16 @@ class HttpRunner(object): result = self.unittest_runner.run(testcase) testcase_summary = report.get_summary(result) - testcase_summary["name"] = testcase.config.name - testcase_summary["in_out"] = { - "in": testcase.config.variables, - "out": testcase.config.export - } + testcase_summary.name = testcase.config.name + testcase_summary.in_out.vars = testcase.config.variables + testcase_summary.in_out.out = testcase.config.export if self.save_tests and log_handler: logger.remove(log_handler) logs_file_abs_path = utils.prepare_log_file_abs_path( self.test_path, f"testcase_{index+1}.log" ) - testcase_summary["log"] = logs_file_abs_path + testcase_summary.log = logs_file_abs_path if result.wasSuccessful(): tests_results.append(testcase_summary) @@ -128,14 +126,14 @@ class HttpRunner(object): return tests_results - def _aggregate(self, tests_results: List[Dict]): + def _aggregate(self, tests_results: List[TestCaseSummary]) -> TestSuiteSummary: """ aggregate multiple testcase results Args: tests_results (list): list of testcase summary """ - summary = { + testsuite_summary = { "success": True, "stat": { "testcases": { @@ -151,21 +149,21 @@ class HttpRunner(object): } for testcase_summary in tests_results: - if testcase_summary["success"]: - summary["stat"]["testcases"]["success"] += 1 + if testcase_summary.success: + testsuite_summary["stat"]["testcases"]["success"] += 1 else: - summary["stat"]["testcases"]["fail"] += 1 + testsuite_summary["stat"]["testcases"]["fail"] += 1 - summary["success"] &= testcase_summary["success"] + testsuite_summary["success"] &= testcase_summary.success - report.aggregate_stat(summary["stat"]["teststeps"], testcase_summary["stat"]) - report.aggregate_stat(summary["time"], testcase_summary["time"]) + report.aggregate_stat(testsuite_summary["stat"]["teststeps"], testcase_summary.stat.dict()) + report.aggregate_stat(testsuite_summary["time"], testcase_summary.time.dict()) - summary["details"].append(testcase_summary) + testsuite_summary["details"].append(testcase_summary) - return summary + return TestSuiteSummary.parse_obj(testsuite_summary) - def run_tests(self, tests_mapping): + def run_tests(self, tests_mapping) -> TestSuiteSummary: """ run testcase/testsuite data """ tests = TestsMapping.parse_obj(tests_mapping) @@ -195,7 +193,7 @@ class HttpRunner(object): if self.save_tests: utils.dump_json_file( - self._summary, + self._summary.dict(), utils.prepare_log_file_abs_path(self.test_path, "summary.json") ) # save variables and export data @@ -207,7 +205,7 @@ class HttpRunner(object): return self._summary - def run_path(self, path, dot_env_path=None, mapping=None): + def run_path(self, path, dot_env_path=None, mapping=None) -> TestSuiteSummary: """ run testcase/testsuite file or folder. Args: diff --git a/httprunner/v3/response.py b/httprunner/v3/response.py index 57f47eae..01af0bd8 100644 --- a/httprunner/v3/response.py +++ b/httprunner/v3/response.py @@ -25,7 +25,7 @@ class ResponseObject(object): "headers": resp_obj.headers, "body": resp_obj.json() } - self.validation_results = {} + self.validation_results: Dict = {} def __getattr__(self, key): try: diff --git a/httprunner/v3/runner.py b/httprunner/v3/runner.py index 4de890d8..b426b2ea 100644 --- a/httprunner/v3/runner.py +++ b/httprunner/v3/runner.py @@ -1,4 +1,4 @@ -from typing import List +from typing import List, Dict from loguru import logger @@ -7,7 +7,7 @@ from httprunner.client import HttpSession from httprunner.exceptions import ValidationFailure from httprunner.v3.parser import build_url, parse_data, parse_variables_mapping from httprunner.v3.response import ResponseObject -from httprunner.v3.schema import TestsConfig, TestStep, VariablesMapping, TestCase +from httprunner.v3.schema import TestsConfig, TestStep, VariablesMapping, TestCase, MetaData class TestCaseRunner(object): @@ -15,8 +15,8 @@ class TestCaseRunner(object): config: TestsConfig = {} teststeps: List[TestStep] = [] session: HttpSession = None - meta_datas: List = [] - validation_results: List = [] + meta_datas: List[MetaData] = [] + validation_results: Dict = {} def init(self, testcase: TestCase) -> "TestCaseRunner": self.config = testcase.config @@ -92,8 +92,8 @@ class TestCaseRunner(object): finally: self.validation_results = resp_obj.validation_results # save request & response meta data - self.session.meta_data["validators"] = self.validation_results - self.session.meta_data["name"] = step.name + self.session.meta_data.validators = self.validation_results + self.session.meta_data.name = step.name self.meta_datas.append(self.session.meta_data) return extract_mapping diff --git a/httprunner/v3/schema.py b/httprunner/v3/schema.py index 41721a60..de2f144c 100644 --- a/httprunner/v3/schema.py +++ b/httprunner/v3/schema.py @@ -80,3 +80,74 @@ class ProjectMeta(BaseModel): class TestsMapping(BaseModel): project_mapping: ProjectMeta # TODO: rename to project_meta testcases: List[TestCase] + + +class Stat(BaseModel): + testcases: Dict + teststeps: Dict + + +class TestCaseTime(BaseModel): + start_at: float + duration: float + start_datetime: Text = "" + + +class TestCaseStat(BaseModel): + total: int = 0 + successes: int = 0 + failures: int = 0 + errors: int = 0 + skipped: int = 0 + expectedFailures: int = 0 + unexpectedSuccesses: int = 0 + + +class TestCaseInOut(BaseModel): + vars: VariablesMapping = {} + out: Export = [] + + +class RequestStat(BaseModel): + content_size: Text = "N/A" + response_time_ms: Text = "N/A" + elapsed_ms: Text = "N/A" + + +class MetaData(BaseModel): + name: Text = "" + data: List[Dict] + stat: RequestStat + validators: Dict = {} + + +class Record(BaseModel): + name: Text = "" + status: Text = "" + attachment: Text = "" + meta_datas: List[MetaData] = [] + response_time: Text = "N/A" + + +class TestCaseSummary(BaseModel): + name: Text = "" + success: bool + stat: TestCaseStat + time: TestCaseTime + records: List = [Record] + in_out: TestCaseInOut = {} + log: Text = "" + + +class PlatformInfo(BaseModel): + httprunner_version: Text + python_version: Text + platform: Text + + +class TestSuiteSummary(BaseModel): + success: bool + stat: Stat + time: TestCaseTime + platform: PlatformInfo + details: List[TestCaseSummary] From 3df31fb0c940bad20bd55505c654326a35eba6e1 Mon Sep 17 00:00:00 2001 From: debugtalk Date: Wed, 22 Apr 2020 22:13:11 +0800 Subject: [PATCH 061/169] fix html report --- httprunner/report/html/result.py | 17 +++++------- httprunner/report/html/template.html | 39 ++++++++++++---------------- httprunner/report/stringify.py | 8 +++--- httprunner/report/summarize.py | 4 +-- httprunner/v3/api_test.py | 12 ++++----- httprunner/v3/schema.py | 2 +- 6 files changed, 36 insertions(+), 46 deletions(-) diff --git a/httprunner/report/html/result.py b/httprunner/report/html/result.py index e9d88f2f..9cb16ab3 100644 --- a/httprunner/report/html/result.py +++ b/httprunner/report/html/result.py @@ -7,21 +7,18 @@ from httprunner.v3.schema import Record class HtmlTestResult(unittest.TextTestResult): - """ A html result class that can generate formatted html results. - Used by TextTestRunner. + """ A html result class that can generate formatted html results, used by TextTestRunner. + Each testcase is corresponding to one HtmlTestResult instance """ def __init__(self, stream, descriptions, verbosity): super(HtmlTestResult, self).__init__(stream, descriptions, verbosity) - self.records = [] + self.record = Record() def _record_test(self, test, status, attachment=''): - record = Record( - name=test.shortDescription(), - status=status, - attachment=attachment, - meta_datas=test.meta_datas - ) - self.records.append(record) + self.record.name = test.shortDescription() + self.record.status = status + self.record.attachment = attachment + self.record.meta_datas = test.meta_datas def startTestRun(self): self.start_at = time.time() diff --git a/httprunner/report/html/template.html b/httprunner/report/html/template.html index a205a73f..9492e42c 100644 --- a/httprunner/report/html/template.html +++ b/httprunner/report/html/template.html @@ -181,17 +181,10 @@

Details

- {% for test_suite_summary in details %} - {% set suite_index = loop.index %} -

{{test_suite_summary.name}}

- - - - - - - - + {% for testcase_summary in details %} + {% set testcase_index = loop.index %} +

{{testcase_summary.name}}

+
TOTAL: {{test_suite_summary.stat.total}}SUCCESS: {{test_suite_summary.stat.successes}}FAILED: {{test_suite_summary.stat.failures}}ERROR: {{test_suite_summary.stat.errors}}SKIPPED: {{test_suite_summary.stat.skipped}}
@@ -199,22 +192,22 @@ - {% for record in test_suite_summary.records %} - {% set record_index = "{}_{}".format(suite_index, loop.index) %} - {% set record_meta_datas = record.meta_datas %} - + {% set record_meta_datas = testcase_summary.record.meta_datas %} + {% for meta_data in record_meta_datas %} + {% set step_index = "{}_{}".format(testcase_index, loop.index) %} +
Status NameDetail
{{record.status}} {{record.name}} {{ record.response_time }} ms - {% for meta_data in record_meta_datas %} - {% set meta_data_index = "{}_{}".format(record_index, loop.index) %} - log-{{loop.index}} -