diff --git a/.github/workflows/unittest.yml b/.github/workflows/unittest.yml index 851dabfc..632f342f 100644 --- a/.github/workflows/unittest.yml +++ b/.github/workflows/unittest.yml @@ -31,7 +31,7 @@ jobs: poetry run hmake poetry run hrun poetry run har2case - poetry run coverage run --source=httprunner -m pytest httprunner + poetry run coverage run --source=httprunner -m pytest tests poetry run coverage xml poetry run coverage report -m - name: Codecov diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 90a7953b..9c736212 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,25 @@ # Release History +## 3.0.6 (2020-05-29) + +**Added** + +- feat: make referenced testcase as pytest class + +**Fixed** + +- fix: ensure converted python file in utf-8 encoding +- fix: duplicate running referenced testcase +- fix: ensure compatibility issues between testcase format v2 and v3 +- fix: ensure compatibility with deprecated cli args in v2, include --failfast/--report-file/--save-tests +- fix: UnicodeDecodeError when request body in protobuf + +**Changed** + +- change: make `allure-pytest`, `requests-toolbelt`, `filetype` as optional dependencies +- change: move all unittests to tests folder +- change: save testcase log in PWD/logs/ directory + ## 3.0.5 (2020-05-22) **Added** @@ -83,476 +103,3 @@ - generate reports/logs folder in current working directory - remove cli `--validate` - remove cli `--pretty` - -## 2.5.7 (2020-02-21) - -**Changed** - -- feat: validate with python script, display print message - -**Fixed** - -- fix: validate script missing indents in html report -- fix: validate with python script, display lineno error - -## 2.5.6 (2020-02-19) - -**Added** - -- feat: save variables and export data to JSON files (named xx.io.json) when specified `--save-tests` - -**Changed** - -- change: alter HttpRunner default log_level to WARNING - -**Fixed** - -- fix: abort test when failed to parse all cases -- fix: log error when parse failed - -## 2.5.5 (2020-01-06) - -**Fixed** - -- fix: HTTP method missed "CONNECT", "TRACE" - -**Changed** - -- change: remove method validation from runner.Runner - -## 2.5.4 (2020-01-03) - -**Added** - -- doc: add examples in json schema - -**Fixed** - -- fix #835: UnicodeDecodeError when loading json schema files -- fix: RecursionError caused by checking root dir incorrectly on Windows - -## 2.5.3 (2020-01-03) - -**Fixed** - -- fix json schema: variables maybe in string type, e.g. '${prepare_variables()}' -- fix json schema: post json maybe in string type, e.g. '${prepare_post_data()}', '$post_data' - -## 2.5.2 (2020-01-02) - -**Fixed** - -- fix #826: Windows does not support file name include ":" -- fix #819: maximum recursion error in locusts -- fix #818: request missed url in setup_hooks -- fix #808: project_working_directory is not initialized when running passed in data structure - -## 2.5.1 (2020-01-02) - -**Fixed** - -- fix: RefResolutionError on Windows platform - -## 2.5.0 (2020-01-01) - -**Added** - -- feat: add json schema validation for api -- feat: add json schema validation for testcase v1 & v2 -- feat: add json schema validation for testsuite v1 & v2 - -**Changed** - -- refactor: use loader.load_cases to validate test files -- refactor: use is_test_path to check if path is valid json/yaml file or a existed directory -- refactor: use is_test_content to check if data_structure is apis/testcases/testsuites - -## 2.4.9 (2019-12-29) - -**Added** - -- test: add unittest for cli - -**Changed** - -- change: html report name defaults to be in UTC ISO 8601 format - -**Fixed** - -- fix: display validators in report when validate raised exception -- fix: eval validator python script before validating -- fix: do not strip string content when preparing lazy data -- fix: catch ApiNotFound exception when loading testcases -- fix: print exception string with exception stage - -## 2.4.8 (2019-12-25) - -**Added** - -- feat: store parse failed api/testcase/testsuite file path in `logs/xxx.parse_failed.json` -- feat: add exception SummaryEmpty - -**Fixed** - -- fix: display request & response details in report when extraction failed -- fix: include CHANGELOG in package - -**Changed** - -- change: use sys.exit(code) in hrun main - -## 2.4.7 (2019-12-24) - -**Added** - -- feat: report user id to sentry - -**Fixed** - -- fix #797: locusts command error - -## 2.4.6 (2019-12-23) - -**Added** - -- feat: report tests start event and running exception to sentry - -**Fixed** - -- fix: ensure initializing sentry_sdk on startup - -**Fixed** - -## 2.4.5 (2019-12-20) - -**Added** - -- feat: integrate sentry sdk - -**Fixed** - -- fix: catch UnicodeDecodeError when json loads request body -- fix: display indented json for request json body - -**Changed** - -- change: detect request/response bytes encoding, instead of assuming utf-8 -- refactor: make report as submodule - -## 2.4.4 (2019-12-17) - -**Added** - -- feat: add keyword `body` to reference response body - -**Changed** - -- refactor: dumps request/response headers, display indented json in html report -- refactor: dumps request/response body if it is in json format, display indented json in html report -- change: unify response field(content/json/text) to `body` in html report - -## 2.4.3 (2019-12-16) - -**Added** - -- feat: load api content on demand - -**Changed** - -- refactor: use poetry>=1.0.0 -- test: migrate from travis CI to github actions -- test: migrate from coveralls to codecov -- test: run matrix tests on linux/macos/~~windows~~ and Python 2.7/3.5/3.6/3.7/3.8 - -## 2.4.2 (2019-12-13) - -**Changed** - -- refactor: replace with open file handler, avoid reading files into memory -- refactor: rename plugin to extension, httprunner/plugins -> httprunner/ext -- docs: update installation doc for developers - -## 2.4.1 (2019-12-12) - -**Added** - -- feat: add `upload` keyword for upload test, see [doc](https://docs.httprunner.org/prepare/upload-case/) -- test: pip install package -- test: hrun command - -**Fixed** - -- fix: typo testfile_paths -- fix: check if locustio installed -- fix: dump json file name is empty when running relative testfile - -## 2.4.0 (2019-12-11) - -**Added** - -- feat: validate with python script, ref #773 -- feat: rearrange html report, failed testcases will be displayed on top. - -**Changed** - -- refactor: make loader as submodule, split to check/locate/load/buildup -- refactor: make built_in as submodule, split to comparators and functions -- refactor: adjust code for context and validator -- docs: update cli argument help -- adjust format code, remove unused import - -**Fixed** - -- fix: keep setup/teardown hooks original order when merge & override. -- fix: length comparator exceptions when running in CSV data-driven mode. - -## 2.3.3 (2019-12-04) - -**Fixed** - -- fix #768: dump json file path error when folder name contains dot, such as `a.b.c` - -**Changed** - -- change: rename builtin function, sleep_N_secs => sleep - -## 2.3.2 (2019-11-01) - -**Added** - -- docs: add docs content to repo, visit at `https://docs.httprunner.org` -- docs: update developer interface docs - -**Changed** - -- rename `render_html_report` to `gen_html_report` -- make gen_html_report separate with HttpRunner().run_tests() -- `--report-file`: specify report file path, this has higher priority than specifying report dir. -- remove `summary` property from HttpRunner - -## 2.3.1 (2019-10-28) - -**Fixed** - -- fix locusts entry configuration - -**Changed** - -- update PyPi classifiers - -## 2.3.0 (2019-10-27) - -**Added** - -- feat: implement plugin system prototype, make locusts as plugin -- test: add Python 3.8 to Travis-CI -- feat: add `__main__.py`, `python -m httprunner` can be used to hrun tests - -**Changed** - -- update dependency versions in pyproject.toml -- rename folder, httprunner/templates => httprunner/static -- log httprunner version before running tests -- remove unused import & code - -**Fixed** - -- fix #707: duration stat error in multiple testsuites - -## 2.2.6 (2019-09-18) - -**Added** - -- feat: config variables support parsing from function -- feat: support [jsonpath](https://goessner.net/articles/JsonPath/) to parse json response [#679](https://github.com/httprunner/httprunner/pull/679) -- feat: generate html report with specified report file [#704](https://github.com/httprunner/httprunner/pull/704) - -**Changed** - -- remove unused import -- adjust code format - -**Fixed** - -- fix: dev-rules link 404 - -## 2.2.5 (2019-07-28) - -**Added** - -- log HttpRunner version when initializing - -**Fixed** - -- fix #658: sys.exit 1 if any testcase failed -- fix ModuleNotFoundError in debugging mode if httprunner uninstalled - -## 2.2.4 (2019-07-18) - -**Changed** - -- replace pipenv & setup.py with poetry -- drop support for Python 3.4 as it was EOL on 2019-03-16 -- relocate debugging scripts, move from main-debug.py to httprunner.cli - -**Fixed** - -- fix #574: delete unnecessary code -- fix #551: raise if times is not digit -- fix #572: tests_def_mapping["testcases"] typo error - -## 2.2.3 (2019-06-30) - -**Fixed** - -- fix yaml FullLoader AttributeError when PyYAML version < 5.1 - -## 2.2.2 (2019-06-26) - -**Changed** - -- `extract` is used to replace `output` when passing former teststep's (as a testcase) export value to next teststep -- `export` is used to replace `output` in testcase config - -## 2.2.1 (2019-06-25) - -**Added** - -- add demo api/testcase/testsuite to new created scaffold project -- update default `.gitignore` of new created scaffold project -- add demo content to `debugtalk.py`/`.env` of new created scaffold project - -**Fixed** - -- fix extend with testcase reference in format version 2 -- fix ImportError when locustio is not installed -- fix YAMLLoadWarning by specify yaml loader - -## 2.2.0 (2019-06-24) - -**Added** - -- support testcase/testsuite in format version 2 - -**Fixed** - -- add wheel in dev packages -- fix exception when teststep name reference former extracted variable - -## 2.1.3 (2019-04-24) - -**Fixed** - -- replace eval mechanism with builtins to prevent security vulnerabilities -- ImportError for builtins in Python2.7 - -## 2.1.2 (2019-04-17) - -**Added** - -- support new variable notation ${var} -- use \$\$ to escape \$ notation -- add Python 3.7 for travis CI - -**Fixed** - -- match duplicate variable/function in single raw string -- escape '{' and '}' notation in raw string -- print_info: TypeError when value is None -- display api name when running api as testcase - -## 2.1.1 (2019-04-11) - -**Changed** - -refactor upload files mechanism with [requests-toolbelt](https://toolbelt.readthedocs.io/en/latest/user.html#multipart-form-data-encoder): - -- simplify usage syntax, detect mimetype with [filetype](https://github.com/h2non/filetype.py). -- support upload multiple fields. - -## 2.1.0 (2019-04-10) - -**Added** - -- implement json dump Python objects when save tests -- implement lazy parser -- remove project_mapping from parse_tests result - -**Fixed** - -- reference output variables -- pass output variables between testcases - -## 2.0.6 (2019-03-18) - -**Added** - -- create .gitignore file when initializing new project - -**Fixed** - -- fix CSV relative path detection -- fix current validators displaying the former one when they are empty - -## 2.0.5 (2019-03-04) - -**Added** - -- implement method to get variables and output - -**Fixed** - -- fix xss in response json - -## 2.0.4 (2019-02-28) - -**Fixed** - -- fix verify priority with nested testcase -- fix function in config variables called multiple times -- dump loaded tests when running tests_mapping directly - -## 2.0.3 (2019-02-24) - -**Fixed** - -- fix verify priority: teststep > config -- fix Chinese charactor in log_file encoding error in Windows -- fix dump file with Chinese charactor in Python 3 - -## 2.0.2 (2019-01-21) - -**Fixed** - -- each teststeps in one testcase share the same session -- fix duplicate API definition output - -**Changed** - -- display result from hook functions in DEBUG level log -- change log level of "Variables & Output" to INFO -- print Invalid testcase path or testcases -- print testcase output in INFO level log - -## 2.0.1 (2019-01-18) - -**Fixed** - -- override current teststep variables with former testcase output variables -- Fixed compatibility when testcase name is empty -- skip undefined variable when parsing string content - -**Changed** - -- add back request method in report - -## 2.0.0 (2019-01-01) - -**Changed** - -- Massive Refactor and Simplification -- Redesign testcase structure -- Module pipline -- Start Semantic Versioning -- Switch to Apache 2.0 license -- Change logo diff --git a/docs/FAQ.md b/docs/FAQ.md deleted file mode 100644 index 782cb020..00000000 --- a/docs/FAQ.md +++ /dev/null @@ -1,14 +0,0 @@ -# 常见问题 - -## HTTPS SSLError - -请求 HTTPS 接口时,若本地开启了代理软件(Charles/Fiddler),由于 HTTPS 证书的原因,会导致 SSLError 的报错。 - -解决的方式是,在 config 中增加 `verify: False`,原理见 requests 的 [`SSL Cert Verification`](https://requests.kennethreitz.org/en/master/user/advanced/#ssl-cert-verification) 部分。 - -```yaml -config: - name: XXX - base_url: XXX - verify: False -``` diff --git a/docs/Installation.md b/docs/Installation.md deleted file mode 100644 index dc3ed753..00000000 --- a/docs/Installation.md +++ /dev/null @@ -1,145 +0,0 @@ -## 运行环境 - -HttpRunner 是一个基于 Python 开发的测试框架,可以运行在 macOS、Linux、Windows 系统平台上。 - -**Python 版本**:HttpRunner 支持 Python 3.5 及以上的所有版本,并使用 Travis-CI 进行了[持续集成测试][travis-ci],测试覆盖的版本包括 2.7/3.5/3.6/3.7/3.8。虽然 HttpRunner 暂时保留了对 Python 2.7 的兼容支持,但强烈建议使用 Python 3.6 及以上版本。 - -**操作系统**:推荐使用 macOS/Linux。 - -## 安装方式 - -HttpRunner 的稳定版本托管在 PyPI 上,可以使用 `pip` 进行安装。 - -```bash -$ pip install httprunner -``` - -如果你需要使用最新的开发版本,那么可以采用项目的 GitHub 仓库地址进行安装: - -```bash -$ pip install git+https://github.com/HttpRunner/HttpRunner.git@master -``` - -## 版本升级 - -假如你之前已经安装过了 HttpRunner,现在需要升级到最新版本,那么你可以使用`-U`参数。该参数对以上三种安装方式均生效。 - -```bash -$ pip install -U HttpRunner -$ pip install -U git+https://github.com/HttpRunner/HttpRunner.git@master -``` - -## 安装校验 - -在 HttpRunner 安装成功后,系统中会新增如下 5 个命令: - -- `httprunner`: 核心命令 -- `ate`: 曾经用过的命令(当时框架名称为 ApiTestEngine),功能与 httprunner 完全相同 -- `hrun`: httprunner 的缩写,功能与 httprunner 完全相同 -- `locusts`: 基于 [Locust][Locust] 实现[性能测试](run-tests/load-test.md) -- [`har2case`][har2case]: 辅助工具,可将标准通用的 HAR 格式(HTTP Archive)转换为`YAML/JSON`格式的测试用例 - -httprunner、hrun、ate 三个命令完全等价,功能特性完全相同,个人推荐使用`hrun`命令。 - -运行如下命令,若正常显示版本号,则说明 HttpRunner 安装成功。 - -```text -$ hrun -V -2.4.1 - -$ har2case -V -0.3.1 -``` - -## 开发者模式 - -默认情况下,安装 HttpRunner 的时候只会安装运行 HttpRunner 的必要依赖库。 - -如果你不仅仅是使用 HttpRunner,还需要对 HttpRunner 进行开发调试(debug),那么就需要进行如下操作。 - -HttpRunner 使用 [poetry][poetry] 对依赖包进行管理,若你还没有安装 poetry,需要先执行如下命令进行安装: - -```bash -$ curl -sSL https://raw.githubusercontent.com/python-poetry/poetry/master/get-poetry.py | python -``` - -获取 HttpRunner 源码: - -```bash -$ git clone https://github.com/HttpRunner/HttpRunner.git -``` - -进入仓库目录,安装所有依赖: - -```bash -$ poetry install -``` - -运行单元测试,若测试全部通过,则说明环境正常。 - -```bash -$ poetry run python -m unittest discover -``` - -查看 HttpRunner 的依赖情况: - -```bash -$ poetry show --tree -colorama 0.4.1 Cross-platform colored terminal text. -colorlog 4.0.2 Log formatting with colors! -└── colorama * -coverage 4.5.4 Code coverage measurement for Python -coveralls 1.8.2 Show coverage stats online via coveralls.io -├── coverage >=3.6,<5.0 -├── docopt >=0.6.1 -├── requests >=1.0.0 -│ ├── certifi >=2017.4.17 -│ ├── chardet >=3.0.2,<3.1.0 -│ ├── idna >=2.5,<2.9 -│ └── urllib3 >=1.21.1,<1.25.0 || >1.25.0,<1.25.1 || >1.25.1,<1.26 -└── urllib3 * -filetype 1.0.5 Infer file type and MIME type of any file/buffer. No external dependencies. -flask 0.12.4 A microframework based on Werkzeug, Jinja2 and good intentions -├── click >=2.0 -├── itsdangerous >=0.21 -├── jinja2 >=2.4 -│ └── markupsafe >=0.23 -└── werkzeug >=0.7 -future 0.18.1 Clean single-source support for Python 3 and 2 -har2case 0.3.1 Convert HAR(HTTP Archive) to YAML/JSON testcases for HttpRunner. -└── pyyaml * -jinja2 2.10.3 A very fast and expressive template engine. -└── markupsafe >=0.23 -jsonpath 0.82 An XPath for JSON -pyyaml 5.1.2 YAML parser and emitter for Python -requests 2.22.0 Python HTTP for Humans. -├── certifi >=2017.4.17 -├── chardet >=3.0.2,<3.1.0 -├── idna >=2.5,<2.9 -└── urllib3 >=1.21.1,<1.25.0 || >1.25.0,<1.25.1 || >1.25.1,<1.26 -requests-toolbelt 0.9.1 A utility belt for advanced users of python-requests -└── requests >=2.0.1,<3.0.0 - ├── certifi >=2017.4.17 - ├── chardet >=3.0.2,<3.1.0 - ├── idna >=2.5,<2.9 - └── urllib3 >=1.21.1,<1.25.0 || >1.25.0,<1.25.1 || >1.25.1,<1.26 -``` - -调试运行方式: - -```bash -# 调试运行 hrun -$ poetry run python -m httprunner -h - -# 调试运行 locusts -$ pipenv run python -m httprunner.ext.locusts -h -``` - -## Docker - -TODO - -[travis-ci]: https://travis-ci.org/HttpRunner/HttpRunner -[Locust]: http://locust.io/ -[har2case]: https://github.com/HttpRunner/har2case -[poetry]: https://github.com/sdispater/poetry \ No newline at end of file diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index ebb30df6..00000000 --- a/docs/README.md +++ /dev/null @@ -1,41 +0,0 @@ -# HttpRunner V2.x 中文使用文档 - -## 在线阅读 - -本文档托管在`GitHub Pages`上,访问地址: - -https://docs.httprunner.org - -## 本地预览 - -### 安装依赖 - -本项目文档采用[`mkdocs`][mkdocs]生成,如需在本地预览查看,则需安装该工具。 - -```bash -$ pip install mkdocs -``` - -`mkdocs`支持主题配置,本项目选择了第三方的[`mkdocs-material`][mkdocs-material]。 - -```bash -$ pip install mkdocs-material -``` - -### 启动本地server - -在项目根目录中运行如下命令: - -```bash -$ mkdocs serve -INFO - Building documentation... -INFO - Cleaning site directory -[I 180211 22:48:35 server:283] Serving on http://127.0.0.1:8000 -[I 180211 22:48:35 handlers:60] Start watching changes -[I 180211 22:48:35 handlers:62] Start detecting changes -``` - -然后在浏览器中访问`http://127.0.0.1:8000`即可。 - -[mkdocs]: http://www.mkdocs.org/ -[mkdocs-material]: https://squidfunk.github.io/mkdocs-material/ \ No newline at end of file diff --git a/docs/concept/nominal.md b/docs/concept/nominal.md deleted file mode 100644 index 3d6af631..00000000 --- a/docs/concept/nominal.md +++ /dev/null @@ -1,67 +0,0 @@ -## 测试用例(testcase) - -从 2.0 版本开始,HttpRunner 开始对测试用例的定义进行进一步的明确,参考 [wiki][wiki_testcase] 上的描述。 - -> A test case is a specification of the inputs, execution conditions, testing procedure, and expected results that define a single test to be executed to achieve a particular software testing objective, such as to exercise a particular program path or to verify compliance with a specific requirement. - -概括下来,一条测试用例(testcase)应该是为了测试某个特定的功能逻辑而精心设计的,并且至少包含如下几点: - -- 明确的测试目的(achieve a particular software testing objective) -- 明确的输入(inputs) -- 明确的运行环境(execution conditions) -- 明确的测试步骤描述(testing procedure) -- 明确的预期结果(expected results) - -对应地,HttpRunner 的测试用例描述方式进行如下设计: - -- 测试用例应该是完整且独立的,每条测试用例应该是都可以独立运行的;在 HttpRunner 中,每个 `YAML/JSON` 文件对应一条测试用例。 -- 测试用例包含 `测试脚本` 和 `测试数据` 两部分: - - `测试用例 = 测试脚本 + 测试数据` - - `测试脚本` 重点是描述测试的 `业务功能逻辑`,包括预置条件、测试步骤、预期结果等,并且可以结合辅助函数(debugtalk.py)实现复杂的运算逻辑;可以将 `测试脚本` 理解为编程语言中的 `类(class)`; - - `测试数据` 重点是对应测试的 `业务数据逻辑`,可以理解为类的实例化数据; - - `测试数据` 和 `测试脚本` 分离后,就可以比较方便地实现数据驱动测试,通过对测试脚本传入一组数据,实现同一业务功能在不同数据逻辑下的测试验证。 - - -## 测试步骤(teststep) - -测试用例是测试步骤的 `有序` 集合,而对于接口测试来说,每一个测试步骤应该就对应一个 API 的请求描述。 - -## 测试用例集(testsuite) - -`测试用例集` 是 `测试用例` 的 `无序` 集合,集合中的测试用例应该都是相互独立,不存在先后依赖关系的。 - -如果确实存在先后依赖关系怎么办,例如登录功能和下单功能。正确的做法应该是,在下单测试用例的前置步骤中执行登录操作。 - -```yaml -- config: - name: order product - -- test: - name: login - testcase: testcases/login.yml - -- test: - name: add to cart - api: api/add_cart.yml - -- test: - name: make order - api: api/make_order.yml -``` - -## 测试场景 - -`测试场景` 和 `测试用例集` 是同一概念,都是 `测试用例` 的 `无序` 集合。 - - -- 接口 -- 测试用例集 -- 参数 -- 变量 -- 测试脚本(YAML/JSON) -- debugtalk.py -- 环境变量 - -## 项目根目录 - -[wiki_testcase]: https://en.wikipedia.org/wiki/Test_case \ No newline at end of file diff --git a/docs/data/account.csv b/docs/data/account.csv deleted file mode 100644 index 2502b5b8..00000000 --- a/docs/data/account.csv +++ /dev/null @@ -1,4 +0,0 @@ -username,password,phone -test1,111111,18600000001 -test2,222222,18600000002 -test3,333333,18600000003 \ No newline at end of file diff --git a/docs/data/api_server.py b/docs/data/api_server.py deleted file mode 100644 index bff04ddf..00000000 --- a/docs/data/api_server.py +++ /dev/null @@ -1,223 +0,0 @@ -import hashlib -import hmac -import json -import random -import string -from functools import wraps - -from flask import Flask, make_response, request - -SECRET_KEY = "DebugTalk" - -app = Flask(__name__) - -""" storage all users' data -data structure: - users_dict = { - 'uid1': { - 'name': 'name1', - 'password': 'pwd1' - }, - 'uid2': { - 'name': 'name2', - 'password': 'pwd2' - } - } -""" -users_dict = {} - -""" storage all token data -data structure: - token_dict = { - 'device_sn1': 'token1', - 'device_sn2': 'token1' - } -""" -token_dict = {} - - -def gen_random_string(str_len): - """ generate random string with specified length - """ - return ''.join( - random.choice(string.ascii_letters + string.digits) for _ in range(str_len)) - -def get_sign(*args): - content = ''.join(args).encode('ascii') - sign_key = SECRET_KEY.encode('ascii') - sign = hmac.new(sign_key, content, hashlib.sha1).hexdigest() - return sign - -def gen_md5(*args): - return hashlib.md5("".join(args).encode('utf-8')).hexdigest() - - -def validate_request(func): - - @wraps(func) - def wrapper(*args, **kwargs): - device_sn = request.headers.get('device_sn', "") - token = request.headers.get('token', "") - - if not device_sn or not token: - result = { - 'success': False, - 'msg': "device_sn or token is null." - } - response = make_response(json.dumps(result), 401) - response.headers["Content-Type"] = "application/json" - return response - - if token_dict.get(device_sn) != token: - result = { - 'success': False, - 'msg': "Authorization failed!" - } - response = make_response(json.dumps(result), 403) - response.headers["Content-Type"] = "application/json" - return response - - return func(*args, **kwargs) - - return wrapper - - -@app.route('/') -def index(): - return "Hello World!" - -@app.route('/api/get-token', methods=['POST']) -def get_token(): - device_sn = request.headers.get('device_sn', "") - os_platform = request.headers.get('os_platform', "") - app_version = request.headers.get('app_version', "") - data = request.get_json() - sign = data.get('sign', "") - - expected_sign = get_sign(device_sn, os_platform, app_version) - - if expected_sign != sign: - result = { - 'success': False, - 'msg': "Authorization failed!" - } - response = make_response(json.dumps(result), 403) - else: - token = gen_random_string(16) - token_dict[device_sn] = token - - result = { - 'success': True, - 'token': token - } - response = make_response(json.dumps(result)) - - response.headers["Content-Type"] = "application/json" - return response - -@app.route('/api/users') -@validate_request -def get_users(): - users_list = [user for uid, user in users_dict.items()] - users = { - 'success': True, - 'count': len(users_list), - 'items': users_list - } - response = make_response(json.dumps(users)) - response.headers["Content-Type"] = "application/json" - return response - -@app.route('/api/reset-all') -@validate_request -def clear_users(): - users_dict.clear() - result = { - 'success': True - } - response = make_response(json.dumps(result)) - response.headers["Content-Type"] = "application/json" - return response - -@app.route('/api/users/', methods=['POST']) -@validate_request -def create_user(uid): - user = request.get_json() - if uid not in users_dict: - result = { - 'success': True, - 'msg': "user created successfully." - } - status_code = 201 - users_dict[uid] = user - else: - result = { - 'success': False, - 'msg': "user already existed." - } - status_code = 500 - - response = make_response(json.dumps(result), status_code) - response.headers["Content-Type"] = "application/json" - return response - -@app.route('/api/users/') -@validate_request -def get_user(uid): - user = users_dict.get(uid, {}) - if user: - result = { - 'success': True, - 'data': user - } - status_code = 200 - else: - result = { - 'success': False, - 'data': user - } - status_code = 404 - - response = make_response(json.dumps(result), status_code) - response.headers["Content-Type"] = "application/json" - return response - -@app.route('/api/users/', methods=['PUT']) -@validate_request -def update_user(uid): - user = users_dict.get(uid, {}) - if user: - user = request.get_json() - success = True - status_code = 200 - users_dict[uid] = user - else: - success = False - status_code = 404 - - result = { - 'success': success, - 'data': user - } - response = make_response(json.dumps(result), status_code) - response.headers["Content-Type"] = "application/json" - return response - -@app.route('/api/users/', methods=['DELETE']) -@validate_request -def delete_user(uid): - user = users_dict.pop(uid, {}) - if user: - success = True - status_code = 200 - else: - success = False - status_code = 404 - - result = { - 'success': success, - 'data': user - } - response = make_response(json.dumps(result), status_code) - response.headers["Content-Type"] = "application/json" - return response diff --git a/docs/data/app_version.csv b/docs/data/app_version.csv deleted file mode 100644 index a0c236a4..00000000 --- a/docs/data/app_version.csv +++ /dev/null @@ -1,3 +0,0 @@ -app_version -2.8.5 -2.8.6 diff --git a/docs/data/debugtalk.py b/docs/data/debugtalk.py deleted file mode 100644 index 53b56d02..00000000 --- a/docs/data/debugtalk.py +++ /dev/null @@ -1,48 +0,0 @@ -import hashlib -import hmac -import random -import string -import time - -SECRET_KEY = "DebugTalk" - -def gen_random_string(str_len): - random_char_list = [] - for _ in range(str_len): - random_char = random.choice(string.ascii_letters + string.digits) - random_char_list.append(random_char) - - random_string = ''.join(random_char_list) - return random_string - -def get_sign(*args): - content = ''.join(args).encode('ascii') - sign_key = SECRET_KEY.encode('ascii') - sign = hmac.new(sign_key, content, hashlib.sha1).hexdigest() - return sign - -def gen_user_id(): - return int(time.time() * 1000) - -def get_user_id(): - return [ - {"user_id": 1001}, - {"user_id": 1002}, - {"user_id": 1003}, - {"user_id": 1004} - ] - -def get_account(num): - accounts = [] - for index in range(1, num+1): - accounts.append( - {"username": "user%s" % index, "password": str(index) * 6}, - ) - - return accounts - -def get_os_platform(): - return [ - {"os_platform": "ios"}, - {"os_platform": "android"} - ] diff --git a/docs/data/demo-parameters-get-token.yml b/docs/data/demo-parameters-get-token.yml deleted file mode 100644 index d1bfb633..00000000 --- a/docs/data/demo-parameters-get-token.yml +++ /dev/null @@ -1,10 +0,0 @@ -config: - name: get token with parameters - -testcases: - get token with $user_agent, $app_version, $os_platform: - testcase: demo-testcase-get-token.yml - parameters: - user_agent: ["iOS/10.1", "iOS/10.2", "iOS/10.3"] - app_version: ${P(app_version.csv)} - os_platform: ${get_os_platform()} diff --git a/docs/data/demo-quickstart-0.json b/docs/data/demo-quickstart-0.json deleted file mode 100644 index 57dd4ce9..00000000 --- a/docs/data/demo-quickstart-0.json +++ /dev/null @@ -1,58 +0,0 @@ -[ - { - "config": { - "name": "testcase description", - "variables": {} - } - }, - { - "test": { - "name": "/api/get-token", - "request": { - "url": "http://127.0.0.1:5000/api/get-token", - "method": "POST", - "headers": { - "User-Agent": "python-requests/2.18.4", - "device_sn": "FwgRiO7CNA50DSU", - "os_platform": "ios", - "app_version": "2.8.6", - "Content-Type": "application/json" - }, - "json": { - "sign": "9c0c7e51c91ae963c833a4ccbab8d683c4a90c98" - } - }, - "validate": [ - {"eq": ["status_code", 200]}, - {"eq": ["headers.Content-Type", "application/json"]}, - {"eq": ["content.success", true]}, - {"eq": ["content.token", "baNLX1zhFYP11Seb"]} - ] - } - }, - { - "test": { - "name": "/api/users/1000", - "request": { - "url": "http://127.0.0.1:5000/api/users/1000", - "method": "POST", - "headers": { - "User-Agent": "python-requests/2.18.4", - "device_sn": "FwgRiO7CNA50DSU", - "token": "baNLX1zhFYP11Seb", - "Content-Type": "application/json" - }, - "json": { - "name": "user1", - "password": "123456" - } - }, - "validate": [ - {"eq": ["status_code", 201]}, - {"eq": ["headers.Content-Type", "application/json"]}, - {"eq": ["content.success", true]}, - {"eq": ["content.msg", "user created successfully."]} - ] - } - } -] \ No newline at end of file diff --git a/docs/data/demo-quickstart-0.yml b/docs/data/demo-quickstart-0.yml deleted file mode 100644 index 9e50c8b6..00000000 --- a/docs/data/demo-quickstart-0.yml +++ /dev/null @@ -1,41 +0,0 @@ -- config: - name: testcase description - variables: {} - -- test: - name: /api/get-token - request: - headers: - Content-Type: application/json - User-Agent: python-requests/2.18.4 - app_version: 2.8.6 - device_sn: FwgRiO7CNA50DSU - os_platform: ios - json: - sign: 9c0c7e51c91ae963c833a4ccbab8d683c4a90c98 - method: POST - url: http://127.0.0.1:5000/api/get-token - validate: - - eq: [status_code, 200] - - eq: [headers.Content-Type, application/json] - - eq: [content.success, true] - - eq: [content.token, baNLX1zhFYP11Seb] - -- test: - name: /api/users/1000 - request: - headers: - Content-Type: application/json - User-Agent: python-requests/2.18.4 - device_sn: FwgRiO7CNA50DSU - token: baNLX1zhFYP11Seb - json: - name: user1 - password: '123456' - method: POST - url: http://127.0.0.1:5000/api/users/1000 - validate: - - eq: [status_code, 201] - - eq: [headers.Content-Type, application/json] - - eq: [content.success, true] - - eq: [content.msg, user created successfully.] \ No newline at end of file diff --git a/docs/data/demo-quickstart-1.json b/docs/data/demo-quickstart-1.json deleted file mode 100644 index 433666c8..00000000 --- a/docs/data/demo-quickstart-1.json +++ /dev/null @@ -1,57 +0,0 @@ -[ - { - "config": { - "name": "testcase description", - "variables": {} - } - }, - { - "test": { - "name": "/api/get-token", - "request": { - "url": "http://127.0.0.1:5000/api/get-token", - "method": "POST", - "headers": { - "User-Agent": "python-requests/2.18.4", - "device_sn": "FwgRiO7CNA50DSU", - "os_platform": "ios", - "app_version": "2.8.6", - "Content-Type": "application/json" - }, - "json": { - "sign": "9c0c7e51c91ae963c833a4ccbab8d683c4a90c98" - } - }, - "validate": [ - {"eq": ["status_code", 200]}, - {"eq": ["headers.Content-Type", "application/json"]}, - {"eq": ["content.success", true]} - ] - } - }, - { - "test": { - "name": "/api/users/1000", - "request": { - "url": "http://127.0.0.1:5000/api/users/1000", - "method": "POST", - "headers": { - "User-Agent": "python-requests/2.18.4", - "device_sn": "FwgRiO7CNA50DSU", - "token": "baNLX1zhFYP11Seb", - "Content-Type": "application/json" - }, - "json": { - "name": "user1", - "password": "123456" - } - }, - "validate": [ - {"eq": ["status_code", 201]}, - {"eq": ["headers.Content-Type", "application/json"]}, - {"eq": ["content.success", true]}, - {"eq": ["content.msg", "user created successfully."]} - ] - } - } -] \ No newline at end of file diff --git a/docs/data/demo-quickstart-1.yml b/docs/data/demo-quickstart-1.yml deleted file mode 100644 index 1874c75d..00000000 --- a/docs/data/demo-quickstart-1.yml +++ /dev/null @@ -1,40 +0,0 @@ -- config: - name: testcase description - variables: {} - -- test: - name: /api/get-token - request: - headers: - Content-Type: application/json - User-Agent: python-requests/2.18.4 - app_version: 2.8.6 - device_sn: FwgRiO7CNA50DSU - os_platform: ios - json: - sign: 9c0c7e51c91ae963c833a4ccbab8d683c4a90c98 - method: POST - url: http://127.0.0.1:5000/api/get-token - validate: - - eq: [status_code, 200] - - eq: [headers.Content-Type, application/json] - - eq: [content.success, true] - -- test: - name: /api/users/1000 - request: - headers: - Content-Type: application/json - User-Agent: python-requests/2.18.4 - device_sn: FwgRiO7CNA50DSU - token: baNLX1zhFYP11Seb - json: - name: user1 - password: '123456' - method: POST - url: http://127.0.0.1:5000/api/users/1000 - validate: - - eq: [status_code, 201] - - eq: [headers.Content-Type, application/json] - - eq: [content.success, true] - - eq: [content.msg, user created successfully.] \ No newline at end of file diff --git a/docs/data/demo-quickstart-2.json b/docs/data/demo-quickstart-2.json deleted file mode 100644 index 6c24da28..00000000 --- a/docs/data/demo-quickstart-2.json +++ /dev/null @@ -1,60 +0,0 @@ -[ - { - "config": { - "name": "testcase description", - "variables": {} - } - }, - { - "test": { - "name": "/api/get-token", - "request": { - "url": "http://127.0.0.1:5000/api/get-token", - "method": "POST", - "headers": { - "User-Agent": "python-requests/2.18.4", - "device_sn": "FwgRiO7CNA50DSU", - "os_platform": "ios", - "app_version": "2.8.6", - "Content-Type": "application/json" - }, - "json": { - "sign": "9c0c7e51c91ae963c833a4ccbab8d683c4a90c98" - } - }, - "extract": [ - {"token": "content.token"} - ], - "validate": [ - {"eq": ["status_code", 200]}, - {"eq": ["headers.Content-Type", "application/json"]}, - {"eq": ["content.success", true]} - ] - } - }, - { - "test": { - "name": "/api/users/1000", - "request": { - "url": "http://127.0.0.1:5000/api/users/1000", - "method": "POST", - "headers": { - "User-Agent": "python-requests/2.18.4", - "device_sn": "FwgRiO7CNA50DSU", - "token": "$token", - "Content-Type": "application/json" - }, - "json": { - "name": "user1", - "password": "123456" - } - }, - "validate": [ - {"eq": ["status_code", 201]}, - {"eq": ["headers.Content-Type", "application/json"]}, - {"eq": ["content.success", true]}, - {"eq": ["content.msg", "user created successfully."]} - ] - } - } -] diff --git a/docs/data/demo-quickstart-2.yml b/docs/data/demo-quickstart-2.yml deleted file mode 100644 index 17924f0d..00000000 --- a/docs/data/demo-quickstart-2.yml +++ /dev/null @@ -1,42 +0,0 @@ -- config: - name: testcase description - variables: {} - -- test: - name: /api/get-token - request: - headers: - Content-Type: application/json - User-Agent: python-requests/2.18.4 - app_version: 2.8.6 - device_sn: FwgRiO7CNA50DSU - os_platform: ios - json: - sign: 9c0c7e51c91ae963c833a4ccbab8d683c4a90c98 - method: POST - url: http://127.0.0.1:5000/api/get-token - extract: - token: content.token - validate: - - eq: [status_code, 200] - - eq: [headers.Content-Type, application/json] - - eq: [content.success, true] - -- test: - name: /api/users/1000 - request: - headers: - Content-Type: application/json - User-Agent: python-requests/2.18.4 - device_sn: FwgRiO7CNA50DSU - token: $token - json: - name: user1 - password: '123456' - method: POST - url: http://127.0.0.1:5000/api/users/1000 - validate: - - eq: [status_code, 201] - - eq: [headers.Content-Type, application/json] - - eq: [content.success, true] - - eq: [content.msg, user created successfully.] diff --git a/docs/data/demo-quickstart-3.json b/docs/data/demo-quickstart-3.json deleted file mode 100644 index fd276ca7..00000000 --- a/docs/data/demo-quickstart-3.json +++ /dev/null @@ -1,61 +0,0 @@ -[ - { - "config": { - "name": "testcase description", - "base_url": "http://127.0.0.1:5000", - "variables": {} - } - }, - { - "test": { - "name": "/api/get-token", - "request": { - "url": "/api/get-token", - "method": "POST", - "headers": { - "User-Agent": "python-requests/2.18.4", - "device_sn": "FwgRiO7CNA50DSU", - "os_platform": "ios", - "app_version": "2.8.6", - "Content-Type": "application/json" - }, - "json": { - "sign": "9c0c7e51c91ae963c833a4ccbab8d683c4a90c98" - } - }, - "extract": [ - {"token": "content.token"} - ], - "validate": [ - {"eq": ["status_code", 200]}, - {"eq": ["headers.Content-Type", "application/json"]}, - {"eq": ["content.success", true]} - ] - } - }, - { - "test": { - "name": "/api/users/1000", - "request": { - "url": "/api/users/1000", - "method": "POST", - "headers": { - "User-Agent": "python-requests/2.18.4", - "device_sn": "FwgRiO7CNA50DSU", - "token": "$token", - "Content-Type": "application/json" - }, - "json": { - "name": "user1", - "password": "123456" - } - }, - "validate": [ - {"eq": ["status_code", 201]}, - {"eq": ["headers.Content-Type", "application/json"]}, - {"eq": ["content.success", true]}, - {"eq": ["content.msg", "user created successfully."]} - ] - } - } -] diff --git a/docs/data/demo-quickstart-3.yml b/docs/data/demo-quickstart-3.yml deleted file mode 100644 index 654a383b..00000000 --- a/docs/data/demo-quickstart-3.yml +++ /dev/null @@ -1,43 +0,0 @@ -- config: - name: testcase description - base_url: http://127.0.0.1:5000 - variables: {} - -- test: - name: /api/get-token - request: - headers: - Content-Type: application/json - User-Agent: python-requests/2.18.4 - app_version: 2.8.6 - device_sn: FwgRiO7CNA50DSU - os_platform: ios - json: - sign: 9c0c7e51c91ae963c833a4ccbab8d683c4a90c98 - method: POST - url: /api/get-token - extract: - token: content.token - validate: - - eq: [status_code, 200] - - eq: [headers.Content-Type, application/json] - - eq: [content.success, true] - -- test: - name: /api/users/1000 - request: - headers: - Content-Type: application/json - User-Agent: python-requests/2.18.4 - device_sn: FwgRiO7CNA50DSU - token: $token - json: - name: user1 - password: '123456' - method: POST - url: /api/users/1000 - validate: - - eq: [status_code, 201] - - eq: [headers.Content-Type, application/json] - - eq: [content.success, true] - - eq: [content.msg, user created successfully.] diff --git a/docs/data/demo-quickstart-4.json b/docs/data/demo-quickstart-4.json deleted file mode 100644 index ef03348e..00000000 --- a/docs/data/demo-quickstart-4.json +++ /dev/null @@ -1,70 +0,0 @@ -[ - { - "config": { - "name": "testcase description", - "base_url": "http://127.0.0.1:5000", - "variables": {} - } - }, - { - "test": { - "name": "/api/get-token", - "variables": { - "device_sn": "FwgRiO7CNA50DSU", - "os_platform": "ios", - "app_version": "2.8.6" - }, - "request": { - "url": "/api/get-token", - "method": "POST", - "headers": { - "User-Agent": "python-requests/2.18.4", - "device_sn": "$device_sn", - "os_platform": "$os_platform", - "app_version": "$app_version", - "Content-Type": "application/json" - }, - "json": { - "sign": "9c0c7e51c91ae963c833a4ccbab8d683c4a90c98" - } - }, - "extract": [ - {"token": "content.token"} - ], - "validate": [ - {"eq": ["status_code", 200]}, - {"eq": ["headers.Content-Type", "application/json"]}, - {"eq": ["content.success", true]} - ] - } - }, - { - "test": { - "name": "/api/users/$user_id", - "variables": { - "device_sn": "FwgRiO7CNA50DSU", - "user_id": "1000" - }, - "request": { - "url": "/api/users/$user_id", - "method": "POST", - "headers": { - "User-Agent": "python-requests/2.18.4", - "device_sn": "$device_sn", - "token": "$token", - "Content-Type": "application/json" - }, - "json": { - "name": "user1", - "password": "123456" - } - }, - "validate": [ - {"eq": ["status_code", 201]}, - {"eq": ["headers.Content-Type", "application/json"]}, - {"eq": ["content.success", true]}, - {"eq": ["content.msg", "user created successfully."]} - ] - } - } -] \ No newline at end of file diff --git a/docs/data/demo-quickstart-4.yml b/docs/data/demo-quickstart-4.yml deleted file mode 100644 index 514b25f6..00000000 --- a/docs/data/demo-quickstart-4.yml +++ /dev/null @@ -1,50 +0,0 @@ -- config: - name: testcase description - base_url: http://127.0.0.1:5000 - variables: {} - -- test: - name: /api/get-token - variables: - app_version: 2.8.6 - device_sn: FwgRiO7CNA50DSU - os_platform: ios - request: - headers: - Content-Type: application/json - User-Agent: python-requests/2.18.4 - app_version: $app_version - device_sn: $device_sn - os_platform: $os_platform - json: - sign: 9c0c7e51c91ae963c833a4ccbab8d683c4a90c98 - method: POST - url: /api/get-token - extract: - token: content.token - validate: - - eq: [status_code, 200] - - eq: [headers.Content-Type, application/json] - - eq: [content.success, true] - -- test: - name: /api/users/$user_id - variables: - device_sn: FwgRiO7CNA50DSU - user_id: 1000 - request: - headers: - Content-Type: application/json - User-Agent: python-requests/2.18.4 - device_sn: $device_sn - token: $token - json: - name: user1 - password: '123456' - method: POST - url: /api/users/$user_id - validate: - - eq: [status_code, 201] - - eq: [headers.Content-Type, application/json] - - eq: [content.success, true] - - eq: [content.msg, user created successfully.] diff --git a/docs/data/demo-quickstart-5.json b/docs/data/demo-quickstart-5.json deleted file mode 100644 index 4ede41f7..00000000 --- a/docs/data/demo-quickstart-5.json +++ /dev/null @@ -1,70 +0,0 @@ -[ - { - "config": { - "name": "testcase description", - "base_url": "http://127.0.0.1:5000", - "variables": { - "device_sn": "FwgRiO7CNA50DSU" - } - } - }, - { - "test": { - "name": "/api/get-token", - "variables": { - "os_platform": "ios", - "app_version": "2.8.6" - }, - "request": { - "url": "/api/get-token", - "method": "POST", - "headers": { - "User-Agent": "python-requests/2.18.4", - "device_sn": "$device_sn", - "os_platform": "$os_platform", - "app_version": "$app_version", - "Content-Type": "application/json" - }, - "json": { - "sign": "9c0c7e51c91ae963c833a4ccbab8d683c4a90c98" - } - }, - "extract": [ - {"token": "content.token"} - ], - "validate": [ - {"eq": ["status_code", 200]}, - {"eq": ["headers.Content-Type", "application/json"]}, - {"eq": ["content.success", true]} - ] - } - }, - { - "test": { - "name": "/api/users/$user_id", - "variables": { - "user_id": "1000" - }, - "request": { - "url": "/api/users/$user_id", - "method": "POST", - "headers": { - "User-Agent": "python-requests/2.18.4", - "device_sn": "$device_sn", - "token": "$token", - "Content-Type": "application/json" - }, - "json": { - "name": "user1", - "password": "123456" - } - }, - "validate": [ - {"eq": ["status_code", 201]}, - {"eq": ["headers.Content-Type", "application/json"]}, - {"eq": ["content.success", true]}, - {"eq": ["content.msg", "user created successfully."]} - ] - } - } -] \ No newline at end of file diff --git a/docs/data/demo-quickstart-5.yml b/docs/data/demo-quickstart-5.yml deleted file mode 100644 index 98445b7f..00000000 --- a/docs/data/demo-quickstart-5.yml +++ /dev/null @@ -1,49 +0,0 @@ -- config: - name: testcase description - base_url: http://127.0.0.1:5000 - variables: - device_sn: FwgRiO7CNA50DSU - -- test: - name: /api/get-token - variables: - app_version: 2.8.6 - os_platform: ios - request: - headers: - Content-Type: application/json - User-Agent: python-requests/2.18.4 - app_version: $app_version - device_sn: $device_sn - os_platform: $os_platform - json: - sign: 9c0c7e51c91ae963c833a4ccbab8d683c4a90c98 - method: POST - url: /api/get-token - extract: - token: content.token - validate: - - eq: [status_code, 200] - - eq: [headers.Content-Type, application/json] - - eq: [content.success, true] - -- test: - name: /api/users/$user_id - variables: - user_id: 1000 - request: - headers: - Content-Type: application/json - User-Agent: python-requests/2.18.4 - device_sn: $device_sn - token: $token - json: - name: user1 - password: '123456' - method: POST - url: /api/users/$user_id - validate: - - eq: [status_code, 201] - - eq: [headers.Content-Type, application/json] - - eq: [content.success, true] - - eq: [content.msg, user created successfully.] diff --git a/docs/data/demo-quickstart-6.json b/docs/data/demo-quickstart-6.json deleted file mode 100644 index da126fca..00000000 --- a/docs/data/demo-quickstart-6.json +++ /dev/null @@ -1,70 +0,0 @@ -[ - { - "config": { - "name": "testcase description", - "base_url": "http://127.0.0.1:5000", - "variables": { - "device_sn": "${gen_random_string(15)}" - } - } - }, - { - "test": { - "name": "/api/get-token", - "variables": { - "os_platform": "ios", - "app_version": "2.8.6" - }, - "request": { - "url": "/api/get-token", - "method": "POST", - "headers": { - "User-Agent": "python-requests/2.18.4", - "device_sn": "$device_sn", - "os_platform": "$os_platform", - "app_version": "$app_version", - "Content-Type": "application/json" - }, - "json": { - "sign": "${get_sign($device_sn, $os_platform, $app_version)}" - } - }, - "extract": [ - {"token": "content.token"} - ], - "validate": [ - {"eq": ["status_code", 200]}, - {"eq": ["headers.Content-Type", "application/json"]}, - {"eq": ["content.success", true]} - ] - } - }, - { - "test": { - "name": "/api/users/$user_id", - "variables": { - "user_id": "${gen_user_id()}" - }, - "request": { - "url": "/api/users/$user_id", - "method": "POST", - "headers": { - "User-Agent": "python-requests/2.18.4", - "device_sn": "$device_sn", - "token": "$token", - "Content-Type": "application/json" - }, - "json": { - "name": "user1", - "password": "123456" - } - }, - "validate": [ - {"eq": ["status_code", 201]}, - {"eq": ["headers.Content-Type", "application/json"]}, - {"eq": ["content.success", true]}, - {"eq": ["content.msg", "user created successfully."]} - ] - } - } -] \ No newline at end of file diff --git a/docs/data/demo-quickstart-6.yml b/docs/data/demo-quickstart-6.yml deleted file mode 100644 index b5acf937..00000000 --- a/docs/data/demo-quickstart-6.yml +++ /dev/null @@ -1,49 +0,0 @@ -- config: - name: testcase description - base_url: http://127.0.0.1:5000 - variables: - device_sn: ${gen_random_string(15)} - -- test: - name: /api/get-token - variables: - app_version: 2.8.6 - os_platform: ios - request: - headers: - Content-Type: application/json - User-Agent: python-requests/2.18.4 - app_version: $app_version - device_sn: $device_sn - os_platform: $os_platform - json: - sign: ${get_sign($device_sn, $os_platform, $app_version)} - method: POST - url: /api/get-token - extract: - token: content.token - validate: - - eq: [status_code, 200] - - eq: [headers.Content-Type, application/json] - - eq: [content.success, true] - -- test: - name: /api/users/$user_id - variables: - user_id: ${gen_user_id()} - request: - headers: - Content-Type: application/json - User-Agent: python-requests/2.18.4 - device_sn: $device_sn - token: $token - json: - name: user1 - password: '123456' - method: POST - url: /api/users/$user_id - validate: - - eq: [status_code, 201] - - eq: [headers.Content-Type, application/json] - - eq: [content.success, true] - - eq: [content.msg, user created successfully.] diff --git a/docs/data/demo-quickstart-7.json b/docs/data/demo-quickstart-7.json deleted file mode 100644 index 89d57f8d..00000000 --- a/docs/data/demo-quickstart-7.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "config": { - "name": "create users with parameters" - }, - "testcases": { - "create user $user_id": { - "testcase": "demo-quickstart-6.yml", - "parameters": { - "user_id": [1001, 1002, 1003, 1004] - } - } - } -} \ No newline at end of file diff --git a/docs/data/demo-quickstart-7.yml b/docs/data/demo-quickstart-7.yml deleted file mode 100644 index 770dc45f..00000000 --- a/docs/data/demo-quickstart-7.yml +++ /dev/null @@ -1,8 +0,0 @@ -config: - name: testcase description - -testcases: - create user $user_id: - testcase: demo-quickstart-6.yml - parameters: - user_id: [1001, 1002, 1003, 1004] diff --git a/docs/data/demo-quickstart.har b/docs/data/demo-quickstart.har deleted file mode 100644 index d4924ba4..00000000 --- a/docs/data/demo-quickstart.har +++ /dev/null @@ -1,221 +0,0 @@ -{ - "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": "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\": \"9c0c7e51c91ae963c833a4ccbab8d683c4a90c98\"}" - }, - "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==", - "encoding": "base64" - }, - "redirectURL": null, - "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" - }, - "redirectURL": null, - "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/docs/data/demo-quickstart.json b/docs/data/demo-quickstart.json deleted file mode 100644 index 88933729..00000000 --- a/docs/data/demo-quickstart.json +++ /dev/null @@ -1,98 +0,0 @@ -[ - { - "config": { - "name": "testcase description", - "variables": {} - } - }, - { - "test": { - "name": "/api/get-token", - "request": { - "url": "http://127.0.0.1:5000/api/get-token", - "method": "POST", - "headers": { - "User-Agent": "python-requests/2.18.4", - "device_sn": "FwgRiO7CNA50DSU", - "os_platform": "ios", - "app_version": "2.8.6", - "Content-Type": "application/json" - }, - "json": { - "sign": "9c0c7e51c91ae963c833a4ccbab8d683c4a90c98" - } - }, - "validate": [ - { - "eq": [ - "status_code", - 200 - ] - }, - { - "eq": [ - "headers.Content-Type", - "application/json" - ] - }, - { - "eq": [ - "content.success", - true - ] - }, - { - "eq": [ - "content.token", - "baNLX1zhFYP11Seb" - ] - } - ] - } - }, - { - "test": { - "name": "/api/users/1000", - "request": { - "url": "http://127.0.0.1:5000/api/users/1000", - "method": "POST", - "headers": { - "User-Agent": "python-requests/2.18.4", - "device_sn": "FwgRiO7CNA50DSU", - "token": "baNLX1zhFYP11Seb", - "Content-Type": "application/json" - }, - "json": { - "name": "user1", - "password": "123456" - } - }, - "validate": [ - { - "eq": [ - "status_code", - 201 - ] - }, - { - "eq": [ - "headers.Content-Type", - "application/json" - ] - }, - { - "eq": [ - "content.success", - true - ] - }, - { - "eq": [ - "content.msg", - "user created successfully." - ] - } - ] - } - } -] \ No newline at end of file diff --git a/docs/data/demo-quickstart.yml b/docs/data/demo-quickstart.yml deleted file mode 100644 index 6eb2f64c..00000000 --- a/docs/data/demo-quickstart.yml +++ /dev/null @@ -1,55 +0,0 @@ -- config: - name: testcase description - variables: {} -- test: - name: /api/get-token - request: - headers: - Content-Type: application/json - User-Agent: python-requests/2.18.4 - app_version: 2.8.6 - device_sn: FwgRiO7CNA50DSU - os_platform: ios - json: - sign: 9c0c7e51c91ae963c833a4ccbab8d683c4a90c98 - method: POST - url: http://127.0.0.1:5000/api/get-token - validate: - - eq: - - status_code - - 200 - - eq: - - headers.Content-Type - - application/json - - eq: - - content.success - - true - - eq: - - content.token - - baNLX1zhFYP11Seb -- test: - name: /api/users/1000 - request: - headers: - Content-Type: application/json - User-Agent: python-requests/2.18.4 - device_sn: FwgRiO7CNA50DSU - token: baNLX1zhFYP11Seb - json: - name: user1 - password: '123456' - method: POST - url: http://127.0.0.1:5000/api/users/1000 - validate: - - eq: - - status_code - - 201 - - eq: - - headers.Content-Type - - application/json - - eq: - - content.success - - true - - eq: - - content.msg - - user created successfully. diff --git a/docs/data/demo-testcase-get-token.yml b/docs/data/demo-testcase-get-token.yml deleted file mode 100644 index 32db8d1e..00000000 --- a/docs/data/demo-testcase-get-token.yml +++ /dev/null @@ -1,27 +0,0 @@ -- config: - name: get token - base_url: http://127.0.0.1:5000 - variables: - device_sn: ${gen_random_string(15)} - os_platform: 'ios' - app_version: '2.8.6' - -- test: - name: get token with $device_sn, $os_platform, $app_version - request: - headers: - Content-Type: application/json - User-Agent: python-requests/2.18.4 - app_version: $app_version - device_sn: $device_sn - os_platform: $os_platform - json: - sign: ${get_sign($device_sn, $os_platform, $app_version)} - method: POST - url: /api/get-token - extract: - token: content.token - validate: - - eq: [status_code, 200] - - eq: [headers.Content-Type, application/json] - - eq: [content.success, true] diff --git a/docs/data/demo_parameters.yml b/docs/data/demo_parameters.yml deleted file mode 100644 index 76a8a4b9..00000000 --- a/docs/data/demo_parameters.yml +++ /dev/null @@ -1,34 +0,0 @@ -- config: - name: "user management testcase." - parameters: - - user_agent: ["iOS/10.1", "iOS/10.2", "iOS/10.3"] - - app_version: ${P(app_version.csv)} - - os_platform: ${get_os_platform()} - variables: - - user_agent: 'iOS/10.3' - - device_sn: ${gen_random_string(15)} - - os_platform: 'ios' - - app_version: '2.8.6' - request: - base_url: http://127.0.0.1:5000 - headers: - Content-Type: application/json - device_sn: $device_sn - -- test: - name: get token with $user_agent, $os_platform, $app_version - request: - url: /api/get-token - method: POST - headers: - app_version: $app_version - os_platform: $os_platform - user_agent: $user_agent - json: - sign: ${get_sign($user_agent, $device_sn, $os_platform, $app_version)} - extract: - - token: content.token - validate: - - eq: [status_code, 200] - - eq: [headers.Content-Type, application/json] - - eq: [content.success, true] \ No newline at end of file diff --git a/docs/data/testerhome-login.har b/docs/data/testerhome-login.har deleted file mode 100644 index f54e6707..00000000 --- a/docs/data/testerhome-login.har +++ /dev/null @@ -1 +0,0 @@ -{"log":{"version":"1.2","creator":{"name":"Charles Proxy","version":"4.2.6"},"entries":[{"startedDateTime":"2019-04-19T14:14:14.014+08:00","time":227,"request":{"method":"GET","url":"https://testerhome.com/account/sign_in","httpVersion":"HTTP/1.1","cookies":[{"name":"_ga","value":"GA1.2.162109905.1516957848"},{"name":"gsScrollPos-3842","value":"0"},{"name":"gsScrollPos-3871","value":""},{"name":"gsScrollPos-1094","value":"0"},{"name":"gsScrollPos-1837","value":"0"},{"name":"gsScrollPos-1993","value":""},{"name":"gsScrollPos-324","value":"0"},{"name":"gsScrollPos-861","value":"0"},{"name":"gsScrollPos-1568","value":"0"},{"name":"gsScrollPos-2909","value":"0"},{"name":"gsScrollPos-2429","value":""},{"name":"gsScrollPos-5380","value":"0"},{"name":"gsScrollPos-5417","value":"0"},{"name":"gsScrollPos-1937750581","value":""},{"name":"gsScrollPos-1937751827","value":"0"},{"name":"gsScrollPos-1937751846","value":"0"},{"name":"gsScrollPos-1937756244","value":"0"},{"name":"gsScrollPos-1937759407","value":"0"},{"name":"gsScrollPos-968","value":"0"},{"name":"gsScrollPos-3085","value":""},{"name":"hasSkipWelcomePage","value":"1"},{"name":"gsScrollPos-1874","value":"0"},{"name":"gsScrollPos-2049","value":"0"},{"name":"gsScrollPos-3406","value":""},{"name":"user_id","value":"NjEwOQ%3D%3D--a85617b1d508c153c6ac2d40bd49b25a1466988f"},{"name":"gsScrollPos-891","value":"0"},{"name":"gsScrollPos-931","value":""},{"name":"gsScrollPos-2436","value":"0"},{"name":"_gid","value":"GA1.2.113994526.1555584877"},{"name":"_homeland_session","value":"pMGgyE29RXzrANx5RkqLjCMIE%2FHB%2FxbtHPs1pETwS54JTS%2BaV7QSR10JxAXa6wXxVIKDfMclOhVyQF0ztWr51Z3pEDEZ8P5HRgW7UnnUXHU9LqyAA%2FLF8V3LptG6jYLODkS43KqwfgHQfq1oF9X%2FLDyuYhyfJH%2FQFJLanFfH7lq%2Bg6wJXSVBTvDDZh8m5wITIVhYd63fo8B5Eu3XkSbPpY%2B3PivBAyRiC5AjXEWnhDTDIGA%2BYXZ5hjGIOJRdhjhD1nF5OPq%2FNfG96u6yp5EBWbDknUicZ2YHGQ%3D%3D--G4cowQKL8hiscX9U--48zlnNPSvWTk2tgnQlIhzg%3D%3D"},{"name":"_gat","value":"1"}],"headers":[{"name":"Host","value":"testerhome.com"},{"name":"Connection","value":"keep-alive"},{"name":"Cache-Control","value":"max-age=0"},{"name":"Upgrade-Insecure-Requests","value":"1"},{"name":"User-Agent","value":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.103 Safari/537.36"},{"name":"Accept","value":"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3"},{"name":"Referer","value":"https://testerhome.com/notifications/personal"},{"name":"Accept-Encoding","value":"gzip, deflate, br"},{"name":"Accept-Language","value":"en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7"},{"name":"Cookie","value":"_ga=GA1.2.162109905.1516957848; gsScrollPos-3842=0; gsScrollPos-3871=; gsScrollPos-1094=0; gsScrollPos-1837=0; gsScrollPos-1993=; gsScrollPos-324=0; gsScrollPos-861=0; gsScrollPos-1568=0; gsScrollPos-2909=0; gsScrollPos-2429=; gsScrollPos-5380=0; gsScrollPos-5417=0; gsScrollPos-1937750581=; gsScrollPos-1937751827=0; gsScrollPos-1937751846=0; gsScrollPos-1937756244=0; gsScrollPos-1937759407=0; gsScrollPos-968=0; gsScrollPos-3085=; hasSkipWelcomePage=1; gsScrollPos-1874=0; gsScrollPos-2049=0; gsScrollPos-3406=; user_id=NjEwOQ%3D%3D--a85617b1d508c153c6ac2d40bd49b25a1466988f; gsScrollPos-891=0; gsScrollPos-931=; gsScrollPos-2436=0; _gid=GA1.2.113994526.1555584877; _homeland_session=pMGgyE29RXzrANx5RkqLjCMIE%2FHB%2FxbtHPs1pETwS54JTS%2BaV7QSR10JxAXa6wXxVIKDfMclOhVyQF0ztWr51Z3pEDEZ8P5HRgW7UnnUXHU9LqyAA%2FLF8V3LptG6jYLODkS43KqwfgHQfq1oF9X%2FLDyuYhyfJH%2FQFJLanFfH7lq%2Bg6wJXSVBTvDDZh8m5wITIVhYd63fo8B5Eu3XkSbPpY%2B3PivBAyRiC5AjXEWnhDTDIGA%2BYXZ5hjGIOJRdhjhD1nF5OPq%2FNfG96u6yp5EBWbDknUicZ2YHGQ%3D%3D--G4cowQKL8hiscX9U--48zlnNPSvWTk2tgnQlIhzg%3D%3D; _gat=1"},{"name":"If-None-Match","value":"W/\"bc9ae267fdcbd89bf1dfaea10dea2b0e\""}],"queryString":[],"headersSize":1666,"bodySize":0},"response":{"_charlesStatus":"COMPLETE","status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[{"name":"_homeland_session","value":"%2BPqmS9%2B8BS0lpK1Q9Nzg5IkUR3B%2BECd1ApYtz33R4UNx60WgtFBIhojxj7lIlJniLVx2U%2BDTZm3wSK%2F85EjQNzjLlxKDMn%2FCkPR%2F9EOpyIhFV7NxFnrSFOAStblX8680tHPAI7uYY5bcR%2FF%2BuT4tAhM0uIRjMCIuFfSEFEvt%2BUnj7aVIkTzLjmmQMZ6EiiXjgwsseai2BDuRKH8Dhww0i%2FeizFdKEq2Uv1FoouIThFd0kjc9LpGnOIruhyE77h7XZw%2FoNOsGTZvH%2F%2BvLcRzBMbTGJGznvljXNg%3D%3D--NOyNIYFvHUu1Bf4g--ZP94D8QQDbRtiIVLWBidWQ%3D%3D","path":"/","domain":null,"expires":"Thu, 18 Jul 2019 06:14:16 -0000","httpOnly":true,"secure":true,"comment":null,"_maxAge":null}],"headers":[{"name":"Server","value":"nginx/1.10.2"},{"name":"Date","value":"Fri, 19 Apr 2019 06:14:16 GMT"},{"name":"Content-Type","value":"text/html; charset=utf-8"},{"name":"Transfer-Encoding","value":"chunked"},{"name":"Vary","value":"Accept-Encoding"},{"name":"X-Frame-Options","value":"SAMEORIGIN"},{"name":"X-XSS-Protection","value":"1; mode=block"},{"name":"X-Content-Type-Options","value":"nosniff"},{"name":"X-Download-Options","value":"noopen"},{"name":"X-Permitted-Cross-Domain-Policies","value":"none"},{"name":"Referrer-Policy","value":"strict-origin-when-cross-origin"},{"name":"ETag","value":"W/\"587c0b9f9cf24943552b421690023012\""},{"name":"Cache-Control","value":"max-age=0, private, must-revalidate"},{"name":"Content-Security-Policy","value":";"},{"name":"Set-Cookie","value":"_homeland_session=%2BPqmS9%2B8BS0lpK1Q9Nzg5IkUR3B%2BECd1ApYtz33R4UNx60WgtFBIhojxj7lIlJniLVx2U%2BDTZm3wSK%2F85EjQNzjLlxKDMn%2FCkPR%2F9EOpyIhFV7NxFnrSFOAStblX8680tHPAI7uYY5bcR%2FF%2BuT4tAhM0uIRjMCIuFfSEFEvt%2BUnj7aVIkTzLjmmQMZ6EiiXjgwsseai2BDuRKH8Dhww0i%2FeizFdKEq2Uv1FoouIThFd0kjc9LpGnOIruhyE77h7XZw%2FoNOsGTZvH%2F%2BvLcRzBMbTGJGznvljXNg%3D%3D--NOyNIYFvHUu1Bf4g--ZP94D8QQDbRtiIVLWBidWQ%3D%3D; path=/; expires=Thu, 18 Jul 2019 06:14:16 -0000; secure; HttpOnly"},{"name":"X-Request-Id","value":"d12cd1e4-2289-428f-8755-cabf6ad974ab"},{"name":"X-Runtime","value":"0.041729"},{"name":"Strict-Transport-Security","value":"max-age=15552000; includeSubDomains"},{"name":"Content-Encoding","value":"gzip"},{"name":"Connection","value":"keep-alive"}],"content":{"size":12610,"compression":8072,"mimeType":"text/html; charset=utf-8","text":"\n\n\n\n \n \n \n \n Sign In · TesterHome<\/title>\n <link rel=\"icon\" href=\"/assets/favicon-cd32144f74c18746f3dce33e1040e7dfe4c07c8e611e37f3868b1c16b5095da3.png\"/>\n <link rel=\"apple-touch-icon-precomposed\" href=\"/assets/ios-icon-cd32144f74c18746f3dce33e1040e7dfe4c07c8e611e37f3868b1c16b5095da3.png\"/>\n <link rel=\"shortcut icon\" href=\"/assets/big_logo-cd32144f74c18746f3dce33e1040e7dfe4c07c8e611e37f3868b1c16b5095da3.png\"/>\n <link rel=\"apple-touch-icon\" href=\"/assets/favicon-cd32144f74c18746f3dce33e1040e7dfe4c07c8e611e37f3868b1c16b5095da3.png\">\n\n <!-- http://www.favicon-generator.org/ -->\n <link rel=\"apple-touch-icon\" sizes=\"57x57\" href=\"/apple-icon-57x57.png\">\n <link rel=\"apple-touch-icon\" sizes=\"60x60\" href=\"/apple-icon-60x60.png\">\n <link rel=\"apple-touch-icon\" sizes=\"72x72\" href=\"/apple-icon-72x72.png\">\n <link rel=\"apple-touch-icon\" sizes=\"76x76\" href=\"/apple-icon-76x76.png\">\n <link rel=\"apple-touch-icon\" sizes=\"114x114\" href=\"/apple-icon-114x114.png\">\n <link rel=\"apple-touch-icon\" sizes=\"120x120\" href=\"/apple-icon-120x120.png\">\n <link rel=\"apple-touch-icon\" sizes=\"144x144\" href=\"/apple-icon-144x144.png\">\n <link rel=\"apple-touch-icon\" sizes=\"152x152\" href=\"/apple-icon-152x152.png\">\n <link rel=\"apple-touch-icon\" sizes=\"180x180\" href=\"/apple-icon-180x180.png\">\n <link rel=\"icon\" type=\"image/png\" sizes=\"192x192\" href=\"/android-icon-192x192.png\">\n <link rel=\"icon\" type=\"image/png\" sizes=\"32x32\" href=\"/favicon-32x32.png\">\n <link rel=\"icon\" type=\"image/png\" sizes=\"96x96\" href=\"/favicon-96x96.png\">\n <link rel=\"icon\" type=\"image/png\" sizes=\"16x16\" href=\"/favicon-16x16.png\">\n <link rel=\"manifest\" href=\"/manifest.json\">\n <meta name=\"msapplication-TileColor\" content=\"#ffffff\">\n <meta name=\"msapplication-TileImage\" content=\"/ms-icon-144x144.png\">\n <meta name=\"theme-color\" content=\"#ffffff\">\n\n <meta name=\"apple-mobile-web-app-capable\" content=\"no\">\n <meta content='True' name='HandheldFriendly'/>\n <link rel=\"alternate\" type=\"application/rss+xml\" title=\"订阅最新帖\" href=\"https://testerhome.com/topics/feed\"/>\n <link rel=\"stylesheet\" media=\"screen\" href=\"/assets/front-15185e3983cd68677b04329670c6a6b7fedecb51f1b39ff7fac48180c1850eaa.css\" data-turbolinks-track=\"reload\" />\n \n \n <meta name=\"action-cable-url\" content=\"/cable\" />\n <meta name=\"csrf-param\" content=\"authenticity_token\" />\n<meta name=\"csrf-token\" content=\"0zAKFDDPnNI2+Vwq/iwDPR9vo7KWobfNLAye4EaGBTlsSxMzTNf39lLF9z35f5mcROM7JgOP+azBCuDe84G+XA==\" />\n <meta itemprop=\"name\" content=\"TesterHome\"/>\r\n <meta itemprop=\"image\" content=\"//10.url.cn/qqcourse_logo_ng/ajNVdqHZLLAH8YXbXMDFib2SnIqhac60vw3BspyLd0TO82PiaJ2xfbvXynrocic7ajbCGribAic88wgc/\"/>\r\n\r\n<style type=\"text/css\">\r\n\r\n.blog-description{\r\n text-align: left;\r\n padding: 18px;\r\n}\r\n.blog-description:first-letter {\r\nfont-size : 200%;\r\nfont-weight : bold;\r\nfloat : left;\r\nmargin-right: 3px;\r\n}\r\n\r\n/*\r\n.new-topic:before {\r\n content:url(\"https://testerhome.com/uploads/photo/2019/d80c7d7c-7727-45b2-a98d-2c87a68c65e8.png\");\r\n position: absolute;\r\n left:60%;\r\n margin-top:-35px;\r\n}\r\n*/\r\n<\/style>\n\n <script src=\"/assets/app-04f83676fc1f6160a4410556b3298892a4536d9e8ee6c9b54df9191dd8dd9060.js\" data-turbolinks-track=\"reload\"><\/script>\n \n<\/head>\n<body class=\"page-sessions\" data-controller-name=\"sessions\">\n\n <div id=\"welcome-page\" class=\"hide-page\">\n <div \r\nstyle=\"background-image: url(https://testerhome.com/uploads/photo/2019/971bc4ea-b6eb-4d64-b6a1-1e71928314af.jpg); background-position: top center; background-attachment: fixed; background-size: cover; background-repeat: no-repeat; height:100%\">\r\n<\/div>\n <div class=\"welcome-action-button\">\n <div class=\"btn-group\">\n <a id=\"welcome-page-skip\" type=\"button\" class=\"btn btn-primary\">跳过<\/a>\n <\/div>\n <div class=\"btn-group\">\n <a href=\"http://2019.test-china.org/\" type=\"button\" class=\"btn btn-info\">查看详情<\/a>\n <\/div>\n <\/div>\n <\/div>\n\n <div id=\"main-page\">\n<div class=\"header\">\n <nav class=\"navbar navbar-inverse navbar-fixed-top navbar-default\">\n <div class=\"container\">\n <div class=\"navbar-header\" id=\"navbar-header\" data-turbolinks-permanent>\n <button type=\"button\" class=\"navbar-toggle collapsed\" data-toggle=\"collapse\" data-target=\"#main-navbar-collapse\">\n <span class=\"sr-only\">Toggle<\/span>\n <i class=\"fa fa-reorder\"><\/i>\n <\/button>\n <a href=\"/\" class=\"navbar-brand\"><b>TesterHome<\/b><\/a>\n\n <\/div>\n <div class=\"collapse navbar-collapse\" id=\"main-navbar-collapse\">\n \n <div id=\"main-nav-menu\">\n <ul class=\"nav navbar-nav\">\n <li class=\"\"><a href=\"/topics\">Topics<\/a><\/li><li class=\"\"><a href=\"/bugs\">Bug Tracker<\/a><\/li><li class=\"\"><a href=\"/questions\">QA<\/a><\/li><li class=\"\"><a href=\"/teams\">Teams<\/a><\/li><li class=\"\"><a href=\"/jobs\">招聘<\/a><\/li><li class=\"\"><a href=\"/wiki\">Wiki<\/a><\/li><li class=\"\"><a href=\"/opensource_projects\">开源项目<span class=\"badge-new\">新<\/span><\/a><\/li>\n \n <\/ul>\n<\/div>\n <\/div>\n <ul class=\"nav user-bar navbar-nav navbar-right\">\n <li><a href=\"/account/sign_up\">Sign Up<\/a><\/li>\n <li><a href=\"/account/sign_in\">Sign In<\/a><\/li>\n<\/ul>\n\n<ul class=\"nav navbar-nav user-bar navbar-right\">\n <li class=\"nav-search hidden-xs hidden-sm hidden-md\">\n <form class=\"navbar-form form-search active\" action=\"/search\" method=\"GET\">\n <div class=\"form-group\">\n <input class=\"form-control\" name=\"q\" type=\"text\" value=\"\" placeholder=\"搜索本站内容\" />\n <\/div>\n <i class=\"fa btn-search fa-search\"><\/i>\n <\/form>\n <\/li>\n\n<\/ul>\n\n <\/div>\n <\/nav>\n <div id=\"corner\" class=\"\">\n <a id=\"cornertip\" href=\"\">\n <div id=\"c-content\">\n <div id=\"c-button\">欢迎<\/div>\n <\/div>\n <\/a>\n <\/div>\n<\/div>\n\n\n\n<div id=\"main\" class=\"main-container container\">\n \n \n \n<div class=\"row\">\n <div class=\"col-md-5 col-md-offset-2\">\n <div class=\"panel panel-default\">\n <div class=\"panel-heading\">Sign In<\/div>\n <div class=\"panel-body\">\n <form class=\"simple_form \" id=\"new_user\" novalidate=\"novalidate\" action=\"/account/sign_in\" accept-charset=\"UTF-8\" data-remote=\"true\" method=\"post\"><input name=\"utf8\" type=\"hidden\" value=\"✓\" />\n <div class=\"form-group\">\n <input type=\"email\" class=\"form-control input-lg\" placeholder=\"用户名 / Email\" name=\"user[login]\" id=\"user_login\" />\n <\/div>\n <div class=\"form-group\">\n <input type=\"password\" class=\"form-control input-lg\" placeholder=\"密码\" name=\"user[password]\" id=\"user_password\" />\n <\/div>\n\n <div class=\"from-group checkbox\">\n <label for=\"user_remember_me\">\n <input name=\"user[remember_me]\" type=\"hidden\" value=\"0\" /><input type=\"checkbox\" value=\"1\" name=\"user[remember_me]\" id=\"user_remember_me\" /> Remember me (2 months)\n <\/label>\n <\/div>\n <div class=\"form-actions\">\n <input type=\"submit\" name=\"commit\" value=\"Sign In\" class=\"btn btn-primary btn-lg btn-block\" data-disable-with=\"Sign In...\" />\n <\/div>\n<\/form> <\/div>\n <\/div>\n <\/div>\n <div class=\"col-md-3\">\n <div class=\"panel panel-default\">\n <div class=\"panel-heading\">Sign in with other services<\/div>\n <ul class=\"list-group\">\n <li class=\"list-group-item\"><a class=\"btn btn-default btn-lg btn-block\" href=\"/account/auth/github\"><i class='fa fa-github'><\/i> GitHub<\/a> <\/li>\n <\/ul>\n <\/div>\n\n <div class=\"panel panel-default\">\n <ul class=\"list-group\">\n\n <li class=\"list-group-item\"><a href=\"/account/sign_up\">Sign Up<\/a><\/li>\n\n <li class=\"list-group-item\"><a href=\"/account/password/new\">Forgot your password?<\/a><\/li>\n\n <li class=\"list-group-item\"><a href=\"/account/confirmation/new\">Havn't received your confirmation email?<\/a><\/li>\n\n <li class=\"list-group-item\"><a href=\"/account/unlock/new\">Havn't received your unlock email?<\/a><\/li>\n<\/ul>\n <\/div>\n <\/div>\n<\/div>\n\n<script>\n $('#new_user').on('ajax:error', function(event, xhr, status, error) {\n App.alert(xhr.responseText, '#main');\n })\n<\/script>\n\n<\/div>\n\n <footer class=\"footer\" id=\"footer\" data-turbolinks-permanent>\n <div class=\"container\">\n \r\n\r\n<div class=\"row\">\r\n <div class=\"col-sm-9\">\r\n <div class=\"media\">\r\n <div class=\"media-left\">\r\n <img src=\"https://testerhome.com/uploads/photo/2016/274e7ebc8ad0db0b7e718fceea3628a9.png!large\" style=\"width:48px;\">\r\n <\/div>\r\n <div class=\"media-body\">\r\n\r\n <div class=\"links\">\r\n <a href=\"https://testerhome.com/wiki/about\">关于<\/a> / \r\n <a href=\"https://testerhome.com/users\">活跃用户<\/a> / \r\n <a href=\"http://test-china.org/\" target=\"_blank\">中国移动互联网测试技术大会<\/a> / \r\n <a href=\"/topics/node13\">反馈<\/a> / \r\n <a href=\"https://github.com/testerhome\">Github<\/a> / \r\n <a href=\"https://testerhome.com//api-doc/\">API<\/a> / \r\n <a href=\"/wiki/spreadtesterhome\">帮助推广<\/a>\r\n\r\n <\/div>\r\n <div class=\"copyright\" style=\"font-size:14px; color:#9CA4A9;margin-top:0px;margin-bottom:5px\">\r\n TesterHome 移动测试社区,由众多移动测试工作者维护,致力于推进国内测试技术。Inspired by RubyChina\r\n <\/div>\r\n<div class=\"links\" data-no-turbolink=\"\">\r\n<span style=\"font-size:14px; color:#666;\">友情链接<\/span><span style=\"margin-left:5px\">\r\n <a style=\"color:#317DDA;\" href=\"http://wetest.qq.com/?from=links_testerhome\" target=\"_blank\">WeTest腾讯质量开放平台<\/a> / \r\n <a style=\"color:#317DDA;\" href=\"http://www.infoq.com/cn\" target=\"_blank\">InfoQ<\/a> / \r\n <a style=\"color:#317DDA;\" href=\"http://www.testtao.com/portal.php\" target=\"_blank\">测试之道<\/a> / \r\n <a style=\"color:#317DDA;\" href=\"https://www.testwo.com/\" target=\"_blank\">测试窝<\/a> / \r\n <a style=\"color:#317DDA;\" href=\"http://tieba.baidu.com/f?ie=utf-8&kw=%E8%BD%AF%E4%BB%B6%E6%B5%8B%E8%AF%95&fr=search\" target=\"_blank\">百度测试吧<\/a> /\r\n <a style=\"color:#317DDA;\" href=\"http://www.itdks.com/\" target=\"_blank\">IT大咖说<\/a>\r\n<\/span>\r\n <\/div>\r\n <div class=\"links\" style=\"margin-top:0px\" data-no-turbolink=\"\">\r\n <a href=\"?locale=zh-CN\" rel=\"nofollow\">简体中文<\/a> / <a href=\"?locale=zh-TW\" rel=\"nofollow\">正體中文<\/a> / <a href=\"?locale=en\"\r\n rel=\"nofollow\">English<\/a>\r\n <\/div>\r\n <\/div>\r\n <\/div>\r\n <\/div>\r\n <div class=\"col-sm-3 friends\">\r\n <a href=\"http://www.ucloud.cn/?utm_source=zanzhu&utm_campaign=testerhome&utm_medium=display&utm_content=yejiao&ytag=testerhome_logo\"\r\n target=\"_blank\" rel=\"twipsy\" style=\"display:inline-block;margin-right:5px;\" data-original-title=\"本站服务器由 Ucloud 赞助\"><img src=\"https://testerhome.com/photo/2016/4ce97c93f9433f654884c4839408327a.png\" style=\"height:28px\"><\/a>\r\n\r\n <a href=\"http://www.sendcloud.net/\"\r\n target=\"_blank\" rel=\"twipsy\" style=\"display:inline-block;margin-right:5px;\" data-original-title=\"邮件服务由 SendCloud 赞助\"><img src=\"https://testerhome.com/uploads/photo/2017/0f9fb5db-2472-4430-9f7e-6c051f28c3c9.png\" style=\"height:28px\"><\/a>\r\n\r\n <\/div>\r\n<\/div>\r\n\r\n\r\n\n <\/div>\n <\/footer>\n\n<script type=\"text/javascript\" data-turbolinks-eval=\"false\">\n App.root_url = \"https://testerhome.com/\";\n App.asset_url = \"\";\n App.twemoji_url = \"https://twemoji.b0.upaiyun.com/2\";\n App.locale = \"en\";\n<\/script>\n\n<script>\n ga('create', 'UA-45014075-1', 'auto');\n ga('require', 'displayfeatures');\n ga('send', 'pageview');\n<\/script>\n<div class=\"zoom-overlay\"><\/div>\n<\/div>\n<\/body>\n<\/html>\n"},"redirectURL":null,"headersSize":0,"bodySize":4538},"serverIPAddress":"106.75.214.88","cache":{},"timings":{"dns":12,"connect":125,"ssl":91,"send":1,"wait":82,"receive":7}},{"startedDateTime":"2019-04-19T14:14:14.971+08:00","time":83,"request":{"method":"GET","url":"https://testerhome.com/assets/big_logo-cd32144f74c18746f3dce33e1040e7dfe4c07c8e611e37f3868b1c16b5095da3.png","httpVersion":"HTTP/1.1","cookies":[{"name":"_ga","value":"GA1.2.162109905.1516957848"},{"name":"gsScrollPos-3842","value":"0"},{"name":"gsScrollPos-3871","value":""},{"name":"gsScrollPos-1094","value":"0"},{"name":"gsScrollPos-1837","value":"0"},{"name":"gsScrollPos-1993","value":""},{"name":"gsScrollPos-324","value":"0"},{"name":"gsScrollPos-861","value":"0"},{"name":"gsScrollPos-1568","value":"0"},{"name":"gsScrollPos-2909","value":"0"},{"name":"gsScrollPos-2429","value":""},{"name":"gsScrollPos-5380","value":"0"},{"name":"gsScrollPos-5417","value":"0"},{"name":"gsScrollPos-1937750581","value":""},{"name":"gsScrollPos-1937751827","value":"0"},{"name":"gsScrollPos-1937751846","value":"0"},{"name":"gsScrollPos-1937756244","value":"0"},{"name":"gsScrollPos-1937759407","value":"0"},{"name":"gsScrollPos-968","value":"0"},{"name":"gsScrollPos-3085","value":""},{"name":"hasSkipWelcomePage","value":"1"},{"name":"gsScrollPos-1874","value":"0"},{"name":"gsScrollPos-2049","value":"0"},{"name":"gsScrollPos-3406","value":""},{"name":"user_id","value":"NjEwOQ%3D%3D--a85617b1d508c153c6ac2d40bd49b25a1466988f"},{"name":"gsScrollPos-891","value":"0"},{"name":"gsScrollPos-931","value":""},{"name":"gsScrollPos-2436","value":"0"},{"name":"_gid","value":"GA1.2.113994526.1555584877"},{"name":"_gat","value":"1"},{"name":"_homeland_session","value":"%2BPqmS9%2B8BS0lpK1Q9Nzg5IkUR3B%2BECd1ApYtz33R4UNx60WgtFBIhojxj7lIlJniLVx2U%2BDTZm3wSK%2F85EjQNzjLlxKDMn%2FCkPR%2F9EOpyIhFV7NxFnrSFOAStblX8680tHPAI7uYY5bcR%2FF%2BuT4tAhM0uIRjMCIuFfSEFEvt%2BUnj7aVIkTzLjmmQMZ6EiiXjgwsseai2BDuRKH8Dhww0i%2FeizFdKEq2Uv1FoouIThFd0kjc9LpGnOIruhyE77h7XZw%2FoNOsGTZvH%2F%2BvLcRzBMbTGJGznvljXNg%3D%3D--NOyNIYFvHUu1Bf4g--ZP94D8QQDbRtiIVLWBidWQ%3D%3D"}],"headers":[{"name":"Host","value":"testerhome.com"},{"name":"Connection","value":"keep-alive"},{"name":"Pragma","value":"no-cache"},{"name":"Cache-Control","value":"no-cache"},{"name":"User-Agent","value":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.103 Safari/537.36"},{"name":"Accept","value":"image/webp,image/apng,image/*,*/*;q=0.8"},{"name":"Referer","value":"https://testerhome.com/account/sign_in"},{"name":"Accept-Encoding","value":"gzip, deflate, br"},{"name":"Accept-Language","value":"en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7"},{"name":"Cookie","value":"_ga=GA1.2.162109905.1516957848; gsScrollPos-3842=0; gsScrollPos-3871=; gsScrollPos-1094=0; gsScrollPos-1837=0; gsScrollPos-1993=; gsScrollPos-324=0; gsScrollPos-861=0; gsScrollPos-1568=0; gsScrollPos-2909=0; gsScrollPos-2429=; gsScrollPos-5380=0; gsScrollPos-5417=0; gsScrollPos-1937750581=; gsScrollPos-1937751827=0; gsScrollPos-1937751846=0; gsScrollPos-1937756244=0; gsScrollPos-1937759407=0; gsScrollPos-968=0; gsScrollPos-3085=; hasSkipWelcomePage=1; gsScrollPos-1874=0; gsScrollPos-2049=0; gsScrollPos-3406=; user_id=NjEwOQ%3D%3D--a85617b1d508c153c6ac2d40bd49b25a1466988f; gsScrollPos-891=0; gsScrollPos-931=; gsScrollPos-2436=0; _gid=GA1.2.113994526.1555584877; _gat=1; _homeland_session=%2BPqmS9%2B8BS0lpK1Q9Nzg5IkUR3B%2BECd1ApYtz33R4UNx60WgtFBIhojxj7lIlJniLVx2U%2BDTZm3wSK%2F85EjQNzjLlxKDMn%2FCkPR%2F9EOpyIhFV7NxFnrSFOAStblX8680tHPAI7uYY5bcR%2FF%2BuT4tAhM0uIRjMCIuFfSEFEvt%2BUnj7aVIkTzLjmmQMZ6EiiXjgwsseai2BDuRKH8Dhww0i%2FeizFdKEq2Uv1FoouIThFd0kjc9LpGnOIruhyE77h7XZw%2FoNOsGTZvH%2F%2BvLcRzBMbTGJGznvljXNg%3D%3D--NOyNIYFvHUu1Bf4g--ZP94D8QQDbRtiIVLWBidWQ%3D%3D"}],"queryString":[],"headersSize":1591,"bodySize":0},"response":{"_charlesStatus":"COMPLETE","status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Server","value":"nginx/1.10.2"},{"name":"Date","value":"Fri, 19 Apr 2019 06:14:17 GMT"},{"name":"Content-Type","value":"image/png"},{"name":"Content-Length","value":"15229"},{"name":"Last-Modified","value":"Sat, 19 Nov 2016 15:26:39 GMT"},{"name":"ETag","value":"\"58306f2f-3b7d\""},{"name":"Expires","value":"Sat, 18 Apr 2020 06:14:17 GMT"},{"name":"Cache-Control","value":"max-age=31536000"},{"name":"Cache-Control","value":"public"},{"name":"Accept-Ranges","value":"bytes"},{"name":"Connection","value":"keep-alive"}],"content":{"size":15229,"mimeType":"image/png","text":"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","encoding":"base64"},"redirectURL":null,"headersSize":0,"bodySize":15229},"serverIPAddress":"106.75.214.88","cache":{},"timings":{"dns":-1,"connect":-1,"ssl":-1,"send":1,"wait":36,"receive":46}},{"startedDateTime":"2019-04-19T14:14:27.834+08:00","time":164,"request":{"method":"POST","url":"https://testerhome.com/account/sign_in","httpVersion":"HTTP/1.1","cookies":[{"name":"_ga","value":"GA1.2.162109905.1516957848"},{"name":"gsScrollPos-3842","value":"0"},{"name":"gsScrollPos-3871","value":""},{"name":"gsScrollPos-1094","value":"0"},{"name":"gsScrollPos-1837","value":"0"},{"name":"gsScrollPos-1993","value":""},{"name":"gsScrollPos-324","value":"0"},{"name":"gsScrollPos-861","value":"0"},{"name":"gsScrollPos-1568","value":"0"},{"name":"gsScrollPos-2909","value":"0"},{"name":"gsScrollPos-2429","value":""},{"name":"gsScrollPos-5380","value":"0"},{"name":"gsScrollPos-5417","value":"0"},{"name":"gsScrollPos-1937750581","value":""},{"name":"gsScrollPos-1937751827","value":"0"},{"name":"gsScrollPos-1937751846","value":"0"},{"name":"gsScrollPos-1937756244","value":"0"},{"name":"gsScrollPos-1937759407","value":"0"},{"name":"gsScrollPos-968","value":"0"},{"name":"gsScrollPos-3085","value":""},{"name":"hasSkipWelcomePage","value":"1"},{"name":"gsScrollPos-1874","value":"0"},{"name":"gsScrollPos-2049","value":"0"},{"name":"gsScrollPos-3406","value":""},{"name":"user_id","value":"NjEwOQ%3D%3D--a85617b1d508c153c6ac2d40bd49b25a1466988f"},{"name":"gsScrollPos-891","value":"0"},{"name":"gsScrollPos-931","value":""},{"name":"gsScrollPos-2436","value":"0"},{"name":"_gid","value":"GA1.2.113994526.1555584877"},{"name":"_gat","value":"1"},{"name":"_homeland_session","value":"%2BPqmS9%2B8BS0lpK1Q9Nzg5IkUR3B%2BECd1ApYtz33R4UNx60WgtFBIhojxj7lIlJniLVx2U%2BDTZm3wSK%2F85EjQNzjLlxKDMn%2FCkPR%2F9EOpyIhFV7NxFnrSFOAStblX8680tHPAI7uYY5bcR%2FF%2BuT4tAhM0uIRjMCIuFfSEFEvt%2BUnj7aVIkTzLjmmQMZ6EiiXjgwsseai2BDuRKH8Dhww0i%2FeizFdKEq2Uv1FoouIThFd0kjc9LpGnOIruhyE77h7XZw%2FoNOsGTZvH%2F%2BvLcRzBMbTGJGznvljXNg%3D%3D--NOyNIYFvHUu1Bf4g--ZP94D8QQDbRtiIVLWBidWQ%3D%3D"}],"headers":[{"name":"Host","value":"testerhome.com"},{"name":"Connection","value":"keep-alive"},{"name":"Content-Length","value":"134"},{"name":"Accept","value":"*/*;q=0.5, text/javascript, application/javascript, application/ecmascript, application/x-ecmascript"},{"name":"Origin","value":"https://testerhome.com"},{"name":"X-CSRF-Token","value":"0zAKFDDPnNI2+Vwq/iwDPR9vo7KWobfNLAye4EaGBTlsSxMzTNf39lLF9z35f5mcROM7JgOP+azBCuDe84G+XA=="},{"name":"X-Requested-With","value":"XMLHttpRequest"},{"name":"User-Agent","value":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.103 Safari/537.36"},{"name":"Content-Type","value":"application/x-www-form-urlencoded; charset=UTF-8"},{"name":"Referer","value":"https://testerhome.com/account/sign_in"},{"name":"Accept-Encoding","value":"gzip, deflate, br"},{"name":"Accept-Language","value":"en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7"},{"name":"Cookie","value":"_ga=GA1.2.162109905.1516957848; gsScrollPos-3842=0; gsScrollPos-3871=; gsScrollPos-1094=0; gsScrollPos-1837=0; gsScrollPos-1993=; gsScrollPos-324=0; gsScrollPos-861=0; gsScrollPos-1568=0; gsScrollPos-2909=0; gsScrollPos-2429=; gsScrollPos-5380=0; gsScrollPos-5417=0; gsScrollPos-1937750581=; gsScrollPos-1937751827=0; gsScrollPos-1937751846=0; gsScrollPos-1937756244=0; gsScrollPos-1937759407=0; gsScrollPos-968=0; gsScrollPos-3085=; hasSkipWelcomePage=1; gsScrollPos-1874=0; gsScrollPos-2049=0; gsScrollPos-3406=; user_id=NjEwOQ%3D%3D--a85617b1d508c153c6ac2d40bd49b25a1466988f; gsScrollPos-891=0; gsScrollPos-931=; gsScrollPos-2436=0; _gid=GA1.2.113994526.1555584877; _gat=1; _homeland_session=%2BPqmS9%2B8BS0lpK1Q9Nzg5IkUR3B%2BECd1ApYtz33R4UNx60WgtFBIhojxj7lIlJniLVx2U%2BDTZm3wSK%2F85EjQNzjLlxKDMn%2FCkPR%2F9EOpyIhFV7NxFnrSFOAStblX8680tHPAI7uYY5bcR%2FF%2BuT4tAhM0uIRjMCIuFfSEFEvt%2BUnj7aVIkTzLjmmQMZ6EiiXjgwsseai2BDuRKH8Dhww0i%2FeizFdKEq2Uv1FoouIThFd0kjc9LpGnOIruhyE77h7XZw%2FoNOsGTZvH%2F%2BvLcRzBMbTGJGznvljXNg%3D%3D--NOyNIYFvHUu1Bf4g--ZP94D8QQDbRtiIVLWBidWQ%3D%3D"}],"queryString":[],"postData":{"mimeType":"application/x-www-form-urlencoded; charset=UTF-8","params":[{"name":"utf8","value":"✓"},{"name":"user[login]","value":"debugtalk"},{"name":"user[password]","value":"LongForEver"},{"name":"user[remember_me]","value":"0"},{"name":"user[remember_me]","value":"1"},{"name":"commit","value":"Sign In"}]},"headersSize":1796,"bodySize":134},"response":{"_charlesStatus":"COMPLETE","status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[{"name":"remember_user_token","value":"eyJfcmFpbHMiOnsibWVzc2FnZSI6IlcxczJNVEE1WFN3aUpESmhKREV3SkRkbEwwWTVTREZXTm1waFREaG1lR1V1ZEVocFJFOGlMQ0l4TlRVMU5qVTBORFk1TGprNU1qUTJNalFpWFE9PSIsImV4cCI6IjIwMTktMDYtMTlUMDY6MTQ6MjkuOTkyWiIsInB1ciI6bnVsbH19--343d68b57a6619d293f944bfbf63fa557e5a0dd8","path":"/","domain":null,"expires":"Wed, 19 Jun 2019 06:14:29 -0000","httpOnly":true,"secure":true,"comment":null,"_maxAge":null},{"name":"_homeland_session","value":"PbiWoS7j4O3tTBlLCp9RoWTpYGmGCCJWcCjXhgTJWqCajO540nkdCTYA9PmiCneuZcJm4L7VYhPxnE%2FsQ1g%2BBPzdkzFkkpHT0FF8Htn8iWolqAiK3VNoeUz5EZEVLlrrSqVOoXLjYvylSMoWK624mba%2F4Fkg3E2uXqq4G1jRISyOAr1RtvMwYEo8U1Vo2TLFNNxDeyVM3IEqXOprE3M1nlP9qpeHPDnmWdWyRoiNo5MGIAkaReJIeoxECjdgts3sqYsqpWGuh0lJjdtIbmXNlBap8vU%2B54X7x6h9%2B0s%3D--KR8JpaHy431CsKRy--ueD9I5%2B%2FH%2BlH5vq1eUq4cg%3D%3D","path":"/","domain":null,"expires":"Thu, 18 Jul 2019 06:14:30 -0000","httpOnly":true,"secure":true,"comment":null,"_maxAge":null}],"headers":[{"name":"Server","value":"nginx/1.10.2"},{"name":"Date","value":"Fri, 19 Apr 2019 06:14:30 GMT"},{"name":"Content-Type","value":"text/javascript; charset=utf-8"},{"name":"Transfer-Encoding","value":"chunked"},{"name":"Vary","value":"Accept-Encoding"},{"name":"X-Frame-Options","value":"SAMEORIGIN"},{"name":"X-XSS-Protection","value":"1; mode=block"},{"name":"X-Content-Type-Options","value":"nosniff"},{"name":"X-Download-Options","value":"noopen"},{"name":"X-Permitted-Cross-Domain-Policies","value":"none"},{"name":"Referrer-Policy","value":"strict-origin-when-cross-origin"},{"name":"Location","value":"https://testerhome.com/"},{"name":"ETag","value":"W/\"d2d52e29771efe4882102d03bcbbc5ff\""},{"name":"Cache-Control","value":"max-age=0, private, must-revalidate"},{"name":"Set-Cookie","value":"remember_user_token=eyJfcmFpbHMiOnsibWVzc2FnZSI6IlcxczJNVEE1WFN3aUpESmhKREV3SkRkbEwwWTVTREZXTm1waFREaG1lR1V1ZEVocFJFOGlMQ0l4TlRVMU5qVTBORFk1TGprNU1qUTJNalFpWFE9PSIsImV4cCI6IjIwMTktMDYtMTlUMDY6MTQ6MjkuOTkyWiIsInB1ciI6bnVsbH19--343d68b57a6619d293f944bfbf63fa557e5a0dd8; path=/; expires=Wed, 19 Jun 2019 06:14:29 -0000; secure; HttpOnly"},{"name":"Set-Cookie","value":"_homeland_session=PbiWoS7j4O3tTBlLCp9RoWTpYGmGCCJWcCjXhgTJWqCajO540nkdCTYA9PmiCneuZcJm4L7VYhPxnE%2FsQ1g%2BBPzdkzFkkpHT0FF8Htn8iWolqAiK3VNoeUz5EZEVLlrrSqVOoXLjYvylSMoWK624mba%2F4Fkg3E2uXqq4G1jRISyOAr1RtvMwYEo8U1Vo2TLFNNxDeyVM3IEqXOprE3M1nlP9qpeHPDnmWdWyRoiNo5MGIAkaReJIeoxECjdgts3sqYsqpWGuh0lJjdtIbmXNlBap8vU%2B54X7x6h9%2B0s%3D--KR8JpaHy431CsKRy--ueD9I5%2B%2FH%2BlH5vq1eUq4cg%3D%3D; path=/; expires=Thu, 18 Jul 2019 06:14:30 -0000; secure; HttpOnly"},{"name":"X-Request-Id","value":"149b5636-67ef-4ea6-825b-6894894c1e97"},{"name":"X-Runtime","value":"0.124799"},{"name":"Strict-Transport-Security","value":"max-age=15552000; includeSubDomains"},{"name":"Content-Encoding","value":"gzip"},{"name":"Connection","value":"keep-alive"}],"content":{"size":89,"mimeType":"text/javascript; charset=utf-8","text":"Turbolinks.clearCache()\nTurbolinks.visit(\"https://testerhome.com/\", {\"action\":\"replace\"})"},"redirectURL":"https://testerhome.com/","headersSize":0,"bodySize":109},"serverIPAddress":"106.75.214.88","cache":{},"timings":{"dns":-1,"connect":-1,"ssl":-1,"send":1,"wait":162,"receive":1}},{"startedDateTime":"2019-04-19T14:14:28.011+08:00","time":158,"request":{"method":"GET","url":"https://testerhome.com/","httpVersion":"HTTP/1.1","cookies":[{"name":"_ga","value":"GA1.2.162109905.1516957848"},{"name":"gsScrollPos-3842","value":"0"},{"name":"gsScrollPos-3871","value":""},{"name":"gsScrollPos-1094","value":"0"},{"name":"gsScrollPos-1837","value":"0"},{"name":"gsScrollPos-1993","value":""},{"name":"gsScrollPos-324","value":"0"},{"name":"gsScrollPos-861","value":"0"},{"name":"gsScrollPos-1568","value":"0"},{"name":"gsScrollPos-2909","value":"0"},{"name":"gsScrollPos-2429","value":""},{"name":"gsScrollPos-5380","value":"0"},{"name":"gsScrollPos-5417","value":"0"},{"name":"gsScrollPos-1937750581","value":""},{"name":"gsScrollPos-1937751827","value":"0"},{"name":"gsScrollPos-1937751846","value":"0"},{"name":"gsScrollPos-1937756244","value":"0"},{"name":"gsScrollPos-1937759407","value":"0"},{"name":"gsScrollPos-968","value":"0"},{"name":"gsScrollPos-3085","value":""},{"name":"hasSkipWelcomePage","value":"1"},{"name":"gsScrollPos-1874","value":"0"},{"name":"gsScrollPos-2049","value":"0"},{"name":"gsScrollPos-3406","value":""},{"name":"user_id","value":"NjEwOQ%3D%3D--a85617b1d508c153c6ac2d40bd49b25a1466988f"},{"name":"gsScrollPos-891","value":"0"},{"name":"gsScrollPos-931","value":""},{"name":"gsScrollPos-2436","value":"0"},{"name":"_gid","value":"GA1.2.113994526.1555584877"},{"name":"_gat","value":"1"},{"name":"remember_user_token","value":"eyJfcmFpbHMiOnsibWVzc2FnZSI6IlcxczJNVEE1WFN3aUpESmhKREV3SkRkbEwwWTVTREZXTm1waFREaG1lR1V1ZEVocFJFOGlMQ0l4TlRVMU5qVTBORFk1TGprNU1qUTJNalFpWFE9PSIsImV4cCI6IjIwMTktMDYtMTlUMDY6MTQ6MjkuOTkyWiIsInB1ciI6bnVsbH19--343d68b57a6619d293f944bfbf63fa557e5a0dd8"},{"name":"_homeland_session","value":"PbiWoS7j4O3tTBlLCp9RoWTpYGmGCCJWcCjXhgTJWqCajO540nkdCTYA9PmiCneuZcJm4L7VYhPxnE%2FsQ1g%2BBPzdkzFkkpHT0FF8Htn8iWolqAiK3VNoeUz5EZEVLlrrSqVOoXLjYvylSMoWK624mba%2F4Fkg3E2uXqq4G1jRISyOAr1RtvMwYEo8U1Vo2TLFNNxDeyVM3IEqXOprE3M1nlP9qpeHPDnmWdWyRoiNo5MGIAkaReJIeoxECjdgts3sqYsqpWGuh0lJjdtIbmXNlBap8vU%2B54X7x6h9%2B0s%3D--KR8JpaHy431CsKRy--ueD9I5%2B%2FH%2BlH5vq1eUq4cg%3D%3D"}],"headers":[{"name":"Host","value":"testerhome.com"},{"name":"Connection","value":"keep-alive"},{"name":"Accept","value":"text/html, application/xhtml+xml"},{"name":"Turbolinks-Referrer","value":"https://testerhome.com/account/sign_in"},{"name":"User-Agent","value":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.103 Safari/537.36"},{"name":"Referer","value":"https://testerhome.com/account/sign_in"},{"name":"Accept-Encoding","value":"gzip, deflate, br"},{"name":"Accept-Language","value":"en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7"},{"name":"Cookie","value":"_ga=GA1.2.162109905.1516957848; gsScrollPos-3842=0; gsScrollPos-3871=; gsScrollPos-1094=0; gsScrollPos-1837=0; gsScrollPos-1993=; gsScrollPos-324=0; gsScrollPos-861=0; gsScrollPos-1568=0; gsScrollPos-2909=0; gsScrollPos-2429=; gsScrollPos-5380=0; gsScrollPos-5417=0; gsScrollPos-1937750581=; gsScrollPos-1937751827=0; gsScrollPos-1937751846=0; gsScrollPos-1937756244=0; gsScrollPos-1937759407=0; gsScrollPos-968=0; gsScrollPos-3085=; hasSkipWelcomePage=1; gsScrollPos-1874=0; gsScrollPos-2049=0; gsScrollPos-3406=; user_id=NjEwOQ%3D%3D--a85617b1d508c153c6ac2d40bd49b25a1466988f; gsScrollPos-891=0; gsScrollPos-931=; gsScrollPos-2436=0; _gid=GA1.2.113994526.1555584877; _gat=1; remember_user_token=eyJfcmFpbHMiOnsibWVzc2FnZSI6IlcxczJNVEE1WFN3aUpESmhKREV3SkRkbEwwWTVTREZXTm1waFREaG1lR1V1ZEVocFJFOGlMQ0l4TlRVMU5qVTBORFk1TGprNU1qUTJNalFpWFE9PSIsImV4cCI6IjIwMTktMDYtMTlUMDY6MTQ6MjkuOTkyWiIsInB1ciI6bnVsbH19--343d68b57a6619d293f944bfbf63fa557e5a0dd8; _homeland_session=PbiWoS7j4O3tTBlLCp9RoWTpYGmGCCJWcCjXhgTJWqCajO540nkdCTYA9PmiCneuZcJm4L7VYhPxnE%2FsQ1g%2BBPzdkzFkkpHT0FF8Htn8iWolqAiK3VNoeUz5EZEVLlrrSqVOoXLjYvylSMoWK624mba%2F4Fkg3E2uXqq4G1jRISyOAr1RtvMwYEo8U1Vo2TLFNNxDeyVM3IEqXOprE3M1nlP9qpeHPDnmWdWyRoiNo5MGIAkaReJIeoxECjdgts3sqYsqpWGuh0lJjdtIbmXNlBap8vU%2B54X7x6h9%2B0s%3D--KR8JpaHy431CsKRy--ueD9I5%2B%2FH%2BlH5vq1eUq4cg%3D%3D"},{"name":"If-None-Match","value":"W/\"bad62c68dac27b01151516aad5c7f0be\""}],"queryString":[],"headersSize":1829,"bodySize":0},"response":{"_charlesStatus":"COMPLETE","status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[{"name":"_homeland_session","value":"2H2EpF3BCnG%2FjK7qt8ZryfK3zYKsTqxtfoXvNeb7oYUNjCHZr3lV2lIhH4D6KZ1SWDEgItreKFMI2f%2FuR8UE5Iy1P9VSXrPhtRuMxGQnshQgAftJ83KwBriYXSA0MdRuqEE1%2B5nJxDNbnN5sxwsyVCQZe6x5GV%2FCice0d4bczcrV744Hngp1licLOa29YPMpHy5CbU4mcLNRxi%2Bx2f%2B%2B86Lw7d16u10Dake2OZNVjksDTGJYxF9jXQzNNqF2ZUSSq202gB2xUjME4ZURDdcrTE6bIp66KULllVOWmHTkvuBtJA3G6DyZhqoD5gZ5OXZYzgY%2FRQm7u1GeJrNRquJKmZ9u6%2FxIiDF%2FxHjZv%2Fb%2FptJQWVwm2MfCFbnR1bKSfQyi3WnrBkylE84Fk5hFQw%3D%3D--B5BuYCY2BWs3sTg%2B--e9ZCJstvL2HPacQ1gd51sQ%3D%3D","path":"/","domain":null,"expires":"Thu, 18 Jul 2019 06:14:30 -0000","httpOnly":true,"secure":true,"comment":null,"_maxAge":null}],"headers":[{"name":"Server","value":"nginx/1.10.2"},{"name":"Date","value":"Fri, 19 Apr 2019 06:14:30 GMT"},{"name":"Content-Type","value":"text/html; charset=utf-8"},{"name":"Transfer-Encoding","value":"chunked"},{"name":"Vary","value":"Accept-Encoding"},{"name":"X-Frame-Options","value":"SAMEORIGIN"},{"name":"X-XSS-Protection","value":"1; mode=block"},{"name":"X-Content-Type-Options","value":"nosniff"},{"name":"X-Download-Options","value":"noopen"},{"name":"X-Permitted-Cross-Domain-Policies","value":"none"},{"name":"Referrer-Policy","value":"strict-origin-when-cross-origin"},{"name":"ETag","value":"W/\"d5e2bdc2e5b529a1b78123e327656514\""},{"name":"Cache-Control","value":"max-age=0, private, must-revalidate"},{"name":"Content-Security-Policy","value":";"},{"name":"Set-Cookie","value":"_homeland_session=2H2EpF3BCnG%2FjK7qt8ZryfK3zYKsTqxtfoXvNeb7oYUNjCHZr3lV2lIhH4D6KZ1SWDEgItreKFMI2f%2FuR8UE5Iy1P9VSXrPhtRuMxGQnshQgAftJ83KwBriYXSA0MdRuqEE1%2B5nJxDNbnN5sxwsyVCQZe6x5GV%2FCice0d4bczcrV744Hngp1licLOa29YPMpHy5CbU4mcLNRxi%2Bx2f%2B%2B86Lw7d16u10Dake2OZNVjksDTGJYxF9jXQzNNqF2ZUSSq202gB2xUjME4ZURDdcrTE6bIp66KULllVOWmHTkvuBtJA3G6DyZhqoD5gZ5OXZYzgY%2FRQm7u1GeJrNRquJKmZ9u6%2FxIiDF%2FxHjZv%2Fb%2FptJQWVwm2MfCFbnR1bKSfQyi3WnrBkylE84Fk5hFQw%3D%3D--B5BuYCY2BWs3sTg%2B--e9ZCJstvL2HPacQ1gd51sQ%3D%3D; path=/; expires=Thu, 18 Jul 2019 06:14:30 -0000; secure; HttpOnly"},{"name":"X-Request-Id","value":"bfe4bffb-f4f5-4b94-b381-8799f7a029bd"},{"name":"X-Runtime","value":"0.114561"},{"name":"Strict-Transport-Security","value":"max-age=15552000; includeSubDomains"},{"name":"Content-Encoding","value":"gzip"},{"name":"Connection","value":"keep-alive"}],"content":{"size":54096,"compression":42723,"mimeType":"text/html; charset=utf-8","text":"<!--\n _ _ _ _\n | | | | | | | |\n | |_| | ___ _ __ ___ ___| | __ _ _ __ __| |\n | _ |/ _ \\| '_ ` _ \\ / _ \\ |/ _` | '_ \\ / _` |\n | | | | (_) | | | | | | __/ | (_| | | | | (_| |\n \\_| |_/\\___/|_| |_| |_|\\___|_|\\__,_|_| |_|\\__,_|\n ------------------------------------------------\n https://gethomeland.com\n\n - Ruby: 2.4.0-p0\n - Rails: 5.2.0.rc1\n - Homeland: 3.1.0.beta9\n-->\n<!DOCTYPE html>\n<html>\n<head>\n <meta charset='utf-8'/>\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no\"/>\n <meta name=\"keywords\" content=\"移动测试,游戏测试,性能测试,软件测试,软件测试社区,软件测试资料,软件测试工具,软件测试报告,软件测试方法,自动化测试,软件测试招聘,\">\n <meta name=\"description\" content=\"TesterHome软件测试社区,人气最旺的软件测试技术门户,提供软件测试社区交流,测试沙龙。\">\n <title>TesterHome<\/title>\n <link rel=\"icon\" href=\"/assets/favicon-cd32144f74c18746f3dce33e1040e7dfe4c07c8e611e37f3868b1c16b5095da3.png\"/>\n <link rel=\"apple-touch-icon-precomposed\" href=\"/assets/ios-icon-cd32144f74c18746f3dce33e1040e7dfe4c07c8e611e37f3868b1c16b5095da3.png\"/>\n <link rel=\"shortcut icon\" href=\"/assets/big_logo-cd32144f74c18746f3dce33e1040e7dfe4c07c8e611e37f3868b1c16b5095da3.png\"/>\n <link rel=\"apple-touch-icon\" href=\"/assets/favicon-cd32144f74c18746f3dce33e1040e7dfe4c07c8e611e37f3868b1c16b5095da3.png\">\n\n <!-- http://www.favicon-generator.org/ -->\n <link rel=\"apple-touch-icon\" sizes=\"57x57\" href=\"/apple-icon-57x57.png\">\n <link rel=\"apple-touch-icon\" sizes=\"60x60\" href=\"/apple-icon-60x60.png\">\n <link rel=\"apple-touch-icon\" sizes=\"72x72\" href=\"/apple-icon-72x72.png\">\n <link rel=\"apple-touch-icon\" sizes=\"76x76\" href=\"/apple-icon-76x76.png\">\n <link rel=\"apple-touch-icon\" sizes=\"114x114\" href=\"/apple-icon-114x114.png\">\n <link rel=\"apple-touch-icon\" sizes=\"120x120\" href=\"/apple-icon-120x120.png\">\n <link rel=\"apple-touch-icon\" sizes=\"144x144\" href=\"/apple-icon-144x144.png\">\n <link rel=\"apple-touch-icon\" sizes=\"152x152\" href=\"/apple-icon-152x152.png\">\n <link rel=\"apple-touch-icon\" sizes=\"180x180\" href=\"/apple-icon-180x180.png\">\n <link rel=\"icon\" type=\"image/png\" sizes=\"192x192\" href=\"/android-icon-192x192.png\">\n <link rel=\"icon\" type=\"image/png\" sizes=\"32x32\" href=\"/favicon-32x32.png\">\n <link rel=\"icon\" type=\"image/png\" sizes=\"96x96\" href=\"/favicon-96x96.png\">\n <link rel=\"icon\" type=\"image/png\" sizes=\"16x16\" href=\"/favicon-16x16.png\">\n <link rel=\"manifest\" href=\"/manifest.json\">\n <meta name=\"msapplication-TileColor\" content=\"#ffffff\">\n <meta name=\"msapplication-TileImage\" content=\"/ms-icon-144x144.png\">\n <meta name=\"theme-color\" content=\"#ffffff\">\n\n <meta name=\"apple-mobile-web-app-capable\" content=\"no\">\n <meta content='True' name='HandheldFriendly'/>\n <link rel=\"alternate\" type=\"application/rss+xml\" title=\"订阅最新帖\" href=\"https://testerhome.com/topics/feed\"/>\n <link rel=\"stylesheet\" media=\"screen\" href=\"/assets/front-15185e3983cd68677b04329670c6a6b7fedecb51f1b39ff7fac48180c1850eaa.css\" data-turbolinks-track=\"reload\" />\n \n \n <meta name=\"action-cable-url\" content=\"/cable\" />\n <meta name=\"csrf-param\" content=\"authenticity_token\" />\n<meta name=\"csrf-token\" content=\"jmdCmpEL4XJ0dsx9KfGu5m2XI1rDE5wBYn7FmGqzPss2kszANWkmYblmvJtqydpgdWrDQktsHETJYVo+BER5Ww==\" />\n <meta itemprop=\"name\" content=\"TesterHome\"/>\r\n <meta itemprop=\"image\" content=\"//10.url.cn/qqcourse_logo_ng/ajNVdqHZLLAH8YXbXMDFib2SnIqhac60vw3BspyLd0TO82PiaJ2xfbvXynrocic7ajbCGribAic88wgc/\"/>\r\n\r\n<style type=\"text/css\">\r\n\r\n.blog-description{\r\n text-align: left;\r\n padding: 18px;\r\n}\r\n.blog-description:first-letter {\r\nfont-size : 200%;\r\nfont-weight : bold;\r\nfloat : left;\r\nmargin-right: 3px;\r\n}\r\n\r\n/*\r\n.new-topic:before {\r\n content:url(\"https://testerhome.com/uploads/photo/2019/d80c7d7c-7727-45b2-a98d-2c87a68c65e8.png\");\r\n position: absolute;\r\n left:60%;\r\n margin-top:-35px;\r\n}\r\n*/\r\n<\/style>\n <meta name=\"current-user\" data-user-id=\"6109\" data-user-login=\"debugtalk\" data-user-name=\"debugtalk\" data-user-email=\"mail@debugtalk.com\" data-user-avatar-url=\"/uploads/user/avatar/6109.jpg!md\">\n\n <script src=\"/assets/app-04f83676fc1f6160a4410556b3298892a4536d9e8ee6c9b54df9191dd8dd9060.js\" data-turbolinks-track=\"reload\"><\/script>\n \n<\/head>\n<body class=\"page-home\" data-controller-name=\"home\">\n\n <div id=\"welcome-page\" class=\"hide-page\">\n <div \r\nstyle=\"background-image: url(https://testerhome.com/uploads/photo/2019/971bc4ea-b6eb-4d64-b6a1-1e71928314af.jpg); background-position: top center; background-attachment: fixed; background-size: cover; background-repeat: no-repeat; height:100%\">\r\n<\/div>\n <div class=\"welcome-action-button\">\n <div class=\"btn-group\">\n <a id=\"welcome-page-skip\" type=\"button\" class=\"btn btn-primary\">跳过<\/a>\n <\/div>\n <div class=\"btn-group\">\n <a href=\"http://2019.test-china.org/\" type=\"button\" class=\"btn btn-info\">查看详情<\/a>\n <\/div>\n <\/div>\n <\/div>\n\n <div id=\"main-page\">\n<div class=\"header\">\n <nav class=\"navbar navbar-inverse navbar-fixed-top navbar-default\">\n <div class=\"container\">\n <div class=\"navbar-header\" id=\"navbar-header\" data-turbolinks-permanent>\n <button type=\"button\" class=\"navbar-toggle collapsed\" data-toggle=\"collapse\" data-target=\"#main-navbar-collapse\">\n <span class=\"sr-only\">Toggle<\/span>\n <i class=\"fa fa-reorder\"><\/i>\n <\/button>\n <a href=\"/\" class=\"navbar-brand\"><b>TesterHome<\/b><\/a>\n\n <\/div>\n <div class=\"collapse navbar-collapse\" id=\"main-navbar-collapse\">\n \n <div id=\"main-nav-menu\">\n <ul class=\"nav navbar-nav\">\n <li class=\"\"><a href=\"/topics\">Topics<\/a><\/li><li class=\"\"><a href=\"/bugs\">Bug Tracker<\/a><\/li><li class=\"\"><a href=\"/questions\">QA<\/a><\/li><li class=\"\"><a href=\"/teams\">Teams<\/a><\/li><li class=\"\"><a href=\"/jobs\">招聘<\/a><\/li><li class=\"\"><a href=\"/wiki\">Wiki<\/a><\/li><li class=\"\"><a href=\"/opensource_projects\">开源项目<span class=\"badge-new\">新<\/span><\/a><\/li>\n \n <\/ul>\n<\/div>\n <\/div>\n <ul class=\"nav user-bar navbar-nav navbar-right\">\n <li class=\"dropdown dropdown-avatar\">\n <a href=\"#\" class=\"dropdown-toggle\" data-toggle=\"dropdown\" role=\"button\" aria-expanded=\"false\">\n <img class=\"media-object avatar-32\" src=\"/uploads/user/avatar/6109.jpg!sm\" /> <span class=\"caret\"><\/span>\n <\/a>\n <button class=\"navbar-toggle\" type=\"button\" data-toggle=\"dropdown\" role=\"button\" aria-expanded=\"false\">\n <span class=\"sr-only\">Toggle<\/span>\n <img class=\"media-object avatar-32\" src=\"/uploads/user/avatar/6109.jpg!sm\" />\n <\/button>\n <ul class=\"dropdown-menu\" role=\"menu\"><li class=\"\"><a href=\"/debugtalk\">debugtalk<\/a><\/li><li class=\"\"><div class='divider'><\/div><\/li><li class=\"\"><a href=\"/setting\">Account Profile<\/a><\/li><li class=\"\"><a href=\"/debugtalk/columns\">我的专栏<\/a><\/li><li class=\"\"><a href=\"/topics/favorites\">Favorites<\/a><\/li><li class=\"\"><a href=\"/notes\">记事本<\/a><\/li><li class=\"\"><div class='divider'><\/div><\/li><li class=\"\"><a rel=\"nofollow\" data-method=\"delete\" href=\"/account/sign_out\">Sign Out<\/a><\/li><\/ul>\n <\/li>\n<\/ul>\n\n<ul class=\"nav navbar-nav user-bar navbar-right\">\n <li class=\"nav-search hidden-xs hidden-sm hidden-md\">\n <form class=\"navbar-form form-search active\" action=\"/search\" method=\"GET\">\n <div class=\"form-group\">\n <input class=\"form-control\" name=\"q\" type=\"text\" value=\"\" placeholder=\"搜索本站内容\" />\n <\/div>\n <i class=\"fa btn-search fa-search\"><\/i>\n <\/form>\n <\/li>\n\n\n <li class=\"visible-xs\">\n <a href=\"/topics/new\" title=\"New Topic\"><i class=\"fa fa-edit\"><\/i><\/a>\n <\/li>\n\n <li class=\"notification-count hidden-sm\">\n <a href=\"/notifications\" class=\"\" title=\"通知\"><i class=\"fa fa-bell\"><\/i><span class=\"count\">0<\/span><\/a>\n <\/li>\n <li class=\"hidden-sm\">\n <a href=\"#\" class=\"dropdown-toggle\" data-toggle=\"dropdown\" role=\"button\" aria-expanded=\"false\">\n <i class=\"fa fa-plus\"><\/i> <span class=\"caret\"><\/span>\n <\/a>\n <ul class=\"dropdown-menu\" role=\"menu\"><li class=\"\"><a href=\"/topics/new\">New Topic<\/a><\/li><li class=\"\"><div class='divider'><\/div><\/li><li class=\"\"><a href=\"/teams/new\">New team<\/a><\/li><\/ul>\n <\/li>\n<\/ul>\n\n <\/div>\n <\/nav>\n <div id=\"corner\" class=\"\">\n <a id=\"cornertip\" href=\"\">\n <div id=\"c-content\">\n <div id=\"c-button\">欢迎<\/div>\n <\/div>\n <\/a>\n <\/div>\n<\/div>\n\n\n\n<div id=\"main\" class=\"main-container container\">\n \n \n \n\n\n<div class=\"row\">\n <div class=\"col-md-9 home-main\">\n \n \n<div class=\"home_suggest_topics panel panel-default\">\n <div class=\"panel-heading\">社区置顶<\/div>\n <div class=\"panel-body topics row\">\n <div class=\"col-md-6\">\n <div class=\"topic media topic-18791\">\n <div class=\"avatar media-left\">\n <a title=\"kasi (卡斯)\" href=\"/kasi\"><img class=\"media-object avatar-48\" src=\"/uploads/user/avatar/441/ee76af.png!md\" /><\/a>\n <\/div>\n <div class=\"infos media-body\">\n <div class=\"title media-heading\">\n <a title=\"TesterHome 深圳沙龙议题征集\" href=\"/topics/18791\">TesterHome 深圳沙龙议题征集<\/a>\n <i class=\"fa fa-thumb-tack\" title=\"置顶\"><\/i>\n \n \n <\/div>\n <div class=\"info\">\n <a class=\"user-name\" data-name=\"卡斯\" title=\"卡斯(kasi)\" href=\"/kasi\">卡斯<\/a>\n <\/div>\n <\/div>\n <div class=\"count media-right\">\n <\/div>\n<\/div>\n<div class=\"topic media topic-18682\">\n <div class=\"avatar media-left\">\n <a title=\"chenhengjie123 (陈恒捷)\" href=\"/chenhengjie123\"><img class=\"media-object avatar-48\" src=\"/uploads/user/avatar/605.jpg!md\" /><\/a>\n <\/div>\n <div class=\"infos media-body\">\n <div class=\"title media-heading\">\n <a title=\"TesterHome 广州沙龙 2019年 第 1 期报名中!\" href=\"/topics/18682\">TesterHome 广州沙龙 2019年 第 1 期报名中!<\/a>\n <i class=\"fa fa-thumb-tack\" title=\"置顶\"><\/i>\n \n \n <\/div>\n <div class=\"info\">\n <a class=\"user-name\" data-name=\"陈恒捷\" title=\"陈恒捷(chenhengjie123)\" href=\"/chenhengjie123\">陈恒捷<\/a>\n <\/div>\n <\/div>\n <div class=\"count media-right\">\n <a class=\"state-false\" href=\"/topics/18682#reply-141543\">8<\/a>\n <\/div>\n<\/div>\n\n <\/div>\n <div class=\"col-md-6\">\n <div class=\"topic media topic-18683\">\n <div class=\"avatar media-left\">\n <a title=\"LMD (Raymond)\" href=\"/LMD\"><img class=\"media-object avatar-48\" src=\"/uploads/user/avatar/22711/395fcb.png!md\" /><\/a>\n <\/div>\n <div class=\"infos media-body\">\n <div class=\"title media-heading\">\n <a title=\"QA 最佳实践:大厂如何提升软件质量?|福利\" href=\"/topics/18683\">QA 最佳实践:大厂如何提升软件质量?|福利<\/a>\n <i class=\"fa fa-thumb-tack\" title=\"置顶\"><\/i>\n \n \n <\/div>\n <div class=\"info\">\n <a class=\"user-name\" data-name=\"Raymond\" title=\"Raymond(LMD)\" href=\"/LMD\">Raymond<\/a>\n <\/div>\n <\/div>\n <div class=\"count media-right\">\n <a class=\"state-false\" href=\"/topics/18683#reply-141529\">4<\/a>\n <\/div>\n<\/div>\n<div class=\"topic media topic-17705\">\n <div class=\"avatar media-left\">\n <a title=\"TesterHome (TesterHome)\" href=\"/TesterHome\"><img class=\"media-object avatar-48\" src=\"/uploads/user/avatar/555/652855.jpg!md\" /><\/a>\n <\/div>\n <div class=\"infos media-body\">\n <div class=\"title media-heading\">\n <a title=\"2019 中国移动互联网测试大会议题征集\" href=\"/topics/17705\">2019 中国移动互联网测试大会议题征集<\/a>\n <i class=\"fa fa-thumb-tack\" title=\"置顶\"><\/i>\n \n \n <\/div>\n <div class=\"info\">\n <a class=\"user-name\" data-name=\"TesterHome\" title=\"TesterHome(TesterHome)\" href=\"/TesterHome\">TesterHome<\/a>\n <\/div>\n <\/div>\n <div class=\"count media-right\">\n <a class=\"state-false\" href=\"/topics/17705#reply-140118\">21<\/a>\n <\/div>\n<\/div>\n\n <\/div>\n <\/div>\n<\/div>\n\n\n<div class=\"home_suggest_topics panel panel-default\">\n <div class=\"panel-heading\">\n 最新帖子\n <div class=\"pull-right\">\n <a href=\"/topics/last\">查看更多...<\/a>\n <\/div>\n <\/div>\n <div class=\"panel-body topics row\">\n <div class=\"col-md-6\">\n <div class=\"topic media topic-18830\">\n <div class=\"avatar media-left\">\n <a title=\"debugtalk (debugtalk)\" href=\"/debugtalk\"><img class=\"media-object avatar-48\" src=\"/uploads/user/avatar/6109.jpg!md\" /><\/a>\n <\/div>\n <div class=\"infos media-body\">\n <div class=\"title media-heading\">\n <a title=\"HttpRunner 的测试用例分层机制 (适用于 2.X)\" href=\"/articles/18830\">HttpRunner 的测试用例分层机制 (适用于 2.X)<\/a>\n \n \n <\/div>\n <div class=\"info\">\n <a class=\"user-name\" data-name=\"debugtalk\" title=\"debugtalk(debugtalk)\" href=\"/debugtalk\">debugtalk<\/a>\n <\/div>\n <\/div>\n <div class=\"count media-right\">\n <a class=\"state-false\" href=\"/topics/18830#reply-141527\">2<\/a>\n <\/div>\n<\/div>\n<div class=\"topic media topic-18825\">\n <div class=\"avatar media-left\">\n <a title=\"fengzhaoxi (dishierjie)\" href=\"/fengzhaoxi\"><img class=\"media-object avatar-48\" src=\"/uploads/user/avatar/41499/2ec04d.png!md\" /><\/a>\n <\/div>\n <div class=\"infos media-body\">\n <div class=\"title media-heading\">\n <a title=\"求助,pytest+allure 报错\" href=\"/topics/18825\">求助,pytest+allure 报错<\/a>\n \n \n <\/div>\n <div class=\"info\">\n <a class=\"user-name\" data-name=\"dishierjie\" title=\"dishierjie(fengzhaoxi)\" href=\"/fengzhaoxi\">dishierjie<\/a>\n <\/div>\n <\/div>\n <div class=\"count media-right\">\n <a class=\"state-false\" href=\"/topics/18825#reply-141549\">2<\/a>\n <\/div>\n<\/div>\n<div class=\"topic media topic-18823\">\n <div class=\"avatar media-left\">\n <a title=\"Anitalisk (llsskkii)\" href=\"/Anitalisk\"><img class=\"media-object avatar-48\" src=\"/uploads/user/avatar/42652/d49ba5.jpeg!md\" /><\/a>\n <\/div>\n <div class=\"infos media-body\">\n <div class=\"title media-heading\">\n <a title=\"[深圳] 字节跳动头条研发团队招聘大量测试开发工程师\" href=\"/topics/18823\">[深圳] 字节跳动头条研发团队招聘大量测试开发工程师<\/a>\n \n \n <\/div>\n <div class=\"info\">\n <a class=\"user-name\" data-name=\"llsskkii\" title=\"llsskkii(Anitalisk)\" href=\"/Anitalisk\">llsskkii<\/a>\n <\/div>\n <\/div>\n <div class=\"count media-right\">\n <a class=\"state-false\" href=\"/topics/18823#reply-141508\">2<\/a>\n <\/div>\n<\/div>\n\n <\/div>\n <div class=\"col-md-6\">\n <div class=\"topic media topic-18837\">\n <div class=\"avatar media-left\">\n <a title=\"a861357276 (吴先森)\" href=\"/a861357276\"><img class=\"media-object avatar-48\" src=\"/uploads/user/avatar/40667/05fb6a.jpg!md\" /><\/a>\n <\/div>\n <div class=\"infos media-body\">\n <div class=\"title media-heading\">\n <a title=\"软件产品出了问题,就一定是测试的工作没做足,你怎么看\" href=\"/topics/18837\">软件产品出了问题,就一定是测试的工作没做足,你怎么看<\/a>\n \n \n <\/div>\n <div class=\"info\">\n <a class=\"user-name\" data-name=\"吴先森\" title=\"吴先森(a861357276)\" href=\"/a861357276\">吴先森<\/a>\n <\/div>\n <\/div>\n <div class=\"count media-right\">\n <a class=\"state-false\" href=\"/topics/18837#reply-141561\">1<\/a>\n <\/div>\n<\/div>\n<div class=\"topic media topic-18828\">\n <div class=\"avatar media-left\">\n <a title=\"kasijia (kasijia)\" href=\"/kasijia\"><img class=\"media-object avatar-48\" src=\"/uploads/user/avatar/19929/afdd72.png!md\" /><\/a>\n <\/div>\n <div class=\"infos media-body\">\n <div class=\"title media-heading\">\n <a title=\"[已解决] Appium 启动 iOS 真机 app 后无限重启\" href=\"/topics/18828\">[已解决] Appium 启动 iOS 真机 app 后无限重启<\/a>\n \n \n <\/div>\n <div class=\"info\">\n <a class=\"user-name\" data-name=\"kasijia\" title=\"kasijia(kasijia)\" href=\"/kasijia\">kasijia<\/a>\n <\/div>\n <\/div>\n <div class=\"count media-right\">\n <a class=\"state-false\" href=\"/topics/18828#reply-141556\">3<\/a>\n <\/div>\n<\/div>\n<div class=\"topic media topic-18824\">\n <div class=\"avatar media-left\">\n <a title=\"slowchen (慢慢慢慢热)\" href=\"/slowchen\"><img class=\"media-object avatar-48\" src=\"/uploads/user/avatar/28176/ebf091.jpg!md\" /><\/a>\n <\/div>\n <div class=\"infos media-body\">\n <div class=\"title media-heading\">\n <a title=\"请教一个 Jenkins 发送邮件带附件的问题\" href=\"/topics/18824\">请教一个 Jenkins 发送邮件带附件的问题<\/a>\n \n \n <\/div>\n <div class=\"info\">\n <a class=\"user-name\" data-name=\"慢慢慢慢热\" title=\"慢慢慢慢热(slowchen)\" href=\"/slowchen\">慢慢慢慢热<\/a>\n <\/div>\n <\/div>\n <div class=\"count media-right\">\n <a class=\"state-false\" href=\"/topics/18824#reply-141528\">7<\/a>\n <\/div>\n<\/div>\n\n <\/div>\n <\/div>\n<\/div>\n\n<div class=\"home_suggest_topics panel panel-default\">\n <div class=\"panel-heading\">\n 社区精华帖\n <div class=\"pull-right\">\n <a href=\"/topics/excellent\">查看更多精华帖...<\/a>\n <\/div>\n <\/div>\n <div class=\"panel-body topics row\">\n <div class=\"col-md-6 topics-group\">\n <div class=\"topic media topic-18475\">\n <div class=\"avatar media-left\">\n <a title=\"Never_More (Never_More)\" href=\"/Never_More\"><img class=\"media-object avatar-48\" src=\"/uploads/user/avatar/11427.jpg!md\" /><\/a>\n <\/div>\n <div class=\"infos media-body\">\n <div class=\"title media-heading\">\n <a title=\"基于模型的测试 (Model-based Testing),希望大家能给一些建议\" href=\"/topics/18475\">基于模型的测试 (Model-based Testing),希望大家能给一些建议<\/a>\n <i title=\"精华帖\" class=\"fa fa-diamond\" data-toggle=\"tooltip\"><\/i>\n \n <\/div>\n <div class=\"info\">\n <a class=\"user-name\" data-name=\"Never_More\" title=\"Never_More(Never_More)\" href=\"/Never_More\">Never_More<\/a>\n <\/div>\n <\/div>\n <div class=\"count media-right\">\n <a class=\"state-false\" href=\"/topics/18475#reply-141489\">26<\/a>\n <\/div>\n<\/div>\n<div class=\"topic media topic-18448\">\n <div class=\"avatar media-left\">\n <a title=\"codeskyblue (codeskyblue)\" href=\"/codeskyblue\"><img class=\"media-object avatar-48\" src=\"/uploads/user/avatar/6853/28cf21.jpeg!md\" /><\/a>\n <\/div>\n <div class=\"infos media-body\">\n <div class=\"title media-heading\">\n <a title=\"iOS 远程真机方案整理\" href=\"/topics/18448\">iOS 远程真机方案整理<\/a>\n <i title=\"精华帖\" class=\"fa fa-diamond\" data-toggle=\"tooltip\"><\/i>\n \n <\/div>\n <div class=\"info\">\n <a class=\"user-name\" data-name=\"codeskyblue\" title=\"codeskyblue(codeskyblue)\" href=\"/codeskyblue\">codeskyblue<\/a>\n <\/div>\n <\/div>\n <div class=\"count media-right\">\n <a class=\"state-false\" href=\"/topics/18448#reply-139214\">14<\/a>\n <\/div>\n<\/div>\n<div class=\"topic media topic-18141\">\n <div class=\"avatar media-left\">\n <a title=\"RealLau (非洲赵子龙)\" href=\"/RealLau\"><img class=\"media-object avatar-48\" src=\"/uploads/user/avatar/18875/0ee193.jpg!md\" /><\/a>\n <\/div>\n <div class=\"infos media-body\">\n <div class=\"title media-heading\">\n <a title=\"强大的全新 Web UI 测试框架 Cypress - 初尝甜头\" href=\"/topics/18141\">强大的全新 Web UI 测试框架 Cypress - 初尝甜头<\/a>\n <i title=\"精华帖\" class=\"fa fa-diamond\" data-toggle=\"tooltip\"><\/i>\n \n <\/div>\n <div class=\"info\">\n <a class=\"user-name\" data-name=\"非洲赵子龙\" title=\"非洲赵子龙(RealLau)\" href=\"/RealLau\">非洲赵子龙<\/a>\n for <a class=\"team-name\" data-name=\"Cypress\" title=\"Cypress(CypressChina)\" href=\"/CypressChina\">Cypress<\/a>\n <\/div>\n <\/div>\n <div class=\"count media-right\">\n <a class=\"state-false\" href=\"/topics/18141#reply-141263\">36<\/a>\n <\/div>\n<\/div>\n<div class=\"topic media topic-18047\">\n <div class=\"avatar media-left\">\n <a title=\"rodman1985 (江城子)\" href=\"/rodman1985\"><img class=\"media-object avatar-48\" src=\"/uploads/user/avatar/5895.jpg!md\" /><\/a>\n <\/div>\n <div class=\"infos media-body\">\n <div class=\"title media-heading\">\n <a title=\"一九得九,依旧得酒!-- 我的测试总结和展望\" href=\"/topics/18047\">一九得九,依旧得酒!-- 我的测试总结和展望<\/a>\n <i title=\"精华帖\" class=\"fa fa-diamond\" data-toggle=\"tooltip\"><\/i>\n \n <\/div>\n <div class=\"info\">\n <a class=\"user-name\" data-name=\"江城子\" title=\"江城子(rodman1985)\" href=\"/rodman1985\">江城子<\/a>\n <\/div>\n <\/div>\n <div class=\"count media-right\">\n <a class=\"state-false\" href=\"/topics/18047#reply-139366\">61<\/a>\n <\/div>\n<\/div>\n<div class=\"topic media topic-17764\">\n <div class=\"avatar media-left\">\n <a title=\"debugtalk (debugtalk)\" href=\"/debugtalk\"><img class=\"media-object avatar-48\" src=\"/uploads/user/avatar/6109.jpg!md\" /><\/a>\n <\/div>\n <div class=\"infos media-body\">\n <div class=\"title media-heading\">\n <a title=\"如何度量测试开发的价值产出?\" href=\"/topics/17764\">如何度量测试开发的价值产出?<\/a>\n <i title=\"精华帖\" class=\"fa fa-diamond\" data-toggle=\"tooltip\"><\/i>\n \n <\/div>\n <div class=\"info\">\n <a class=\"user-name\" data-name=\"debugtalk\" title=\"debugtalk(debugtalk)\" href=\"/debugtalk\">debugtalk<\/a>\n <\/div>\n <\/div>\n <div class=\"count media-right\">\n <a class=\"state-false\" href=\"/topics/17764#reply-141214\">50<\/a>\n <\/div>\n<\/div>\n<div class=\"topic media topic-17646\">\n <div class=\"avatar media-left\">\n <a title=\"simple\" href=\"/simple\"><img class=\"media-object avatar-48\" src=\"/uploads/user/avatar/50.jpg!md\" /><\/a>\n <\/div>\n <div class=\"infos media-body\">\n <div class=\"title media-heading\">\n <a title=\"[精彩盘点] TesterHome 社区 2018年 度精华帖\" href=\"/topics/17646\">[精彩盘点] TesterHome 社区 2018年 度精华帖<\/a>\n <i title=\"精华帖\" class=\"fa fa-diamond\" data-toggle=\"tooltip\"><\/i>\n \n <\/div>\n <div class=\"info\">\n <a class=\"user-name\" data-name=\"simple\" title=\"simple(simple)\" href=\"/simple\">simple<\/a>\n <\/div>\n <\/div>\n <div class=\"count media-right\">\n <a class=\"state-false\" href=\"/topics/17646#reply-137269\">29<\/a>\n <\/div>\n<\/div>\n<div class=\"topic media topic-17540\">\n <div class=\"avatar media-left\">\n <a title=\"youzancoder (有赞测试团队)\" href=\"/youzancoder\"><img class=\"media-object avatar-48\" src=\"/uploads/user/avatar/38409/c59d23.png!md\" /><\/a>\n <\/div>\n <div class=\"infos media-body\">\n <div class=\"title media-heading\">\n <a title=\"资损防控体系介绍\" href=\"/articles/17540\">资损防控体系介绍<\/a>\n <i title=\"精华帖\" class=\"fa fa-diamond\" data-toggle=\"tooltip\"><\/i>\n \n <\/div>\n <div class=\"info\">\n <a class=\"user-name\" data-name=\"有赞测试团队\" title=\"有赞测试团队(youzancoder)\" href=\"/youzancoder\">有赞测试团队<\/a>\n <\/div>\n <\/div>\n <div class=\"count media-right\">\n <a class=\"state-false\" href=\"/topics/17540#reply-135391\">6<\/a>\n <\/div>\n<\/div>\n<div class=\"topic media topic-17292\">\n <div class=\"avatar media-left\">\n <a title=\"xinxi1990 (xinxi)\" href=\"/xinxi1990\"><img class=\"media-object avatar-48\" src=\"/uploads/user/avatar/26433/ebf4e4.jpeg!md\" /><\/a>\n <\/div>\n <div class=\"infos media-body\">\n <div class=\"title media-heading\">\n <a title=\"使用 uiautomator2+pytest+allure 进行 Android 的 UI 自动化测试\" href=\"/topics/17292\">使用 uiautomator2+pytest+allure 进行 Android 的 UI 自动化测试<\/a>\n <i title=\"精华帖\" class=\"fa fa-diamond\" data-toggle=\"tooltip\"><\/i>\n \n <\/div>\n <div class=\"info\">\n <a class=\"user-name\" data-name=\"xinxi\" title=\"xinxi(xinxi1990)\" href=\"/xinxi1990\">xinxi<\/a>\n <\/div>\n <\/div>\n <div class=\"count media-right\">\n <a class=\"state-false\" href=\"/topics/17292#reply-139303\">35<\/a>\n <\/div>\n<\/div>\n<div class=\"topic media topic-17232\">\n <div class=\"avatar media-left\">\n <a title=\"xinxi1990 (xinxi)\" href=\"/xinxi1990\"><img class=\"media-object avatar-48\" src=\"/uploads/user/avatar/26433/ebf4e4.jpeg!md\" /><\/a>\n <\/div>\n <div class=\"infos media-body\">\n <div class=\"title media-heading\">\n <a title=\"iOS 启动时间测试\" href=\"/topics/17232\">iOS 启动时间测试<\/a>\n <i title=\"精华帖\" class=\"fa fa-diamond\" data-toggle=\"tooltip\"><\/i>\n \n <\/div>\n <div class=\"info\">\n <a class=\"user-name\" data-name=\"xinxi\" title=\"xinxi(xinxi1990)\" href=\"/xinxi1990\">xinxi<\/a>\n <\/div>\n <\/div>\n <div class=\"count media-right\">\n <a class=\"state-false\" href=\"/topics/17232#reply-138912\">10<\/a>\n <\/div>\n<\/div>\n<div class=\"topic media topic-17117\">\n <div class=\"avatar media-left\">\n <a title=\"xinxi1990 (xinxi)\" href=\"/xinxi1990\"><img class=\"media-object avatar-48\" src=\"/uploads/user/avatar/26433/ebf4e4.jpeg!md\" /><\/a>\n <\/div>\n <div class=\"infos media-body\">\n <div class=\"title media-heading\">\n <a title=\"SonarQube 的安装与使用\" href=\"/topics/17117\">SonarQube 的安装与使用<\/a>\n <i title=\"精华帖\" class=\"fa fa-diamond\" data-toggle=\"tooltip\"><\/i>\n \n <\/div>\n <div class=\"info\">\n <a class=\"user-name\" data-name=\"xinxi\" title=\"xinxi(xinxi1990)\" href=\"/xinxi1990\">xinxi<\/a>\n <\/div>\n <\/div>\n <div class=\"count media-right\">\n <a class=\"state-false\" href=\"/topics/17117#reply-129553\">13<\/a>\n <\/div>\n<\/div>\n\n <\/div>\n <div class=\"col-md-6 topics-group\">\n <div class=\"topic media topic-18494\">\n <div class=\"avatar media-left\">\n <a title=\"hlylearner (大豆子)\" href=\"/hlylearner\"><img class=\"media-object avatar-48\" src=\"/uploads/user/avatar/41005/cb690e.jpg!md\" /><\/a>\n <\/div>\n <div class=\"infos media-body\">\n <div class=\"title media-heading\">\n <a title=\"深入了解 sonar 自定义规则开发 (入门强烈推荐)\" href=\"/topics/18494\">深入了解 sonar 自定义规则开发 (入门强烈推荐)<\/a>\n <i title=\"精华帖\" class=\"fa fa-diamond\" data-toggle=\"tooltip\"><\/i>\n \n <\/div>\n <div class=\"info\">\n <a class=\"user-name\" data-name=\"大豆子\" title=\"大豆子(hlylearner)\" href=\"/hlylearner\">大豆子<\/a>\n <\/div>\n <\/div>\n <div class=\"count media-right\">\n <a class=\"state-false\" href=\"/topics/18494#reply-139963\">9<\/a>\n <\/div>\n<\/div>\n<div class=\"topic media topic-18460\">\n <div class=\"avatar media-left\">\n <a title=\"fenny.ren (fenny)\" href=\"/fenny.ren\"><img class=\"media-object avatar-48\" src=\"/uploads/user/avatar/38212/6e7237.jpeg!md\" /><\/a>\n <\/div>\n <div class=\"infos media-body\">\n <div class=\"title media-heading\">\n <a title=\"读阿里《不止代码》\u2014\u2014面试\" href=\"/topics/18460\">读阿里《不止代码》\u2014\u2014面试<\/a>\n <i title=\"精华帖\" class=\"fa fa-diamond\" data-toggle=\"tooltip\"><\/i>\n \n <\/div>\n <div class=\"info\">\n <a class=\"user-name\" data-name=\"fenny\" title=\"fenny(fenny.ren)\" href=\"/fenny.ren\">fenny<\/a>\n <\/div>\n <\/div>\n <div class=\"count media-right\">\n <a class=\"state-false\" href=\"/topics/18460#reply-141436\">13<\/a>\n <\/div>\n<\/div>\n<div class=\"topic media topic-18171\">\n <div class=\"avatar media-left\">\n <a title=\"chenhengjie123 (陈恒捷)\" href=\"/chenhengjie123\"><img class=\"media-object avatar-48\" src=\"/uploads/user/avatar/605.jpg!md\" /><\/a>\n <\/div>\n <div class=\"infos media-body\">\n <div class=\"title media-heading\">\n <a title=\"谷歌开源模糊测试工具 ClusterFuzz 尝鲜记录 (进行中)\" href=\"/topics/18171\">谷歌开源模糊测试工具 ClusterFuzz 尝鲜记录 (进行中)<\/a>\n <i title=\"精华帖\" class=\"fa fa-diamond\" data-toggle=\"tooltip\"><\/i>\n \n <\/div>\n <div class=\"info\">\n <a class=\"user-name\" data-name=\"陈恒捷\" title=\"陈恒捷(chenhengjie123)\" href=\"/chenhengjie123\">陈恒捷<\/a>\n for <a class=\"team-name\" data-name=\"PPmoney\" title=\"PPmoney(ppmoney)\" href=\"/ppmoney\">PPmoney<\/a>\n <\/div>\n <\/div>\n <div class=\"count media-right\">\n <a class=\"state-false\" href=\"/topics/18171#reply-141020\">46<\/a>\n <\/div>\n<\/div>\n<div class=\"topic media topic-18064\">\n <div class=\"avatar media-left\">\n <a title=\"debugtalk (debugtalk)\" href=\"/debugtalk\"><img class=\"media-object avatar-48\" src=\"/uploads/user/avatar/6109.jpg!md\" /><\/a>\n <\/div>\n <div class=\"infos media-body\">\n <div class=\"title media-heading\">\n <a title=\"我的 2018 年终总结\" href=\"/topics/18064\">我的 2018 年终总结<\/a>\n <i title=\"精华帖\" class=\"fa fa-diamond\" data-toggle=\"tooltip\"><\/i>\n \n <\/div>\n <div class=\"info\">\n <a class=\"user-name\" data-name=\"debugtalk\" title=\"debugtalk(debugtalk)\" href=\"/debugtalk\">debugtalk<\/a>\n <\/div>\n <\/div>\n <div class=\"count media-right\">\n <a class=\"state-false\" href=\"/topics/18064#reply-138728\">28<\/a>\n <\/div>\n<\/div>\n<div class=\"topic media topic-17986\">\n <div class=\"avatar media-left\">\n <a title=\"t880216t (81\u20141)\" href=\"/t880216t\"><img class=\"media-object avatar-48\" src=\"/uploads/user/avatar/6859.jpg!md\" /><\/a>\n <\/div>\n <div class=\"infos media-body\">\n <div class=\"title media-heading\">\n <a title=\"基于 Jmeter 的 web 端接口自动化测试平台\" href=\"/topics/17986\">基于 Jmeter 的 web 端接口自动化测试平台<\/a>\n <i title=\"精华帖\" class=\"fa fa-diamond\" data-toggle=\"tooltip\"><\/i>\n \n <\/div>\n <div class=\"info\">\n <a class=\"user-name\" data-name=\"81\u20141\" title=\"81\u20141(t880216t)\" href=\"/t880216t\">81\u20141<\/a>\n <\/div>\n <\/div>\n <div class=\"count media-right\">\n <a class=\"state-false\" href=\"/topics/17986#reply-141340\">75<\/a>\n <\/div>\n<\/div>\n<div class=\"topic media topic-17746\">\n <div class=\"avatar media-left\">\n <a title=\"weijb (jb)\" href=\"/weijb\"><img class=\"media-object avatar-48\" src=\"/uploads/user/avatar/13676/f12b84.jpg!md\" /><\/a>\n <\/div>\n <div class=\"infos media-body\">\n <div class=\"title media-heading\">\n <a title=\"听说安卓微信 7.0 不能抓 https?\" href=\"/articles/17746\">听说安卓微信 7.0 不能抓 https?<\/a>\n <i title=\"精华帖\" class=\"fa fa-diamond\" data-toggle=\"tooltip\"><\/i>\n \n <\/div>\n <div class=\"info\">\n <a class=\"user-name\" data-name=\"jb\" title=\"jb(weijb)\" href=\"/weijb\">jb<\/a>\n <\/div>\n <\/div>\n <div class=\"count media-right\">\n <a class=\"state-false\" href=\"/topics/17746#reply-141478\">45<\/a>\n <\/div>\n<\/div>\n<div class=\"topic media topic-17554\">\n <div class=\"avatar media-left\">\n <a title=\"devin (Devin)\" href=\"/devin\"><img class=\"media-object avatar-48\" src=\"/uploads/user/avatar/3791/69f795.jpg!md\" /><\/a>\n <\/div>\n <div class=\"infos media-body\">\n <div class=\"title media-heading\">\n <a title=\"(开源) XMind2TestCase:一个高效测试用例设计的解决方案! \" href=\"/topics/17554\">(开源) XMind2TestCase:一个高效测试用例设计的解决方案! <\/a>\n <i title=\"精华帖\" class=\"fa fa-diamond\" data-toggle=\"tooltip\"><\/i>\n \n <\/div>\n <div class=\"info\">\n <a class=\"user-name\" data-name=\"Devin\" title=\"Devin(devin)\" href=\"/devin\">Devin<\/a>\n <\/div>\n <\/div>\n <div class=\"count media-right\">\n <a class=\"state-false\" href=\"/topics/17554#reply-141235\">78<\/a>\n <\/div>\n<\/div>\n<div class=\"topic media topic-17355\">\n <div class=\"avatar media-left\">\n <a title=\"youzancoder (有赞测试团队)\" href=\"/youzancoder\"><img class=\"media-object avatar-48\" src=\"/uploads/user/avatar/38409/c59d23.png!md\" /><\/a>\n <\/div>\n <div class=\"infos media-body\">\n <div class=\"title media-heading\">\n <a title=\"有赞全链路压测实战\" href=\"/articles/17355\">有赞全链路压测实战<\/a>\n <i title=\"精华帖\" class=\"fa fa-diamond\" data-toggle=\"tooltip\"><\/i>\n \n <\/div>\n <div class=\"info\">\n <a class=\"user-name\" data-name=\"有赞测试团队\" title=\"有赞测试团队(youzancoder)\" href=\"/youzancoder\">有赞测试团队<\/a>\n <\/div>\n <\/div>\n <div class=\"count media-right\">\n <a class=\"state-false\" href=\"/topics/17355#reply-141550\">14<\/a>\n <\/div>\n<\/div>\n<div class=\"topic media topic-17251\">\n <div class=\"avatar media-left\">\n <a title=\"cay (蒋刚毅)\" href=\"/cay\"><img class=\"media-object avatar-48\" src=\"/uploads/user/avatar/8985/1522fe.jpg!md\" /><\/a>\n <\/div>\n <div class=\"infos media-body\">\n <div class=\"title media-heading\">\n <a title=\"[持续交付实践] Jenkins Pipeline 高可用设计方法\" href=\"/topics/17251\">[持续交付实践] Jenkins Pipeline 高可用设计方法<\/a>\n <i title=\"精华帖\" class=\"fa fa-diamond\" data-toggle=\"tooltip\"><\/i>\n \n <\/div>\n <div class=\"info\">\n <a class=\"user-name\" data-name=\"蒋刚毅\" title=\"蒋刚毅(cay)\" href=\"/cay\">蒋刚毅<\/a>\n <\/div>\n <\/div>\n <div class=\"count media-right\">\n <a class=\"state-false\" href=\"/topics/17251#reply-129650\">7<\/a>\n <\/div>\n<\/div>\n<div class=\"topic media topic-17179\">\n <div class=\"avatar media-left\">\n <a title=\"appetizer.io (AppetizerIO)\" href=\"/appetizer.io\"><img class=\"media-object avatar-48\" src=\"/uploads/user/avatar/11797.jpg!md\" /><\/a>\n <\/div>\n <div class=\"infos media-body\">\n <div class=\"title media-heading\">\n <a title=\"WiFi 无线拉起执行 APP 稳定性 / 压力测试,免 USB 连线\" href=\"/topics/17179\">WiFi 无线拉起执行 APP 稳定性 / 压力测试,免 USB 连线<\/a>\n <i title=\"精华帖\" class=\"fa fa-diamond\" data-toggle=\"tooltip\"><\/i>\n \n <\/div>\n <div class=\"info\">\n <a class=\"user-name\" data-name=\"AppetizerIO\" title=\"AppetizerIO(appetizer.io)\" href=\"/appetizer.io\">AppetizerIO<\/a>\n for <a class=\"team-name\" data-name=\"AppetizerIO\" title=\"AppetizerIO(appetizerio)\" href=\"/appetizerio\">AppetizerIO<\/a>\n <\/div>\n <\/div>\n <div class=\"count media-right\">\n <a class=\"state-false\" href=\"/topics/17179#reply-139462\">19<\/a>\n <\/div>\n<\/div>\n\n <\/div>\n <\/div>\n<\/div>\n\n\n <div class=\"index-locations panel panel-default\">\n <div class=\"panel-heading\">热门节点 <i class=\"fa fa-dot-circle-o\" aria-hidden=\"true\"><\/i><\/div>\n <div class=\"panel-body location-list\" style=\"text-align:center;\">\n <span class=\"name\"><a title=\"Appium\" data-id=\"23\" href=\"/topics/node23\">Appium<\/a><\/span>\n <span class=\"name\"><a title=\"招聘\" data-id=\"19\" href=\"/topics/node19\">招聘<\/a><\/span>\n <span class=\"name\"><a title=\"新手区\" data-id=\"36\" href=\"/topics/node36\">新手区<\/a><\/span>\n <span class=\"name\"><a title=\"接口测试\" data-id=\"62\" href=\"/topics/node62\">接口测试<\/a><\/span>\n <span class=\"name\"><a title=\"Bug 曝光台\" data-id=\"47\" href=\"/topics/node47\">Bug 曝光台<\/a><\/span>\n <span class=\"name\"><a title=\"违规处理区\" data-id=\"55\" href=\"/topics/node55\">违规处理区<\/a><\/span>\n <span class=\"name\"><a title=\"通用技术\" data-id=\"25\" href=\"/topics/node25\">通用技术<\/a><\/span>\n <span class=\"name\"><a title=\"移动测试基础\" data-id=\"33\" href=\"/topics/node33\">移动测试基础<\/a><\/span>\n <span class=\"name\"><a title=\"灌水\" data-id=\"11\" href=\"/topics/node11\">灌水<\/a><\/span>\n <span class=\"name\"><a title=\"自动化工具\" data-id=\"2\" href=\"/topics/node2\">自动化工具<\/a><\/span>\n <span class=\"name\"><a title=\"匿名吐槽\" data-id=\"37\" href=\"/topics/node37\">匿名吐槽<\/a><\/span>\n <span class=\"name\"><a title=\"Macaca\" data-id=\"68\" href=\"/topics/node68\">Macaca<\/a><\/span>\n <span class=\"name\"><a title=\"性能测试工具\" data-id=\"3\" href=\"/topics/node3\">性能测试工具<\/a><\/span>\n <span class=\"name\"><a title=\"Selenium\" data-id=\"73\" href=\"/topics/node73\">Selenium<\/a><\/span>\n <span class=\"name\"><a title=\"其他测试框架\" data-id=\"31\" href=\"/topics/node31\">其他测试框架<\/a><\/span>\n <span class=\"name\"><a title=\"问答\" data-id=\"20\" href=\"/topics/node20\">问答<\/a><\/span>\n <span class=\"name\"><a title=\"职业经验\" data-id=\"12\" href=\"/topics/node12\">职业经验<\/a><\/span>\n <span class=\"name\"><a title=\"活动沙龙\" data-id=\"24\" href=\"/topics/node24\">活动沙龙<\/a><\/span>\n <span class=\"name\"><a title=\"霍格沃兹测试学院\" data-id=\"81\" href=\"/topics/node81\">霍格沃兹测试学院<\/a><\/span>\n <span class=\"name\"><a title=\"移动性能测试\" data-id=\"32\" href=\"/topics/node32\">移动性能测试<\/a><\/span>\n <span class=\"name\"><a title=\"持续集成\" data-id=\"46\" href=\"/topics/node46\">持续集成<\/a><\/span>\n <span class=\"name\"><a title=\"Robotium\" data-id=\"39\" href=\"/topics/node39\">Robotium<\/a><\/span>\n <span class=\"name\"><a title=\"iOS 测试\" data-id=\"51\" href=\"/topics/node51\">iOS 测试<\/a><\/span>\n <span class=\"name\"><a title=\"UiAutomator\" data-id=\"53\" href=\"/topics/node53\">UiAutomator<\/a><\/span>\n <span class=\"name\"><a title=\"AI测试\" data-id=\"134\" href=\"/topics/node134\">AI测试<\/a><\/span>\n <span class=\"name\"><a title=\"测试管理\" data-id=\"44\" href=\"/topics/node44\">测试管理<\/a><\/span>\n <span class=\"name\"><a title=\"STF\" data-id=\"137\" href=\"/topics/node137\">STF<\/a><\/span>\n <span class=\"name\"><a title=\"社区管理\" data-id=\"13\" href=\"/topics/node13\">社区管理<\/a><\/span>\n <span class=\"name\"><a title=\"WeTest腾讯质量开发平台\" data-id=\"80\" href=\"/topics/node80\">WeTest腾讯质量开发平台<\/a><\/span>\n <span class=\"name\"><a title=\"树洞\" data-id=\"132\" href=\"/topics/node132\">树洞<\/a><\/span>\n <span class=\"name\"><a title=\"前端测试\" data-id=\"16\" href=\"/topics/node16\">前端测试<\/a><\/span>\n <span class=\"name\"><a title=\"fir.im\" data-id=\"49\" href=\"/topics/node49\">fir.im<\/a><\/span>\n <span class=\"name\"><a title=\"ATX\" data-id=\"78\" href=\"/topics/node78\">ATX<\/a><\/span>\n <span class=\"name\"><a title=\"Docker\" data-id=\"48\" href=\"/topics/node48\">Docker<\/a><\/span>\n <span class=\"name\"><a title=\"Linux\" data-id=\"65\" href=\"/topics/node65\">Linux<\/a><\/span>\n <\/div>\n <\/div>\n\n\n <div class=\"index-locations panel panel-default\">\n <div class=\"panel-heading\">Popular locations <i class=\"fa fa-map-marker\"><\/i> <\/div>\n <div class=\"panel-body location-list\" style=\"text-align:center;\">\n <span class=\"name\"><a href=\"/users/city/%E5%8C%97%E4%BA%AC\">北京<\/a><\/span>\n <span class=\"name\"><a href=\"/users/city/%E4%B8%8A%E6%B5%B7\">上海<\/a><\/span>\n <span class=\"name\"><a href=\"/users/city/%E6%B7%B1%E5%9C%B3\">深圳<\/a><\/span>\n <span class=\"name\"><a href=\"/users/city/%E6%9D%AD%E5%B7%9E\">杭州<\/a><\/span>\n <span class=\"name\"><a href=\"/users/city/%E5%B9%BF%E5%B7%9E\">广州<\/a><\/span>\n <span class=\"name\"><a href=\"/users/city/%E6%88%90%E9%83%BD\">成都<\/a><\/span>\n <span class=\"name\"><a href=\"/users/city/%E5%8D%97%E4%BA%AC\">南京<\/a><\/span>\n <span class=\"name\"><a href=\"/users/city/%E8%A5%BF%E5%AE%89\">西安<\/a><\/span>\n <span class=\"name\"><a href=\"/users/city/%E6%AD%A6%E6%B1%89\">武汉<\/a><\/span>\n <span class=\"name\"><a href=\"/users/city/%E8%8B%8F%E5%B7%9E\">苏州<\/a><\/span>\n <span class=\"name\"><a href=\"/users/city/%E5%8E%A6%E9%97%A8\">厦门<\/a><\/span>\n <span class=\"name\"><a href=\"/users/city/%E9%87%8D%E5%BA%86\">重庆<\/a><\/span>\n <\/div>\n <\/div>\n\n \r\n\r\n<div class=\"row home-icons hidden-sm hidden-xs\">\r\n <div class=\"col-md-3\">\r\n <div class=\"item item5\">\r\n <div class=\"icon\">\r\n <img src=\"https://testerhome.com/weixin.jpg\" width=\"100%\" height=\"100%\">\r\n <\/div>\r\n <div class=\"text\">\r\n <a href=\"/topics/popular\">公众号<i class=\"pull-right fa fa-arrow-right\"><\/i><\/a>\r\n <\/div>\r\n <\/div>\r\n <\/div>\r\n <div class=\"col-md-3\">\r\n <div class=\"item item6\">\r\n <div class=\"icon\">\r\n <img src=\"/photo/2016/45740b8b1487cc191b585bc2da978f06.jpeg\" width=\"100%\" height=\"100%\">\r\n <\/div>\r\n <div class=\"text\">\r\n <a href=\"/topics/popular\">个人号助手<i class=\"pull-right fa fa-arrow-right\"><\/i><\/a>\r\n <\/div>\r\n <\/div>\r\n <\/div>\r\n\r\n <div class=\"col-md-3\">\r\n <div class=\"item item6\">\r\n <div class=\"icon\">\r\n <img src=\"https://testerhome.com/uploads/photo/2017/94db8410-69e5-4cfa-b4c7-66d8783f2bc5.png!large\" width=\"100%\" height=\"100%\">\r\n <\/div>\r\n <div class=\"text\">\r\n <a href=\"https://fir.im/w2j5\">Android 客户端<i class=\"pull-right fa fa-arrow-right\"><\/i><\/a>\r\n <\/div>\r\n <\/div>\r\n <\/div>\r\n\r\n <div class=\"col-md-3\">\r\n <div class=\"item item6\">\r\n <div class=\"icon\">\r\n <img src=\"https://testerhome.com/uploads/photo/2016/bcff0e5a77254c32d9f35760dbc9b369.png!large\" width=\"100%\" height=\"100%\">\r\n <\/div>\r\n <div class=\"text\">\r\n <a href=\"https://itunes.apple.com/us/app/testerhome-guan-fang-ke-hu/id1182812600?ls=1&mt=8\">iOS 客户端<i class=\"pull-right fa fa-arrow-right\"><\/i><\/a>\r\n <\/div>\r\n <\/div>\r\n <\/div>\r\n\r\n\r\n\r\n<\/div>\n <\/div>\n <div class=\"col-md-3 home-side-bar\">\n \n\n <div class=\"home_suggest_topics panel panel-default\">\n <div class=\"panel-heading\">七日最热 Top10<\/div>\n <div class=\"panel-body\">\n <div class=\"sidebar_topic topic-18785\">\n <div class=\"title\">\n <span class=\"label label-default top3\">1<\/span><\/a>\n\n <a title=\"一道有魔性的面试题 (来自今日头条)\" href=\"/topics/18785\">\n 一道有魔性的面试题 (来自今日头条)\n<\/a> <\/div>\n<\/div>\n<div class=\"sidebar_topic topic-18775\">\n <div class=\"title\">\n <span class=\"label label-default top3\">2<\/span><\/a>\n\n <a title=\"社会萌新 想问一下不能长期做手工测试的原因\" href=\"/topics/18775\">\n 社会萌新 想问一下不能长期做手工测试的原因\n<\/a> <\/div>\n<\/div>\n<div class=\"sidebar_topic topic-18813\">\n <div class=\"title\">\n <span class=\"label label-default top3\">3<\/span><\/a>\n\n <a title=\"请问大家自动化时数据依赖的解决方式?感谢\" href=\"/topics/18813\">\n 请问大家自动化时数据依赖的解决方式?感谢\n<\/a> <\/div>\n<\/div>\n<div class=\"sidebar_topic topic-18798\">\n <div class=\"title\">\n <span class=\"label label-default\">4<\/span><\/a>\n\n <a title=\"乐信集团 (原分期乐) 招人啦,各位大大们快到碗里来\" href=\"/topics/18798\">\n 乐信集团 (原分期乐) 招人啦,各位大大们快到碗里来\n<\/a> <\/div>\n<\/div>\n<div class=\"sidebar_topic topic-18824\">\n <div class=\"title\">\n <span class=\"label label-default\">5<\/span><\/a>\n\n <a title=\"请教一个 Jenkins 发送邮件带附件的问题\" href=\"/topics/18824\">\n 请教一个 Jenkins 发送邮件带附件的问题\n<\/a> <\/div>\n<\/div>\n<div class=\"sidebar_topic topic-18777\">\n <div class=\"title\">\n <span class=\"label label-default\">6<\/span><\/a>\n\n <a title=\"有一个疑惑想问大家\" href=\"/topics/18777\">\n 有一个疑惑想问大家\n<\/a> <\/div>\n<\/div>\n<div class=\"sidebar_topic topic-18750\">\n <div class=\"title\">\n <span class=\"label label-default\">7<\/span><\/a>\n\n <a title=\"修改网页 JS 解除 某资格网 登录 60s 限制问题\" href=\"/topics/18750\">\n 修改网页 JS 解除 某资格网 登录 60s 限制问题\n<\/a> <\/div>\n<\/div>\n<div class=\"sidebar_topic topic-18789\">\n <div class=\"title\">\n <span class=\"label label-default\">8<\/span><\/a>\n\n <a title=\"[求助] Battery Historian 工具 网页加载报错 无法上传日志文件 推测是 historian-optimized.js 缺失\" href=\"/topics/18789\">\n [求助] Battery Historian 工具 网页加载报错 无法上传日志文件 推测是 historian-optimized.js 缺失\n<\/a> <\/div>\n<\/div>\n<div class=\"sidebar_topic topic-18770\">\n <div class=\"title\">\n <span class=\"label label-default\">9<\/span><\/a>\n\n <a title=\"这几天很火的套路招聘图片\u2026\u2026\" href=\"/topics/18770\">\n 这几天很火的套路招聘图片\u2026\u2026\n<\/a> <\/div>\n<\/div>\n<div class=\"sidebar_topic topic-18752\">\n <div class=\"title\">\n <span class=\"label label-default\">10<\/span><\/a>\n\n <a title=\"记一次基于 Robotium 改造的测试实践\" href=\"/topics/18752\">\n 记一次基于 Robotium 改造的测试实践\n<\/a> <\/div>\n<\/div>\n\n <\/div>\n <\/div>\n\n <div class=\"home_suggest_topics panel panel-default\">\n <div class=\"panel-heading\">最新 bug <i class=\"fa fa-bug\" aria-hidden=\"true\"><\/i><\/div>\n <div class=\"panel-body\">\n <div class=\"sidebar_topic topic-18838\">\n <div class=\"title\">\n <span class=\"label label-default top3\">1<\/span><\/a>\n\n <a title=\"使用微信共享 iphone 的语音备忘录,出现 \u201c发生异常,无法分享\u201d 的提示弹框。企业微信可以正常分享。\" href=\"/topics/18838\">\n 使用微信共享 iphone 的语音备忘录,出现 \u201c发生异常,无法分享\u201d 的提示弹框。企业微信可以正常分享。\n<\/a> <\/div>\n<\/div>\n<div class=\"sidebar_topic topic-18827\">\n <div class=\"title\">\n <span class=\"label label-default top3\">2<\/span><\/a>\n\n <a title=\"马蜂窝的一个小 bug\" href=\"/topics/18827\">\n 马蜂窝的一个小 bug\n<\/a> <\/div>\n<\/div>\n<div class=\"sidebar_topic topic-18803\">\n <div class=\"title\">\n <span class=\"label label-default top3\">3<\/span><\/a>\n\n <a title=\"阿里旺旺的一个 bug\" href=\"/topics/18803\">\n 阿里旺旺的一个 bug\n<\/a> <\/div>\n<\/div>\n<div class=\"sidebar_topic topic-18800\">\n <div class=\"title\">\n <span class=\"label label-default\">4<\/span><\/a>\n\n <a title=\"testerhome 手机端 app bug\" href=\"/topics/18800\">\n testerhome 手机端 app bug\n<\/a> <\/div>\n<\/div>\n<div class=\"sidebar_topic topic-18764\">\n <div class=\"title\">\n <span class=\"label label-default\">5<\/span><\/a>\n\n <a title=\"mongodb 数据库官网出现的 bug\" href=\"/topics/18764\">\n mongodb 数据库官网出现的 bug\n<\/a> <\/div>\n<\/div>\n\n <\/div>\n <\/div>\n\n<div class=\"home_suggest_topics panel panel-default\">\n<div class=\"panel-heading\">最新收录开源测试项目<\/div>\n<div class=\"panel-body\">\n <div class=\"sidebar_topic topic-72\">\n <div class=\"title\">\n <img class=\"avatar-small\" src=\"/uploads/opensource_project/2019/2ee30f36-7a18-4799-95f9-284a8a5b448c.png\">\n <a title=\"基于图像识别的UI自动化解决方案-FITCH\" href=\"/opensource_projects/https---github-com-williamfzc-fitch\">\n 基于图像识别的UI自动化解决方案-FITCH\n<\/a> <\/div>\n<\/div>\n<div class=\"sidebar_topic topic-67\">\n <div class=\"title\">\n <img class=\"avatar-small\" src=\"/uploads/opensource_project/2019/47ca5738-f46e-4ed1-9888-b8a3d23d99be.png\">\n <a title=\"《 iOS-checkIPA 》ipa文件信息检查工具\" href=\"/opensource_projects/ios-checkipa\">\n 《 iOS-checkIPA 》ipa文件信息检查工具\n<\/a> <\/div>\n<\/div>\n<div class=\"sidebar_topic topic-73\">\n <div class=\"title\">\n <img class=\"avatar-small\" src=\"/uploads/opensource_project/2019/6e016e67-df4c-497d-9466-e2c2c65fc091.png\">\n <a title=\"Android APP 小工具测试\u201c利器\u201d\" href=\"/opensource_projects/android-app-testtools\">\n Android APP 小工具测试\u201c利器\u201d\n<\/a> <\/div>\n<\/div>\n<div class=\"sidebar_topic topic-71\">\n <div class=\"title\">\n <img class=\"avatar-small\" src=\"/uploads/opensource_project/2019/f329de76-35f3-471c-b598-b386c8ad6109.png\">\n <a title=\"RDebug\" href=\"/opensource_projects/rdebug\">\n RDebug\n<\/a> <\/div>\n<\/div>\n<div class=\"sidebar_topic topic-70\">\n <div class=\"title\">\n <img class=\"avatar-small\" src=\"/uploads/opensource_project/2019/37f745ce-7406-49ce-bb79-30eea20233d4.png\">\n <a title=\"HAF\" href=\"/opensource_projects/haf\">\n HAF\n<\/a> <\/div>\n<\/div>\n\n<\/div>\n<\/div>\n\n\n<div id=\"hall-of-fames\" class=\"panel panel-default\">\n <div class=\"panel-heading\">最新加入 <i class='fa fa-fire'><\/i>(新同学)<\/div>\n <div class=\"panel-body\">\n <div class=\"users-label\">\n <a class=\"users-label-item\" href=\"/hulcwater\" title=\"\">\n <img class=\"avatar-small inline-block\" src=\"/uploads/user/avatar/42774/915ed5.jpg!sm\">\n hulcwater\n <\/a>\n <a class=\"users-label-item\" href=\"/Simonluepang\" title=\"Shenwei Xu\">\n <img class=\"avatar-small inline-block\" src=\"/uploads/user/avatar/42771/634f68.jpg!sm\">\n Simonluepang\n <\/a>\n <a class=\"users-label-item\" href=\"/iamlonghan\" title=\"longhan\">\n <img class=\"avatar-small inline-block\" src=\"/uploads/user/avatar/42762/0f1bcc.jpg!sm\">\n iamlonghan\n <\/a>\n <a class=\"users-label-item\" href=\"/HondaXX\" title=\"\">\n <img class=\"avatar-small inline-block\" src=\"/uploads/user/avatar/42760/58cb18.jpg!sm\">\n HondaXX\n <\/a>\n <a class=\"users-label-item\" href=\"/ttsiii\" title=\"天都\">\n <img class=\"avatar-small inline-block\" src=\"/uploads/user/avatar/42759/bfd30a.jpg!sm\">\n ttsiii\n <\/a>\n <a class=\"users-label-item\" href=\"/DDDDanny\" title=\"TDanny\">\n <img class=\"avatar-small inline-block\" src=\"/uploads/user/avatar/42756/ef97fc.jpg!sm\">\n DDDDanny\n <\/a>\n <a class=\"users-label-item\" href=\"/yhx0326\" title=\"yhx\">\n <img class=\"avatar-small inline-block\" src=\"/uploads/user/avatar/42754/8c6f19.png!sm\">\n yhx0326\n <\/a>\n <a class=\"users-label-item\" href=\"/zhl12345678\" title=\"\">\n <img class=\"avatar-small inline-block\" src=\"/uploads/user/avatar/42750/f8b97a.jpg!sm\">\n zhl12345678\n <\/a>\n <a class=\"users-label-item\" href=\"/shuaimingming\" title=\"帅洺洺\">\n <img class=\"avatar-small inline-block\" src=\"/uploads/user/avatar/42746/53cbf6.jpg!sm\">\n shuaimingming\n <\/a>\n <a class=\"users-label-item\" href=\"/todayno\" title=\"今天不打烊\">\n <img class=\"avatar-small inline-block\" src=\"/uploads/user/avatar/42743/04e73c.jpg!sm\">\n todayno\n <\/a>\n <\/div>\n\n <\/div>\n<\/div>\n\n\n\n <div class=\"panel panel-default\">\n <div class=\"panel-heading\">Statistics<\/div>\n <ul class=\"list-group\">\n <li class=\"list-group-item\">社区会员: 37899 人<\/li>\n <li class=\"list-group-item\">帖子数: 18702 个<\/li>\n <li class=\"list-group-item\">回帖数: 141051 条<\/li>\n <\/ul>\n <\/div>\n\n <\/div>\n<\/div>\n<\/div>\n\n <footer class=\"footer\" id=\"footer\" data-turbolinks-permanent>\n <div class=\"container\">\n \r\n\r\n<div class=\"row\">\r\n <div class=\"col-sm-9\">\r\n <div class=\"media\">\r\n <div class=\"media-left\">\r\n <img src=\"https://testerhome.com/uploads/photo/2016/274e7ebc8ad0db0b7e718fceea3628a9.png!large\" style=\"width:48px;\">\r\n <\/div>\r\n <div class=\"media-body\">\r\n\r\n <div class=\"links\">\r\n <a href=\"https://testerhome.com/wiki/about\">关于<\/a> / \r\n <a href=\"https://testerhome.com/users\">活跃用户<\/a> / \r\n <a href=\"http://test-china.org/\" target=\"_blank\">中国移动互联网测试技术大会<\/a> / \r\n <a href=\"/topics/node13\">反馈<\/a> / \r\n <a href=\"https://github.com/testerhome\">Github<\/a> / \r\n <a href=\"https://testerhome.com//api-doc/\">API<\/a> / \r\n <a href=\"/wiki/spreadtesterhome\">帮助推广<\/a>\r\n\r\n <\/div>\r\n <div class=\"copyright\" style=\"font-size:14px; color:#9CA4A9;margin-top:0px;margin-bottom:5px\">\r\n TesterHome 移动测试社区,由众多移动测试工作者维护,致力于推进国内测试技术。Inspired by RubyChina\r\n <\/div>\r\n<div class=\"links\" data-no-turbolink=\"\">\r\n<span style=\"font-size:14px; color:#666;\">友情链接<\/span><span style=\"margin-left:5px\">\r\n <a style=\"color:#317DDA;\" href=\"http://wetest.qq.com/?from=links_testerhome\" target=\"_blank\">WeTest腾讯质量开放平台<\/a> / \r\n <a style=\"color:#317DDA;\" href=\"http://www.infoq.com/cn\" target=\"_blank\">InfoQ<\/a> / \r\n <a style=\"color:#317DDA;\" href=\"http://www.testtao.com/portal.php\" target=\"_blank\">测试之道<\/a> / \r\n <a style=\"color:#317DDA;\" href=\"https://www.testwo.com/\" target=\"_blank\">测试窝<\/a> / \r\n <a style=\"color:#317DDA;\" href=\"http://tieba.baidu.com/f?ie=utf-8&kw=%E8%BD%AF%E4%BB%B6%E6%B5%8B%E8%AF%95&fr=search\" target=\"_blank\">百度测试吧<\/a> /\r\n <a style=\"color:#317DDA;\" href=\"http://www.itdks.com/\" target=\"_blank\">IT大咖说<\/a>\r\n<\/span>\r\n <\/div>\r\n <div class=\"links\" style=\"margin-top:0px\" data-no-turbolink=\"\">\r\n <a href=\"?locale=zh-CN\" rel=\"nofollow\">简体中文<\/a> / <a href=\"?locale=zh-TW\" rel=\"nofollow\">正體中文<\/a> / <a href=\"?locale=en\"\r\n rel=\"nofollow\">English<\/a>\r\n <\/div>\r\n <\/div>\r\n <\/div>\r\n <\/div>\r\n <div class=\"col-sm-3 friends\">\r\n <a href=\"http://www.ucloud.cn/?utm_source=zanzhu&utm_campaign=testerhome&utm_medium=display&utm_content=yejiao&ytag=testerhome_logo\"\r\n target=\"_blank\" rel=\"twipsy\" style=\"display:inline-block;margin-right:5px;\" data-original-title=\"本站服务器由 Ucloud 赞助\"><img src=\"https://testerhome.com/photo/2016/4ce97c93f9433f654884c4839408327a.png\" style=\"height:28px\"><\/a>\r\n\r\n <a href=\"http://www.sendcloud.net/\"\r\n target=\"_blank\" rel=\"twipsy\" style=\"display:inline-block;margin-right:5px;\" data-original-title=\"邮件服务由 SendCloud 赞助\"><img src=\"https://testerhome.com/uploads/photo/2017/0f9fb5db-2472-4430-9f7e-6c051f28c3c9.png\" style=\"height:28px\"><\/a>\r\n\r\n <\/div>\r\n<\/div>\r\n\r\n\r\n\n <\/div>\n <\/footer>\n\n<script type=\"text/javascript\" data-turbolinks-eval=\"false\">\n App.root_url = \"https://testerhome.com/\";\n App.asset_url = \"\";\n App.twemoji_url = \"https://twemoji.b0.upaiyun.com/2\";\n App.locale = \"en\";\n App.current_user_id = 6109;\n<\/script>\n\n<script>\n ga('create', 'UA-45014075-1', 'auto');\n ga('require', 'displayfeatures');\n ga('send', 'pageview');\n<\/script>\n<div class=\"zoom-overlay\"><\/div>\n<\/div>\n<\/body>\n<\/html>\n"},"redirectURL":null,"headersSize":0,"bodySize":11373},"serverIPAddress":"106.75.214.88","cache":{},"timings":{"dns":-1,"connect":-1,"ssl":-1,"send":1,"wait":152,"receive":5}}]}} \ No newline at end of file diff --git a/docs/data/testerhome-login.yml b/docs/data/testerhome-login.yml deleted file mode 100644 index e24dccf8..00000000 --- a/docs/data/testerhome-login.yml +++ /dev/null @@ -1,66 +0,0 @@ -- config: - name: testcase description - variables: {} - verify: False - -- test: - name: /account/sign_in - request: - headers: - If-None-Match: W/"bc9ae267fdcbd89bf1dfaea10dea2b0e" - User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_3) AppleWebKit/537.36 - (KHTML, like Gecko) Chrome/73.0.3683.103 Safari/537.36 - method: GET - url: https://testerhome.com/account/sign_in - extract: - X_CSRF_Token: <meta name="csrf-token" content="(.*)" /> - validate: - - eq: [status_code, 200] - - eq: [headers.Content-Type, text/html; charset=utf-8] - -- test: - name: /assets/big_logo-cd32144f74c18746f3dce33e1040e7dfe4c07c8e611e37f3868b1c16b5095da3.png - request: - headers: - User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_3) AppleWebKit/537.36 - (KHTML, like Gecko) Chrome/73.0.3683.103 Safari/537.36 - method: GET - url: https://testerhome.com/assets/big_logo-cd32144f74c18746f3dce33e1040e7dfe4c07c8e611e37f3868b1c16b5095da3.png - validate: - - eq: [status_code, 200] - - eq: [headers.Content-Type, image/png] - -- test: - name: /account/sign_in - request: - data: - commit: Sign In - user[login]: debugtalk - user[password]: XXXXXXXX - user[remember_me]: '1' - utf8: ✓ - headers: - Content-Type: application/x-www-form-urlencoded; charset=UTF-8 - User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_3) AppleWebKit/537.36 - (KHTML, like Gecko) Chrome/73.0.3683.103 Safari/537.36 - X-CSRF-Token: $X_CSRF_Token - X-Requested-With: XMLHttpRequest - method: POST - url: https://testerhome.com/account/sign_in - validate: - - eq: [status_code, 200] - - eq: [headers.Content-Type, text/javascript; charset=utf-8] - -- test: - name: / - request: - headers: - If-None-Match: W/"bad62c68dac27b01151516aad5c7f0be" - Turbolinks-Referrer: https://testerhome.com/account/sign_in - User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_3) AppleWebKit/537.36 - (KHTML, like Gecko) Chrome/73.0.3683.103 Safari/537.36 - method: GET - url: https://testerhome.com/ - validate: - - eq: [status_code, 200] - - eq: [headers.Content-Type, text/html; charset=utf-8] diff --git a/docs/data/user_id.csv b/docs/data/user_id.csv deleted file mode 100644 index ae3d9c5f..00000000 --- a/docs/data/user_id.csv +++ /dev/null @@ -1,5 +0,0 @@ -user_id -1001 -1002 -1003 -1004 diff --git a/docs/development/architecture.md b/docs/development/architecture.md deleted file mode 100644 index f126bc21..00000000 --- a/docs/development/architecture.md +++ /dev/null @@ -1,2 +0,0 @@ - -![](../images/HttpRunner-architecture-diagram.svg) diff --git a/docs/development/dev-api.md b/docs/development/dev-api.md deleted file mode 100644 index 9bd0d36d..00000000 --- a/docs/development/dev-api.md +++ /dev/null @@ -1,339 +0,0 @@ -# 开发扩展 - -HttpRunner 除了作为命令行工具使用外,还可以作为软件包集成到你自己的项目中。 - -简单来说,HttpRunner 提供了运行 YAML/JSON 格式测试用例的能力,并能返回详细的测试结果信息。 - -## HttpRunner class - -### TL;DR - -HttpRunner 以 `类(class)` 的形式对外提供调用支持,类名为 `HttpRunner`。使用方式如下: - -```python -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) -``` - -### 初始化参数说明 - -通常情况下,初始化 `HttpRunner` 时的参数有如下几个: - -- `failfast`(可选): 设置为 True 时,测试在首次遇到错误或失败时会停止运行;默认值为 False -- `save_tests`(可选): 设置为 True 时,会将运行过程中的状态(loaded/parsed/summary)保存为 JSON 文件,存储于 logs 目录下;默认为 False -- `log_level`(可选): 设置日志级别,默认为 "INFO" -- `log_file`(可选): 设置日志文件路径,指定后将同时输出日志文件;默认不输出日志文件 - -### 调用方法说明 - -在 `HttpRunner` 中,对外提供了一个 `run` 方法,用于运行测试用例。 - -run 方法有三个参数: - -- `path_or_tests`(必传): 指定要运行的测试用例;支持传入两类参数 - - str: YAML/JSON 格式测试用例文件路径 - - dict: 标准的测试用例结构体 -- `dot_env_path`(可选): 指定加载环境变量文件(.env)的路径,默认值为当前工作目录(PWD)中的 `.env` 文件 -- `mapping`(可选): 变量映射,可用于对传入测试用例中的变量进行覆盖替换。 - -#### 传入测试用例文件路径 - -指定测试用例文件路径支持三种形式: - -- YAML/JSON 文件路径,支持绝对路径和相对路径 -- 包含 YAML/JSON 文件的文件夹,支持绝对路径和相对路径 -- 文件路径和文件夹路径的混合情况(list/set) - -```python -# 文件路径 -runner.run("docs/data/demo-quickstart-2.yml") - -# 文件夹路径 -runner.run("docs/data/") - -# 混合情况 -runner.run(["docs/data/", "files/demo-quickstart-2.yml"]) -``` - -如需指定加载环境变量文件(.env)的路径,或者需要对测试用例中的变量进行覆盖替换,则可使用 `dot_env_path` 和 `mapping` 参数。 - -```python -# dot_env_path -runner.run("docs/data/demo-quickstart-2.yml", dot_env_path="/path/to/.env") - -# mapping -override_mapping = { - "device_sn": "XXX" -} -runner.run("docs/data/demo-quickstart-2.yml", mapping=override_mapping) -``` - -#### 传入标准的测试用例结构体 - -除了传入测试用例文件路径,还可以直接传入标准的测试用例结构体。 - -以 [demo-quickstart-2.yml](/data/demo-quickstart-2.yml) 为例,对应的数据结构体如下所示: - -```json -[ - { - "config": { - "name": "testcase description", - "request": { - "base_url": "", - "headers": { - "User-Agent": "python-requests/2.18.4" - } - }, - "variables": [], - "output": ["token"], - "path": "/abs-path/to/demo-quickstart-2.yml", - "refs": { - "env": {}, - "debugtalk": { - "variables": { - "SECRET_KEY": "DebugTalk" - }, - "functions": { - "gen_random_string": <function gen_random_string at 0x108596268>, - "get_sign": <function get_sign at 0x1085962f0>, - "get_user_id": <function get_user_id at 0x108596378>, - "get_account": <function get_account at 0x108596400>, - "get_os_platform": <function get_os_platform at 0x108596488> - } - }, - "def-api": {}, - "def-testcase": {} - } - }, - "teststeps": [ - { - "name": "/api/get-token", - "request": { - "url": "http://127.0.0.1:5000/api/get-token", - "method": "POST", - "headers": {"Content-Type": "application/json", "app_version": "2.8.6", "device_sn": "FwgRiO7CNA50DSU", "os_platform": "ios", "user_agent": "iOS/10.3"}, - "json": {"sign": "9c0c7e51c91ae963c833a4ccbab8d683c4a90c98"} - }, - "extract": [ - {"token": "content.token"} - ], - "validate": [ - {"eq": ["status_code", 200]}, - {"eq": ["headers.Content-Type", "application/json"]}, - {"eq": ["content.success", true]} - ] - }, - { - "name": "/api/users/1000", - "request": {"url": "http://127.0.0.1:5000/api/users/1000", "method": "POST", "headers": {"Content-Type": "application/json", "device_sn": "FwgRiO7CNA50DSU", "token": "$token"}, - "json": {"name": "user1", "password": "123456"}}, - "validate": [ - {"eq": ["status_code", 201]}, - {"eq": ["headers.Content-Type", "application/json"]}, - {"eq": ["content.success", true]}, - {"eq": ["content.msg", "user created successfully."]} - ] - } - ] - }, - {...} # another testcase -] -``` - -传入测试用例结构体时,支持传入单个结构体(dict),或者多个结构体(list of dict)。 - -```python -# 运行单个结构体 -runner.run(testcase) - -# 运行多个结构体 -runner.run([testcase1, testcase2]) -``` - -### 加载 `debugtalk.py` && `.env` - -通过传入测试用例文件路径运行测试用例时,HttpRunner 会自动以指定测试用例文件路径为起点,向上搜索 `debugtalk.py` 文件,并将 `debugtalk.py` 文件所在的文件目录作为当前工作目录(PWD)。 - -同时,HttpRunner 会在当前工作目录(PWD)下搜索 `.env` 文件,以及 `api` 和 `testcases` 文件夹,并自动进行加载。 - -最终加载得到的存储结构如下所示: - -```json -{ - "env": {}, - "debugtalk": { - "variables": { - "SECRET_KEY": "DebugTalk" - }, - "functions": { - "gen_random_string": <function gen_random_string at 0x108596268>, - "get_sign": <function get_sign at 0x1085962f0>, - "get_user_id": <function get_user_id at 0x108596378>, - "get_account": <function get_account at 0x108596400>, - "get_os_platform": <function get_os_platform at 0x108596488> - } - }, - "def-api": {}, - "def-testcase": {} -} -``` - -其中,`env` 对应的是 `.env` 文件中的环境变量,`debugtalk` 对应的是 `debugtalk.py` 文件中定义的变量和函数,`def-api` 对应的是 `api` 文件夹下定义的接口描述,`def-testcase` 对应的是 `testcases` 文件夹下定义的测试用例。 - -通过传入标准的测试用例结构体执行测试时,传入的数据应包含所有信息,包括 `debugtalk.py`、`.env`、依赖的 api 和 测试用例等;因此也无需再使用 `dot_env_path` 和 `mapping` 参数,所有信息都要通过 `refs` 传入。 - -## 返回详细测试结果数据 - -运行完成后,通过 `run()` 方法的返回结果可获取详尽的运行结果数据。 - -```python -summary = runner.run(path_or_tests) -``` - -其数据结构为: - -```json -{ - "success": true, - "stat": { - "testsRun": 2, - "failures": 0, - "errors": 0, - "skipped": 0, - "expectedFailures": 0, - "unexpectedSuccesses": 0, - "successes": 2 - }, - "time": { - "start_at": 1538449655.944404, - "duration": 0.03181314468383789 - }, - "platform": { - "httprunner_version": "1.5.14", - "python_version": "CPython 3.6.5+", - "platform": "Darwin-17.6.0-x86_64-i386-64bit" - }, - "details": [ - { - "success": true, - "name": "testcase description", - "base_url": "", - "stat": {"testsRun": 2, "failures": 0, "errors": 0, "skipped": 0, "expectedFailures": 0, "unexpectedSuccesses": 0, "successes": 2}, - "time": {"start_at": 1538449655.944404, "duration": 0.03181314468383789}, - "records": [ - { - "name": "/api/get-token", - "status": "success", - "attachment": "", - "meta_data": { - "request": { - "url": "http://127.0.0.1:5000/api/get-token", - "method": "POST", - "headers": {"User-Agent": "python-requests/2.18.4", "Accept-Encoding": "gzip, deflate", "Accept": "*/*", "Connection": "keep-alive", "Content-Type": "application/json", "app_version": "2.8.6", "device_sn": "FwgRiO7CNA50DSU", "os_platform": "ios", "user_agent": "iOS/10.3", "Content-Length": "52"}, - "start_timestamp": 1538449655.944801, - "json": {"sign": "9c0c7e51c91ae963c833a4ccbab8d683c4a90c98"}, - "body": b'{"sign": "9c0c7e51c91ae963c833a4ccbab8d683c4a90c98"}' - }, - "response": { - "status_code": 200, - "headers": {"Content-Type": "application/json", "Content-Length": "46", "Server": "Werkzeug/0.14.1 Python/3.6.5+", "Date": "Tue, 02 Oct 2018 03:07:35 GMT"}, - "content_size": 46, - "response_time_ms": 12.87, - "elapsed_ms": 6.955, - "encoding": null, - "content": b'{"success": true, "token": "CcQ7dBjZZbjIXRkG"}', - "content_type": "application/json", - "ok": true, - "url": "http://127.0.0.1:5000/api/get-token", - "reason": "OK", - "cookies": {}, - "text": '{"success": true, "token": "CcQ7dBjZZbjIXRkG"}', - "json": {"success": true, "token": "CcQ7dBjZZbjIXRkG"} - }, - "validators": [ - {"check": "status_code", "expect": 200, "comparator": "eq", "check_value": 200, "check_result": "pass"}, - {"check": "headers.Content-Type", "expect": "application/json", "comparator": "eq", "check_value": "application/json", "check_result": "pass"}, - {"check": "content.success", "expect": true, "comparator": "eq", "check_value": true, "check_result": "pass"} - ] - } - }, - { - "name": "/api/users/1000", - "status": "success", - "attachment": "", - "meta_data": { - "request": { - "url": "http://127.0.0.1:5000/api/users/1000", - "method": "POST", - "headers": {"User-Agent": "python-requests/2.18.4", "Accept-Encoding": "gzip, deflate", "Accept": "*/*", "Connection": "keep-alive", "Content-Type": "application/json", "device_sn": "FwgRiO7CNA50DSU", "token": "CcQ7dBjZZbjIXRkG", "Content-Length": "39"}, - "start_timestamp": 1538449655.958944, - "json": {"name": "user1", "password": "123456"}, - "body": b'{"name": "user1", "password": "123456"}' - }, - "response": { - "status_code": 201, - "headers": {"Content-Type": "application/json", "Content-Length": "54", "Server": "Werkzeug/0.14.1 Python/3.6.5+", "Date": "Tue, 02 Oct 2018 03:07:35 GMT"}, - "content_size": 54, - "response_time_ms": 3.34, - "elapsed_ms": 2.16, - "encoding": null, - "content": b'{"success": true, "msg": "user created successfully."}', - "content_type": "application/json", - "ok": true, - "url": "http://127.0.0.1:5000/api/users/1000", - "reason": "CREATED", - "cookies": {}, - "text": '{"success": true, "msg": "user created successfully."}', - "json": {"success": true, "msg": "user created successfully."} - }, - "validators": [ - {"check": "status_code", "expect": 201, "comparator": "eq", "check_value": 201, "check_result": "pass"}, - {"check": "headers.Content-Type", "expect": "application/json", "comparator": "eq", "check_value": "application/json", "check_result": "pass"}, - {"check": "content.success", "expect": true, "comparator": "eq", "check_value": true, "check_result": "pass"}, - {"check": "content.msg", "expect": "user created successfully.", "comparator": "eq", "check_value": "user created successfully.", "check_result": "pass"} - ] - } - } - ], - "in_out": { - "in": {"SECRET_KEY": "DebugTalk"}, - "out": {"token": "CcQ7dBjZZbjIXRkG"} - } - } - ] -} -``` - -## 生成 HTML 测试报告 - -如需生成 HTML 测试报告,可调用 `report.gen_html_report` 方法。 - -```python -from httprunner import report - -report_path = report.gen_html_report( - summary, - report_template="/path/to/custom_report_template", - report_dir="/path/to/reports_dir", - report_file="/path/to/report_file_path" -) -``` - -`gen_html_report()` 的参数有四个: - -- summary(必传): 测试运行结果汇总数据 -- report_template(可选): 指定自定义的 HTML 报告模板,模板必须采用 Jinja2 的格式 -- report_dir(可选): 指定生成报告的文件夹路径 -- report_file(可选): 指定生成报告的文件路径,该参数的优先级高于 report_dir - -关于测试报告的详细内容,请查看[测试报告](/run-tests/report/)部分。 - -[TextTestRunner]: https://docs.python.org/3.6/library/unittest.html#unittest.TextTestRunner \ No newline at end of file diff --git a/docs/examples/demo-klook/README.md b/docs/examples/demo-klook/README.md deleted file mode 100644 index b178defd..00000000 --- a/docs/examples/demo-klook/README.md +++ /dev/null @@ -1,30 +0,0 @@ -## 案例介绍 - -- 被测案例:[klook](https://www.klook.com/) -- 案例作者:[readyou](https://github.com/readyou) - -我们团队选择了 HttpRunner 作为接口测试框架,并整理了一份案例,供大家参考。 - -## 注意事项 - -1. 本例子中有些地方用到了`localhost:8085`作为base_url,这些接口是不能访问的,仅作为示例学习怎样组织测试用例。 -2. `https://maps.googleapis.com`是可以用的,自己申请一个key,替换掉文件中的`your_google_map_key`即可。 - -## 相关文件说明 - -模块 | 文件 | 用途 | 备注 ----|----|------|------ -google map 接口测试 | api/find_place_api.yml | google map根据名称搜索地址的api | 比较全面地使用了api可以使用的关键字:name, base_url, request, variables, validate, extract -google map 接口测试 | testcases/find_place_testcase.yml | google map根据名称搜索地址的testcase | 使用了testcase标准的写法:testcase由teststep组成,teststep中引用api(just_request_testcase.yml中演示了直接使用request而不是引用api的方式)。teststep中还使用了variables。 -google map 接口测试 | testcases/place_detail_testcase.yml | google map获取地址详情的testcase | config中使用variables -google map 接口测试 | testsuites/place_detail_testsuite.yml | google map接口测试的testsuite包含上面两个testcase | 使用了多种方式来做数据驱动测试 - | | -klook地理位置搜索接口测试 | api/search_area_by_name_api.yml | 根据名字查询区域(支持多语言)——api | -klook地理位置搜索接口测试 | api/search_area_by_name_testcase.yml | 根据名字查询区域(支持多语言)——testcase | -klook地理位置搜索接口测试 | api/get_area_groups_api.yml | 查询地理位置下面的组——api | -klook地理位置搜索接口测试 | api/get_area_groups_testcase.yml | 查询地理位置下面的组——testcase | -klook地理位置搜索接口测试 | api/area_manage_testsuite.yml | 区域管理——testsuite | - | | -baidu首页demo | testcases/just_request_testcase.yml | 提取百度首页title的demo | 演示了直接使用request而不是引用api的方式,使用了teardown_hooks的使用 - -完整的案例访问[地址](https://github.com/httprunner/httprunner/tree/master/docs/examples/demo-klook)。 diff --git a/docs/examples/demo-klook/api/find_place_api.yml b/docs/examples/demo-klook/api/find_place_api.yml deleted file mode 100644 index 394def72..00000000 --- a/docs/examples/demo-klook/api/find_place_api.yml +++ /dev/null @@ -1,19 +0,0 @@ -name: find place from text -base_url: https://maps.googleapis.com -request: - method: GET - url: /maps/api/place/findplacefromtext/json - params: - key: $key - inputtype: textquery - input: $input - fields: 'formatted_address,geometry,name,permanently_closed,place_id,plus_code,types' - language: zh_CN -variables: - input: 宝安 - key: your_google_map_key -validate: - - eq: [status_code, 200] - - eq: [content.status, OK] -extract: - - place_id: content.candidates.0.place_id diff --git a/docs/examples/demo-klook/api/get_area_groups_api.yml b/docs/examples/demo-klook/api/get_area_groups_api.yml deleted file mode 100644 index 44b66ef5..00000000 --- a/docs/examples/demo-klook/api/get_area_groups_api.yml +++ /dev/null @@ -1,11 +0,0 @@ -name: get_groups -base_url: http://localhost:8085 -request: - url: /v1/transferairportadminsrv/area/getGroups - method: POST - headers: - Content-Type: application/x-www-form-urlencoded - Accept-language: zh_CN - cookie: $admin_cookie - data: - parentAreaId: 7037 # 广东省 diff --git a/docs/examples/demo-klook/api/place_detail_api.yml b/docs/examples/demo-klook/api/place_detail_api.yml deleted file mode 100644 index 158a867b..00000000 --- a/docs/examples/demo-klook/api/place_detail_api.yml +++ /dev/null @@ -1,17 +0,0 @@ -name: get place detail -request: - method: GET - url: https://maps.googleapis.com/maps/api/place/details/json - params: - key: $key - placeid: $place_id - fields: 'address_component,formatted_address,geometry' - language: zh-CN -variables: - key: your_google_map_key - place_id: ChIJzyoujG6SAzQRRD3Jr26PFfM -validate: - - eq: [status_code, 200] - - eq: [content.status, OK] -extract: - - place_name: content.result.formatted_address diff --git a/docs/examples/demo-klook/api/search_area_by_name_api.yml b/docs/examples/demo-klook/api/search_area_by_name_api.yml deleted file mode 100644 index fe04bb06..00000000 --- a/docs/examples/demo-klook/api/search_area_by_name_api.yml +++ /dev/null @@ -1,11 +0,0 @@ -name: search_area_by_name -base_url: http://localhost:8085 -request: - url: /v1/transferairportadminsrv/area/search_area_by_name - method: GET - headers: - Content-Type: application/x-www-form-urlencoded - Accept-language: zh_CN - cookie: $admin_cookie - params: - areaName: $in diff --git a/docs/examples/demo-klook/data/place_detail.csv b/docs/examples/demo-klook/data/place_detail.csv deleted file mode 100644 index 863c196c..00000000 --- a/docs/examples/demo-klook/data/place_detail.csv +++ /dev/null @@ -1,3 +0,0 @@ -input,formatted_address,address_components_len -娄底,中国湖南省娄底市,3 -新化,中国湖南省娄底市新化县,4 \ No newline at end of file diff --git a/docs/examples/demo-klook/debugtalk.py b/docs/examples/demo-klook/debugtalk.py deleted file mode 100644 index 7c2e552a..00000000 --- a/docs/examples/demo-klook/debugtalk.py +++ /dev/null @@ -1,11 +0,0 @@ -from utils.validators.validators_of_area import * -from utils.validators.validators_of_common import * -from utils.setup_hooks import * -from utils.teardown_hooks import * - -__all__ = [ - klook_len_eq, - check_search_area_result, - exists_default_group, - teardown_hook_set_encoding -] diff --git a/docs/examples/demo-klook/testcases/find_place_testcase.yml b/docs/examples/demo-klook/testcases/find_place_testcase.yml deleted file mode 100644 index 339cc90b..00000000 --- a/docs/examples/demo-klook/testcases/find_place_testcase.yml +++ /dev/null @@ -1,19 +0,0 @@ -name: find place from text testcase -config: - base_url: https://maps.googleapis.com - # config 这里的variables优先级更高 - # 如果本testcase被其他testcase引用,variables无法覆盖这里配置的值 - # variables: - # input: 福田 - # formatted_address: 中国广东省深圳市福田区 -teststeps: - - name: find place - api: api/find_place_api.yml - # 如果config上面配置了,这里的同名变量会被覆盖 - variables: - input: 深圳 - formatted_address: 中国广东省深圳市 - validate: - - eq: [content.candidates.0.formatted_address, $formatted_address] - extract: - - formatted_address: formatted_address"\s?:\s?"(.*)" \ No newline at end of file diff --git a/docs/examples/demo-klook/testcases/get_area_groups_testcase.yml b/docs/examples/demo-klook/testcases/get_area_groups_testcase.yml deleted file mode 100644 index 43e414ca..00000000 --- a/docs/examples/demo-klook/testcases/get_area_groups_testcase.yml +++ /dev/null @@ -1,13 +0,0 @@ -config: - base_url: http://127.0.0.1:8085 - variables: - admin_cookie: 'cookies' - -teststeps: - - name: get group by parent area id - api: api/get_area_groups_api.yml - validate: - - eq: [status_code, 200] - - eq: [headers.Content-Type, application/json] - - eq: [content.success, true] - - exists_default_group: [content.result, ''] diff --git a/docs/examples/demo-klook/testcases/just_request_testcase.yml b/docs/examples/demo-klook/testcases/just_request_testcase.yml deleted file mode 100644 index 98a5ec85..00000000 --- a/docs/examples/demo-klook/testcases/just_request_testcase.yml +++ /dev/null @@ -1,9 +0,0 @@ -teststeps: - - name: extract title - request: - method: GET - url: http://www.baidu.com - extract: - - title: <title>(.*) - teardown_hooks: - - ${teardown_hook_set_encoding($response, utf-8)} \ No newline at end of file diff --git a/docs/examples/demo-klook/testcases/place_detail_testcase.yml b/docs/examples/demo-klook/testcases/place_detail_testcase.yml deleted file mode 100644 index 01eb1e27..00000000 --- a/docs/examples/demo-klook/testcases/place_detail_testcase.yml +++ /dev/null @@ -1,19 +0,0 @@ -name: get place detail -config: - base_url: https://maps.googleapis.com - variables: - input: 娄底 - formatted_address: 中国湖南省娄底市 - address_components_len: 3 -teststeps: - # 文档里面说这里可以使用testcase,测试了一下,这种方式还有问题,使用testcase时,api里面的变量无法正常覆盖 - # 所以:teststep中最好不要使用testcase引用 - - name: find place - api: api/find_place_api.yml - validate: - - eq: [content.candidates.0.formatted_address, $formatted_address] - - name: get place detail - api: api/place_detail_api.yml - validate: - - eq: [content.result.formatted_address, $formatted_address] - - klook_len_eq: [content.result.address_components, $address_components_len] diff --git a/docs/examples/demo-klook/testcases/search_area_by_name_api_testcase.yml b/docs/examples/demo-klook/testcases/search_area_by_name_api_testcase.yml deleted file mode 100644 index f16554d6..00000000 --- a/docs/examples/demo-klook/testcases/search_area_by_name_api_testcase.yml +++ /dev/null @@ -1,14 +0,0 @@ -config: - base_url: http://localhost:8085 - variables: - admin_cookie: 'cookies' - -teststeps: - - name: search area by name - api: api/search_area_by_name_api.yml - variables: - in: 北京 - out: 中国北京市 - validate: - - eq: [content.success, true] - - check_search_area_result: [content.result, $out] diff --git a/docs/examples/demo-klook/testsuites/area_manage_testsuite.yml b/docs/examples/demo-klook/testsuites/area_manage_testsuite.yml deleted file mode 100644 index 10fc016c..00000000 --- a/docs/examples/demo-klook/testsuites/area_manage_testsuite.yml +++ /dev/null @@ -1,16 +0,0 @@ -config: - name: area manage testsuite - variables: - admin_cookie: 'cookies' - base_url: http://127.0.0.1:8085 - verify: False - -testcases: - - name: search area by name - testcase: testcases/search_area_by_name_api_testcase.yml - parameters: - in-out: - - ['北京', '中国, 北京市'] - - ['广东', '中国, 广东省'] - - ['shenzhen', '中国, 广东省, 深圳市'] - - ['서울', '韩国, 首尔'] diff --git a/docs/examples/demo-klook/testsuites/place_detail_testsuite.yml b/docs/examples/demo-klook/testsuites/place_detail_testsuite.yml deleted file mode 100644 index e7f0afe3..00000000 --- a/docs/examples/demo-klook/testsuites/place_detail_testsuite.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: get place detail testsuites -config: - base_url: https://maps.googleapis.com -testcases: - - name: find place from text - testcase: testcases/find_place_testcase.yml - parameters: - input-formatted_address: - - [娄底, 中国湖南省娄底市] - - [新化, 中国湖南省娄底市新化县] - - ['北京', '中国北京市'] - - ['广东', '中国广东省'] - - ['shenzhen', '中国广东省深圳市'] - - ['서울', '韩国汉城'] - - - name: get place detail - testcase: testcases/place_detail_testcase.yml - parameters: - input-formatted_address-address_components_len: ${P(data/place_detail.csv)} - # input-formatted_address-address_components_len: - # - [娄底, 中国湖南省娄底市, 3] - # - [新化, 中国湖南省娄底市新化县, 4] \ No newline at end of file diff --git a/docs/examples/demo-klook/utils/teardown_hooks.py b/docs/examples/demo-klook/utils/teardown_hooks.py deleted file mode 100644 index 83443829..00000000 --- a/docs/examples/demo-klook/utils/teardown_hooks.py +++ /dev/null @@ -1,6 +0,0 @@ -def teardown_hook_set_encoding(response, encoding): - """ - Set encoding of response. - """ - response.resp_obj.encoding = encoding - return response diff --git a/docs/examples/demo-klook/utils/validators/validators_of_area.py b/docs/examples/demo-klook/utils/validators/validators_of_area.py deleted file mode 100644 index d91d75b7..00000000 --- a/docs/examples/demo-klook/utils/validators/validators_of_area.py +++ /dev/null @@ -1,18 +0,0 @@ -def check_search_area_result(content, expect_name): - print(content, expect_name) - found = False - for item in content: - if item['fullName'] == expect_name: - found = True - break - assert found - - -def exists_default_group(content, expect): - found = False - for item in content: - if item['defaultGroup']: - print('defaultGroup found, id={}, parentAreaId={}'.format(item['id'], item['parentAreaId'])) - found = True - break - assert found diff --git a/docs/examples/demo-klook/utils/validators/validators_of_common.py b/docs/examples/demo-klook/utils/validators/validators_of_common.py deleted file mode 100644 index 86c88da3..00000000 --- a/docs/examples/demo-klook/utils/validators/validators_of_common.py +++ /dev/null @@ -1,3 +0,0 @@ -def klook_len_eq(check_value, expect_value): - # 校验列表、字典、字符串等长度是否相等 - assert len(check_value) == int(expect_value) diff --git a/docs/examples/testerhome-login.md b/docs/examples/testerhome-login.md deleted file mode 100644 index 8aec167f..00000000 --- a/docs/examples/testerhome-login.md +++ /dev/null @@ -1,246 +0,0 @@ - -## 案例介绍 - -通过接口自动化实现 TesterHome 的登录退出功能。 - -![](../images/testerhome-login.png) - -功能描述: - -- 进入[登录页面](https://testerhome.com/account/sign_in) -- 输入账号和密码 -- 点击【Sign In】进行登录 - -## 准备工作 - -### 抓包生成 HAR 文件 - -在浏览器中人工进行登录操作,同时使用抓包工具进行抓包。抓包时建议使用过滤器(Filter),常用的做法是采用被测系统的 host,将无关请求过滤掉。 - -![](../images/testerhome-login-charles.png) - -选择需要生成测试用例的请求,导出为 HTTP Archive (.har) 格式的文件。 - -![](../images/testerhome-charles-export.png) - -![](../images/charles-export-har.png) - -### 转换生成测试用例 - -成功安装 HttpRunner 后,系统中会新增 `har2case` 命令,使用该命令可将 HAR 数据包转换为 HttpRunner 支持的 `YAML/JSON` 测试用例文件。 - -```bash -$ har2case docs/data/testerhome-login.har -2y -INFO:root:Start to generate testcase. -INFO:root:dump testcase to YAML format. -INFO:root:Generate YAML testcase successfully: docs/data/testerhome-login.yml -``` - -生成的测试用例内容如下: - -
-点击查看 - -```yaml -- config: - name: testcase description - variables: {} - -- test: - name: /account/sign_in - request: - headers: - If-None-Match: W/"bc9ae267fdcbd89bf1dfaea10dea2b0e" - User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_3) AppleWebKit/537.36 - (KHTML, like Gecko) Chrome/73.0.3683.103 Safari/537.36 - method: GET - url: https://testerhome.com/account/sign_in - validate: - - eq: [status_code, 200] - - eq: [headers.Content-Type, text/html; charset=utf-8] - -- test: - name: /assets/big_logo-cd32144f74c18746f3dce33e1040e7dfe4c07c8e611e37f3868b1c16b5095da3.png - request: - headers: - User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_3) AppleWebKit/537.36 - (KHTML, like Gecko) Chrome/73.0.3683.103 Safari/537.36 - method: GET - url: https://testerhome.com/assets/big_logo-cd32144f74c18746f3dce33e1040e7dfe4c07c8e611e37f3868b1c16b5095da3.png - validate: - - eq: [status_code, 200] - - eq: [headers.Content-Type, image/png] - -- test: - name: /account/sign_in - request: - data: - commit: Sign In - user[login]: debugtalk - user[password]: XXXXXXXX - user[remember_me]: '1' - utf8: ✓ - headers: - Content-Type: application/x-www-form-urlencoded; charset=UTF-8 - User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_3) AppleWebKit/537.36 - (KHTML, like Gecko) Chrome/73.0.3683.103 Safari/537.36 - X-CSRF-Token: 0zAKFDDPnNI2+Vwq/iwDPR9vo7KWobfNLAye4EaGBTlsSxMzTNf39lLF9z35f5mcROM7JgOP+azBCuDe84G+XA== - X-Requested-With: XMLHttpRequest - method: POST - url: https://testerhome.com/account/sign_in - validate: - - eq: [status_code, 200] - - eq: [headers.Content-Type, text/javascript; charset=utf-8] - -- test: - name: / - request: - headers: - If-None-Match: W/"bad62c68dac27b01151516aad5c7f0be" - Turbolinks-Referrer: https://testerhome.com/account/sign_in - User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_3) AppleWebKit/537.36 - (KHTML, like Gecko) Chrome/73.0.3683.103 Safari/537.36 - method: GET - url: https://testerhome.com/ - validate: - - eq: [status_code, 200] - - eq: [headers.Content-Type, text/html; charset=utf-8] -``` -
- -### 首次运行测试用例 - -成功安装 HttpRunner 后,系统中会新增 `hrun` 命令,该命令是 HttpRunner 的核心命令,用于运行 HttpRunner 支持的 `YAML/JSON` 测试用例文件。 - -生成测试用例后,我们可以先尝试运行一次,大多数情况,如果被测场景中不存在关联的情况,是可以直接运行成功的。 - -```bash -$ hrun docs/data/testerhome-login.yml --failfast --log-level info -INFO Start to run testcase: testcase description -/account/sign_in -INFO GET https://testerhome.com/account/sign_in -INFO status_code: 200, response_time(ms): 189.66 ms, response_length: 12584 bytes - -. -/assets/big_logo-cd32144f74c18746f3dce33e1040e7dfe4c07c8e611e37f3868b1c16b5095da3.png -INFO GET https://testerhome.com/assets/big_logo-cd32144f74c18746f3dce33e1040e7dfe4c07c8e611e37f3868b1c16b5095da3.png -INFO status_code: 200, response_time(ms): 83.98 ms, response_length: 15229 bytes - -. -/account/sign_in -INFO POST https://testerhome.com/account/sign_in -INFO status_code: 200, response_time(ms): 172.8 ms, response_length: 89 bytes - -. -/ -INFO GET https://testerhome.com/ -INFO status_code: 200, response_time(ms): 257.41 ms, response_length: 52463 bytes - -. - ----------------------------------------------------------------------- -Ran 4 tests in 0.722s - -OK -INFO Start to render Html report ... -INFO Generated Html report: /Users/debugtalk/MyProjects/HttpRunner-dev/httprunner-docs-v2x/reports/1555662601.html -``` - -比较幸运,脚本在没有做任何修改的情况下运行成功了。 - - -## 调试 & 优化测试用例 - -虽然脚本运行成功了,但是为了更好地管理和维护脚本,需要对脚本进行优化调整。 - -### 关联处理 - -查看录制生成的脚本,可以看到在发起登录请求时包含了 `X-CSRF-Token`,如果熟悉网络信息安全的基础知识,就会联想到该字段是动态变化的,每次都是先从服务器端返回至客户端,客户端在后续发起请求时需要携带该字段。 - -```yaml -- test: - name: /account/sign_in - request: - data: - commit: Sign In - user[login]: debugtalk - user[password]: XXXXXXXX - user[remember_me]: '1' - utf8: ✓ - headers: - Content-Type: application/x-www-form-urlencoded; charset=UTF-8 - User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_3) AppleWebKit/537.36 - (KHTML, like Gecko) Chrome/73.0.3683.103 Safari/537.36 - X-CSRF-Token: 0zAKFDDPnNI2+Vwq/iwDPR9vo7KWobfNLAye4EaGBTlsSxMzTNf39lLF9z35f5mcROM7JgOP+azBCuDe84G+XA== - X-Requested-With: XMLHttpRequest - method: POST - url: https://testerhome.com/account/sign_in - validate: - - eq: [status_code, 200] - - eq: [headers.Content-Type, text/html; charset=utf-8] -``` - -虽然当前直接运行录制生成的脚本也是成功的,但很有可能在过了一段时间后,`X-CSRF-Token` 失效,脚本也就无法再成功运行了。因此在测试脚本中,该字段不能写死为抓包时获取的值,而是要每次动态地从前面的接口响应中获取。 - -那要怎么确定该字段是在之前的哪个接口中返回的呢? - -操作方式也很简单,可以在抓包工具中对该字段进行搜索,特别地,搜索范围限定为响应内容(Response Header、Response Body)。 - -即可搜索得到该字段是在哪个接口中从服务器端返回值客户端的。 - -![](../images/charles-search-response.png) - -有时候可能搜索会得到多个结果,那么在确定是使用哪个接口响应的时候,遵循两个原则即可: - -- 响应一定是出现在当前接口之前 -- 如果在当前接口之前存在多个接口均有此返回,那么取最靠近当前接口的即可 - -通过前面的搜索可知,`X-CSRF-Token` 的值是在第一个接口中响应返回的。 - -![](../images/charles-locate-response-token.png) - -确定出具体的接口后,那么就可以在测试脚本中从该接口使用 `extract` 提取对应的字段,然后在后续接口中引用提取出的字段。 - -在当前案例中,第一个接口的响应内容为 HTML 页面,要提取字段可以使用正则匹配的方式。具体的做法就是指定目标字段的左右边界,目标字段使用 `(.*)` 匹配获取。 - -```yaml -- test: - name: /account/sign_in - request: - headers: - If-None-Match: W/"bc9ae267fdcbd89bf1dfaea10dea2b0e" - User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_3) AppleWebKit/537.36 - (KHTML, like Gecko) Chrome/73.0.3683.103 Safari/537.36 - method: GET - url: https://testerhome.com/account/sign_in - extract: - X_CSRF_Token: - validate: - - eq: [status_code, 200] - - eq: [headers.Content-Type, text/html; charset=utf-8] -``` - -然后,在后续使用到该字段的接口中,引用提取出的字段即可。 - -```yaml -- test: - name: /account/sign_in - request: - data: - commit: Sign In - user[login]: debugtalk - user[password]: XXXXXXXX - user[remember_me]: '1' - utf8: ✓ - headers: - Content-Type: application/x-www-form-urlencoded; charset=UTF-8 - User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_3) AppleWebKit/537.36 - (KHTML, like Gecko) Chrome/73.0.3683.103 Safari/537.36 - X-CSRF-Token: $X_CSRF_Token - X-Requested-With: XMLHttpRequest - method: POST - url: https://testerhome.com/account/sign_in - validate: - - eq: [status_code, 200] - - eq: [headers.Content-Type, text/html; charset=utf-8] -``` diff --git a/docs/images/HttpRunner-architecture-diagram.svg b/docs/images/HttpRunner-architecture-diagram.svg deleted file mode 100644 index b6db8ac8..00000000 --- a/docs/images/HttpRunner-architecture-diagram.svg +++ /dev/null @@ -1 +0,0 @@ -initializeadd_testsparsed testcaseparsed testcaseunittestTestSuiteload_testsvalidateparse_testsreportrunnerteststepiteration prepareHARCharles/FiddlerPostmancollectionrecord & generateJSONfile loaderapitestcasestestsuitesparse config nameparsed testcases(list)parse config requestJSON Schema ValidateRunner()Context()HttpSession()TestCasetestcase setup hookstestcase teardown hookscheck if skipprepare APIapi setup hookssend requestapi teardown hooksextract responsevalidateaggregateaggregate resultsgenerate html reportdefault templatecustom templatemake ResponseObjectYAML.envdebugtalk.pyRaw valid data.env validateimport builtin moduleparse config variablesparse config parametersextend & merge valid testcasesapi: validate, extract, variablestestcase: variablesaddTestCaseunittestTextTestRunner()TestLoader()parsed testcaseinitlocate debugtalk.py => PWDload custom.env / .envimport debugtalk.py module \ No newline at end of file diff --git a/docs/images/charles-export-har.png b/docs/images/charles-export-har.png deleted file mode 100644 index 82db6a05..00000000 Binary files a/docs/images/charles-export-har.png and /dev/null differ diff --git a/docs/images/charles-export.jpg b/docs/images/charles-export.jpg deleted file mode 100644 index a6d2ef7d..00000000 Binary files a/docs/images/charles-export.jpg and /dev/null differ diff --git a/docs/images/charles-locate-response-token.png b/docs/images/charles-locate-response-token.png deleted file mode 100644 index edab6fa2..00000000 Binary files a/docs/images/charles-locate-response-token.png and /dev/null differ diff --git a/docs/images/charles-save-har.jpg b/docs/images/charles-save-har.jpg deleted file mode 100644 index 1c86ed3c..00000000 Binary files a/docs/images/charles-save-har.jpg and /dev/null differ diff --git a/docs/images/charles-search-response.png b/docs/images/charles-search-response.png deleted file mode 100644 index 88c7e04a..00000000 Binary files a/docs/images/charles-search-response.png and /dev/null differ diff --git a/docs/images/demo-quickstart-http-1.jpg b/docs/images/demo-quickstart-http-1.jpg deleted file mode 100644 index ae89002e..00000000 Binary files a/docs/images/demo-quickstart-http-1.jpg and /dev/null differ diff --git a/docs/images/demo-quickstart-http-2.jpg b/docs/images/demo-quickstart-http-2.jpg deleted file mode 100644 index d831f096..00000000 Binary files a/docs/images/demo-quickstart-http-2.jpg and /dev/null differ diff --git a/docs/images/loadtest-schematic-diagram.jpg b/docs/images/loadtest-schematic-diagram.jpg deleted file mode 100644 index 1d774c61..00000000 Binary files a/docs/images/loadtest-schematic-diagram.jpg and /dev/null differ diff --git a/docs/images/locusts-full-speed.jpg b/docs/images/locusts-full-speed.jpg deleted file mode 100644 index 67efa3ea..00000000 Binary files a/docs/images/locusts-full-speed.jpg and /dev/null differ diff --git a/docs/images/qrcode_for_httprunner.jpg b/docs/images/qrcode_for_httprunner.jpg deleted file mode 100644 index 80687bbf..00000000 Binary files a/docs/images/qrcode_for_httprunner.jpg and /dev/null differ diff --git a/docs/images/report-demo-quickstart-1-log1.jpg b/docs/images/report-demo-quickstart-1-log1.jpg deleted file mode 100644 index c390732a..00000000 Binary files a/docs/images/report-demo-quickstart-1-log1.jpg and /dev/null differ diff --git a/docs/images/report-demo-quickstart-1-log2.jpg b/docs/images/report-demo-quickstart-1-log2.jpg deleted file mode 100644 index 6eaddeae..00000000 Binary files a/docs/images/report-demo-quickstart-1-log2.jpg and /dev/null differ diff --git a/docs/images/report-demo-quickstart-1-overview.jpg b/docs/images/report-demo-quickstart-1-overview.jpg deleted file mode 100644 index 27a85fa5..00000000 Binary files a/docs/images/report-demo-quickstart-1-overview.jpg and /dev/null differ diff --git a/docs/images/report-demo-quickstart-1-traceback.jpg b/docs/images/report-demo-quickstart-1-traceback.jpg deleted file mode 100644 index a830102f..00000000 Binary files a/docs/images/report-demo-quickstart-1-traceback.jpg and /dev/null differ diff --git a/docs/images/run-demo-quickstart-0.jpg b/docs/images/run-demo-quickstart-0.jpg deleted file mode 100644 index 287634fc..00000000 Binary files a/docs/images/run-demo-quickstart-0.jpg and /dev/null differ diff --git a/docs/images/run-demo-quickstart-1.jpg b/docs/images/run-demo-quickstart-1.jpg deleted file mode 100644 index 34f72a60..00000000 Binary files a/docs/images/run-demo-quickstart-1.jpg and /dev/null differ diff --git a/docs/images/run-demo-quickstart-2.jpg b/docs/images/run-demo-quickstart-2.jpg deleted file mode 100644 index 55fe4bc8..00000000 Binary files a/docs/images/run-demo-quickstart-2.jpg and /dev/null differ diff --git a/docs/images/run-demo-quickstart-6.jpg b/docs/images/run-demo-quickstart-6.jpg deleted file mode 100644 index 40e69055..00000000 Binary files a/docs/images/run-demo-quickstart-6.jpg and /dev/null differ diff --git a/docs/images/testcase-layer.png b/docs/images/testcase-layer.png deleted file mode 100644 index bcb8b6f3..00000000 Binary files a/docs/images/testcase-layer.png and /dev/null differ diff --git a/docs/images/testcase-structure.png b/docs/images/testcase-structure.png deleted file mode 100644 index f63c0024..00000000 Binary files a/docs/images/testcase-structure.png and /dev/null differ diff --git a/docs/images/testerhome-charles-export.png b/docs/images/testerhome-charles-export.png deleted file mode 100644 index 2a981acf..00000000 Binary files a/docs/images/testerhome-charles-export.png and /dev/null differ diff --git a/docs/images/testerhome-login-charles.png b/docs/images/testerhome-login-charles.png deleted file mode 100644 index cbaf7d37..00000000 Binary files a/docs/images/testerhome-login-charles.png and /dev/null differ diff --git a/docs/images/testerhome-login.png b/docs/images/testerhome-login.png deleted file mode 100644 index b3fe28b9..00000000 Binary files a/docs/images/testerhome-login.png and /dev/null differ diff --git a/docs/index.md b/docs/index.md index 1719125e..fabb9740 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,36 +1,49 @@ -HttpRunner 是一款面向 HTTP(S) 协议的通用测试框架,只需编写维护一份 `YAML/JSON` 脚本,即可实现自动化测试、性能测试、线上监控、持续集成等多种测试需求。 +# HttpRunner -此文档适用于全新发布的 `HttpRunner 2.x` 版本,`1.x` 版本的使用文档请查看[历史链接][httprunner1]。 +[![downloads](https://pepy.tech/badge/httprunner)](https://pepy.tech/project/httprunner) +[![unittest](https://github.com/httprunner/httprunner/workflows/unittest/badge.svg +)](https://github.com/httprunner/httprunner/actions) +[![integration-test](https://github.com/httprunner/httprunner/workflows/integration_test/badge.svg +)](https://github.com/httprunner/httprunner/actions) +[![codecov](https://codecov.io/gh/httprunner/httprunner/branch/master/graph/badge.svg)](https://codecov.io/gh/httprunner/httprunner) +[![pypi version](https://img.shields.io/pypi/v/httprunner.svg)](https://pypi.python.org/pypi/httprunner) +[![pyversions](https://img.shields.io/pypi/pyversions/httprunner.svg)](https://pypi.python.org/pypi/httprunner) +[![TesterHome](https://img.shields.io/badge/TTF-TesterHome-2955C5.svg)](https://testerhome.com/github_statistics) -## 设计理念 +*HttpRunner* is a simple & elegant, yet powerful HTTP(S) testing framework. Enjoy! ✨ 🚀 ✨ -- 充分复用优秀的开源项目,不追求重复造轮子,而是将强大的轮子组装成战车 -- 遵循 `约定大于配置` 的准则,在框架功能中融入自动化测试最佳工程实践 -- 追求投入产出比,一份投入即可实现多种测试需求 +> This docs site is corresponding to the latest version `3.x`, for `2.x` you can reference [`archive link`](https://v2.httprunner.org/). -## 核心特性 +## Design Philosophy -- 继承 [Requests][Requests] 的全部特性,轻松实现 HTTP(S) 的各种测试需求 -- 采用 `YAML/JSON` 的形式描述测试场景,保障测试用例描述的统一性和可维护性 -- 借助辅助函数(debugtalk.py),在测试脚本中轻松实现复杂的动态计算逻辑 -- 支持完善的测试用例分层机制,充分实现测试用例的复用 -- 测试前后支持完善的 hook 机制 -- 响应结果支持丰富的校验机制 -- 基于 HAR 实现接口录制和用例生成功能([har2case][har2case]) -- 结合 [Locust][Locust] 框架,无需额外的工作即可实现分布式性能测试 -- 执行方式采用 CLI 调用,可与 Jenkins 等持续集成工具完美结合 -- 测试结果统计报告简洁清晰,附带详尽统计信息和日志记录 -- 极强的可扩展性,轻松实现二次开发和 Web 平台化 +- Convention over configuration +- ROI matters +- Embrace open source, leverage [`requests`][requests], [`pytest`][pytest], [`pydantic`][pydantic], [`allure`][allure] and [`locust`][locust]. -## 更多信息 +## Key Features + +- Inherit all powerful features of [`requests`][requests], just have fun to handle HTTP(S) in human way. +- Define testcase in YAML or JSON format, run with [`pytest`][pytest] in concise and elegant manner. +- Record and generate testcases with [`HAR`][HAR] support. +- Supports `variables`/`extract`/`validate`/`hooks` mechanisms to create extremely complex test scenarios. +- With `debugtalk.py` plugin, any function can be used in any part of your testcase. +- With [`jmespath`][jmespath], extract and validate json response has never been easier. +- With [`pytest`][pytest], hundreds of plugins are readily available. +- With [`allure`][allure], test report can be pretty nice and powerful. +- With reuse of [`locust`][locust], you can run performance test without extra work. +- CLI command supported, perfect combination with `CI/CD`. + +## Subscribe 关注 HttpRunner 的微信公众号,第一时间获得最新资讯。 -![](./assets/qrcode.jpg) +![](/assets/qrcode.jpg) - -[httprunner1]: https://v1.httprunner.org/ -[Requests]: http://docs.python-requests.org/en/master/ -[Locust]: http://locust.io/ -[har2case]: https://github.com/HttpRunner/har2case \ No newline at end of file +[requests]: http://docs.python-requests.org/en/master/ +[pytest]: https://docs.pytest.org/ +[pydantic]: https://pydantic-docs.helpmanual.io/ +[locust]: http://locust.io/ +[jmespath]: https://jmespath.org/ +[allure]: https://docs.qameta.io/allure/ +[HAR]: http://httparchive.org/ diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 00000000..e9affc5b --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,64 @@ + +`HttpRunner` is developed with Python, it supports Python `3.6+` and most operating systems. Combination of Python `3.6/3.7/3.8` and `macOS/Linux/Windows` are tested continuously on [GitHub-Actions][github-actions]. + +## Installation + +`HttpRunner` is available on [`PyPI`][PyPI] and can be installed through `pip`. + +```bash +$ pip install httprunner +``` + +If you want to keep up with the latest version, you can install with github repository url. + +```bash +$ pip install git+https://github.com/httprunner/httprunner.git@master +``` + +If you have installed `HttpRunner` before and want to upgrade to the latest version, you can use the `-U` option. + +```bash +$ pip install -U httprunner +$ pip install -U git+https://github.com/httprunner/httprunner.git@master +``` + +## Check Installation + +When HttpRunner is installed, 4 commands will be added in your system. + +- `httprunner`: main command, used for all functions +- `hrun`: alias for `httprunner run`, used to run YAML/JSON testcases +- `hmake`: alias for `httprunner make`, used to convert YAML/JSON testcases to pytest files +- `har2case`: alias for `httprunner har2case`, used to convert HAR to YAML/JSON testcases + +To see `HttpRunner` version: + +```text +$ httprunner -V +3.0.6 +``` + +To see available options, run: + +```text +$ httprunner -h +usage: httprunner [-h] [-V] {run,startproject,har2case,make} ... + +One-stop solution for HTTP(S) testing. + +positional arguments: + {run,startproject,har2case,make} + sub-command help + run Make HttpRunner testcases and run with pytest. + startproject Create a new project with template structure. + har2case Convert HAR(HTTP Archive) to YAML/JSON testcases for + HttpRunner. + make Convert YAML/JSON testcases to pytest cases. + +optional arguments: + -h, --help show this help message and exit + -V, --version show version +``` + +[PyPI]: https://pypi.python.org/pypi +[github-actions]: https://github.com/httprunner/httprunner/actions \ No newline at end of file diff --git a/docs/js/slardar.js b/docs/js/slardar.js deleted file mode 100644 index f6ea9150..00000000 --- a/docs/js/slardar.js +++ /dev/null @@ -1,11 +0,0 @@ - -(function(i,s,o,g,r,a,m){i["SlardarMonitorObject"]=r;i[r]=i[r]||function(){(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date;a=s.createElement(o),m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)})(window,document,"script","https://i.snssdk.com/slardar/sdk.js?bid=httprunner","Slardar"); -window.Slardar('config', { - sampleRate: 1, - bid: 'httprunner', - ignoreAjax: [], - ignoreStatic: [], - hookFetch: true, - enableSizeStats: true -}); -window.Slardar('send', 'pageview'); \ No newline at end of file diff --git a/docs/prepare/dot-env.md b/docs/prepare/dot-env.md deleted file mode 100644 index 35fefaad..00000000 --- a/docs/prepare/dot-env.md +++ /dev/null @@ -1,112 +0,0 @@ - -## 环境变量的作用 - -在自动化测试中,有时需要借助环境变量实现某些特定的目的,常见的场景包括: - -- 切换测试环境 -- 切换测试配置 -- 存储敏感数据(从[信息安全](/prepare/security/)的角度出发) - -## 设置环境变量 - -### 在终端中预设环境变量 - -使用环境变量之前,需要先在系统中设置环境变量名称和值,传统的方式为使用 export 命令(Windows系统中使用 set 命令): - -```bash -$ export UserName=admin -$ echo $UserName -admin -$ export Password=123456 -$ echo $Password -123456 -``` - -然后,在程序中就可以对系统中的环境变量进行读取。 - -```bash -$ python ->>> import os ->>> os.environ["UserName"] -'admin' -``` - -### 通过 .env 文件设置环境变量 - -除了这种方式,HttpRunner 还借鉴了 pipenv [加载 `.env` 的方式][pipenv_load_env]。 - -默认情况下,在自动化测试项目的根目录中,创建 `.env` 文件,并将敏感数据信息放置到其中,存储采用 `name=value` 的格式: - -```bash -$ cat .env -UserName=admin -Password=123456 -PROJECT_KEY=ABCDEFGH -``` - -同时,`.env` 文件不应该添加到代码仓库中,建议将 `.env` 加入到 `.gitignore` 中。 - -HttpRunner 运行时,会自动将 `.env` 文件中的内容加载到运行时(RunTime)的环境变量中,然后在运行时中就可以对环境变量进行读取了。 - -若需加载不位于自动化项目根目录中的 `.env`,或者其它名称的 `.env` 文件(例如 `production.env`),可以采用 `--dot-env-path` 参数指定文件路径: - -```bash -$ hrun /path/to/testcase.yml --dot-env-path /path/to/.env --log-level debug -INFO Loading environment variables from /path/to/.env -DEBUG Loaded variable: UserName -DEBUG Loaded variable: Password -DEBUG Loaded variable: PROJECT_KEY -... -``` - -## 引用环境变量 - -在 HttpRunner 中内置了函数 `environ`(简称 `ENV`),可用于在 YAML/JSON 脚本中直接引用环境变量。 - -```yaml -- test: - name: login - request: - url: http://host/api/login - method: POST - headers: - Content-Type: application/json - json: - username: ${ENV(UserName)} - password: ${ENV(Password)} - validate: - - eq: [status_code, 200] -``` - -若还需对读取的环境变量做进一步处理,则可以在 `debugtalk.py` 通过 Python 内置的函数 `os.environ` 对环境变量进行引用,然后再实现处理逻辑。 - -例如,若发起请求的密码需要先与密钥进行拼接并生成 MD5,那么就可以在 `debugtalk.py` 文件中实现如下函数: - -```python -import os - -def get_encrypt_password(): - raw_passwd = os.environ["Password"] - PROJECT_KEY = os.environ["PROJECT_KEY"]) - password = (raw_passwd + PROJECT_KEY).encode('ascii') - return hmac.new(password, hashlib.sha1).hexdigest() -``` - -然后,在 YAML/JSON 格式的测试用例中,就可以通过`${func()}`的方式引用环境变量的值了。 - -```yaml -- test: - name: login - request: - url: http://host/api/login - method: POST - headers: - Content-Type: application/json - json: - username: ${ENV(UserName)} - password: ${get_encrypt_password()} - validate: - - eq: [status_code, 200] -``` - -[pipenv_load_env]: https://docs.pipenv.org/advanced/#automatic-loading-of-env diff --git a/docs/prepare/parameters.md b/docs/prepare/parameters.md deleted file mode 100644 index fe3d8f7a..00000000 --- a/docs/prepare/parameters.md +++ /dev/null @@ -1,581 +0,0 @@ -## 介绍 - -在自动化测试中,经常会遇到如下场景: - -- 测试搜索功能,只有一个搜索输入框,但有 10 种不同类型的搜索关键字; -- 测试账号登录功能,需要输入用户名和密码,按照等价类划分后有 20 种组合情况。 - -这里只是随意找了两个典型的例子,相信大家都有遇到过很多类似的场景。总结下来,就是在我们的自动化测试脚本中存在参数,并且我们需要采用不同的参数去运行。 - -经过概括,参数基本上分为两种类型: - -- 单个独立参数:例如前面的第一种场景,我们只需要变换搜索关键字这一个参数 -- 多个具有关联性的参数:例如前面的第二种场景,我们需要变换用户名和密码两个参数,并且这两个参数需要关联组合 - -然后,对于参数而言,我们可能具有一个参数列表,在脚本运行时需要按照不同的规则去取值,例如顺序取值、随机取值、循环取值等等。 - -这就是典型的参数化和数据驱动。 - -如需了解 HttpRunner 参数化数据驱动机制的实现原理和技术细节,可前往阅读[《HttpRunner 实现参数化数据驱动机制》](http://debugtalk.com/post/httprunner-data-driven/)。 - -## 测试用例集(testsuite)准备 - -从 2.0.0 版本开始,HttpRunner 不再支持在测试用例文件中进行参数化配置;参数化的功能需要在 testsuite 中实现。变更的目的是让测试用例(testcase)的概念更纯粹,关于测试用例和测试用例集的概念定义,详见[《测试用例组织》](/prepare/parameters/)。 - -参数化机制需要在测试用例集(testsuite)中实现。如需实现数据驱动机制,需要创建一个 testsuite,在 testsuite 中引用测试用例,并定义参数化配置。 - -测试用例集(testsuite)的格式如下所示: - -```yaml -config: - name: testsuite description - -testcases: - testcase1_name: - testcase: /path/to/testcase1 - - testcase2_name: - testcase: /path/to/testcase2 -``` - -需要注意的是,testsuite 和 testcase 的格式存在较大区别,详见[《测试用例组织》](/prepare/testcase-structure/)。 - - -## 参数配置概述 - -如需对某测试用例(testcase)实现参数化数据驱动,需要使用 `parameters` 关键字,定义参数名称并指定数据源取值方式。 - -参数名称的定义分为两种情况: - -- 独立参数单独进行定义; -- 多个参数具有关联性的参数需要将其定义在一起,采用短横线(`-`)进行连接。 - -数据源指定支持三种方式: - -- 在 YAML/JSON 中直接指定参数列表:该种方式最为简单易用,适合参数列表比较小的情况 -- 通过内置的 parameterize(可简写为P)函数引用 CSV 文件:该种方式需要准备 CSV 数据文件,适合数据量比较大的情况 -- 调用 debugtalk.py 中自定义的函数生成参数列表:该种方式最为灵活,可通过自定义 Python 函数实现任意场景的数据驱动机制,当需要动态生成参数列表时也需要选择该种方式 - -三种方式可根据实际项目需求进行灵活选择,同时支持多种方式的组合使用。假如测试用例中定义了多个参数,那么测试用例在运行时会对参数进行笛卡尔积组合,覆盖所有参数组合情况。 - -使用方式概览如下: - -```yaml -config: - name: "demo" - -testcases: - testcase1_name: - testcase: /path/to/testcase1 - parameters: - user_agent: ["iOS/10.1", "iOS/10.2", "iOS/10.3"] - user_id: ${P(user_id.csv)} - username-password: ${get_account(10)} -``` - - -## 参数配置详解 - -将参数名称定义和数据源指定方式进行组合,共有 6 种形式。现分别针对每一类情况进行详细说明。 - -### 独立参数 & 直接指定参数列表 - -对于参数列表比较小的情况,最简单的方式是直接在 YAML/JSON 中指定参数列表内容。 - -例如,对于独立参数 `user_id`,参数列表为 `[1001, 1002, 1003, 1004]`,那么就可以按照如下方式进行配置: - -```yaml -config: - name: testcase description - -testcases: - create user: - testcase: demo-quickstart-6.yml - parameters: - user_id: [1001, 1002, 1003, 1004] -``` - -进行该配置后,测试用例在运行时就会对 user_id 实现数据驱动,即分别使用 `[1001, 1002, 1003, 1004]` 四个值运行测试用例。 - -
-点击查看运行日志 - -```text -$ hrun docs/data/demo-quickstart-7.json -INFO Start to run testcase: create user 1001 -/api/get-token -INFO POST http://127.0.0.1:5000/api/get-token -INFO status_code: 200, response_time(ms): 8.95 ms, response_length: 46 bytes - -. -/api/users/1001 -INFO POST http://127.0.0.1:5000/api/users/1001 -INFO status_code: 201, response_time(ms): 3.02 ms, response_length: 54 bytes - -. - ----------------------------------------------------------------------- -Ran 2 tests in 0.021s - -OK -INFO Start to run testcase: create user 1002 -/api/get-token -INFO POST http://127.0.0.1:5000/api/get-token -INFO status_code: 200, response_time(ms): 2.78 ms, response_length: 46 bytes - -. -/api/users/1002 -INFO POST http://127.0.0.1:5000/api/users/1002 -INFO status_code: 201, response_time(ms): 2.84 ms, response_length: 54 bytes - -. - ----------------------------------------------------------------------- -Ran 2 tests in 0.007s - -OK -INFO Start to run testcase: create user 1003 -/api/get-token -INFO POST http://127.0.0.1:5000/api/get-token -INFO status_code: 200, response_time(ms): 2.92 ms, response_length: 46 bytes - -. -/api/users/1003 -INFO POST http://127.0.0.1:5000/api/users/1003 -INFO status_code: 201, response_time(ms): 5.56 ms, response_length: 54 bytes - -. - ----------------------------------------------------------------------- -Ran 2 tests in 0.011s - -OK -INFO Start to run testcase: create user 1004 -/api/get-token -INFO POST http://127.0.0.1:5000/api/get-token -INFO status_code: 200, response_time(ms): 5.25 ms, response_length: 46 bytes - -. -/api/users/1004 -INFO POST http://127.0.0.1:5000/api/users/1004 -INFO status_code: 201, response_time(ms): 7.02 ms, response_length: 54 bytes - -. - ----------------------------------------------------------------------- -Ran 2 tests in 0.016s - -OK -INFO Start to render Html report ... -INFO Generated Html report: /Users/debugtalk/MyProjects/HttpRunner-dev/httprunner-docs-v2x/reports/1548518757.html -``` - -
- -可以看出,测试用例总共运行了 4 次,并且每次运行时都是采用的不同 user_id。 - -### 关联参数 & 直接指定参数列表 - -对于具有关联性的多个参数,例如 username 和 password,那么就可以按照如下方式进行配置: - -```yaml -config: - name: "demo" - -testcases: - testcase1_name: - testcase: /path/to/testcase1 - parameters: - username-password: - - ["user1", "111111"] - - ["user2", "222222"] - - ["user3", "333333"] -``` - -进行该配置后,测试用例在运行时就会对 username 和 password 实现数据驱动,即分别使用 `{"username": "user1", "password": "111111"}`、`{"username": "user2", "password": "222222"}`、`{"username": "user3", "password": "333333"}` 运行 3 次测试,并且保证参数值总是成对使用。 - -### 独立参数 & 引用 CSV 文件 - -对于已有参数列表,并且数据量比较大的情况,比较适合的方式是将参数列表值存储在 CSV 数据文件中。 - -对于 CSV 数据文件,需要遵循如下几项约定的规则: - -- CSV 文件中的第一行必须为参数名称,从第二行开始为参数值,每个(组)值占一行; -- 若同一个 CSV 文件中具有多个参数,则参数名称和数值的间隔符需实用英文逗号; -- 在 YAML/JSON 文件引用 CSV 文件时,文件路径为基于项目根目录(debugtalk.py 所在路径)的相对路径。 - -例如,user_id 的参数取值范围为 1001~2000,那么我们就可以创建 user_id.csv,并且在文件中按照如下形式进行描述。 - -```csv -user_id -1001 -1002 -... -1999 -2000 -``` - -然后在 YAML/JSON 测试用例文件中,就可以通过内置的 `parameterize`(可简写为 `P`)函数引用 CSV 文件。 - -假设项目的根目录下有 data 文件夹,user_id.csv 位于其中,那么 user_id.csv 的引用描述如下: - -```yaml -config: - name: "demo" - -testcases: - testcase1_name: - testcase: /path/to/testcase1 - parameters: - user_id: ${P(data/user_id.csv)} -``` - -即 `P` 函数的参数(CSV 文件路径)是相对于项目根目录的相对路径。当然,这里也可以使用 CSV 文件在系统中的绝对路径,不过这样的话在项目路径变动时就会出现问题,因此推荐使用相对路径的形式。 - -### 关联参数 & 引用 CSV 文件 - -对于具有关联性的多个参数,例如 username 和 password,那么就可以创建 [account.csv](/data/account.csv),并在文件中按照如下形式进行描述。 - -```csv -username,password -test1,111111 -test2,222222 -test3,333333 -``` - -然后在 YAML/JSON 测试用例文件中,就可以通过内置的 `parameterize`(可简写为 `P`)函数引用 CSV 文件。 - -假设项目的根目录下有 data 文件夹,account.csv 位于其中,那么 account.csv 的引用描述如下: - -```yaml -config: - name: "demo" - -testcases: - testcase1_name: - testcase: /path/to/testcase1 - parameters: - username-password: ${P(data/account.csv)} -``` - -需要说明的是,在 parameters 中指定的参数名称必须与 CSV 文件中第一行的参数名称一致,顺序可以不一致,参数个数也可以不一致。 - -例如,在 [account.csv](/data/account.csv) 文件中可以包含多个参数,username、password、phone、age: - -```csv -username,password,phone,age -test1,111111,18600000001,21 -test2,222222,18600000002,22 -test3,333333,18600000003,23 -``` - -而在 YAML/JSON 测试用例文件中指定参数时,可以只使用部分参数,并且参数顺序无需与 CSV 文件中参数名称的顺序一致。 - -```yaml -config: - name: "demo" - -testcases: - testcase1_name: - testcase: /path/to/testcase1 - parameters: - phone-username: ${P(account.csv)} -``` - -### 独立参数 & 引用自定义函数 - -对于没有现成参数列表,或者需要更灵活的方式动态生成参数的情况,可以通过在 debugtalk.py 中自定义函数生成参数列表,并在 YAML/JSON 引用自定义函数的方式。 - -例如,若需对 user_id 进行参数化数据驱动,参数取值范围为 1001~1004,那么就可以在 debugtalk.py 中定义一个函数,返回参数列表。 - -```python -def get_user_id(): - return [ - {"user_id": 1001}, - {"user_id": 1002}, - {"user_id": 1003}, - {"user_id": 1004} - ] -``` - -然后,在 YAML/JSON 的 parameters 中就可以通过调用自定义函数的形式来指定数据源。 - -```yaml -config: - name: "demo" - -testcases: - testcase1_name: - testcase: /path/to/testcase1 - parameters: - user_id: ${get_user_id()} -``` - -另外,通过函数的传参机制,还可以实现更灵活的参数生成功能,在调用函数时指定需要生成的参数个数。 - -### 关联参数 & 引用自定义函数 - -对于具有关联性的多个参数,实现方式也类似。 - -例如,在 debugtalk.py 中定义函数 get_account,生成指定数量的账号密码参数列表。 - -```python -def get_account(num): - accounts = [] - for index in range(1, num+1): - accounts.append( - {"username": "user%s" % index, "password": str(index) * 6}, - ) - - return accounts -``` - -那么在 YAML/JSON 的 parameters 中就可以调用自定义函数生成指定数量的参数列表。 - -```yaml -config: - name: "demo" - -testcases: - testcase1_name: - testcase: /path/to/testcase1 - parameters: - username-password: ${get_account(10)} -``` - -> 需要注意的是,在自定义函数中,生成的参数列表必须为 `list of dict` 的数据结构,该设计主要是为了与 CSV 文件的处理机制保持一致。 - - -## 参数化运行 - -完成以上参数定义和数据源准备工作之后,参数化运行与普通测试用例的运行完全一致。 - -采用 hrun 命令运行自动化测试: - -```bash -$ hrun tests/data/demo_parameters.yml -``` - -采用 locusts 命令运行性能测试: - -```bash -$ locusts -f tests/data/demo_parameters.yml -``` - -区别在于,自动化测试时遍历一遍后会终止执行,性能测试时每个并发用户都会循环遍历所有参数。 - -## 案例演示 - -假设我们有一个获取 token 的[测试用例](/data/demo-testcase-get-token.yml)。 - -
-点击查看 YAML 测试用例 -```yaml -- config: - name: get token - base_url: http://127.0.0.1:5000 - variables: - device_sn: ${gen_random_string(15)} - os_platform: 'ios' - app_version: '2.8.6' - -- test: - name: get token with $device_sn, $os_platform, $app_version - request: - headers: - Content-Type: application/json - User-Agent: python-requests/2.18.4 - app_version: $app_version - device_sn: $device_sn - os_platform: $os_platform - json: - sign: ${get_sign($device_sn, $os_platform, $app_version)} - method: POST - url: /api/get-token - extract: - token: content.token - validate: - - eq: [status_code, 200] - - eq: [headers.Content-Type, application/json] - - eq: [content.success, true] -``` -
- -如果我们需要使用 device_sn、app_version 和 os_platform 这三个参数来进行参数化数据驱动,那么就可以创建一个 [testsuite](/data/demo-parameters-get-token.yml),并且进行参数化配置。 - -```yaml -config: - name: get token with parameters - -testcases: - get token with $user_agent, $app_version, $os_platform: - testcase: demo-testcase-get-token.yml - parameters: - user_agent: ["iOS/10.1", "iOS/10.2", "iOS/10.3"] - app_version: ${P(app_version.csv)} - os_platform: ${get_os_platform()} -``` - -其中,`user_agent` 使用了直接指定参数列表的形式。 - -[app_version](/data/app_version.csv) 通过 CSV 文件进行参数配置,对应的文件内容为: - -```csv -app_version -2.8.5 -2.8.6 -``` - -os_platform 使用自定义函数的形式生成参数列表,对应的函数内容为: - -```python -def get_os_platform(): - return [ - {"os_platform": "ios"}, - {"os_platform": "android"} - ] -``` - -那么,经过笛卡尔积组合,应该总共有 `3*2*2=12` 种参数组合情况。 - - -
-点击查看完整运行日志 -```text -$ hrun docs/data/demo-parameters-get-token.yml -INFO Start to run testcase: get token with iOS/10.1, 2.8.5, ios -get token with PBJda7SXM2ReWlu, ios, 2.8.5 -INFO POST http://127.0.0.1:5000/api/get-token -INFO status_code: 200, response_time(ms): 10.66 ms, response_length: 46 bytes - -. - ----------------------------------------------------------------------- -Ran 1 test in 0.026s - -OK -INFO Start to run testcase: get token with iOS/10.1, 2.8.5, android -get token with PBJda7SXM2ReWlu, android, 2.8.5 -INFO POST http://127.0.0.1:5000/api/get-token -INFO status_code: 200, response_time(ms): 3.03 ms, response_length: 46 bytes - -. - ----------------------------------------------------------------------- -Ran 1 test in 0.004s - -OK -INFO Start to run testcase: get token with iOS/10.1, 2.8.6, ios -get token with PBJda7SXM2ReWlu, ios, 2.8.6 -INFO POST http://127.0.0.1:5000/api/get-token -INFO status_code: 200, response_time(ms): 10.76 ms, response_length: 46 bytes - -. - ----------------------------------------------------------------------- -Ran 1 test in 0.012s - -OK -INFO Start to run testcase: get token with iOS/10.1, 2.8.6, android -get token with PBJda7SXM2ReWlu, android, 2.8.6 -INFO POST http://127.0.0.1:5000/api/get-token -INFO status_code: 200, response_time(ms): 4.49 ms, response_length: 46 bytes - -. - ----------------------------------------------------------------------- -Ran 1 test in 0.006s - -OK -INFO Start to run testcase: get token with iOS/10.2, 2.8.5, ios -get token with PBJda7SXM2ReWlu, ios, 2.8.5 -INFO POST http://127.0.0.1:5000/api/get-token -INFO status_code: 200, response_time(ms): 4.39 ms, response_length: 46 bytes - -. - ----------------------------------------------------------------------- -Ran 1 test in 0.006s - -OK -INFO Start to run testcase: get token with iOS/10.2, 2.8.5, android -get token with PBJda7SXM2ReWlu, android, 2.8.5 -INFO POST http://127.0.0.1:5000/api/get-token -INFO status_code: 200, response_time(ms): 4.04 ms, response_length: 46 bytes - -. - ----------------------------------------------------------------------- -Ran 1 test in 0.005s - -OK -INFO Start to run testcase: get token with iOS/10.2, 2.8.6, ios -get token with PBJda7SXM2ReWlu, ios, 2.8.6 -INFO POST http://127.0.0.1:5000/api/get-token -INFO status_code: 200, response_time(ms): 3.44 ms, response_length: 46 bytes - -. - ----------------------------------------------------------------------- -Ran 1 test in 0.004s - -OK -INFO Start to run testcase: get token with iOS/10.2, 2.8.6, android -get token with PBJda7SXM2ReWlu, android, 2.8.6 -INFO POST http://127.0.0.1:5000/api/get-token -INFO status_code: 200, response_time(ms): 4.03 ms, response_length: 46 bytes - -. - ----------------------------------------------------------------------- -Ran 1 test in 0.005s - -OK -INFO Start to run testcase: get token with iOS/10.3, 2.8.5, ios -get token with PBJda7SXM2ReWlu, ios, 2.8.5 -INFO POST http://127.0.0.1:5000/api/get-token -INFO status_code: 200, response_time(ms): 5.14 ms, response_length: 46 bytes - -. - ----------------------------------------------------------------------- -Ran 1 test in 0.008s - -OK -INFO Start to run testcase: get token with iOS/10.3, 2.8.5, android -get token with PBJda7SXM2ReWlu, android, 2.8.5 -INFO POST http://127.0.0.1:5000/api/get-token -INFO status_code: 200, response_time(ms): 7.62 ms, response_length: 46 bytes - -. - ----------------------------------------------------------------------- -Ran 1 test in 0.010s - -OK -INFO Start to run testcase: get token with iOS/10.3, 2.8.6, ios -get token with PBJda7SXM2ReWlu, ios, 2.8.6 -INFO POST http://127.0.0.1:5000/api/get-token -INFO status_code: 200, response_time(ms): 4.88 ms, response_length: 46 bytes - -. - ----------------------------------------------------------------------- -Ran 1 test in 0.006s - -OK -INFO Start to run testcase: get token with iOS/10.3, 2.8.6, android -get token with PBJda7SXM2ReWlu, android, 2.8.6 -INFO POST http://127.0.0.1:5000/api/get-token -INFO status_code: 200, response_time(ms): 5.41 ms, response_length: 46 bytes - -. - ----------------------------------------------------------------------- -Ran 1 test in 0.008s - -OK -INFO Start to render Html report ... -INFO Generated Html report: /Users/debugtalk/MyProjects/HttpRunner-dev/httprunner-docs-v2x/reports/1551950193.html -``` -
diff --git a/docs/prepare/project-structure.md b/docs/prepare/project-structure.md deleted file mode 100644 index efbc5e9e..00000000 --- a/docs/prepare/project-structure.md +++ /dev/null @@ -1,30 +0,0 @@ - -## 文件类型说明 - -在 HttpRunner 自动化测试项目中,主要存在如下几类文件: - -- `YAML/JSON`(必须):测试用例文件,存储接口测试相关信息 -- `debugtalk.py`(可选):存储项目中逻辑运算辅助函数 - - 该文件存在时,将作为项目根目录定位标记,其所在目录即被视为项目工程根目录 - - 该文件不存在时,运行测试的所在路径(`CWD`)将被视为项目工程根目录 - - 测试用例文件中的相对路径(例如`.csv`)均需基于项目工程根目录 - - 运行测试后,测试报告文件夹(`reports`)会生成在项目工程根目录 -- `.env`(可选):存储项目环境变量,通常用于存储项目敏感信息 -- `.csv`(可选):项目数据文件,用于进行数据驱动 -- `reports`:默认生成测试报告的存储文件夹 - -## 项目文件结构 - -对于接口数比较少,或者测试场景比较简单的项目,组织测试用例时无需分层。在此种情况下,项目文件的目录结构没有任何要求,在项目中只需要一堆 `YAML/JSON` 文件即可,每一个文件单独对应一条测试用例;根据需要,项目中可能还会有 `debugtalk.py`、`.env`等文件。 - -推荐的项目文件目录结构示例如下: - -```bash -$ tree demo -a -demo -├── .env -├── debugtalk.py -├── reports -├── testcase1.yml -└── testcase2.json -``` diff --git a/docs/prepare/record.md b/docs/prepare/record.md deleted file mode 100644 index 7834b39e..00000000 --- a/docs/prepare/record.md +++ /dev/null @@ -1,38 +0,0 @@ - -为了简化测试用例的编写工作,HttpRunner 实现了测试用例生成的功能,对应的转换工具为一个独立的项目:[har2case][har2case]。 - -简单来说,就是当前主流的抓包工具和浏览器都支持将抓取得到的数据包导出为标准通用的 HAR 格式(HTTP Archive),然后 HttpRunner 实现了将 HAR 格式的数据包转换为`YAML/JSON`格式的测试用例文件的功能。 - -## 获取 HAR 数据包 - -在转换生成测试用例之前,需要先将抓取得到的数据包导出为 HAR 格式的文件。在`Charles Proxy`中的操作方式为,选中需要转换的接口(可多选或全选),点击右键,在悬浮的菜单目录中点击【Export...】,格式选择`HTTP Archive(.har)`后保存即可;假设我们保存的文件名称为 demo.har。 - -![](../images/charles-export.jpg) - -![](../images/charles-save-har.jpg) - -## 转换生成测试用例 - -然后,在命令行终端中运行 har2case 命令,即可将 demo.har 转换为 HttpRunner 的测试用例文件。 - -使用 `har2case` 转换脚本时默认转换为 JSON 格式。 - -```bash -$ har2case docs/data/demo-quickstart.har -INFO:root:Start to generate testcase. -INFO:root:dump testcase to JSON format. -INFO:root:Generate JSON testcase successfully: docs/data/demo-quickstart.json -``` - -加上 `-2y`/`--to-yml` 参数后转换为 YAML 格式。 - -```bash -$ har2case docs/data/demo-quickstart.har -2y -INFO:root:Start to generate testcase. -INFO:root:dump testcase to YAML format. -INFO:root:Generate YAML testcase successfully: docs/data/demo-quickstart.yml -``` - -两种格式完全等价,YAML 格式更简洁,JSON 格式支持的工具更丰富,大家可根据个人喜好进行选择。 - -[har2case]: https://github.com/HttpRunner/har2case \ No newline at end of file diff --git a/docs/prepare/request-hook.md b/docs/prepare/request-hook.md deleted file mode 100644 index 6b268040..00000000 --- a/docs/prepare/request-hook.md +++ /dev/null @@ -1,145 +0,0 @@ -## 概述 - -HttpRunner 从 `1.4.5` 版本开始实现了全新的 hook 机制,可以在请求前和请求后调用钩子函数。 - -## 调用 hook 函数 - -hook 机制分为两个层级: - -- 测试用例层面(testcase) -- 测试步骤层面(teststep) - -### 测试用例层面(testcase) - -在 YAML/JSON 测试用例的 `config` 中新增关键字 `setup_hooks` 和 `teardown_hooks`。 - -- setup_hooks: 在整个用例开始执行前触发 hook 函数,主要用于准备工作。 -- teardown_hooks: 在整个用例结束执行后触发 hook 函数,主要用于测试后的清理工作。 - -```yaml -- config: - name: basic test with httpbin - request: - base_url: http://127.0.0.1:3458/ - setup_hooks: - - ${hook_print(setup)} - teardown_hooks: - - ${hook_print(teardown)} -``` - -### 测试步骤层面(teststep) - -在 YAML/JSON 测试步骤的 `test` 中新增关键字 `setup_hooks` 和 `teardown_hooks`。 - -- setup_hooks: 在 HTTP 请求发送前执行 hook 函数,主要用于准备工作;也可以实现对请求的 request 内容进行预处理。 -- teardown_hooks: 在 HTTP 请求发送后执行 hook 函数,主要用于测试后的清理工作;也可以实现对响应的 response 进行修改,例如进行加解密等处理。 - -```json -"test": { - "name": "get token with $user_agent, $os_platform, $app_version", - "request": { - "url": "/api/get-token", - "method": "POST", - "headers": { - "app_version": "$app_version", - "os_platform": "$os_platform", - "user_agent": "$user_agent" - }, - "json": { - "sign": "${get_sign($user_agent, $device_sn, $os_platform, $app_version)}" - } - }, - "validate": [ - {"eq": ["status_code", 200]} - ], - "setup_hooks": [ - "${setup_hook_prepare_kwargs($request)}", - "${setup_hook_httpntlmauth($request)}" - ], - "teardown_hooks": [ - "${teardown_hook_sleep_N_secs($response, 2)}" - ] -} -``` - -## 编写 hook 函数 - -hook 函数的定义放置在项目的 `debugtalk.py` 中,在 YAML/JSON 中调用 hook 函数仍然是采用 `${func($a, $b)}` 的形式。 - -对于测试用例层面的 hook 函数,与 YAML/JSON 中自定义的函数完全相同,可通过自定义参数传参的形式来实现灵活应用。 - -```python -def hook_print(msg): - print(msg) -``` - -对于单个测试用例层面的 hook 函数,除了可传入自定义参数外,还可以传入与当前测试用例相关的信息,包括请求的 `$request` 和响应的 `$response`,用于实现更复杂场景的灵活应用。 - -### setup_hooks - -在测试步骤层面的 setup_hooks 函数中,除了可传入自定义参数外,还可以传入 `$request`,该参数对应着当前测试步骤 request 的全部内容。因为 request 是可变参数类型(dict),因此该函数参数为引用传递,当我们需要对请求参数进行预处理时尤其有用。 - -e.g. - -```python -def setup_hook_prepare_kwargs(request): - if request["method"] == "POST": - content_type = request.get("headers", {}).get("content-type") - if content_type and "data" in request: - # if request content-type is application/json, request data should be dumped - if content_type.startswith("application/json") and isinstance(request["data"], (dict, list)): - request["data"] = json.dumps(request["data"]) - - if isinstance(request["data"], str): - request["data"] = request["data"].encode('utf-8') - -def setup_hook_httpntlmauth(request): - if "httpntlmauth" in request: - from requests_ntlm import HttpNtlmAuth - auth_account = request.pop("httpntlmauth") - request["auth"] = HttpNtlmAuth( - auth_account["username"], auth_account["password"]) -``` - -通过上述的 `setup_hook_prepare_kwargs` 函数,可以实现根据请求方法和请求的 Content-Type 来对请求的 data 进行加工处理;通过 `setup_hook_httpntlmauth` 函数,可以实现 HttpNtlmAuth 权限授权。 - -### teardown_hooks - -在测试步骤层面的 teardown_hooks 函数中,除了可传入自定义参数外,还可以传入 `$response`,该参数对应着当前请求的响应实例(requests.Response)。 - -e.g. - -```python -def teardown_hook_sleep_N_secs(response, n_secs): - """ sleep n seconds after request - """ - if response.status_code == 200: - time.sleep(0.1) - else: - time.sleep(n_secs) -``` - -通过上述的 `teardown_hook_sleep_N_secs` 函数,可以根据接口响应的状态码来进行不同时间的延迟等待。 - -另外,在 teardown_hooks 函数中还可以对 response 进行修改。当我们需要先对响应内容进行处理(例如加解密、参数运算),再进行参数提取(extract)和校验(validate)时尤其有用。 - -例如在下面的测试步骤中,在执行测试后,通过 teardown_hooks 函数将响应结果的状态码和 headers 进行了修改,然后再进行了校验。 - -```yaml -- test: - name: alter response - request: - url: /headers - method: GET - teardown_hooks: - - ${alter_response($response)} - validate: - - eq: ["status_code", 500] - - eq: ["headers.content-type", "html/text"] -``` - -```python -def alter_response(response): - response.status_code = 500 - response.headers["Content-Type"] = "html/text" -``` diff --git a/docs/prepare/security.md b/docs/prepare/security.md deleted file mode 100644 index 2ad9b3b2..00000000 --- a/docs/prepare/security.md +++ /dev/null @@ -1,19 +0,0 @@ - -## 背景 - -很多时候项目代码在运行时需要使用到账号、密码、key等敏感数据信息,但是从信息安全的角度考虑,我们是不能将这些敏感数据提交到代码仓库的,主要原因有两个: - -- 加强权限管控:参与项目的开发人员可能会有很多,大家都有读取代码仓库的权限,但是像 key 这类极度敏感的信息不应该所有人都有权限获取; -- 减少代码泄漏的危害性:假如代码出现泄漏,敏感数据信息不应该也同时泄漏。 - -## 解决方案 - -那代码部署到服务器或 Jenkins 执行机后,运行时要使用到这些敏感数据信息,该怎么操作呢? - -推荐的操作方式为: - -- 对服务器进行权限管控,只有运维人员(或者核心开发人员)才有登录服务器的权限; -- 运维人员(或者核心开发人员):在运行的机器上将敏感数据设置到系统的环境变量中; -- 普通开发人员:只需要知道敏感信息的变量名称,在代码中通过读取环境变量的方式获取敏感数据。 - -存储敏感数据(设置环境变量)和使用敏感数据(引用环境变量)的具体方法,可参考[环境变量](/prepare/dot-env/)使用说明文档。 diff --git a/docs/prepare/testcase-layer.md b/docs/prepare/testcase-layer.md deleted file mode 100644 index eaf31df8..00000000 --- a/docs/prepare/testcase-layer.md +++ /dev/null @@ -1,272 +0,0 @@ - - -## 测试用例分层模型 - -在自动化测试领域,自动化测试用例的可维护性是极其重要的因素,直接关系到自动化测试能否持续有效地在项目中开展。 - -概括来说,测试用例分层机制的核心是将接口定义、测试步骤、测试用例、测试场景进行分离,单独进行描述和维护,从而尽可能地减少自动化测试用例的维护成本。 - -逻辑关系图如下所示: - -![](../images/testcase-layer.png) - -同时,强调如下几点核心概念: - -- 测试用例(testcase)应该是完整且独立的,每条测试用例应该是都可以独立运行的 -- 测试用例是测试步骤(teststep)的 `有序` 集合,每一个测试步骤对应一个 API 的请求描述 -- 测试用例集(testsuite)是测试用例的 `无序` 集合,集合中的测试用例应该都是相互独立,不存在先后依赖关系的;如果确实存在先后依赖关系,那就需要在测试用例中完成依赖的处理 - -如果对于上述第三点感觉难以理解,不妨看下上图中的示例: - -- testcase1 依赖于 testcase2,那么就可以在测试步骤(teststep12)中对 testcase2 进行引用,然后 testcase1 就是完整且可独立运行的; -- 在 testsuite 中,testcase1 与 testcase2 相互独立,运行顺序就不再有先后依赖关系了。 - -## 分层描述详解 - -理解了测试用例分层模型,接下来我们再来看下在分层模型下,接口、测试用例、测试用例集的描述形式。 - -### 接口定义(API) - -为了更好地对接口描述进行管理,推荐使用独立的文件对接口描述进行存储,即每个文件对应一个接口描述。 - -接口定义描述的主要内容包括:**name**、variables、**request**、base_url、validate 等,形式如下: - -```yaml -name: get headers -base_url: http://httpbin.org -variables: - expected_status_code: 200 -request: - url: /headers - method: GET -validate: - - eq: ["status_code", $expected_status_code] - - eq: [content.headers.Host, "httpbin.org"] -``` - -其中,name 和 request 部分是必须的,request 中的描述形式与 [requests.request](http://docs.python-requests.org/en/master/api/) 完全相同。 - -另外,API 描述需要尽量保持完整,做到可以单独运行。如果在接口描述中存在变量引用的情况,可在 variables 中对参数进行定义。通过这种方式,可以很好地实现单个接口的调试。 - -```bash -$ hrun api/get_headers.yml -INFO Start to run testcase: get headers -headers -INFO GET http://httpbin.org/headers -INFO status_code: 200, response_time(ms): 477.32 ms, response_length: 157 bytes - -. - ----------------------------------------------------------------------- -Ran 1 test in 0.478s - -OK -``` - -### 测试用例(testcase) - -#### 引用接口定义 - -有了接口的定义描述后,我们编写测试场景时就可以直接引用接口定义了。 - -在测试步骤(teststep)中,可通过 `api` 字段引用接口定义,引用方式为对应 API 文件的路径,绝对路径或相对路径均可。推荐使用相对路径,路径基准为项目根目录,即 `debugtalk.py` 所在的目录路径。 - -```yaml -- config: - name: "setup and reset all." - 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 - output: - - session_token - -- test: - 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] - -- test: - name: reset all users - api: api/reset_all.yml - variables: - token: $session_token -``` - -若需要控制或改变接口定义中的参数值,可在测试步骤中指定 variables 参数,覆盖 API 中的 variables 实现。 - -同样地,在测试步骤中定义 validate 后,也会与 API 中的 validate 合并覆盖。因此推荐的做法是,在 API 定义中的 validate 只描述最基本的校验项,例如 status_code,对于与业务逻辑相关的更多校验项,在测试步骤的 validate 中进行描述。 - -#### 引用测试用例 - -在测试用例的测试步骤中,除了可以引用接口定义,还可以引用其它测试用例。通过这种方式,可以在避免重复描述的同时,解决测试用例的依赖关系,从而保证每个测试用例都是独立可运行的。 - -在测试步骤(teststep)中,可通过 `testcase` 字段引用其它测试用例,引用方式为对应测试用例文件的路径,绝对路径或相对路径均可。推荐使用相对路径,路径基准为项目根目录,即 `debugtalk.py` 所在的目录路径。 - -例如,在上面的测试用例("setup and reset all.")中,实现了对获取 token 功能的测试;同时,在很多其它功能中都会依赖于获取 token 的功能,如果将该功能的测试步骤脚本拷贝到其它功能的测试用例中,那么就会存在大量重复,当需要对该部分进行修改时就需要修改所有地方,显然不便于维护。 - -比较好的做法是,在其它功能的测试用例(如创建用户)中,引用获取 token 功能的测试用例(testcases/setup.yml)作为一个测试步骤,从而创建用户("create user and check result.")这个测试用例也变得独立可运行了。 - -```yaml -- 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" - output: - - session_token - -- test: - name: setup and reset all (override) for $device_sn. - testcase: testcases/setup.yml - output: - - session_token - -- test: - name: create user and check result. - variables: - token: $session_token - testcase: testcases/deps/check_and_create.yml -``` - -### 测试用例集(testsuite) - -当测试用例数量比较多以后,为了方便管理和实现批量运行,通常需要使用测试用例集来对测试用例进行组织。 - -在前文的测试用例分层模型中也强调了,测试用例集(testsuite)是测试用例的 `无序` 集合,集合中的测试用例应该都是相互独立,不存在先后依赖关系的;如果确实存在先后依赖关系,那就需要在测试用例中完成依赖的处理。 - -因为是 `无序` 集合,因此测试用例集的描述形式会与测试用例有些不同,在每个测试用例集文件中,第一层级存在两类字段: - -- config: 测试用例集的总体配置参数 -- testcases: 值为字典结构(无序),key 为测试用例的名称,value 为测试用例的内容;在引用测试用例时也可以指定 variables,实现对引用测试用例中 variables 的覆盖。 - -#### 非参数化场景 - -```yaml -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: - 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 -``` - -#### 参数化场景(parameters) - -对于参数化场景,可通过 parameters 实现,描述形式如下所示。 - -```yaml -config: - name: create users with parameters - variables: - device_sn: ${gen_random_string(15)} - 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] -``` - -参数化后,parameters 中的变量将采用笛卡尔积组合形成参数列表,依次覆盖 variables 中的参数,驱动测试用例的运行。 - -## 文件目录结构管理 && 脚手架工具 - -在对测试用例文件进行组织管理时,对于文件的存储位置均没有要求和限制,在引用时只需要指定对应的文件路径即可。但从约定大于配置的角度,最好是按照推荐的文件夹名称进行存储管理,并可通过子目录实现项目模块分类管理。 - -推荐的方式汇总如下: - -- `debugtalk.py` 放置在项目根目录下,假设为 `PRJ_ROOT_DIR` -- `.env` 放置在项目根目录下,路径为 `PRJ_ROOT_DIR/.env` -- 接口定义(API)放置在 `PRJ_ROOT_DIR/api/` 目录下 -- 测试用例(testcase)放置在 `PRJ_ROOT_DIR/testcases/` 目录下 -- 测试用例集(testsuite)文件必须放置在 `PRJ_ROOT_DIR/testsuites/` 目录下 -- data 文件夹:存储参数化文件,或者项目依赖的文件,路径为 `PRJ_ROOT_DIR/data/` -- reports 文件夹:存储 HTML 测试报告,生成路径为 `PRJ_ROOT_DIR/reports/` - -目录结构如下所示: - -```bash -$ tree tests -tests -├── .env -├── data -│ ├── app_version.csv -│ └── account.csv -├── api -│ ├── create_user.yml -│ ├── get_headers.yml -│ ├── get_token.yml -│ ├── get_user.yml -│ └── reset_all.yml -├── debugtalk.py -├── testcases -│ ├── create_user.yml -│ ├── deps -│ │ └── check_and_create.yml -│ └── setup.yml -└── testsuites - ├── create_users.yml - └── create_users_with_parameters.yml -``` - -**项目脚手架** - -同时,在 `HttpRunner` 中实现了一个脚手架工具,可以快速创建项目的目录结构。该想法来源于 `Django` 的 `django-admin.py startproject project_name`。 - -使用方式也与 `Django` 类似,只需要通过 `--startproject` 指定新项目的名称即可。 - -```bash -$ hrun --startproject demo -Start to create new project: demo -CWD: /Users/debugtalk/MyProjects/examples - -created folder: demo -created folder: demo/api -created folder: demo/testcases -created folder: demo/testsuites -created folder: demo/reports -created file: demo/debugtalk.py -created file: demo/.env -``` - - -## 相关参考 - -- [《HttpRunner 的测试用例分层机制(已过期)》](/post/HttpRunner-testcase-layer) -- 测试用例分层详细示例:[HttpRunner/tests](https://github.com/HttpRunner/HttpRunner/tree/master/tests) diff --git a/docs/prepare/testcase-structure.md b/docs/prepare/testcase-structure.md deleted file mode 100644 index 04c27980..00000000 --- a/docs/prepare/testcase-structure.md +++ /dev/null @@ -1,178 +0,0 @@ - -## YAML & JSON - -HttpRunner 的测试用例支持两种文件格式:YAML 和 JSON。 - -JSON 和 YAML 格式的测试用例完全等价,包含的信息内容也完全相同。 - -- 对于新手来说,推荐使用 JSON 格式,虽然描述形式上稍显累赘,但是不容易出错(大多编辑器都具有 JSON 格式的检测功能);同时,HttpRunner 也内置了 JSON 格式正确性检测和样式美化功能,详情可查看[《Validate & Prettify》](/testcase//validate-pretty.md)。 -- 对于熟悉 YAML 格式的人来说,编写维护 YAML 格式的测试用例会更简洁,但前提是要保证 YAML 格式没有语法错误。 - -对于两种格式的展示差异,可以对比查看 [demo-quickstart-6.json](/data/demo-quickstart-6.json) 和 [demo-quickstart-6.yml](/data/demo-quickstart-6.yml) 获取初步的印象。 - -后面为了更清晰的描述,统一采用 JSON 格式作为示例。 - -## 测试用例结构 - -![testcase-structure](../images/testcase-structure.png) - -在 HttpRunner 中,测试用例组织主要基于三个概念: - -- 测试用例集(testsuite):对应一个文件夹,包含单个或多个测试用例(`YAML/JSON`)文件 -- 测试用例(testcase):对应一个 `YAML/JSON` 文件,包含单个或多个测试步骤 -- 测试步骤(teststep):对应 `YAML/JSON` 文件中的一个 `test`,描述单次接口测试的全部内容,包括发起接口请求、解析响应结果、校验结果等 - -对于单个 `YAML/JSON` 文件来说,数据存储结构为 `list of dict` 的形式,其中可能包含一个全局配置项(config)和若干个测试步骤(test)。 - -具体地: - -- config:作为整个测试用例的全局配置项 -- test:对应单个测试步骤(teststep),测试用例存在顺序关系,运行时将从前往后依次运行各个测试步骤 - - -对应的 JSON 格式如下所示: - -```json -[ - { - "config": {...} - }, - { - "test": {...} - }, - { - "test": {...} - } -] -``` - -## 变量空间(context)作用域 - -在测试用例内部,HttpRunner 划分了两层变量空间作用域(context)。 - -- config:作为整个测试用例的全局配置项,作用域为整个测试用例; -- test:测试步骤的变量空间(context)会继承或覆盖 config 中定义的内容; - - 若某变量在 config 中定义了,在某 test 中没有定义,则该 test 会继承该变量 - - 若某变量在 config 和某 test 中都定义了,则该 test 中使用自己定义的变量值 -- 各个测试步骤(test)的变量空间相互独立,互不影响; -- 如需在多个测试步骤(test)中传递参数值,则需要使用 extract 关键字,并且只能从前往后传递 - - - - -## config - -```json -"config": { - "name": "testcase description", - "parameters": [ - {"user_agent": ["iOS/10.1", "iOS/10.2", "iOS/10.3"]}, - {"app_version": "${P(app_version.csv)}"}, - {"os_platform": "${get_os_platform()}"} - ], - "variables": [ - {"user_agent": "iOS/10.3"}, - {"device_sn": "${gen_random_string(15)}"}, - {"os_platform": "ios"} - ], - "request": { - "base_url": "http://127.0.0.1:5000", - "headers": { - "Content-Type": "application/json", - "device_sn": "$device_sn" - } - }, - "output": [ - "token" - ] -} -``` - - Key | required? | format | descrption - --- | --------- | ------ | ---------- - name | Yes | string | 测试用例的名称,在测试报告中将作为标题 - variables | No | list of dict | 定义的全局变量,作用域为整个用例 - parameters | No | list of dict | 全局参数,用于实现数据化驱动,作用域为整个用例 - request | No | dict | request 的公共参数,作用域为整个用例;常用参数包括 base_url 和 headers - -**request** - - Key | required? | format | descrption ----------|----------|---------|--------- - base_url | No | string | 测试用例请求 URL 的公共 host,指定该参数后,test 中的 url 可以只描述 path 部分 - headers | No | dict | request 中 headers 的公共参数,作用域为整个用例 - output | No | list | 整个用例输出的参数列表,可输出的参数包括公共的 variable 和 extract 的参数; 在 log-level 为 debug 模式下,会在 terminal 中打印出参数内容 - -## test - -```json -"test": { - "name": "get token with $user_agent, $os_platform, $app_version", - "request": { - "url": "/api/get-token", - "method": "POST", - "headers": { - "app_version": "$app_version", - "os_platform": "$os_platform", - "user_agent": "$user_agent" - }, - "json": { - "sign": "${get_sign($user_agent, $device_sn, $os_platform, $app_version)}" - }, - "extract": [ - {"token": "content.token"} - ], - "validate": [ - {"eq": ["status_code", 200]}, - {"eq": ["headers.Content-Type", "application/json"]}, - {"eq": ["content.success", true]} - ], - "setup_hooks": [], - "teardown_hooks": [] - } -} -``` - - Key | required? | format | descrption - --- | --------- | ------ | ---------- - name | Yes | string | 测试步骤的名称,在测试报告中将作为测试步骤的名称 - request | Yes | dict | HTTP 请求的详细内容;可用参数详见 [python-requests][1] 官方文档 - variables | No | list of dict | 测试步骤中定义的变量,作用域为当前测试步骤 - extract | No | list | 从当前 HTTP 请求的响应结果中提取参数,并保存到参数变量中(例如`token`),后续测试用例可通过`$token`的形式进行引用 - validate | No | list | 测试用例中定义的结果校验项,作用域为当前测试用例,用于实现对当前测试用例运行结果的校验 - setup_hooks | No | list | 在 HTTP 请求发送前执行 hook 函数,主要用于准备工作 - teardown_hooks | No | list | 在 HTTP 请求发送后执行 hook 函数,主要用户测试后的清理工作 - -**extract** - -支持多种提取方式: - -- 响应结果为 JSON 结构,可采用`.`运算符的方式,例如`headers.Content-Type`、`content.success`; -- 响应结果为 text/html 结构,可采用正则表达式的方式,例如`blog-motto\">(.*)` -- 详情可阅读[《ApiTestEngine,不再局限于API的测试》][2] - -**validate** - -支持两种格式: - -- `{"comparator_name": [check_item, expect_value]}` -- `{"check": check_item, "comparator": comparator_name, "expect": expect_value}` - -**hooks** - -setup_hooks 函数放置于 debugtalk.py 中,并且必须包含三个参数: - -- method: 请求方法,e.g. GET, POST, PUT -- url: 请求 URL -- kwargs: request 的参数字典 - -teardown_hooks 函数放置于 debugtalk.py 中,并且必须包含一个参数: - -- resp_obj: requests.Response 实例 - -关于 `setup_hooks` 和 `teardown_hooks` 的更多内容,请参考[《hook 机制》][3]。 - - -[1]: http://docs.python-requests.org/en/master/api/#main-interface -[2]: http://debugtalk.com/post/apitestengine-not-only-about-json-api/ -[3]: ../../prepare/request-hook/ diff --git a/docs/prepare/upload-case.md b/docs/prepare/upload-case.md deleted file mode 100644 index 21b00cb0..00000000 --- a/docs/prepare/upload-case.md +++ /dev/null @@ -1,51 +0,0 @@ - -对于上传文件类型的测试场景,HttpRunner 集成 [requests_toolbelt][1] 实现了上传功能。 - -在使用之前,确保已安装如下依赖库: - -- [requests_toolbelt](https://github.com/requests/toolbelt) -- [filetype](https://github.com/h2non/filetype.py) - -使用内置 `upload` 关键字,可轻松实现上传功能(适用版本:2.4.1+)。 - -```yaml -- test: - name: upload file - request: - url: http://httpbin.org/upload - method: POST - headers: - Cookie: session=AAA-BBB-CCC - upload: - file: "data/file_to_upload" - field1: "value1" - field2: "value2" - validate: - - eq: ["status_code", 200] -``` - -同时,你也可以继续使用之前描述形式(适用版本:2.0+)。 - -```yaml -- test: - name: upload file - variables: - file: "data/file_to_upload" - field1: "value1" - field2: "value2" - m_encoder: ${multipart_encoder(file=$file, field1=$field1, field2=$field2)} - request: - url: http://httpbin.org/upload - method: POST - headers: - Content-Type: ${multipart_content_type($m_encoder)} - Cookie: session=AAA-BBB-CCC - data: $m_encoder - validate: - - eq: ["status_code", 200] -``` - -参考案例:[httprunner/tests/httpbin/upload.yml][2] - -[1]: https://toolbelt.readthedocs.io/en/latest/uploading-data.html -[2]: https://github.com/httprunner/httprunner/blob/master/tests/httpbin/upload.yml \ No newline at end of file diff --git a/docs/quickstart.md b/docs/quickstart.md deleted file mode 100644 index 4e957ada..00000000 --- a/docs/quickstart.md +++ /dev/null @@ -1,624 +0,0 @@ -本文将通过一个简单的示例来展示 HttpRunner 的核心功能使用方法。 - -## 案例介绍 - -该案例作为被测服务,主要有两类接口: - -- 权限校验,获取 token -- 支持 CRUD 操作的 RESTful APIs,所有接口的请求头域中都必须包含有效的 token - -案例的实现形式为 flask 应用服务([api_server.py](data/api_server.py)),启动方式如下: - -```text -$ export FLASK_APP=docs/data/api_server.py -$ export FLASK_ENV=development -$ flask run - * Serving Flask app "docs/data/api_server.py" (lazy loading) - * Environment: development - * Debug mode: on - * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit) - * Restarting with stat - * Debugger is active! - * Debugger PIN: 989-476-348 -``` - -服务启动成功后,我们就可以开始对其进行测试了。 - -## 测试准备 - -### 抓包分析 - -在开始测试之前,我们需要先了解接口的请求和响应细节,而最佳的方式就是采用 `Charles Proxy` 或者 `Fiddler` 这类网络抓包工具进行抓包分析。 - -例如,在本案例中,我们先进行权限校验,然后成功创建一个用户,对应的网络抓包内容如下图所示: - -![](./images/demo-quickstart-http-1.jpg) - -![](./images/demo-quickstart-http-2.jpg) - -通过抓包,我们可以看到具体的接口信息,包括请求的URL、Method、headers、参数和响应内容等内容,基于这些信息,我们就可以开始编写测试用例了。 - -### 生成测试用例 - -为了简化测试用例的编写工作,HttpRunner 实现了测试用例生成的功能。 - -首先,需要将抓取得到的数据包导出为 HAR 格式的文件,假设导出的文件名称为 [demo-quickstart.har](data/demo-quickstart.har)。 - -然后,在命令行终端中运行如下命令,即可将 demo-quickstart.har 转换为 HttpRunner 的测试用例文件。 - -```bash -$ har2case docs/data/demo-quickstart.har -2y -INFO:root:Start to generate testcase. -INFO:root:dump testcase to YAML format. -INFO:root:Generate YAML testcase successfully: docs/data/demo-quickstart.yml -``` - -使用 `har2case` 转换脚本时默认转换为 JSON 格式,加上 `-2y` 参数后转换为 YAML 格式。两种格式完全等价,YAML 格式更简洁,JSON 格式支持的工具更丰富,大家可根据个人喜好进行选择。关于 [har2case][har2case] 的详细使用说明,请查看[《录制生成测试用例》](/prepare/record/)。 - -经过转换,在源 demo-quickstart.har 文件的同级目录下生成了相同文件名称的 YAML 格式测试用例文件 [demo-quickstart.yml](data/demo-quickstart.yml),其内容如下: - -```yaml -- config: - name: testcase description - variables: {} - -- test: - name: /api/get-token - request: - headers: - Content-Type: application/json - User-Agent: python-requests/2.18.4 - app_version: 2.8.6 - device_sn: FwgRiO7CNA50DSU - os_platform: ios - json: - sign: 9c0c7e51c91ae963c833a4ccbab8d683c4a90c98 - method: POST - url: http://127.0.0.1:5000/api/get-token - validate: - - eq: [status_code, 200] - - eq: [headers.Content-Type, application/json] - - eq: [content.success, true] - - eq: [content.token, baNLX1zhFYP11Seb] - -- test: - name: /api/users/1000 - request: - headers: - Content-Type: application/json - User-Agent: python-requests/2.18.4 - device_sn: FwgRiO7CNA50DSU - token: baNLX1zhFYP11Seb - json: - name: user1 - password: '123456' - method: POST - url: http://127.0.0.1:5000/api/users/1000 - validate: - - eq: [status_code, 201] - - eq: [headers.Content-Type, application/json] - - eq: [content.success, true] - - eq: [content.msg, user created successfully.] -``` - -现在我们只需要知道如下几点: - -- 每个 YAML/JSON 文件对应一个测试用例(testcase) -- 每个测试用例为一个`list of dict`结构,其中可能包含全局配置项(config)和若干个测试步骤(test) -- `config` 为全局配置项,作用域为整个测试用例 -- `test` 对应单个测试步骤,作用域仅限于本身 - -如上便是 HttpRunner 测试用例的基本结构。 - -关于测试用例的更多内容,请查看[《测试用例结构描述》](/prepare/testcase-structure/)。 - -### 首次运行测试用例 - -测试用例就绪后,我们可以开始调试运行了。 - -为了演示测试用例文件的迭代优化过程,我们先将 demo-quickstart.json 重命名为 [demo-quickstart-0.json](data/demo-quickstart-0.json)(对应的 YAML 格式:[demo-quickstart-0.yml](data/demo-quickstart-0.yml))。 - -运行测试用例的命令为`hrun`,后面直接指定测试用例文件的路径即可。 - -```text -$ hrun docs/data/demo-quickstart-0.yml -INFO Start to run testcase: testcase description -/api/get-token -INFO POST http://127.0.0.1:5000/api/get-token -INFO status_code: 200, response_time(ms): 9.26 ms, response_length: 46 bytes - -ERROR validate: content.token equals baNLX1zhFYP11Seb(str) ==> fail -tXGuSQgOCVXcltkz(str) equals baNLX1zhFYP11Seb(str) -ERROR ******************************** DETAILED REQUEST & RESPONSE ******************************** -====== request details ====== -url: http://127.0.0.1:5000/api/get-token -method: POST -headers: {'Content-Type': 'application/json', 'User-Agent': 'python-requests/2.18.4', 'app_version': '2.8.6', 'device_sn': 'FwgRiO7CNA50DSU', 'os_platform': 'ios'} -json: {'sign': '9c0c7e51c91ae963c833a4ccbab8d683c4a90c98'} -verify: True - -====== response details ====== -status_code: 200 -headers: {'Content-Type': 'application/json', 'Content-Length': '46', 'Server': 'Werkzeug/0.14.1 Python/3.7.0', 'Date': 'Sat, 26 Jan 2019 14:43:55 GMT'} -body: '{"success": true, "token": "tXGuSQgOCVXcltkz"}' - -F -/api/users/1000 -INFO POST http://127.0.0.1:5000/api/users/1000 -ERROR 403 Client Error: FORBIDDEN for url: http://127.0.0.1:5000/api/users/1000 -ERROR validate: status_code equals 201(int) ==> fail -403(int) equals 201(int) -ERROR validate: content.success equals True(bool) ==> fail -False(bool) equals True(bool) -ERROR validate: content.msg equals user created successfully.(str) ==> fail -Authorization failed!(str) equals user created successfully.(str) -ERROR ******************************** DETAILED REQUEST & RESPONSE ******************************** -====== request details ====== -url: http://127.0.0.1:5000/api/users/1000 -method: POST -headers: {'Content-Type': 'application/json', 'User-Agent': 'python-requests/2.18.4', 'device_sn': 'FwgRiO7CNA50DSU', 'token': 'baNLX1zhFYP11Seb'} -json: {'name': 'user1', 'password': '123456'} -verify: True - -====== response details ====== -status_code: 403 -headers: {'Content-Type': 'application/json', 'Content-Length': '50', 'Server': 'Werkzeug/0.14.1 Python/3.7.0', 'Date': 'Sat, 26 Jan 2019 14:43:55 GMT'} -body: '{"success": false, "msg": "Authorization failed!"}' - -F - -====================================================================== -FAIL: test_0000_000 (httprunner.api.TestSequense) -/api/get-token ----------------------------------------------------------------------- -Traceback (most recent call last): - File "/Users/debugtalk/.pyenv/versions/3.6-dev/lib/python3.6/site-packages/httprunner/api.py", line 54, in test - test_runner.run_test(test_dict) -httprunner.exceptions.ValidationFailure: validate: content.token equals baNLX1zhFYP11Seb(str) ==> fail -tXGuSQgOCVXcltkz(str) equals baNLX1zhFYP11Seb(str) - -During handling of the above exception, another exception occurred: - -Traceback (most recent call last): - File "/Users/debugtalk/.pyenv/versions/3.6-dev/lib/python3.6/site-packages/httprunner/api.py", line 56, in test - self.fail(str(ex)) -AssertionError: validate: content.token equals baNLX1zhFYP11Seb(str) ==> fail -tXGuSQgOCVXcltkz(str) equals baNLX1zhFYP11Seb(str) - -====================================================================== -FAIL: test_0001_000 (httprunner.api.TestSequense) -/api/users/1000 ----------------------------------------------------------------------- -Traceback (most recent call last): - File "/Users/debugtalk/.pyenv/versions/3.6-dev/lib/python3.6/site-packages/httprunner/api.py", line 54, in test - test_runner.run_test(test_dict) -httprunner.exceptions.ValidationFailure: validate: status_code equals 201(int) ==> fail -403(int) equals 201(int) -validate: content.success equals True(bool) ==> fail -False(bool) equals True(bool) -validate: content.msg equals user created successfully.(str) ==> fail -Authorization failed!(str) equals user created successfully.(str) - -During handling of the above exception, another exception occurred: - -Traceback (most recent call last): - File "/Users/debugtalk/.pyenv/versions/3.6-dev/lib/python3.6/site-packages/httprunner/api.py", line 56, in test - self.fail(str(ex)) -AssertionError: validate: status_code equals 201(int) ==> fail -403(int) equals 201(int) -validate: content.success equals True(bool) ==> fail -False(bool) equals True(bool) -validate: content.msg equals user created successfully.(str) ==> fail -Authorization failed!(str) equals user created successfully.(str) - ----------------------------------------------------------------------- -Ran 2 tests in 0.026s - -FAILED (failures=2) -INFO Start to render Html report ... -INFO Generated Html report: /Users/debugtalk/MyProjects/HttpRunner-dev/httprunner-docs-v2x/reports/1548513835.html -``` - -非常不幸,两个接口的测试用例均运行失败了。 - -## 优化测试用例 - -从两个测试步骤的报错信息和堆栈信息(Traceback)可以看出,第一个步骤失败的原因是获取的 token 与预期值不一致,第二个步骤失败的原因是请求权限校验失败(403)。 - -接下来我们将逐步进行进行优化。 - -### 调整校验器 - -默认情况下,[har2case][har2case] 生成用例时,若 HTTP 请求的响应内容为 JSON 格式,则会将第一层级中的所有`key-value`转换为 validator。 - -例如上面的第一个测试步骤,生成的 validator 为: - -```json -"validate": [ - {"eq": ["status_code", 200]}, - {"eq": ["headers.Content-Type", "application/json"]}, - {"eq": ["content.success", true]}, - {"eq": ["content.token", "baNLX1zhFYP11Seb"]} -] -``` - -运行测试用例时,就会对上面的各个项进行校验。 - -问题在于,请求`/api/get-token`接口时,每次生成的 token 都会是不同的,因此将生成的 token 作为校验项的话,校验自然就无法通过了。 - -正确的做法是,在测试步骤的 validate 中应该去掉这类动态变化的值。 - -去除该项后,将用例另存为 [demo-quickstart-1.json](data/demo-quickstart-1.json)(对应的 YAML 格式:[demo-quickstart-1.yml](data/demo-quickstart-1.yml))。 - -再次运行测试用例,运行结果如下: - -```text -$ hrun docs/data/demo-quickstart-1.yml -INFO Start to run testcase: testcase description -/api/get-token -INFO POST http://127.0.0.1:5000/api/get-token -INFO status_code: 200, response_time(ms): 6.61 ms, response_length: 46 bytes - -. -/api/users/1000 -INFO POST http://127.0.0.1:5000/api/users/1000 -ERROR 403 Client Error: FORBIDDEN for url: http://127.0.0.1:5000/api/users/1000 -ERROR validate: status_code equals 201(int) ==> fail -403(int) equals 201(int) -ERROR validate: content.success equals True(bool) ==> fail -False(bool) equals True(bool) -ERROR validate: content.msg equals user created successfully.(str) ==> fail -Authorization failed!(str) equals user created successfully.(str) -ERROR ******************************** DETAILED REQUEST & RESPONSE ******************************** -====== request details ====== -url: http://127.0.0.1:5000/api/users/1000 -method: POST -headers: {'Content-Type': 'application/json', 'User-Agent': 'python-requests/2.18.4', 'device_sn': 'FwgRiO7CNA50DSU', 'token': 'baNLX1zhFYP11Seb'} -json: {'name': 'user1', 'password': '123456'} -verify: True - -====== response details ====== -status_code: 403 -headers: {'Content-Type': 'application/json', 'Content-Length': '50', 'Server': 'Werkzeug/0.14.1 Python/3.7.0', 'Date': 'Sat, 26 Jan 2019 14:45:34 GMT'} -body: '{"success": false, "msg": "Authorization failed!"}' - -F - -====================================================================== -FAIL: test_0001_000 (httprunner.api.TestSequense) -/api/users/1000 ----------------------------------------------------------------------- -Traceback (most recent call last): - File "/Users/debugtalk/.pyenv/versions/3.6-dev/lib/python3.6/site-packages/httprunner/api.py", line 54, in test - test_runner.run_test(test_dict) -httprunner.exceptions.ValidationFailure: validate: status_code equals 201(int) ==> fail -403(int) equals 201(int) -validate: content.success equals True(bool) ==> fail -False(bool) equals True(bool) -validate: content.msg equals user created successfully.(str) ==> fail -Authorization failed!(str) equals user created successfully.(str) - -During handling of the above exception, another exception occurred: - -Traceback (most recent call last): - File "/Users/debugtalk/.pyenv/versions/3.6-dev/lib/python3.6/site-packages/httprunner/api.py", line 56, in test - self.fail(str(ex)) -AssertionError: validate: status_code equals 201(int) ==> fail -403(int) equals 201(int) -validate: content.success equals True(bool) ==> fail -False(bool) equals True(bool) -validate: content.msg equals user created successfully.(str) ==> fail -Authorization failed!(str) equals user created successfully.(str) - ----------------------------------------------------------------------- -Ran 2 tests in 0.018s - -FAILED (failures=1) -INFO Start to render Html report ... -INFO Generated Html report: /Users/debugtalk/MyProjects/HttpRunner-dev/httprunner-docs-v2x/reports/1548513934.html -``` - -经过修改,第一个测试步骤已经运行成功了,第二个步骤仍然运行失败(403),还是因为权限校验的原因。 - -### 参数关联 - -我们继续查看 [demo-quickstart-1.json](data/demo-quickstart-1.json),会发现第二个测试步骤的请求 headers 中的 token 仍然是硬编码的,即抓包时获取到的值。在我们再次运行测试用例时,这个 token 已经失效了,所以会出现 403 权限校验失败的问题。 - -正确的做法是,我们应该在每次运行测试用例的时候,先动态获取到第一个测试步骤中的 token,然后在后续测试步骤的请求中使用前面获取到的 token。 - -在 HttpRunner 中,支持参数提取(`extract`)和参数引用的功能(`$var`)。 - -在测试步骤(test)中,若需要从响应结果中提取参数,则可使用 `extract` 关键字。extract 的列表中可指定一个或多个需要提取的参数。 - -在提取参数时,当 HTTP 的请求响应结果为 JSON 格式,则可以采用`.`运算符的方式,逐级往下获取到参数值;响应结果的整体内容引用方式为 content 或者 body。 - -例如,第一个接口`/api/get-token`的响应结果为: - -```json -{"success": true, "token": "ZQkYhbaQ6q8UFFNE"} -``` - -那么要获取到 token 参数,就可以使用 content.token 的方式;具体的写法如下: - -```json -"extract": [ - {"token": "content.token"} -] -``` - -其中,token 作为提取后的参数名称,可以在后续使用 `$token` 进行引用。 - -```json -"headers": { - "device_sn": "FwgRiO7CNA50DSU", - "token": "$token", - "Content-Type": "application/json" -} -``` - -修改后的测试用例另存为 [demo-quickstart-2.json](data/demo-quickstart-2.json)(对应的 YAML 格式:[demo-quickstart-2.yml](data/demo-quickstart-2.yml))。 - -再次运行测试用例,运行结果如下: - -```text -$ hrun docs/data/demo-quickstart-2.yml -INFO Start to run testcase: testcase description -/api/get-token -INFO POST http://127.0.0.1:5000/api/get-token -INFO status_code: 200, response_time(ms): 8.32 ms, response_length: 46 bytes - -. -/api/users/1000 -INFO POST http://127.0.0.1:5000/api/users/1000 -INFO status_code: 201, response_time(ms): 3.02 ms, response_length: 54 bytes - -. - ----------------------------------------------------------------------- -Ran 2 tests in 0.019s - -OK -INFO Start to render Html report ... -INFO Generated Html report: /Users/debugtalk/MyProjects/HttpRunner-dev/httprunner-docs-v2x/reports/1548514191.html -``` - -经过修改,第二个测试步骤也运行成功了。 - -### base_url - -虽然测试步骤运行都成功了,但是仍然有继续优化的地方。 - -继续查看 [demo-quickstart-2.json](data/demo-quickstart-2.json),我们会发现在每个测试步骤的 URL 中,都采用的是完整的描述(host+path),但大多数情况下同一个用例中的 host 都是相同的,区别仅在于 path 部分。 - -因此,我们可以将各个测试步骤(test) URL 的 `base_url` 抽取出来,放到全局配置模块(config)中,在测试步骤中的 URL 只保留 PATH 部分。 - -```yaml -- config: - name: testcase description - base_url: http://127.0.0.1:5000 - -- test: - name: get token - request: - url: /api/get-token -``` - -调整后的测试用例另存为 [demo-quickstart-3.json](data/demo-quickstart-3.json)(对应的 YAML 格式:[demo-quickstart-3.yml](data/demo-quickstart-3.yml))。 - -重启 flask 应用服务后再次运行测试用例,所有的测试步骤仍然运行成功。 - -### 变量的申明和引用 - -继续查看 [demo-quickstart-3.json](data/demo-quickstart-3.json),我们会发现测试用例中存在较多硬编码的参数,例如 app_version、device_sn、os_platform、user_id 等。 - -大多数情况下,我们可以不用修改这些硬编码的参数,测试用例也能正常运行。但是为了更好地维护测试用例,例如同一个参数值在测试步骤中出现多次,那么比较好的做法是,将这些参数定义为变量,然后在需要参数的地方进行引用。 - -在 HttpRunner 中,支持变量申明(`variables`)和引用(`$var`)的机制。在 config 和 test 中均可以通过 `variables` 关键字定义变量,然后在测试步骤中可以通过 `$ + 变量名称` 的方式引用变量。区别在于,在 config 中定义的变量为全局的,整个测试用例(testcase)的所有地方均可以引用;在 test 中定义的变量作用域仅局限于当前测试步骤(teststep)。 - -对上述各个测试步骤中硬编码的参数进行变量申明和引用调整后,新的测试用例另存为 [demo-quickstart-4.json](data/demo-quickstart-4.json)(对应的 YAML 格式:[demo-quickstart-4.yml](data/demo-quickstart-4.yml))。 - -重启 flask 应用服务后再次运行测试用例,所有的测试步骤仍然运行成功。 - -### 抽取公共变量 - -查看 [demo-quickstart-4.json](data/demo-quickstart-4.json) 可以看出,两个测试步骤中都定义了 device_sn。针对这类公共的参数,我们可以将其统一定义在 config 的 variables 中,在测试步骤中就不用再重复定义。 - -```yaml -- config: - name: testcase description - base_url: http://127.0.0.1:5000 - variables: - device_sn: FwgRiO7CNA50DSU -``` - -调整后的测试用例见 [demo-quickstart-5.json](data/demo-quickstart-5.json)(对应的 YAML 格式:[demo-quickstart-5.yml](data/demo-quickstart-5.yml))。 - -### 实现动态运算逻辑 - -在 [demo-quickstart-5.yml](data/demo-quickstart-5.yml) 中,参数 device_sn 代表的是设备的 SN 编码,虽然采用硬编码的方式暂时不影响测试用例的运行,但这与真实的用户场景不大相符。 - -假设 device_sn 的格式为 15 长度的字符串,那么我们就可以在每次运行测试用例的时候,针对 device_sn 生成一个 15 位长度的随机字符串。与此同时,sign 字段是根据 headers 中的各个字段拼接后生成得到的 MD5 值,因此在 device_sn 变动后,sign 也应该重新进行计算,否则就会再次出现签名校验失败的问题。 - -然而,HttpRunner 的测试用例都是采用 YAML/JSON 格式进行描述的,在文本格式中如何执行代码运算呢? - -HttpRunner 的实现方式为,支持热加载的插件机制(`debugtalk.py`),可以在 YAML/JSON 中调用 Python 函数。 - -具体地做法,我们可以在测试用例文件的同级或其父级目录中创建一个 debugtalk.py 文件,然后在其中定义相关的函数和变量。 - -例如,针对 device_sn 的随机字符串生成功能,我们可以定义一个 gen_random_string 函数;针对 sign 的签名算法,我们可以定义一个 get_sign 函数。 - -```python -import hashlib -import hmac -import random -import string - -SECRET_KEY = "DebugTalk" - -def gen_random_string(str_len): - random_char_list = [] - for _ in range(str_len): - random_char = random.choice(string.ascii_letters + string.digits) - random_char_list.append(random_char) - - random_string = ''.join(random_char_list) - return random_string - -def get_sign(*args): - content = ''.join(args).encode('ascii') - sign_key = SECRET_KEY.encode('ascii') - sign = hmac.new(sign_key, content, hashlib.sha1).hexdigest() - return sign -``` - -然后,我们在 YAML/JSON 测试用例文件中,就可以对定义的函数进行调用,对定义的变量进行引用了。引用变量的方式仍然与前面讲的一样,采用`$ + 变量名称`的方式;调用函数的方式为`${func($var)}`。 - -例如,生成 15 位长度的随机字符串并赋值给 device_sn 的代码为: - -```json -"variables": [ - {"device_sn": "${gen_random_string(15)}"} -] -``` - -使用 $user_agent、$device_sn、$os_platform、$app_version 根据签名算法生成 sign 值的代码为: - -```json -"json": { - "sign": "${get_sign($user_agent, $device_sn, $os_platform, $app_version)}" -} -``` - -对测试用例进行上述调整后,另存为 [demo-quickstart-6.json](data/demo-quickstart-6.json)(对应的 YAML 格式:[demo-quickstart-6.yml](data/demo-quickstart-6.yml))。 - -重启 flask 应用服务后再次运行测试用例,所有的测试步骤仍然运行成功。 - -### 参数化数据驱动 - -> 请确保你使用的 HttpRunner 版本号不低于 2.0.0 - -在 [demo-quickstart-6.yml](data/demo-quickstart-6.yml) 中,user_id 仍然是写死的值,假如我们需要创建 user_id 为 1001~1004 的用户,那我们只能不断地去修改 user_id,然后运行测试用例,重复操作 4 次?或者我们在测试用例文件中将创建用户的 test 复制 4 份,然后在每一份里面分别使用不同的 user_id ? - -很显然,不管是采用上述哪种方式,都会很繁琐,并且也无法应对灵活多变的测试需求。 - -针对这类需求,HttpRunner 支持参数化数据驱动的功能。 - -在 HttpRunner 中,若要采用数据驱动的方式来运行测试用例,需要创建一个文件,对测试用例进行引用,并使用 `parameters` 关键字定义参数并指定数据源取值方式。 - -例如,我们需要在创建用户的接口中对 user_id 进行参数化,参数化列表为 1001~1004,并且取值方式为顺序取值,那么最简单的描述方式就是直接指定参数列表。具体的编写方式为,新建一个测试场景文件 [demo-quickstart-7.yml](data/demo-quickstart-7.yml)(对应的 JSON 格式:[demo-quickstart-7.json](data/demo-quickstart-7.json)),内容如下所示: - -```yaml -config: - name: testcase description - -testcases: - create user: - testcase: demo-quickstart-6.yml - parameters: - user_id: [1001, 1002, 1003, 1004] -``` - -仅需如上配置,针对 user_id 的参数化数据驱动就完成了。 - -重启 flask 应用服务后再次运行测试用例,测试用例运行情况如下所示: - -
-点击查看运行日志 - -```text -$ hrun docs/data/demo-quickstart-7.json -INFO Start to run testcase: create user 1001 -/api/get-token -INFO POST http://127.0.0.1:5000/api/get-token -INFO status_code: 200, response_time(ms): 8.95 ms, response_length: 46 bytes - -. -/api/users/1001 -INFO POST http://127.0.0.1:5000/api/users/1001 -INFO status_code: 201, response_time(ms): 3.02 ms, response_length: 54 bytes - -. - ----------------------------------------------------------------------- -Ran 2 tests in 0.021s - -OK -INFO Start to run testcase: create user 1002 -/api/get-token -INFO POST http://127.0.0.1:5000/api/get-token -INFO status_code: 200, response_time(ms): 2.78 ms, response_length: 46 bytes - -. -/api/users/1002 -INFO POST http://127.0.0.1:5000/api/users/1002 -INFO status_code: 201, response_time(ms): 2.84 ms, response_length: 54 bytes - -. - ----------------------------------------------------------------------- -Ran 2 tests in 0.007s - -OK -INFO Start to run testcase: create user 1003 -/api/get-token -INFO POST http://127.0.0.1:5000/api/get-token -INFO status_code: 200, response_time(ms): 2.92 ms, response_length: 46 bytes - -. -/api/users/1003 -INFO POST http://127.0.0.1:5000/api/users/1003 -INFO status_code: 201, response_time(ms): 5.56 ms, response_length: 54 bytes - -. - ----------------------------------------------------------------------- -Ran 2 tests in 0.011s - -OK -INFO Start to run testcase: create user 1004 -/api/get-token -INFO POST http://127.0.0.1:5000/api/get-token -INFO status_code: 200, response_time(ms): 5.25 ms, response_length: 46 bytes - -. -/api/users/1004 -INFO POST http://127.0.0.1:5000/api/users/1004 -INFO status_code: 201, response_time(ms): 7.02 ms, response_length: 54 bytes - -. - ----------------------------------------------------------------------- -Ran 2 tests in 0.016s - -OK -INFO Start to render Html report ... -INFO Generated Html report: /Users/debugtalk/MyProjects/HttpRunner-dev/httprunner-docs-v2x/reports/1548518757.html -``` - -
- -可以看出,测试用例总共运行了 4 次,并且每次运行时都是采用的不同 user_id。 - -关于参数化数据驱动,这里只描述了最简单的场景和使用方式,如需了解更多,请进一步阅读[《数据驱动使用手册》](/prepare/parameters/)。 - -[har2case]: https://github.com/HttpRunner/har2case - -## 查看测试报告 - -在每次使用 hrun 命令运行测试用例后,均会生成一份 HTML 格式的测试报告。报告文件位于 reports 目录下,文件名称为测试用例的开始运行时间。 - -例如,在运行完 [demo-quickstart-1.json](data/demo-quickstart-1.json) 后,将生成如下形式的测试报告: - -![](./images/report-demo-quickstart-1-overview.jpg) - -![](./images/report-demo-quickstart-1-log2.jpg) - -![](./images/report-demo-quickstart-1-traceback.jpg) - -关于测试报告的详细内容,请查看[《测试报告》](/run-tests/report/)部分。 - -## 总结 - -到此为止,HttpRunner 的核心功能就介绍完了,掌握本文中的功能特性,足以帮助你应对日常项目工作中至少 80% 的自动化测试需求。 - -当然,HttpRunner 不止于此,如需挖掘 HttpRunner 的更多特性,实现更复杂场景的自动化测试需求,可继续阅读后续文档。 diff --git a/docs/related-docs.md b/docs/related-docs.md deleted file mode 100644 index 81ca0198..00000000 --- a/docs/related-docs.md +++ /dev/null @@ -1,17 +0,0 @@ - -## 开发笔记 - -- [DebugTalk](http://debugtalk.com/tags/HttpRunner/) - -## 演讲 - -- [MTSC 2018][MTSC2018]: [《大疆互联网的一站式自动化测试解决方案(基于HttpRunner)》][dji-httprunner] -- [PyCon China 2018][PyConChina2018]: [《借助 Python 开源生态打造企业级自动化测试框架(HttpRunner)》][PyCon-HttpRunner] -- [MTSC 2019][MTSC2019]: [《HttpRunner 2.0 技术架构与接口测试应用》][httprunner-2.0] - -[MTSC2018]: https://www.bagevent.com/event/1193113 -[dji-httprunner]: https://github.com/debugtalk/speech/blob/master/DJI-HttpRunner.pdf -[PyConChina2018]: http://cn.pycon.org/2018/city_beijing.html -[PyCon-HttpRunner]: https://github.com/debugtalk/speech/blob/master/PyCon-HttpRunner.pdf -[MTSC2019]: https://testerhome.com/mtsc/2019 -[httprunner-2.0]: https://github.com/debugtalk/speech/blob/master/MTSC2019-HttpRunner-2.0.pdf diff --git a/docs/run-tests/cli.md b/docs/run-tests/cli.md deleted file mode 100644 index e538ef7d..00000000 --- a/docs/run-tests/cli.md +++ /dev/null @@ -1,218 +0,0 @@ - -HttpRunner 在命令行中启动测试时,通过指定参数,可实现丰富的测试特性控制。 - -```text -$ hrun -h -usage: hrun [-h] [-V] [--log-level LOG_LEVEL] [--log-file LOG_FILE] - [--dot-env-path DOT_ENV_PATH] [--report-template REPORT_TEMPLATE] - [--report-dir REPORT_DIR] [--failfast] [--save-tests] - [--startproject STARTPROJECT] - [--validate [VALIDATE [VALIDATE ...]]] - [--prettify [PRETTIFY [PRETTIFY ...]]] - [testcase_paths [testcase_paths ...]] - -One-stop solution for HTTP(S) testing. - -positional arguments: - testcase_paths testcase file path - -optional arguments: - -h, --help show this help message and exit - -V, --version show version - --log-level LOG_LEVEL - Specify logging level, default is INFO. - --log-file LOG_FILE Write logs to specified file path. - --dot-env-path DOT_ENV_PATH - Specify .env file path, which is useful for keeping - sensitive data. - --report-template REPORT_TEMPLATE - specify report template path. - --report-dir REPORT_DIR - specify report save directory. - --failfast Stop the test run on the first error or failure. - --save-tests Save loaded tests and parsed tests to JSON file. - --startproject STARTPROJECT - Specify new project name. - --validate [VALIDATE [VALIDATE ...]] - Validate JSON testcase format. - --prettify [PRETTIFY [PRETTIFY ...]] - Prettify JSON testcase format. -``` - -## 指定测试用例路径 - -使用 HttpRunner 指定测试用例路径时,支持多种方式。 - -使用 hrun 命令外加单个测试用例文件的路径,运行单个测试用例,并生成一个测试报告文件: - -```text -$ hrun filepath/testcase.yml -``` - -将多个测试用例文件放置到文件夹中,指定文件夹路径可将文件夹下所有测试用例作为测试用例集进行运行,并生成一个测试报告文件: - -```text -$ hrun testcases_folder_path -``` - -## failfast - -默认情况下,HttpRunner 会运行指定用例集中的所有测试用例,并统计测试结果。 - -> 对于某些依赖于执行顺序的测试用例,例如需要先登录成功才能执行后续接口请求的场景,当前面的测试用例执行失败后,后续的测试用例也都必将失败,因此没有继续执行的必要了。 - -若希望测试用例在运行过程中,遇到失败时不再继续运行后续用例,则可通过在命令中添加`--failfast`实现。 - -```text -$ hrun filepath/testcase.yml --failfast -``` - -## 日志级别 - -默认情况下,HttpRunner 运行时的日志级别为`INFO`,只会包含最基本的信息,包括用例名称、请求的URL和Method、响应结果的状态码、耗时和内容大小。 - -```text -$ hrun docs/data/demo-quickstart-6.json -INFO Start to run testcase: testcase description -/api/get-token -INFO POST http://127.0.0.1:5000/api/get-token -INFO status_code: 200, response_time(ms): 9.08 ms, response_length: 46 bytes - -. -/api/users/1548560655759 -INFO POST http://127.0.0.1:5000/api/users/1548560655759 -INFO status_code: 201, response_time(ms): 2.89 ms, response_length: 54 bytes - -. - ----------------------------------------------------------------------- -Ran 2 tests in 0.019s - -OK -INFO Start to render Html report ... -INFO Generated Html report: /Users/debugtalk/MyProjects/HttpRunner-dev/httprunner-docs-v2x/reports/1548560655.html -``` - -若需要查看到更详尽的信息,例如请求的参数和响应的详细内容,可以将日志级别设置为`DEBUG`,即在命令中添加`--log-level debug`。 - -``` -$ hrun docs/data/demo-quickstart-6.json --log-level debug -INFO Start to run testcase: testcase description -/api/get-token -INFO POST http://127.0.0.1:5000/api/get-token -DEBUG request kwargs(raw): {'headers': {'User-Agent': 'python-requests/2.18.4', 'device_sn': 'W5ACRDytKRQJPhC', 'os_platform': 'ios', 'app_version': '2.8.6', 'Content-Type': 'application/json'}, 'json': {'sign': '2e7c3b5d560a1c8a859edcb9c8b0d3f8349abeff'}, 'verify': True} -DEBUG processed request: -> POST http://127.0.0.1:5000/api/get-token -> kwargs: {'headers': {'User-Agent': 'python-requests/2.18.4', 'device_sn': 'W5ACRDytKRQJPhC', 'os_platform': 'ios', 'app_version': '2.8.6', 'Content-Type': 'application/json'}, 'json': {'sign': '2e7c3b5d560a1c8a859edcb9c8b0d3f8349abeff'}, 'verify': True, 'timeout': 120} -DEBUG -================== request details ================== -url : 'http://127.0.0.1:5000/api/get-token' -method : 'POST' -headers : {'User-Agent': 'python-requests/2.18.4', 'Accept-Encoding': 'gzip, deflate', 'Accept': '*/*', 'Connection': 'keep-alive', 'device_sn': 'W5ACRDytKRQJPhC', 'os_platform': 'ios', 'app_version': '2.8.6', 'Content-Type': 'application/json', 'Content-Length': '52'} -body : b'{"sign": "2e7c3b5d560a1c8a859edcb9c8b0d3f8349abeff"}' - -DEBUG -================== response details ================== -ok : True -url : 'http://127.0.0.1:5000/api/get-token' -status_code : 200 -reason : 'OK' -cookies : {} -encoding : None -headers : {'Content-Type': 'application/json', 'Content-Length': '46', 'Server': 'Werkzeug/0.14.1 Python/3.6.5+', 'Date': 'Sun, 27 Jan 2019 03:45:16 GMT'} -content_type : 'application/json' -json : {'success': True, 'token': 'o6uakmubLrCbpRRS'} - -INFO status_code: 200, response_time(ms): 9.28 ms, response_length: 46 bytes - -DEBUG start to extract from response object. -DEBUG extract: content.token => o6uakmubLrCbpRRS -DEBUG start to validate. -DEBUG extract: status_code => 200 -DEBUG validate: status_code equals 200(int) ==> pass -DEBUG extract: headers.Content-Type => application/json -DEBUG validate: headers.Content-Type equals application/json(str) ==> pass -DEBUG extract: content.success => True -DEBUG validate: content.success equals True(bool) ==> pass -. -/api/users/1548560716736 -INFO POST http://127.0.0.1:5000/api/users/1548560716736 -DEBUG request kwargs(raw): {'headers': {'User-Agent': 'python-requests/2.18.4', 'device_sn': 'W5ACRDytKRQJPhC', 'token': 'o6uakmubLrCbpRRS', 'Content-Type': 'application/json'}, 'json': {'name': 'user1', 'password': '123456'}, 'verify': True} -DEBUG processed request: -> POST http://127.0.0.1:5000/api/users/1548560716736 -> kwargs: {'headers': {'User-Agent': 'python-requests/2.18.4', 'device_sn': 'W5ACRDytKRQJPhC', 'token': 'o6uakmubLrCbpRRS', 'Content-Type': 'application/json'}, 'json': {'name': 'user1', 'password': '123456'}, 'verify': True, 'timeout': 120} -DEBUG -================== request details ================== -url : 'http://127.0.0.1:5000/api/users/1548560716736' -method : 'POST' -headers : {'User-Agent': 'python-requests/2.18.4', 'Accept-Encoding': 'gzip, deflate', 'Accept': '*/*', 'Connection': 'keep-alive', 'device_sn': 'W5ACRDytKRQJPhC', 'token': 'o6uakmubLrCbpRRS', 'Content-Type': 'application/json', 'Content-Length': '39'} -body : b'{"name": "user1", "password": "123456"}' - -DEBUG -================== response details ================== -ok : True -url : 'http://127.0.0.1:5000/api/users/1548560716736' -status_code : 201 -reason : 'CREATED' -cookies : {} -encoding : None -headers : {'Content-Type': 'application/json', 'Content-Length': '54', 'Server': 'Werkzeug/0.14.1 Python/3.6.5+', 'Date': 'Sun, 27 Jan 2019 03:45:16 GMT'} -content_type : 'application/json' -json : {'success': True, 'msg': 'user created successfully.'} - -INFO status_code: 201, response_time(ms): 2.77 ms, response_length: 54 bytes - -DEBUG start to validate. -DEBUG extract: status_code => 201 -DEBUG validate: status_code equals 201(int) ==> pass -DEBUG extract: headers.Content-Type => application/json -DEBUG validate: headers.Content-Type equals application/json(str) ==> pass -DEBUG extract: content.success => True -DEBUG validate: content.success equals True(bool) ==> pass -DEBUG extract: content.msg => user created successfully. -DEBUG validate: content.msg equals user created successfully.(str) ==> pass -. - ----------------------------------------------------------------------- -Ran 2 tests in 0.022s - -OK -DEBUG No html report template specified, use default. -INFO Start to render Html report ... -INFO Generated Html report: /Users/debugtalk/MyProjects/HttpRunner-dev/httprunner-docs-v2x/reports/1548560716.html -``` - -## 保存详细过程数据 - -为了方便定位问题,运行测试时可指定 `--save-tests` 参数,即可将运行过程的中间数据保存为日志文件。 - -```text -$ hrun docs/data/demo-quickstart-6.json --save-tests -dump file: /Users/debugtalk/MyProjects/HttpRunner-dev/httprunner-docs-v2x/docs/data/logs/demo-quickstart-6.loaded.json -dump file: /Users/debugtalk/MyProjects/HttpRunner-dev/httprunner-docs-v2x/docs/data/logs/demo-quickstart-6.parsed.json -INFO Start to run testcase: testcase description -/api/get-token -INFO POST http://127.0.0.1:5000/api/get-token -INFO status_code: 200, response_time(ms): 11.42 ms, response_length: 46 bytes - -. -/api/users/1548560768589 -INFO POST http://127.0.0.1:5000/api/users/1548560768589 -INFO status_code: 201, response_time(ms): 2.8 ms, response_length: 54 bytes - -. - ----------------------------------------------------------------------- -Ran 2 tests in 0.028s - -OK -dump file: /Users/debugtalk/MyProjects/HttpRunner-dev/httprunner-docs-v2x/docs/data/logs/demo-quickstart-6.summary.json -INFO Start to render Html report ... -INFO Generated Html report: /Users/debugtalk/MyProjects/HttpRunner-dev/httprunner-docs-v2x/reports/1548560768.html -``` - -日志文件将保存在项目根目录的 `logs` 文件夹中,生成的文件有如下三个(XXX为测试用例名称): - -- `XXX.loaded.json`:测试用例加载后的数据结构内容,加载包括测试用例文件(YAML/JSON)、debugtalk.py、.env 等所有项目文件,例如 [`demo-quickstart-6.loaded.json`](/data/logs/demo-quickstart-6.loaded.json) -- `XXX.parsed.json`:测试用例解析后的数据结构内容,解析内容包括测试用例引用(API/testcase)、变量计算和替换、base_url 拼接等,例如 [`demo-quickstart-6.parsed.json`](/data/logs/demo-quickstart-6.parsed.json) -- `XXX.summary.json`:测试报告生成前的数据结构内容,例如 [`demo-quickstart-6.summary.json`](/data/logs/demo-quickstart-6.summary.json) diff --git a/docs/run-tests/load-test.md b/docs/run-tests/load-test.md deleted file mode 100644 index 00448bda..00000000 --- a/docs/run-tests/load-test.md +++ /dev/null @@ -1,157 +0,0 @@ -HttpRunner 通过复用 [`Locust`][Locust],可以在无需对 YAML/JSON 进行任何修改的情况下,直接运行性能测试。 - -## 原理图 - -![](../images/loadtest-schematic-diagram.jpg) - -## 安装依赖包 - -安装完成 HttpRunner 后,系统中会新增`locusts`命令,但不会同时安装 Locust。 - -在系统中未安装 Locust 的情况下,运行`locusts`命令时会出现如下提示。 - -```bash -$ locusts -V -WARNING Locust is not installed, install first and try again. -install command: pip install locustio -``` - -Locust 的安装方式如下: - -```text -$ pip install locustio -``` - -安装完成后,执行 `locusts -V` 可查看到 Locust 的版本号。 - -```text -$ locusts -V -[2017-08-26 23:45:42,246] bogon/INFO/stdout: Locust 0.8a2 -[2017-08-26 23:45:42,246] bogon/INFO/stdout: -``` - -执行 `locusts -h`,可查看到使用帮助文档。 - -```text -$ locusts -h -Usage: locust [options] [LocustClass [LocustClass2 ... ]] - -Options: - -h, --help show this help message and exit - -H HOST, --host=HOST Host to load test in the following format: - http://10.21.32.33 - --web-host=WEB_HOST Host to bind the web interface to. Defaults to '' (all - interfaces) - -P PORT, --port=PORT, --web-port=PORT - Port on which to run web host - -f LOCUSTFILE, --locustfile=LOCUSTFILE - Python module file to import, e.g. '../other.py'. - Default: locustfile - --csv=CSVFILEBASE, --csv-base-name=CSVFILEBASE - Store current request stats to files in CSV format. - --master Set locust to run in distributed mode with this - process as master - --slave Set locust to run in distributed mode with this - process as slave - --master-host=MASTER_HOST - Host or IP address of locust master for distributed - load testing. Only used when running with --slave. - Defaults to 127.0.0.1. - --master-port=MASTER_PORT - The port to connect to that is used by the locust - master for distributed load testing. Only used when - running with --slave. Defaults to 5557. Note that - slaves will also connect to the master node on this - port + 1. - --master-bind-host=MASTER_BIND_HOST - Interfaces (hostname, ip) that locust master should - bind to. Only used when running with --master. - Defaults to * (all available interfaces). - --master-bind-port=MASTER_BIND_PORT - Port that locust master should bind to. Only used when - running with --master. Defaults to 5557. Note that - Locust will also use this port + 1, so by default the - master node will bind to 5557 and 5558. - --expect-slaves=EXPECT_SLAVES - How many slaves master should expect to connect before - starting the test (only when --no-web used). - --no-web Disable the web interface, and instead start running - the test immediately. Requires -c and -r to be - specified. - -c NUM_CLIENTS, --clients=NUM_CLIENTS - Number of concurrent clients. Only used together with - --no-web - -r HATCH_RATE, --hatch-rate=HATCH_RATE - The rate per second in which clients are spawned. Only - used together with --no-web - -n NUM_REQUESTS, --num-request=NUM_REQUESTS - Number of requests to perform. Only used together with - --no-web - -L LOGLEVEL, --loglevel=LOGLEVEL - Choose between DEBUG/INFO/WARNING/ERROR/CRITICAL. - Default is INFO. - --logfile=LOGFILE Path to log file. If not set, log will go to - stdout/stderr - --print-stats Print stats in the console - --only-summary Only print the summary stats - --no-reset-stats Do not reset statistics once hatching has been - completed - -l, --list Show list of possible locust classes and exit - --show-task-ratio print table of the locust classes' task execution - ratio - --show-task-ratio-json - print json data of the locust classes' task execution - ratio - -V, --version show program's version number and exit -``` - -可以看出,`loucsts` 命令与 `locust` 命令的用法基本相同。 - -相比于 `locust` 命令,`loucsts`命令主要存在如下两项差异。 - -## 运行性能测试 - -在 `-f` 参数后面,`loucsts` 命令不仅可以指定 Locust 支持的 Python 文件,同时可以直接指定 YAML/JSON 格式的测试用例文件。在具体实现上,当 `-f` 指定 YAML/JSON 格式的测试用例文件时,会先将其转换为 Python 格式的 locustfile,然后再将 locustfile.py 传给 locust 命令。 - -```bash -$ locusts -f examples/first-testcase.yml -[2017-08-18 17:20:43,915] Leos-MacBook-Air.local/INFO/locust.main: Starting web monitor at *:8089 -[2017-08-18 17:20:43,918] Leos-MacBook-Air.local/INFO/locust.main: Starting Locust 0.8a2 -``` - -执行上述命令后,即完成了 Locust 服务的启动,后续就可以在 Locust 的 Web 管理界面中进行操作了,使用方式与 Locust 完全相同。 - -## 多进程运行模式 - -默认情况下,在 Locust 中如需使用 master-slave 模式启动多个进程(使用多核处理器的能力),只能先启动 master,然后再逐一启动若干个 slave。 - -```text -$ locust -f locustfile.py --master -$ locust -f locustfile.py --slave & -$ locust -f locustfile.py --slave & -$ locust -f locustfile.py --slave & -$ locust -f locustfile.py --slave & -``` - -在 HttpRunner 中,新增实现 `--processes` 参数,可以一次性启动 1 个 master 和多个 salve。若在 `--processes` 参数后没有指定具体的数值,则启动的 slave 个数与机器的 CPU 核数相同。 - -```bash -$ locusts -f examples/first-testcase.yml --processes 4 -[2017-08-26 23:51:47,071] bogon/INFO/locust.main: Starting web monitor at *:8089 -[2017-08-26 23:51:47,075] bogon/INFO/locust.main: Starting Locust 0.8a2 -[2017-08-26 23:51:47,078] bogon/INFO/locust.main: Starting Locust 0.8a2 -[2017-08-26 23:51:47,080] bogon/INFO/locust.main: Starting Locust 0.8a2 -[2017-08-26 23:51:47,083] bogon/INFO/locust.main: Starting Locust 0.8a2 -[2017-08-26 23:51:47,084] bogon/INFO/locust.runners: Client 'bogon_656e0af8e968a8533d379dd252422ad3' reported as ready. Currently 1 clients ready to swarm. -[2017-08-26 23:51:47,085] bogon/INFO/locust.runners: Client 'bogon_09f73850252ee4ec739ed77d3c4c6dba' reported as ready. Currently 2 clients ready to swarm. -[2017-08-26 23:51:47,084] bogon/INFO/locust.main: Starting Locust 0.8a2 -[2017-08-26 23:51:47,085] bogon/INFO/locust.runners: Client 'bogon_869f7ed671b1a9952b56610f01e2006f' reported as ready. Currently 3 clients ready to swarm. -[2017-08-26 23:51:47,085] bogon/INFO/locust.runners: Client 'bogon_80a804cda36b80fac17b57fd2d5e7cdb' reported as ready. Currently 4 clients ready to swarm. -``` - -![](../images/locusts-full-speed.jpg) - -Enjoy! - - -[Locust]: http://locust.io/ \ No newline at end of file diff --git a/docs/run-tests/report.md b/docs/run-tests/report.md deleted file mode 100644 index c680424d..00000000 --- a/docs/run-tests/report.md +++ /dev/null @@ -1,113 +0,0 @@ - -使用 HttpRunner 执行完自动化测试后,会在当前路径的 reports 目录下生成一份 HTML 格式的测试报告。 - -## 默认情况 - -默认情况下,生成的测试报告文件会位于项目根目录的 reports 文件夹中,文件名称为测试开始的时间戳。 - -```bash -$ hrun docs/data/demo-quickstart-6.yml -INFO Start to run testcase: testcase description -/api/get-token -INFO POST http://127.0.0.1:5000/api/get-token -INFO status_code: 200, response_time(ms): 10.05 ms, response_length: 46 bytes - -. -/api/users/1548561170497 -INFO POST http://127.0.0.1:5000/api/users/1548561170497 -INFO status_code: 201, response_time(ms): 2.88 ms, response_length: 54 bytes - -. - ----------------------------------------------------------------------- -Ran 2 tests in 0.034s - -OK -INFO Start to render Html report ... -INFO Generated Html report: /Users/debugtalk/MyProjects/HttpRunner-dev/httprunner-docs-v2x/reports/1548561170.html -``` - -点击查看[测试报告](/data/reports/1548561170.html)。 - -## 默认报告样式 - -在 HttpRunner 中自带了一个 Jinja2 格式的报告模版,默认情况下,生成的报告样式均基于该模版([httprunner/templates/default_report_template.html][default_report])。 - -测试报告形式如下: - -在 Summary 中,会罗列本次测试的整体信息,包括测试开始时间、总运行时长、运行的Python版本和系统环境、运行结果统计数据。 - -![](../images/report-demo-quickstart-1-overview.jpg) - -在 Details 中,会详细展示每一条测试用例的运行结果。 - -点击测试用例对应的 log 按钮,会在弹出框中展示该用例执行的详细数据,包括请求的 headers 和 body、响应的 headers 和 body、校验结果、响应、响应耗时(elapsed)等信息。 - -![](../images/report-demo-quickstart-1-log1.jpg) -![](../images/report-demo-quickstart-1-log2.jpg) - -若测试用例运行不成功(failed/error/skipped),则在该测试用例的 detail 中会出现 traceback 按钮,点击该按钮后,会在弹出框中展示失败的堆栈日志,或者 skipped 的原因。 - -![](../images/report-demo-quickstart-1-traceback.jpg) - -点击查看[测试报告](/data/reports/1548561464.html)。 - -## 自定义 - -除了默认的报告样式,HttpRunner 还支持使用自定义的报告模板。 - -### 编写自定义模板(Jinja2格式) - -自定义模板需要采用 [Jinja2][Jinja2] 的格式,其中可以使用的数据可参考[数据结构示例][summary_data]。 - -例如,我们需要在自定义模板中展示测试结果的统计数据,就可以采用如下方式进行描述: - -```html - - TOTAL - SUCCESS - FAILED - ERROR - SKIPPED - - - {{stat.testsRun}} - {{stat.successes}} - {{stat.failures}} - {{stat.errors}} - {{stat.skipped}} - -``` - -在自定义报告模板时,可以参考 HttpRunner 的[默认报告模板][default_report],要实现更复杂的模版功能,可参考 [Jinja2][Jinja2] 的使用文档。 - -### 使用自定义模板 - -使用自定义模版时,需要通过 `--report-template` 指定报告模板的路径,然后测试运行完成后,就会采用自定义的模板生成测试报告。 - -```bash -$ hrun docs/data/demo-quickstart-2.yml --report-template /path/to/custom_report_template -... -同上,省略 - -INFO render with html report template: /path/to/custom_report_template -INFO Start to render Html report ... -INFO Generated Html report: reports/1532078874.html -``` - -[Jinja2]: http://jinja.pocoo.org/docs/latest -[default_report]: https://github.com/HttpRunner/HttpRunner/blob/master/httprunner/templates/report_template.html -[summary_data]: /development/#_6 - -### 指定报告生成路径 - -默认情况下,生成的测试报告文件会位于项目根目录的 reports 文件夹中。如需指定生成报告的路径,可以使用 `--report-dir` 参数。 - -```bash -$ hrun docs/data/demo-quickstart-2.yml --dirreport-name /other/path/ -... -同上,省略 - -INFO Start to render Html report ... -INFO Generated Html report: /other/path/1532078874.html -``` diff --git a/docs/sponsors.md b/docs/sponsors.md index fdc764ad..f912cefd 100644 --- a/docs/sponsors.md +++ b/docs/sponsors.md @@ -4,7 +4,7 @@ ## 金牌赞助商(Gold Sponsor) -[霍格沃兹测试学院](https://testing-studio.com) +[霍格沃兹测试学院](https://testing-studio.com) > [霍格沃兹测试学院](https://testing-studio.com) 是由测吧(北京)科技有限公司与知名软件测试社区 [TesterHome](https://testerhome.com/) 合作的高端教育品牌。由 BAT 一线**测试大咖执教**,提供**实战驱动**的接口自动化测试、移动自动化测试、性能测试、持续集成与 DevOps 等技术培训,以及测试开发优秀人才内推服务。[点击学习!](https://ke.qq.com/course/254956?flowToken=1014690) @@ -12,7 +12,7 @@ ### 开源服务赞助商(Open Source Sponsor) -[Sentry](https://sentry.io/_/open-source/) +[Sentry](https://sentry.io/_/open-source/) HttpRunner is in Sentry Sponsored plan. diff --git a/examples/httpbin/basic_test.py b/examples/httpbin/basic_test.py index 4d92b82a..91ffac52 100644 --- a/examples/httpbin/basic_test.py +++ b/examples/httpbin/basic_test.py @@ -1,5 +1,6 @@ -# NOTICE: Generated By HttpRunner. DO'NOT EDIT! +# NOTICE: Generated By HttpRunner. DO NOT EDIT! # FROM: examples/httpbin/basic.yml + from httprunner import HttpRunner, TConfig, TStep @@ -9,6 +10,7 @@ class TestCaseBasic(HttpRunner): "name": "basic test with httpbin", "base_url": "https://httpbin.org/", "path": "examples/httpbin/basic_test.py", + "variables": {}, } ) diff --git a/examples/httpbin/hooks_test.py b/examples/httpbin/hooks_test.py index 84281f9d..0e86ee6b 100644 --- a/examples/httpbin/hooks_test.py +++ b/examples/httpbin/hooks_test.py @@ -1,5 +1,6 @@ -# NOTICE: Generated By HttpRunner. DO'NOT EDIT! +# NOTICE: Generated By HttpRunner. DO NOT EDIT! # FROM: examples/httpbin/hooks.yml + from httprunner import HttpRunner, TConfig, TStep @@ -11,6 +12,7 @@ class TestCaseHooks(HttpRunner): "setup_hooks": ["${hook_print(setup)}"], "teardown_hooks": ["${hook_print(teardown)}"], "path": "examples/httpbin/hooks_test.py", + "variables": {}, } ) diff --git a/examples/httpbin/load_image_test.py b/examples/httpbin/load_image_test.py index b23759e6..2e69653d 100644 --- a/examples/httpbin/load_image_test.py +++ b/examples/httpbin/load_image_test.py @@ -1,5 +1,6 @@ -# NOTICE: Generated By HttpRunner. DO'NOT EDIT! +# NOTICE: Generated By HttpRunner. DO NOT EDIT! # FROM: examples/httpbin/load_image.yml + from httprunner import HttpRunner, TConfig, TStep @@ -9,6 +10,7 @@ class TestCaseLoadImage(HttpRunner): "name": "load images", "base_url": "${get_httpbin_server()}", "path": "examples/httpbin/load_image_test.py", + "variables": {}, } ) diff --git a/examples/httpbin/upload_test.py b/examples/httpbin/upload_test.py index bef8c0fa..986b3746 100644 --- a/examples/httpbin/upload_test.py +++ b/examples/httpbin/upload_test.py @@ -1,5 +1,6 @@ -# NOTICE: Generated By HttpRunner. DO'NOT EDIT! +# NOTICE: Generated By HttpRunner. DO NOT EDIT! # FROM: examples/httpbin/upload.yml + from httprunner import HttpRunner, TConfig, TStep @@ -9,6 +10,7 @@ class TestCaseUpload(HttpRunner): "name": "test upload file with httpbin", "base_url": "${get_httpbin_server()}", "path": "examples/httpbin/upload_test.py", + "variables": {}, } ) diff --git a/examples/httpbin/validate_test.py b/examples/httpbin/validate_test.py index ec88e0a1..fdf2c8a4 100644 --- a/examples/httpbin/validate_test.py +++ b/examples/httpbin/validate_test.py @@ -1,5 +1,6 @@ -# NOTICE: Generated By HttpRunner. DO'NOT EDIT! +# NOTICE: Generated By HttpRunner. DO NOT EDIT! # FROM: examples/httpbin/validate.yml + from httprunner import HttpRunner, TConfig, TStep @@ -9,6 +10,7 @@ class TestCaseValidate(HttpRunner): "name": "basic test with httpbin", "base_url": "http://httpbin.org/", "path": "examples/httpbin/validate_test.py", + "variables": {}, } ) diff --git a/examples/postman_echo/conftest.py b/examples/postman_echo/conftest.py deleted file mode 100644 index 139597f9..00000000 --- a/examples/postman_echo/conftest.py +++ /dev/null @@ -1,2 +0,0 @@ - - diff --git a/examples/postman_echo/debugtalk.py b/examples/postman_echo/debugtalk.py index d0502409..af8b22eb 100644 --- a/examples/postman_echo/debugtalk.py +++ b/examples/postman_echo/debugtalk.py @@ -10,6 +10,4 @@ def sum_two(m, n): def get_variables(): - return { - "foo1": "session_bar1" - } + return {"foo1": "session_bar1"} diff --git a/examples/postman_echo/request_methods/conftest.py b/examples/postman_echo/request_methods/conftest.py new file mode 100644 index 00000000..4f0e444c --- /dev/null +++ b/examples/postman_echo/request_methods/conftest.py @@ -0,0 +1,62 @@ +import uuid +from typing import List + +import pytest +from loguru import logger + +from httprunner.schema import TConfig, TStep + + +@pytest.fixture(scope="session", autouse=True) +def session_fixture(request): + """setup and teardown each task""" + total_testcases_num = request.node.testscollected + testcases = [] + for item in request.node.items: + testcase = { + "name": item.cls.config.name, + "path": item.cls.config.path, + "node_id": item.nodeid, + } + testcases.append(testcase) + + logger.debug(f"collected {total_testcases_num} testcases: {testcases}") + + yield + + logger.debug(f"teardown task fixture") + + # teardown task + # TODO: upload task summary + + +@pytest.fixture(scope="function", autouse=True) +def testcase_fixture(request): + """setup and teardown each testcase""" + config: TConfig = request.cls.config + teststeps: List[TStep] = request.cls.teststeps + + logger.debug(f"setup testcase fixture: {config.name} - {request.module.__name__}") + + def update_request_headers(steps, index): + for teststep in steps: + if teststep.request: + index += 1 + teststep.request.headers["X-Request-ID"] = f"{prefix}-{index}" + elif teststep.testcase and hasattr(teststep.testcase, "teststeps"): + update_request_headers(teststep.testcase.teststeps, index) + + # you can update testcase teststep like this + prefix = f"HRUN-{uuid.uuid4()}" + update_request_headers(teststeps, 0) + + yield + + logger.debug( + f"teardown testcase fixture: {config.name} - {request.module.__name__}" + ) + + summary = request.instance.get_summary() + logger.debug(f"testcase result summary: {summary}") + + # TODO: upload testcase summary diff --git a/examples/postman_echo/request_methods/demo_testsuite_yml/request_with_functions_test.py b/examples/postman_echo/request_methods/demo_testsuite_yml/request_with_functions_test.py index 887c0d28..5c8647f8 100644 --- a/examples/postman_echo/request_methods/demo_testsuite_yml/request_with_functions_test.py +++ b/examples/postman_echo/request_methods/demo_testsuite_yml/request_with_functions_test.py @@ -1,5 +1,6 @@ -# NOTICE: Generated By HttpRunner. DO'NOT EDIT! -# FROM: examples/postman_echo/request_methods/demo_testsuite_yml/request_with_functions.yml +# NOTICE: Generated By HttpRunner. DO NOT EDIT! +# FROM: examples/postman_echo/request_methods/request_with_functions.yml + from httprunner import HttpRunner, TConfig, TStep diff --git a/examples/postman_echo/request_methods/demo_testsuite_yml/request_with_testcase_reference_test.py b/examples/postman_echo/request_methods/demo_testsuite_yml/request_with_testcase_reference_test.py index 9a714755..187b5e23 100644 --- a/examples/postman_echo/request_methods/demo_testsuite_yml/request_with_testcase_reference_test.py +++ b/examples/postman_echo/request_methods/demo_testsuite_yml/request_with_testcase_reference_test.py @@ -1,7 +1,17 @@ -# NOTICE: Generated By HttpRunner. DO'NOT EDIT! -# FROM: examples/postman_echo/request_methods/demo_testsuite_yml/request_with_testcase_reference.yml +# NOTICE: Generated By HttpRunner. DO NOT EDIT! +# FROM: examples/postman_echo/request_methods/request_with_testcase_reference.yml + +import os +import sys + +sys.path.insert(0, os.getcwd()) + from httprunner import HttpRunner, TConfig, TStep +from examples.postman_echo.request_methods.request_with_functions_test import ( + TestCaseRequestWithFunctions as RequestWithFunctions, +) + class TestCaseRequestWithTestcaseReference(HttpRunner): config = TConfig( @@ -19,7 +29,7 @@ class TestCaseRequestWithTestcaseReference(HttpRunner): **{ "name": "request with functions", "variables": {"foo1": "override_bar1"}, - "testcase": "request_methods/request_with_functions.yml", + "testcase": RequestWithFunctions, } ), ] diff --git a/examples/postman_echo/request_methods/hardcode_test.py b/examples/postman_echo/request_methods/hardcode_test.py index 934c0d66..f9931709 100644 --- a/examples/postman_echo/request_methods/hardcode_test.py +++ b/examples/postman_echo/request_methods/hardcode_test.py @@ -1,5 +1,6 @@ -# NOTICE: Generated By HttpRunner. DO'NOT EDIT! +# NOTICE: Generated By HttpRunner. DO NOT EDIT! # FROM: examples/postman_echo/request_methods/hardcode.yml + from httprunner import HttpRunner, TConfig, TStep 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 1933c03a..504b895a 100644 --- a/examples/postman_echo/request_methods/request_with_functions_test.py +++ b/examples/postman_echo/request_methods/request_with_functions_test.py @@ -1,5 +1,6 @@ -# NOTICE: Generated By HttpRunner. DO'NOT EDIT! +# NOTICE: Generated By HttpRunner. DO NOT EDIT! # FROM: examples/postman_echo/request_methods/request_with_functions.yml + from httprunner import HttpRunner, TConfig, TStep diff --git a/examples/postman_echo/request_methods/request_with_testcase_reference_test.py b/examples/postman_echo/request_methods/request_with_testcase_reference_test.py index 229c04fc..7f87b2d6 100644 --- a/examples/postman_echo/request_methods/request_with_testcase_reference_test.py +++ b/examples/postman_echo/request_methods/request_with_testcase_reference_test.py @@ -1,7 +1,17 @@ -# NOTICE: Generated By HttpRunner. DO'NOT EDIT! +# NOTICE: Generated By HttpRunner. DO NOT EDIT! # FROM: examples/postman_echo/request_methods/request_with_testcase_reference.yml + +import os +import sys + +sys.path.insert(0, os.getcwd()) + from httprunner import HttpRunner, TConfig, TStep +from examples.postman_echo.request_methods.request_with_functions_test import ( + TestCaseRequestWithFunctions as RequestWithFunctions, +) + class TestCaseRequestWithTestcaseReference(HttpRunner): config = TConfig( @@ -19,7 +29,7 @@ class TestCaseRequestWithTestcaseReference(HttpRunner): **{ "name": "request with functions", "variables": {"foo1": "override_bar1"}, - "testcase": "request_methods/request_with_functions.yml", + "testcase": RequestWithFunctions, } ), ] diff --git a/examples/postman_echo/request_methods/request_with_variables_test.py b/examples/postman_echo/request_methods/request_with_variables_test.py index 12dade62..6460eb90 100644 --- a/examples/postman_echo/request_methods/request_with_variables_test.py +++ b/examples/postman_echo/request_methods/request_with_variables_test.py @@ -1,5 +1,6 @@ -# NOTICE: Generated By HttpRunner. DO'NOT EDIT! +# NOTICE: Generated By HttpRunner. DO NOT EDIT! # FROM: examples/postman_echo/request_methods/request_with_variables.yml + from httprunner import HttpRunner, TConfig, TStep diff --git a/examples/postman_echo/request_methods/validate_with_functions_test.py b/examples/postman_echo/request_methods/validate_with_functions_test.py index a4561d96..51640531 100644 --- a/examples/postman_echo/request_methods/validate_with_functions_test.py +++ b/examples/postman_echo/request_methods/validate_with_functions_test.py @@ -1,5 +1,6 @@ -# NOTICE: Generated By HttpRunner. DO'NOT EDIT! +# NOTICE: Generated By HttpRunner. DO NOT EDIT! # FROM: examples/postman_echo/request_methods/validate_with_functions.yml + from httprunner import HttpRunner, TConfig, TStep 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 5fe266b9..1cb75fa9 100644 --- a/examples/postman_echo/request_methods/validate_with_variables_test.py +++ b/examples/postman_echo/request_methods/validate_with_variables_test.py @@ -1,5 +1,6 @@ -# NOTICE: Generated By HttpRunner. DO'NOT EDIT! +# NOTICE: Generated By HttpRunner. DO NOT EDIT! # FROM: examples/postman_echo/request_methods/validate_with_variables.yml + from httprunner import HttpRunner, TConfig, TStep diff --git a/httprunner/__init__.py b/httprunner/__init__.py index 36b1d4d9..d6cc44a1 100644 --- a/httprunner/__init__.py +++ b/httprunner/__init__.py @@ -1,4 +1,4 @@ -__version__ = "3.0.5" +__version__ = "3.0.6" __description__ = "One-stop solution for HTTP(S) testing." from httprunner.runner import HttpRunner diff --git a/httprunner/app/routers/debug.py b/httprunner/app/routers/debug.py index b4d4fefb..9d99f04c 100644 --- a/httprunner/app/routers/debug.py +++ b/httprunner/app/routers/debug.py @@ -20,7 +20,7 @@ async def debug_single_testcase(project_meta: ProjectMeta, testcase: TestCase): for func_name in new_added_keys: project_meta.functions[func_name] = locals()[func_name] - runner.with_project_meta(project_meta).run(testcase) + runner.with_project_meta(project_meta).run_testcase(testcase) summary = runner.get_summary() if not summary.success: diff --git a/httprunner/cli.py b/httprunner/cli.py index a8140484..c86cca75 100644 --- a/httprunner/cli.py +++ b/httprunner/cli.py @@ -6,9 +6,10 @@ import pytest from loguru import logger from httprunner import __description__, __version__ +from httprunner.compat import ensure_cli_args from httprunner.ext.har2case import init_har2case_parser, main_har2case -from httprunner.ext.make import init_make_parser, main_make -from httprunner.ext.scaffold import init_parser_scaffold, main_scaffold +from httprunner.make import init_make_parser, main_make +from httprunner.scaffold import init_parser_scaffold, main_scaffold def init_parser_run(subparsers): @@ -19,6 +20,9 @@ def init_parser_run(subparsers): def main_run(extra_args): + # keep compatibility with v2 + extra_args = ensure_cli_args(extra_args) + tests_path_list = [] extra_args_new = [] for item in extra_args: diff --git a/httprunner/client.py b/httprunner/client.py index f5474ce3..e1ade62a 100644 --- a/httprunner/client.py +++ b/httprunner/client.py @@ -46,6 +46,13 @@ def get_req_resp_record(resp_obj: Response) -> ReqRespData: try: request_body = json.loads(request_body) except json.JSONDecodeError: + # str: Unexpected UTF-8 BOM (decode using utf-8-sig) + pass + except UnicodeDecodeError: + # bytes/bytearray: request body in protobuf + pass + except TypeError: + # neither str nor bytes/bytearray, e.g. None pass if request_body: diff --git a/httprunner/compat.py b/httprunner/compat.py new file mode 100644 index 00000000..d34c0001 --- /dev/null +++ b/httprunner/compat.py @@ -0,0 +1,302 @@ +""" +This module handles compatibility issues between testcase format v2 and v3. +""" +import os +from typing import List, Dict, Text, Union + +from loguru import logger + +from httprunner import exceptions +from httprunner.loader import load_project_meta +from httprunner.utils import sort_dict_by_custom_order + + +def convert_jmespath(raw: Text) -> Text: + # content.xx/json.xx => body.xx + if raw.startswith("content"): + raw = f"body{raw[len('content'):]}" + elif raw.startswith("json"): + raw = f"body{raw[len('json'):]}" + + raw_list = [] + for item in raw.split("."): + if "-" in item: + # add quotes for field with separator + # e.g. headers.Content-Type => headers."Content-Type" + item = item.strip('"') + raw_list.append(f'"{item}"') + elif item.isdigit(): + # convert lst.0.name to lst[0].name + if len(raw_list) == 0: + raise exceptions.FileFormatError( + f"Invalid jmespath: {raw}, jmespath should startswith headers/body/status_code/cookies" + ) + + last_item = raw_list.pop() + item = f"{last_item}[{item}]" + raw_list.append(item) + else: + raw_list.append(item) + + return ".".join(raw_list) + + +def convert_extractors(extractors: Union[List, Dict]) -> Dict: + """ convert extract list(v2) to dict(v3) + + Args: + extractors: [{"varA": "content.varA"}, {"varB": "json.varB"}] + + Returns: + {"varA": "body.varA", "varB": "body.varB"} + + """ + v3_extractors: Dict = {} + + if isinstance(extractors, List): + for extractor in extractors: + for k, v in extractor.items(): + v3_extractors[k] = v + elif isinstance(extractors, Dict): + v3_extractors = extractors + else: + raise exceptions.FileFormatError(f"Invalid extractor: {extractors}") + + for k, v in v3_extractors.items(): + v3_extractors[k] = convert_jmespath(v) + + return v3_extractors + + +def convert_validators(validators: List) -> List: + for v in validators: + if "check" in v and "expect" in v: + # format1: {"check": "content.abc", "assert": "eq", "expect": 201} + v["check"] = convert_jmespath(v["check"]) + + elif len(v) == 1: + # format2: {'eq': ['status_code', 201]} + comparator = list(v.keys())[0] + v[comparator][0] = convert_jmespath(v[comparator][0]) + + return validators + + +def sort_request_by_custom_order(request: Dict) -> Dict: + custom_order = [ + "method", + "url", + "params", + "headers", + "cookies", + "data", + "json", + "files", + "timeout", + "allow_redirects", + "proxies", + "verify", + "stream", + "auth", + "cert", + ] + return sort_dict_by_custom_order(request, custom_order) + + +def sort_step_by_custom_order(step: Dict) -> Dict: + custom_order = [ + "name", + "variables", + "request", + "testcase", + "setup_hooks", + "teardown_hooks", + "extract", + "validate", + "validate_script", + ] + return sort_dict_by_custom_order(step, custom_order) + + +def ensure_step_attachment(step: Dict) -> Dict: + test_dict = { + "name": step["name"], + } + + if "variables" in step: + test_dict["variables"] = step["variables"] + + if "setup_hooks" in step: + test_dict["setup_hooks"] = step["setup_hooks"] + + if "teardown_hooks" in step: + test_dict["teardown_hooks"] = step["teardown_hooks"] + + if "extract" in step: + test_dict["extract"] = convert_extractors(step["extract"]) + + if "validate" in step: + test_dict["validate"] = convert_validators(step["validate"]) + + if "validate_script" in step: + test_dict["validate_script"] = step["validate_script"] + + return test_dict + + +def ensure_testcase_v3_api(api_content: Dict) -> Dict: + teststep = { + "request": api_content["request"], + } + teststep.update(ensure_step_attachment(api_content)) + + teststep = sort_step_by_custom_order(teststep) + + return { + "config": {"name": api_content["name"]}, + "teststeps": [teststep], + } + + +def ensure_testcase_v3(test_content: Dict) -> Dict: + v3_content = {"config": test_content["config"], "teststeps": []} + + for step in test_content["teststeps"]: + teststep = {} + + if "request" in step: + teststep["request"] = step.pop("request") + elif "api" in step: + teststep["testcase"] = step.pop("api") + elif "testcase" in step: + teststep["testcase"] = step.pop("testcase") + + teststep.update(ensure_step_attachment(step)) + teststep = sort_step_by_custom_order(teststep) + v3_content["teststeps"].append(teststep) + + return v3_content + + +def ensure_cli_args(args: List) -> List: + """ ensure compatibility with deprecated cli args in v2 + """ + # remove deprecated --failfast + if "--failfast" in args: + args.pop(args.index("--failfast")) + + # convert --report-file to --html + if "--report-file" in args: + index = args.index("--report-file") + args[index] = "--html" + args.append("--self-contained-html") + + # keep compatibility with --save-tests in v2 + if "--save-tests" in args: + args.pop(args.index("--save-tests")) + generate_conftest_for_summary(args) + + return args + + +def generate_conftest_for_summary(args: List): + + for arg in args: + if os.path.exists(arg): + test_path = arg + # FIXME: several test paths maybe specified + break + else: + raise exceptions.FileNotFound(f"No test path specified!") + + project_meta = load_project_meta(test_path) + conftest_path = os.path.join(project_meta.PWD, "conftest.py") + if os.path.isfile(conftest_path): + return + + conftest_content = '''# NOTICE: Generated By HttpRunner. +import json +import os +import time + +import pytest +from loguru import logger + +from httprunner.utils import get_platform + + +@pytest.fixture(scope="session", autouse=True) +def session_fixture(request): + """setup and teardown each task""" + logger.info(f"start running testcases ...") + + start_at = time.time() + + yield + + logger.info(f"task finished, generate task summary for --save-tests") + + summary = { + "success": True, + "stat": { + "testcases": {"total": 0, "success": 0, "fail": 0}, + "teststeps": {"total": 0, "failures": 0, "successes": 0}, + }, + "time": {"start_at": start_at, "duration": time.time() - start_at}, + "platform": get_platform(), + "details": [], + } + + for item in request.node.items: + testcase_summary = item.instance.get_summary() + summary["success"] &= testcase_summary.success + + summary["stat"]["testcases"]["total"] += 1 + summary["stat"]["teststeps"]["total"] += len(testcase_summary.step_datas) + if testcase_summary.success: + summary["stat"]["testcases"]["success"] += 1 + summary["stat"]["teststeps"]["successes"] += len( + testcase_summary.step_datas + ) + else: + summary["stat"]["testcases"]["fail"] += 1 + summary["stat"]["teststeps"]["successes"] += ( + len(testcase_summary.step_datas) - 1 + ) + summary["stat"]["teststeps"]["failures"] += 1 + + summary["details"].append(testcase_summary.dict()) + + summary_path = "{{SUMMARY_PATH_PLACEHOLDER}}" + summary_dir = os.path.dirname(summary_path) + os.makedirs(summary_dir, exist_ok=True) + + with open(summary_path, "w", encoding="utf-8") as f: + json.dump(summary, f, indent=4) + + logger.info(f"generated task summary: {summary_path}") + +''' + + test_path = os.path.abspath(test_path) + logs_dir_path = os.path.join(project_meta.PWD, "logs") + test_path_relative_path = test_path[len(project_meta.PWD) + 1 :] + + if os.path.isdir(test_path): + file_foder_path = os.path.join(logs_dir_path, test_path_relative_path) + dump_file_name = "all.summary.json" + else: + file_relative_folder_path, test_file = os.path.split(test_path_relative_path) + file_foder_path = os.path.join(logs_dir_path, file_relative_folder_path) + test_file_name, _ = os.path.splitext(test_file) + dump_file_name = f"{test_file_name}.summary.json" + + summary_path = os.path.join(file_foder_path, dump_file_name) + conftest_content = conftest_content.replace( + "{{SUMMARY_PATH_PLACEHOLDER}}", summary_path + ) + + with open(conftest_path, "w", encoding="utf-8") as f: + f.write(conftest_content) + + logger.info("generated conftest.py to generate summary.json") diff --git a/httprunner/exceptions.py b/httprunner/exceptions.py index 39e45f7e..6559fd87 100644 --- a/httprunner/exceptions.py +++ b/httprunner/exceptions.py @@ -40,11 +40,11 @@ class FileFormatError(MyBaseError): pass -class TestCaseFormatError(MyBaseError): +class TestCaseFormatError(FileFormatError): pass -class TestSuiteFormatError(MyBaseError): +class TestSuiteFormatError(FileFormatError): pass diff --git a/httprunner/ext/make/__init__.py b/httprunner/ext/make/__init__.py deleted file mode 100644 index 80dad535..00000000 --- a/httprunner/ext/make/__init__.py +++ /dev/null @@ -1,238 +0,0 @@ -import os -import subprocess -from typing import Union, Text, List, Tuple, Dict - -import jinja2 -from loguru import logger - -from httprunner import exceptions -from httprunner.loader import ( - load_folder_files, - load_test_file, - load_testcase, - load_testsuite, - load_project_meta, -) -from httprunner.parser import parse_data - -__TMPL__ = """# NOTICE: Generated By HttpRunner. DO'NOT EDIT! -# FROM: {{ testcase_path }} -from httprunner import HttpRunner, TConfig, TStep - - -class {{ class_name }}(HttpRunner): - config = TConfig(**{{ config }}) - - teststeps = [ - {% for teststep in teststeps %} - TStep(**{{ teststep }}), - {% endfor %} - ] - -if __name__ == "__main__": - {{ class_name }}().test_start() - -""" - - -def convert_testcase_path(testcase_path: Text) -> Tuple[Text, Text]: - """convert single YAML/JSON testcase path to python file""" - if os.path.isdir(testcase_path): - # folder does not need to convert - return testcase_path, "" - - raw_file_name, file_suffix = os.path.splitext(os.path.basename(testcase_path)) - - file_suffix = file_suffix.lower() - if file_suffix not in [".json", ".yml", ".yaml"]: - raise exceptions.ParamsError( - "testcase file should have .yaml/.yml/.json suffix" - ) - - file_name = raw_file_name.replace(" ", "_").replace(".", "_").replace("-", "_") - testcase_dir = os.path.dirname(testcase_path) - testcase_python_path = os.path.join(testcase_dir, f"{file_name}_test.py") - - # convert title case, e.g. request_with_variables => RequestWithVariables - name_in_title_case = file_name.title().replace("_", "") - - return testcase_python_path, name_in_title_case - - -def format_pytest_with_black(python_paths: List[Text]): - logger.info("format pytest cases with black ...") - try: - subprocess.run(["black", *python_paths]) - except subprocess.CalledProcessError as ex: - logger.error(ex) - - -def make_testcase(testcase: Dict) -> Union[str, None]: - """convert valid testcase dict to pytest file path""" - try: - # validate testcase format - load_testcase(testcase) - except exceptions.TestCaseFormatError as ex: - logger.error(f"TestCaseFormatError: {ex}") - raise - - testcase_path = testcase["config"]["path"] - logger.info(f"start to make testcase: {testcase_path}") - - template = jinja2.Template(__TMPL__) - - testcase_python_path, name_in_title_case = convert_testcase_path(testcase_path) - - config = testcase["config"] - - config.setdefault("variables", {}) - if isinstance(config["variables"], Text): - # get variables by function, e.g. ${get_variables()} - project_meta = load_project_meta(testcase_path) - config["variables"] = parse_data( - config["variables"], {}, project_meta.functions - ) - - config["path"] = testcase_python_path - data = { - "testcase_path": testcase_path, - "class_name": f"TestCase{name_in_title_case}", - "config": config, - "teststeps": testcase["teststeps"], - } - content = template.render(data) - - with open(testcase_python_path, "w") as f: - f.write(content) - - logger.info(f"generated testcase: {testcase_python_path}") - return testcase_python_path - - -def make_testsuite(testsuite: Dict) -> List[Text]: - """convert valid testsuite dict to pytest folder with testcases""" - try: - # validate testcase format - load_testsuite(testsuite) - except exceptions.TestSuiteFormatError as ex: - logger.error(f"TestSuiteFormatError: {ex}") - raise - - config = testsuite["config"] - testsuite_path = config["path"] - project_meta = load_project_meta(testsuite_path) - project_working_directory = project_meta.PWD - - testsuite_variables = config.get("variables", {}) - if isinstance(testsuite_variables, Text): - # get variables by function, e.g. ${get_variables()} - testsuite_variables = parse_data( - testsuite_variables, {}, project_meta.functions - ) - - logger.info(f"start to make testsuite: {testsuite_path}") - - # create directory with testsuite file name, put its testcases under this directory - testsuite_dir = testsuite_path.replace(".", "_") - os.makedirs(testsuite_dir, exist_ok=True) - - testcase_files = [] - - for testcase in testsuite["testcases"]: - # get referenced testcase content - testcase_file = testcase["testcase"] - testcase_path = os.path.join(project_working_directory, testcase_file) - testcase_dict = load_test_file(testcase_path) - testcase_dict.setdefault("config", {}) - testcase_dict["config"]["path"] = os.path.join( - testsuite_dir, os.path.basename(testcase_path) - ) - - # override testcase name - testcase_dict["config"]["name"] = testcase["name"] - # override base_url - base_url = testsuite["config"].get("base_url") or testcase.get("base_url") - if base_url: - testcase_dict["config"]["base_url"] = base_url - # override variables - testcase_dict["config"].setdefault("variables", {}) - testcase_dict["config"]["variables"].update(testcase.get("variables", {})) - testcase_dict["config"]["variables"].update(testsuite_variables) - - # make testcase - testcase_path = make_testcase(testcase_dict) - testcase_files.append(testcase_path) - - return testcase_files - - -def __make(tests_path: Text) -> List: - test_files = [] - if os.path.isdir(tests_path): - files_list = load_folder_files(tests_path) - test_files.extend(files_list) - elif os.path.isfile(tests_path): - test_files.append(tests_path) - else: - raise exceptions.TestcaseNotFound(f"Invalid tests path: {tests_path}") - - testcase_path_list = [] - for test_file in test_files: - try: - test_content = load_test_file(test_file) - test_content.setdefault("config", {})["path"] = test_file - except (exceptions.FileNotFound, exceptions.FileFormatError) as ex: - logger.warning(ex) - continue - - # testcase - if "teststeps" in test_content: - try: - testcase_file = make_testcase(test_content) - except exceptions.TestCaseFormatError: - continue - - testcase_path_list.append(testcase_file) - - # testsuite - elif "testcases" in test_content: - try: - testcase_files = make_testsuite(test_content) - except exceptions.TestSuiteFormatError: - continue - - testcase_path_list.extend(testcase_files) - - # invalid format - else: - raise exceptions.FileFormatError( - f"test file is neither testcase nor testsuite: {test_file}" - ) - - if not testcase_path_list: - logger.warning(f"No valid testcase generated on {tests_path}") - return [] - - return testcase_path_list - - -def main_make(tests_paths: List[Text]) -> List: - testcase_path_list = [] - for tests_path in tests_paths: - testcase_path_list.extend(__make(tests_path)) - - format_pytest_with_black(testcase_path_list) - return testcase_path_list - - -def init_make_parser(subparsers): - """ make testcases: parse command line options and run commands. - """ - parser = subparsers.add_parser( - "make", help="Convert YAML/JSON testcases to pytest cases.", - ) - parser.add_argument( - "testcase_path", nargs="*", help="Specify YAML/JSON testcase file/folder path" - ) - - return parser diff --git a/httprunner/ext/uploader/__init__.py b/httprunner/ext/uploader/__init__.py index 2f989953..0d177f65 100644 --- a/httprunner/ext/uploader/__init__.py +++ b/httprunner/ext/uploader/__init__.py @@ -46,20 +46,18 @@ import os import sys from typing import Text, NoReturn +from loguru import logger + from httprunner.parser import parse_variables_mapping from httprunner.schema import TStep, FunctionsMapping try: import filetype from requests_toolbelt import MultipartEncoder -except ImportError: - msg = """ -uploader extension dependencies uninstalled, install first and try again. -install with pip: -$ pip install requests_toolbelt filetype -""" - print(msg) - sys.exit(0) + + UPLOAD_READY = True +except ModuleNotFoundError: + UPLOAD_READY = False def prepare_upload_step(step: TStep, functions: FunctionsMapping) -> "NoReturn": @@ -88,6 +86,15 @@ def prepare_upload_step(step: TStep, functions: FunctionsMapping) -> "NoReturn": if not step.request.upload: return + if not UPLOAD_READY: + msg = """ +uploader extension dependencies uninstalled, install first and try again. +install with pip: +$ pip install requests_toolbelt filetype +""" + logger.error(msg) + sys.exit(1) + params_list = [] for key, value in step.request.upload.items(): step.variables[key] = value @@ -104,8 +111,12 @@ def prepare_upload_step(step: TStep, functions: FunctionsMapping) -> "NoReturn": step.request.data = "$m_encoder" -def multipart_encoder(**kwargs) -> MultipartEncoder: +def multipart_encoder(**kwargs): """ initialize MultipartEncoder with uploading fields. + + Returns: + MultipartEncoder: initialized MultipartEncoder object + """ def get_filetype(file_path): @@ -124,9 +135,11 @@ def multipart_encoder(**kwargs) -> MultipartEncoder: is_exists_file = os.path.isfile(value) else: # value is not absolute file path, check if it is relative file path - from httprunner.loader import project_working_directory + from httprunner.loader import load_project_meta - _file_path = os.path.join(project_working_directory, value) + project_meta = load_project_meta(os.getcwd()) + + _file_path = os.path.join(project_meta.PWD, value) is_exists_file = os.path.isfile(_file_path) if is_exists_file: @@ -142,7 +155,14 @@ def multipart_encoder(**kwargs) -> MultipartEncoder: return MultipartEncoder(fields=fields_dict) -def multipart_content_type(m_encoder: MultipartEncoder) -> Text: +def multipart_content_type(m_encoder) -> Text: """ prepare Content-Type for request headers + + Args: + m_encoder: MultipartEncoder object + + Returns: + content type + """ return m_encoder.content_type diff --git a/httprunner/loader.py b/httprunner/loader.py index 5e6234ed..27ea6124 100644 --- a/httprunner/loader.py +++ b/httprunner/loader.py @@ -23,8 +23,7 @@ except AttributeError: pass -project_meta_cached_mapping: Dict[Text, ProjectMeta] = {} -project_working_directory: Union[Text, None] = None +project_meta: Union[ProjectMeta, None] = None def _load_yaml_file(yaml_file: Text) -> Dict: @@ -174,12 +173,12 @@ def load_csv_file(csv_file: Text) -> List[Dict]: """ if not os.path.isabs(csv_file): - global project_working_directory - if project_working_directory is None: + global project_meta + if project_meta is None: raise exceptions.MyBaseFailure("load_project_meta() has not been called!") # make compatible with Windows/Linux - csv_file = os.path.join(project_working_directory, *csv_file.split("/")) + csv_file = os.path.join(project_meta.PWD, *csv_file.split("/")) if not os.path.isfile(csv_file): # file path not exist @@ -327,13 +326,8 @@ def locate_debugtalk_py(start_path: Text) -> Text: return debugtalk_path -def init_project_working_directory(test_path: Text) -> Tuple[Text, Text]: - """ this should be called at startup - - run test file: - run_path -> load_cases -> load_project_data -> init_project_working_directory - or run passed in data structure: - run -> init_project_working_directory +def locate_project_working_directory(test_path: Text) -> Tuple[Text, Text]: + """ locate debugtalk.py path as project working directory Args: test_path: specified testfile path @@ -359,7 +353,6 @@ def init_project_working_directory(test_path: Text) -> Tuple[Text, Text]: # locate debugtalk.py file debugtalk_path = locate_debugtalk_py(test_path) - global project_working_directory if debugtalk_path: # The folder contains debugtalk.py will be treated as PWD. project_working_directory = os.path.dirname(debugtalk_path) @@ -367,20 +360,9 @@ def init_project_working_directory(test_path: Text) -> Tuple[Text, Text]: # debugtalk.py not found, use os.getcwd() as PWD. project_working_directory = os.getcwd() - # add PWD to sys.path - sys.path.insert(0, project_working_directory) - return debugtalk_path, project_working_directory -def get_project_working_directory(test_path: Text) -> Text: - global project_working_directory - if not project_working_directory: - init_project_working_directory(test_path) - - return project_working_directory - - def load_debugtalk_functions() -> Dict[Text, Callable]: """ load project debugtalk.py module functions debugtalk.py should be located in project working directory. @@ -398,30 +380,36 @@ def load_debugtalk_functions() -> Dict[Text, Callable]: return load_module_functions(imported_module) -def load_project_meta(test_path: Text) -> ProjectMeta: +def load_project_meta(test_path: Text, reload: bool = False) -> ProjectMeta: """ load api, testcases, .env, debugtalk.py functions. api/testcases folder is relative to project_working_directory + by default, project_meta will be loaded only once, unless set reload to true. Args: test_path (str): test file/folder path, locate pwd from this path. + reload: reload project meta if set true, default to false Returns: project loaded api/testcases definitions, environments and debugtalk.py functions. """ + global project_meta + if project_meta and (not reload): + return project_meta + project_meta = ProjectMeta() if not test_path: return project_meta - if test_path in project_meta_cached_mapping: - return project_meta_cached_mapping[test_path] - - debugtalk_path, project_working_directory = init_project_working_directory( + debugtalk_path, project_working_directory = locate_project_working_directory( test_path ) + # add PWD to sys.path + sys.path.insert(0, project_working_directory) + # load .env file # NOTICE: # environment variable maybe loaded in debugtalk.py @@ -442,5 +430,4 @@ def load_project_meta(test_path: Text) -> ProjectMeta: len(project_working_directory) + 1 : ] - project_meta_cached_mapping[test_path] = project_meta return project_meta diff --git a/httprunner/make.py b/httprunner/make.py new file mode 100644 index 00000000..749131dc --- /dev/null +++ b/httprunner/make.py @@ -0,0 +1,333 @@ +import os +import string +import subprocess +from typing import Text, List, Tuple, Dict, Set, NoReturn + +import jinja2 +from loguru import logger + +from httprunner import exceptions +from httprunner.compat import ensure_testcase_v3_api, ensure_testcase_v3 +from httprunner.loader import ( + load_folder_files, + load_test_file, + load_testcase, + load_testsuite, + load_project_meta, +) +from httprunner.parser import parse_data + +""" cache converted pytest files, avoid duplicate making +""" +make_files_cache_set: Set = set() + +__TEMPLATE__ = jinja2.Template( + """# NOTICE: Generated By HttpRunner. DO NOT EDIT! +# FROM: {{ testcase_path }} +{% if imports_list %} +import os +import sys + +sys.path.insert(0, os.getcwd()) +{% endif %} +from httprunner import HttpRunner, TConfig, TStep +{% for import_str in imports_list %} +{{ import_str }} +{% endfor %} + +class {{ class_name }}(HttpRunner): + config = TConfig(**{{ config }}) + + teststeps = [ + {% for teststep in teststeps %} + TStep(**{{ teststep }}), + {% endfor %} + ] + +if __name__ == "__main__": + {{ class_name }}().test_start() + +""" +) + + +def __ensure_file_name(path: Text) -> Text: + """ ensure file name not startswith digit + testcases/19.json => testcases/T19.json + """ + filename = os.path.basename(path) + if filename[0] in string.digits: + path = os.path.join(os.path.dirname(path), f"T{filename}") + + return path + + +def __ensure_absolute(path: Text) -> Text: + project_meta = load_project_meta(path) + + if os.path.isabs(path): + absolute_path = path + else: + absolute_path = os.path.join(project_meta.PWD, path) + + return absolute_path + + +def __ensure_cwd_relative(path: Text) -> Text: + """ convert absolute path to relative path, based on os.getcwd() + + Args: + path: absolute path + + Returns: relative path based on os.getcwd() + + """ + if os.path.isabs(path): + return path[len(os.getcwd()) + 1 :] + else: + return path + + +def __ensure_testcase_module(path: Text) -> NoReturn: + """ ensure pytest files are in python module, generate __init__.py on demand + """ + init_file = os.path.join(os.path.dirname(path), "__init__.py") + if os.path.isfile(init_file): + return + + with open(init_file, "w", encoding="utf-8") as f: + f.write("# NOTICE: Generated By HttpRunner. DO NOT EDIT!") + + +def convert_testcase_path(testcase_path: Text) -> Tuple[Text, Text]: + """convert single YAML/JSON testcase path to python file""" + if os.path.isdir(testcase_path): + # folder does not need to convert + return testcase_path, "" + + testcase_path = __ensure_file_name(testcase_path) + raw_file_name, file_suffix = os.path.splitext(os.path.basename(testcase_path)) + + file_suffix = file_suffix.lower() + if file_suffix not in [".json", ".yml", ".yaml"]: + raise exceptions.ParamsError( + "testcase file should have .yaml/.yml/.json suffix" + ) + + file_name = raw_file_name.replace(" ", "_").replace(".", "_").replace("-", "_") + testcase_dir = os.path.dirname(testcase_path) + testcase_python_path = os.path.join(testcase_dir, f"{file_name}_test.py") + + # convert title case, e.g. request_with_variables => RequestWithVariables + name_in_title_case = file_name.title().replace("_", "") + + return testcase_python_path, name_in_title_case + + +def __format_pytest_with_black(python_paths: List[Text]) -> NoReturn: + logger.info("format pytest cases with black ...") + try: + subprocess.run(["black", *python_paths]) + except subprocess.CalledProcessError as ex: + logger.error(ex) + + +def __make_testcase(testcase: Dict, dir_path: Text = None) -> NoReturn: + """convert valid testcase dict to pytest file path""" + # ensure compatibility with testcase format v2 + testcase = ensure_testcase_v3(testcase) + + # validate testcase format + load_testcase(testcase) + + testcase_path = __ensure_absolute(testcase["config"]["path"]) + logger.info(f"start to make testcase: {testcase_path}") + + testcase_python_path, testcase_cls_name = convert_testcase_path(testcase_path) + if dir_path: + testcase_python_path = os.path.join( + dir_path, os.path.basename(testcase_python_path) + ) + + global make_files_cache_set + if testcase_python_path in make_files_cache_set: + return + + config = testcase["config"] + config["path"] = __ensure_cwd_relative(testcase_python_path) + + # parse config variables + config.setdefault("variables", {}) + if isinstance(config["variables"], Text): + # get variables by function, e.g. ${get_variables()} + project_meta = load_project_meta(testcase_path) + config["variables"] = parse_data( + config["variables"], {}, project_meta.functions + ) + + # prepare reference testcase + imports_list = [] + teststeps = testcase["teststeps"] + for teststep in teststeps: + if not teststep.get("testcase"): + continue + + # make ref testcase pytest file + ref_testcase_path = __ensure_absolute(teststep["testcase"]) + __make(ref_testcase_path) + + # prepare ref testcase class name + ref_testcase_python_path, ref_testcase_cls_name = convert_testcase_path( + ref_testcase_path + ) + teststep["testcase"] = f"CLS_LB({ref_testcase_cls_name})CLS_RB" + + # prepare import ref testcase + ref_testcase_python_path = ref_testcase_python_path[len(os.getcwd()) + 1 :] + ref_module_name, _ = os.path.splitext(ref_testcase_python_path) + ref_module_name = ref_module_name.replace(os.sep, ".") + imports_list.append( + f"from {ref_module_name} import TestCase{ref_testcase_cls_name} as {ref_testcase_cls_name}" + ) + + data = { + "testcase_path": __ensure_cwd_relative(testcase_path), + "class_name": f"TestCase{testcase_cls_name}", + "config": config, + "teststeps": teststeps, + "imports_list": imports_list, + } + content = __TEMPLATE__.render(data) + content = content.replace("'CLS_LB(", "").replace(")CLS_RB'", "") + + with open(testcase_python_path, "w", encoding="utf-8") as f: + f.write(content) + + __ensure_testcase_module(testcase_python_path) + + logger.info(f"generated testcase: {testcase_python_path}") + make_files_cache_set.add(__ensure_cwd_relative(testcase_python_path)) + + +def __make_testsuite(testsuite: Dict) -> NoReturn: + """convert valid testsuite dict to pytest folder with testcases""" + # validate testsuite format + load_testsuite(testsuite) + + config = testsuite["config"] + testsuite_path = config["path"] + + testsuite_variables = config.get("variables", {}) + if isinstance(testsuite_variables, Text): + # get variables by function, e.g. ${get_variables()} + project_meta = load_project_meta(testsuite_path) + testsuite_variables = parse_data( + testsuite_variables, {}, project_meta.functions + ) + + logger.info(f"start to make testsuite: {testsuite_path}") + + # create directory with testsuite file name, put its testcases under this directory + testsuite_dir = os.path.join( + os.path.dirname(testsuite_path), + os.path.basename(testsuite_path).replace(".", "_"), + ) + os.makedirs(testsuite_dir, exist_ok=True) + + for testcase in testsuite["testcases"]: + # get referenced testcase content + testcase_file = testcase["testcase"] + testcase_path = __ensure_absolute(testcase_file) + testcase_dict = load_test_file(testcase_path) + testcase_dict.setdefault("config", {}) + testcase_dict["config"]["path"] = testcase_path + + # override testcase name + testcase_dict["config"]["name"] = testcase["name"] + # override base_url + base_url = testsuite["config"].get("base_url") or testcase.get("base_url") + if base_url: + testcase_dict["config"]["base_url"] = base_url + # override variables + testcase_dict["config"].setdefault("variables", {}) + testcase_dict["config"]["variables"].update(testcase.get("variables", {})) + testcase_dict["config"]["variables"].update(testsuite_variables) + + # make testcase + __make_testcase(testcase_dict, testsuite_dir) + + +def __make(tests_path: Text) -> NoReturn: + """ make testcase(s) with testcase/testsuite/folder absolute path + generated pytest file path will be cached in make_files_cache_set + + Args: + tests_path: should be in absolute path + + """ + test_files = [] + if os.path.isdir(tests_path): + files_list = load_folder_files(tests_path) + test_files.extend(files_list) + elif os.path.isfile(tests_path): + test_files.append(tests_path) + else: + raise exceptions.TestcaseNotFound(f"Invalid tests path: {tests_path}") + + for test_file in test_files: + try: + test_content = load_test_file(test_file) + except (exceptions.FileNotFound, exceptions.FileFormatError) as ex: + logger.warning(ex) + continue + + # api in v2 format, convert to v3 testcase + if "request" in test_content: + test_content = ensure_testcase_v3_api(test_content) + + test_content.setdefault("config", {})["path"] = test_file + + # testcase + if "teststeps" in test_content: + try: + __make_testcase(test_content) + except exceptions.TestCaseFormatError: + continue + + # testsuite + elif "testcases" in test_content: + try: + __make_testsuite(test_content) + except exceptions.TestSuiteFormatError: + continue + + # invalid format + else: + raise exceptions.FileFormatError( + f"test file is neither testcase nor testsuite: {test_file}" + ) + + +def main_make(tests_paths: List[Text]) -> List[Text]: + for tests_path in tests_paths: + if not os.path.isabs(tests_path): + tests_path = os.path.join(os.getcwd(), tests_path) + + __make(tests_path) + + testcase_path_list = list(make_files_cache_set) + __format_pytest_with_black(testcase_path_list) + return testcase_path_list + + +def init_make_parser(subparsers): + """ make testcases: parse command line options and run commands. + """ + parser = subparsers.add_parser( + "make", help="Convert YAML/JSON testcases to pytest cases.", + ) + parser.add_argument( + "testcase_path", nargs="*", help="Specify YAML/JSON testcase file/folder path" + ) + + return parser diff --git a/httprunner/response.py b/httprunner/response.py index bc1f6891..8c698542 100644 --- a/httprunner/response.py +++ b/httprunner/response.py @@ -61,7 +61,7 @@ def uniform_validator(validator): Args: validator (dict): validator maybe in two formats: - format1: this is kept for compatiblity with the previous versions. + format1: this is kept for compatibility 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]} @@ -164,9 +164,14 @@ class ResponseObject(object): # check item check_item = u_validator["check"] - # TODO: validate variable or function - # check_item = parse_data(check_item, variables_mapping, functions_mapping) - check_value = jmespath.search(check_item, self.resp_obj_meta) + if "$" in check_item: + # check_item is variable or function + check_value = parse_data( + check_item, variables_mapping, functions_mapping + ) + else: + check_value = jmespath.search(check_item, self.resp_obj_meta) + check_value = parse_string_value(check_value) # comparator diff --git a/httprunner/runner.py b/httprunner/runner.py index f3f2fb3c..d236d471 100644 --- a/httprunner/runner.py +++ b/httprunner/runner.py @@ -1,10 +1,16 @@ import os import time import uuid -import allure from datetime import datetime from typing import List, Dict, Text +try: + import allure + + USE_ALLURE = True +except ModuleNotFoundError: + USE_ALLURE = False + from loguru import logger from httprunner import utils, exceptions @@ -141,14 +147,35 @@ class HttpRunner(object): step_data = StepData(name=step.name) step_variables = step.variables - ref_testcase_path = os.path.join(self.__project_meta.PWD, step.testcase) - case_result = ( - HttpRunner() - .with_session(self.__session) - .with_case_id(self.__case_id) - .with_variables(step_variables) - .run_path(ref_testcase_path) - ) + if hasattr(step.testcase, "config") and hasattr(step.testcase, "teststeps"): + testcase_cls = step.testcase + case_result = ( + testcase_cls() + .with_session(self.__session) + .with_case_id(self.__case_id) + .with_variables(step_variables) + .run() + ) + + elif isinstance(step.testcase, Text): + if os.path.isabs(step.testcase): + ref_testcase_path = step.testcase + else: + ref_testcase_path = os.path.join(self.__project_meta.PWD, step.testcase) + + case_result = ( + HttpRunner() + .with_session(self.__session) + .with_case_id(self.__case_id) + .with_variables(step_variables) + .run_path(ref_testcase_path) + ) + + else: + raise exceptions.ParamsError( + f"Invalid teststep referenced testcase: {step.dict()}" + ) + step_data.data = case_result.get_step_datas() # list of step data step_data.export = case_result.get_export_variables() step_data.success = case_result.success @@ -185,8 +212,14 @@ class HttpRunner(object): config.base_url, config.variables, self.__project_meta.functions ) - def run(self, testcase: TestCase): - """run testcase""" + def run_testcase(self, testcase: TestCase): + """run specified testcase + + Examples: + >>> testcase_obj = TestCase(config=TConfig(...), teststeps=[TStep(...)]) + >>> HttpRunner().with_project_meta(project_meta).run_testcase(testcase_obj) + + """ self.config = testcase.config self.teststeps = testcase.teststeps @@ -209,7 +242,10 @@ class HttpRunner(object): step.variables, self.__project_meta.functions ) # run step - with allure.step(f"step: {step.name}"): + if USE_ALLURE: + with allure.step(f"step: {step.name}"): + extract_mapping = self.__run_step(step) + else: extract_mapping = self.__run_step(step) # save extracted variables to session variables self.__session_variables.update(extract_mapping) @@ -222,7 +258,17 @@ class HttpRunner(object): raise exceptions.ParamsError(f"Invalid testcase path: {path}") testcase_obj = load_testcase_file(path) - return self.run(testcase_obj) + return self.run_testcase(testcase_obj) + + def run(self) -> "HttpRunner": + """ run current testcase + + Examples: + >>> TestCaseRequestWithFunctions().run() + + """ + testcase_obj = TestCase(config=self.config, teststeps=self.teststeps) + return self.run_testcase(testcase_obj) def get_step_datas(self) -> List[StepData]: return self.__step_datas @@ -261,30 +307,33 @@ class HttpRunner(object): def test_start(self): """main entrance, discovered by pytest""" + self.__project_meta = self.__project_meta or load_project_meta(self.config.path) self.__case_id = self.__case_id or str(uuid.uuid4()) self.__log_path = self.__log_path or os.path.join( - "logs", f"{self.__case_id}.run.log" + self.__project_meta.PWD, "logs", f"{self.__case_id}.run.log" ) log_handler = logger.add(self.__log_path, level="DEBUG") # parse config name - self.__project_meta = self.__project_meta or load_project_meta(self.config.path) variables = self.config.variables variables.update(self.__session_variables) self.config.name = parse_data( self.config.name, variables, self.__project_meta.functions ) - # update allure report meta - allure.dynamic.title(self.config.name) - allure.dynamic.description(f"TestCase ID: {self.__case_id}") + if USE_ALLURE: + # update allure report meta + allure.dynamic.title(self.config.name) + allure.dynamic.description(f"TestCase ID: {self.__case_id}") logger.info( f"Start to run testcase: {self.config.name}, TestCase ID: {self.__case_id}" ) try: - return self.run(TestCase(config=self.config, teststeps=self.teststeps)) + return self.run_testcase( + TestCase(config=self.config, teststeps=self.teststeps) + ) finally: logger.remove(log_handler) logger.info(f"generate testcase log: {self.__log_path}") diff --git a/httprunner/ext/scaffold/__init__.py b/httprunner/scaffold.py similarity index 100% rename from httprunner/ext/scaffold/__init__.py rename to httprunner/scaffold.py diff --git a/httprunner/schema.py b/httprunner/schema.py index c6c0fe97..986f6cb2 100644 --- a/httprunner/schema.py +++ b/httprunner/schema.py @@ -37,7 +37,7 @@ class TConfig(BaseModel): name: Name verify: Verify = False base_url: BaseUrl = "" - # Text: prepare variables in debugtalk.py, ${get_variable()} + # Text: prepare variables in debugtalk.py, ${gen_variables()} variables: Union[VariablesMapping, Text] = {} setup_hooks: Hook = [] teardown_hooks: Hook = [] @@ -64,10 +64,13 @@ class Request(BaseModel): class TStep(BaseModel): name: Name request: Request = None - testcase: Text = "" + testcase: Union[Text, Callable] = "" variables: VariablesMapping = {} + setup_hooks: Hook = [] + teardown_hooks: Hook = [] extract: Dict[Text, Text] = {} validators: Validators = Field([], alias="validate") + validate_script: List[Text] = [] class TestCase(BaseModel): diff --git a/httprunner/utils.py b/httprunner/utils.py index a3c97651..d035b3d6 100644 --- a/httprunner/utils.py +++ b/httprunner/utils.py @@ -2,6 +2,7 @@ import collections import json import os.path import platform +from typing import Dict, List, Any from loguru import logger @@ -151,3 +152,16 @@ def get_platform(): ), "platform": platform.platform(), } + + +def sort_dict_by_custom_order(raw_dict: Dict, custom_order: List): + def get_index_from_list(lst: List, item: Any): + try: + return lst.index(item) + except ValueError: + # item is not in lst + return len(lst) + 1 + + return dict( + sorted(raw_dict.items(), key=lambda i: get_index_from_list(custom_order, i[0])) + ) diff --git a/mkdocs.yml b/mkdocs.yml index fe71cc6b..2899800e 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,21 +1,19 @@ -# require mkdocs-material 3.x - -# -# pip install mkdocs -# pip install mkdocs-material +# install mkdocs +# $ pip install mkdocs # 1.1.2 +# $ pip install mkdocs-material # 5.2.2 # Project information -site_name: HttpRunner V2.x 中文使用文档 -site_description: HttpRunner V2.x User Documentation +site_name: HttpRunner V3.x 中文使用文档 +site_description: HttpRunner V3.x User Documentation site_author: 'debugtalk' # Repository repo_name: HttpRunner -repo_url: https://github.com/HttpRunner/HttpRunner +repo_url: https://github.com/httprunner/httprunner edit_uri: "" # Copyright -copyright: 'Copyright © 2017 - 2019 debugtalk' +copyright: 'Copyright © 2017 - 2020 debugtalk' # Configuration theme: @@ -46,41 +44,14 @@ extra: search: language: 'jp' social: - - type: globe - link: https://debugtalk.com - - type: 'github' - link: 'https://github.com/httprunner' + - icon: material/library + link: https://debugtalk.com + - icon: fontawesome/brands/github-alt + link: 'https://github.com/httprunner' # index pages nav: - - 介绍: index.md - - 安装说明: Installation.md - - 快速上手: quickstart.md - - 基础概念: - - 名词解释: concept/nominal.md - - 测试准备: - - 录制生成用例: prepare/record.md - - 项目文件组织: prepare/project-structure.md - - 测试用例组织: prepare/testcase-structure.md - - hook机制: prepare/request-hook.md - - 环境变量: prepare/dot-env.md - - 测试用例分层: prepare/testcase-layer.md - - 参数化数据驱动: prepare/parameters.md - - 信息安全: prepare/security.md - - 文件上传场景: prepare/upload-case.md - - 测试执行: - - 运行测试(CLI): run-tests/cli.md - - 测试报告: run-tests/report.md - - 性能测试: run-tests/load-test.md - - 开发扩展: - - Pipline: development/architecture.md - - 基础库调用: development/dev-api.md - - FAQ: FAQ.md - - 实践案例: - - TesterHome 登录: examples/testerhome-login.md - - klook: examples/demo-klook/README.md - - 相关资料: related-docs.md + - Introduction: index.md + - Installation: installation.md + - Sponsors: sponsors.md - CHANGELOG: CHANGELOG.md - -extra_javascript: - - 'js/slardar.js' \ No newline at end of file diff --git a/poetry.lock b/poetry.lock index e373caa9..39a6f593 100644 --- a/poetry.lock +++ b/poetry.lock @@ -16,12 +16,12 @@ version = "2.4" category = "main" description = "Allure pytest integration" name = "allure-pytest" -optional = false +optional = true python-versions = "*" -version = "2.8.15" +version = "2.8.16" [package.dependencies] -allure-python-commons = "2.8.15" +allure-python-commons = "2.8.16" pytest = ">=4.5.0" six = ">=1.9.0" @@ -29,9 +29,9 @@ six = ">=1.9.0" category = "main" description = "Common module for integrate allure with python-based frameworks" name = "allure-python-commons" -optional = false +optional = true python-versions = "*" -version = "2.8.15" +version = "2.8.16" [package.dependencies] attrs = ">=16.0.0" @@ -95,7 +95,7 @@ description = "Python package for providing Mozilla's CA Bundle." name = "certifi" optional = false python-versions = "*" -version = "2019.11.28" +version = "2020.4.5.1" [[package]] category = "main" @@ -110,8 +110,8 @@ category = "main" description = "Composable command line interface toolkit" name = "click" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "7.0" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +version = "7.1.2" [[package]] category = "main" @@ -173,9 +173,9 @@ test = ["pytest (>=4.0.0)", "pytest-cov", "mypy", "black", "isort", "requests", category = "main" description = "Infer file type and MIME type of any file/buffer. No external dependencies." name = "filetype" -optional = false +optional = true python-versions = "*" -version = "1.0.5" +version = "1.0.7" [[package]] category = "dev" @@ -203,7 +203,7 @@ description = "Internationalized Domain Names in Applications (IDNA)" name = "idna" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "2.8" +version = "2.9" [[package]] category = "main" @@ -211,8 +211,8 @@ description = "Immutable Collections" marker = "python_version < \"3.7\"" name = "immutables" optional = false -python-versions = "*" -version = "0.11" +python-versions = ">=3.5" +version = "0.14" [[package]] category = "main" @@ -235,8 +235,8 @@ category = "main" description = "A very fast and expressive template engine." name = "jinja2" optional = false -python-versions = "*" -version = "2.10.3" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +version = "2.11.2" [package.dependencies] MarkupSafe = ">=0.23" @@ -285,7 +285,7 @@ description = "More routines for operating on iterables, beyond itertools" name = "more-itertools" optional = false python-versions = ">=3.5" -version = "8.2.0" +version = "8.3.0" [[package]] category = "main" @@ -293,7 +293,7 @@ description = "Core utilities for Python packages" name = "packaging" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "20.3" +version = "20.4" [package.dependencies] pyparsing = ">=2.0.2" @@ -337,7 +337,7 @@ description = "Data validation and settings management using python 3.6 type hin name = "pydantic" optional = false python-versions = ">=3.6" -version = "1.4" +version = "1.5.1" [package.dependencies] [package.dependencies.dataclasses] @@ -383,13 +383,36 @@ version = ">=0.12" checkqa-mypy = ["mypy (v0.761)"] testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] +[[package]] +category = "main" +description = "pytest plugin for generating HTML reports" +name = "pytest-html" +optional = false +python-versions = ">=3.6" +version = "2.1.1" + +[package.dependencies] +pytest = ">=5.0" +pytest-metadata = "*" + +[[package]] +category = "main" +description = "pytest plugin for test session metadata" +name = "pytest-metadata" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" +version = "1.9.0" + +[package.dependencies] +pytest = ">=2.9.0" + [[package]] category = "main" description = "YAML parser and emitter for Python" name = "pyyaml" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "5.2" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +version = "5.3.1" [[package]] category = "main" @@ -405,23 +428,23 @@ description = "Python HTTP for Humans." name = "requests" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "2.22.0" +version = "2.23.0" [package.dependencies] certifi = ">=2017.4.17" -chardet = ">=3.0.2,<3.1.0" -idna = ">=2.5,<2.9" +chardet = ">=3.0.2,<4" +idna = ">=2.5,<3" urllib3 = ">=1.21.1,<1.25.0 || >1.25.0,<1.25.1 || >1.25.1,<1.26" [package.extras] -security = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)"] +security = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)"] socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7)", "win-inet-pton"] [[package]] category = "main" description = "A utility belt for advanced users of python-requests" name = "requests-toolbelt" -optional = false +optional = true python-versions = "*" version = "0.9.1" @@ -434,7 +457,7 @@ description = "Python 2 and 3 compatibility utilities" name = "six" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" -version = "1.14.0" +version = "1.15.0" [[package]] category = "dev" @@ -468,12 +491,12 @@ category = "main" description = "HTTP library with thread-safe connection pooling, file post, and more." name = "urllib3" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, <4" -version = "1.25.7" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" +version = "1.25.9" [package.extras] brotli = ["brotlipy (>=0.6.0)"] -secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] +secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "pyOpenSSL (>=0.14)", "ipaddress"] socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7,<2.0)"] [[package]] @@ -482,7 +505,7 @@ description = "The lightning-fast ASGI server." name = "uvicorn" optional = false python-versions = "*" -version = "0.11.3" +version = "0.11.5" [package.dependencies] click = ">=7.0.0,<8.0.0" @@ -491,6 +514,9 @@ httptools = ">=0.1.0,<0.2.0" uvloop = ">=0.14.0" websockets = ">=8.0.0,<9.0.0" +[package.extras] +watchgodreload = ["watchgod (>=0.6,<0.7)"] + [[package]] category = "dev" description = "Fast implementation of asyncio event loop on top of libuv" @@ -541,8 +567,12 @@ version = "3.1.0" docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] testing = ["jaraco.itertools", "func-timeout"] +[extras] +allure = ["allure-pytest"] +upload = ["requests-toolbelt", "filetype"] + [metadata] -content-hash = "67027f8f78c61b981f3c01613ded1da2a0256a28fb92f95dd2d642b3fd1b43a5" +content-hash = "3b5147c8c95480574c9eaa8f035c536cf18535766f60f768d2e714b257511dae" python-versions = "^3.6" [metadata.files] @@ -551,12 +581,12 @@ aiocontextvars = [ {file = "aiocontextvars-0.2.2.tar.gz", hash = "sha256:f027372dc48641f683c559f247bd84962becaacdc9ba711d583c3871fb5652aa"}, ] allure-pytest = [ - {file = "allure-pytest-2.8.15.tar.gz", hash = "sha256:27f9c75194e95ba069ee2d6d2a2615ed6c7e96617ff9a492ab3a74f3f4e64be2"}, - {file = "allure_pytest-2.8.15-py3-none-any.whl", hash = "sha256:62512bbce3d39b27a8e7ffbfb24e08e99c43df29b4f345168dfc9692bfddef71"}, + {file = "allure-pytest-2.8.16.tar.gz", hash = "sha256:91a8a7481b9914abdaf5918ae9eddf15167f835806c5d04d0881ddbf8744f7b6"}, + {file = "allure_pytest-2.8.16-py3-none-any.whl", hash = "sha256:4a0d4214ed19bc5bae4d6b2bd2329c008facb3e562d8d3dd12d74e455b3203dc"}, ] allure-python-commons = [ - {file = "allure-python-commons-2.8.15.tar.gz", hash = "sha256:c4768e5e1350fe2eb6e1c9dac6158dcb82e23de80c83c4fc6d71765c207c1408"}, - {file = "allure_python_commons-2.8.15-py3-none-any.whl", hash = "sha256:88ad53109b6fa57e6b721f4eab59116db6037e219bf54e1f196a222ba5e2dcfe"}, + {file = "allure-python-commons-2.8.16.tar.gz", hash = "sha256:f67104a51643f2b0f1807acfe324bc13c1fa97f16d9b5c85670199acabd5c40d"}, + {file = "allure_python_commons-2.8.16-py3-none-any.whl", hash = "sha256:3cf65bce770e4d6b6b1bd46bfecad8a04f1f7bef44133f9a3ded4295510187e2"}, ] appdirs = [ {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"}, @@ -575,16 +605,16 @@ black = [ {file = "black-19.10b0.tar.gz", hash = "sha256:c2edb73a08e9e0e6f65a0e6af18b059b8b1cdd5bef997d7a0b181df93dc81539"}, ] certifi = [ - {file = "certifi-2019.11.28-py2.py3-none-any.whl", hash = "sha256:017c25db2a153ce562900032d5bc68e9f191e44e9a0f762f373977de9df1fbb3"}, - {file = "certifi-2019.11.28.tar.gz", hash = "sha256:25b64c7da4cd7479594d035c08c2d809eb4aab3a26e5a990ea98cc450c320f1f"}, + {file = "certifi-2020.4.5.1-py2.py3-none-any.whl", hash = "sha256:1d987a998c75633c40847cc966fcf5904906c920a7f17ef374f5aa4282abd304"}, + {file = "certifi-2020.4.5.1.tar.gz", hash = "sha256:51fcb31174be6e6664c5f69e3e1691a2d72a1a12e90f872cbdb1567eb47b6519"}, ] chardet = [ {file = "chardet-3.0.4-py2.py3-none-any.whl", hash = "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"}, {file = "chardet-3.0.4.tar.gz", hash = "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae"}, ] click = [ - {file = "Click-7.0-py2.py3-none-any.whl", hash = "sha256:2335065e6395b9e67ca716de5f7526736bfa6ceead690adf616d925bdc622b13"}, - {file = "Click-7.0.tar.gz", hash = "sha256:5b94b49521f6456670fdb30cd82a4eca9412788a93fa6dd6df72c94d5a8ff2d7"}, + {file = "click-7.1.2-py2.py3-none-any.whl", hash = "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"}, + {file = "click-7.1.2.tar.gz", hash = "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a"}, ] colorama = [ {file = "colorama-0.4.3-py2.py3-none-any.whl", hash = "sha256:7d73d2a99753107a36ac6b455ee49046802e59d9d076ef8e47b61499fa29afff"}, @@ -636,8 +666,8 @@ fastapi = [ {file = "fastapi-0.49.2.tar.gz", hash = "sha256:68395725aac4342896b4f9aa335c7e7fb773b565df7f96e964e24bffb84dc5a3"}, ] filetype = [ - {file = "filetype-1.0.5-py2.py3-none-any.whl", hash = "sha256:4967124d982a71700d94a08c49c4926423500e79382a92070f5ab248d44fe461"}, - {file = "filetype-1.0.5.tar.gz", hash = "sha256:17a3b885f19034da29640b083d767e0f13c2dcb5dcc267945c8b6e5a5a9013c7"}, + {file = "filetype-1.0.7-py2.py3-none-any.whl", hash = "sha256:353369948bb1c09b8b3ea3d78390b5586e9399bff9aab894a1dff954e31a66f6"}, + {file = "filetype-1.0.7.tar.gz", hash = "sha256:da393ece8d98b47edf2dd5a85a2c8733e44b769e32c71af4cd96ed8d38d96aa7"}, ] h11 = [ {file = "h11-0.9.0-py2.py3-none-any.whl", hash = "sha256:4bc6d6a1238b7615b266ada57e0618568066f57dd6fa967d1290ec9309b2f2f1"}, @@ -658,34 +688,30 @@ httptools = [ {file = "httptools-0.1.1.tar.gz", hash = "sha256:41b573cf33f64a8f8f3400d0a7faf48e1888582b6f6e02b82b9bd4f0bf7497ce"}, ] idna = [ - {file = "idna-2.8-py2.py3-none-any.whl", hash = "sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c"}, - {file = "idna-2.8.tar.gz", hash = "sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407"}, + {file = "idna-2.9-py2.py3-none-any.whl", hash = "sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa"}, + {file = "idna-2.9.tar.gz", hash = "sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb"}, ] immutables = [ - {file = "immutables-0.11-cp35-cp35m-macosx_10_13_x86_64.whl", hash = "sha256:bce27277a2fe91509cca69181971ab509c2ee862e8b37b09f26b64f90e8fe8fb"}, - {file = "immutables-0.11-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:c7eb2d15c35c73bb168c002c6ea145b65f40131e10dede54b39db0b72849b280"}, - {file = "immutables-0.11-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:2de2ec8dde1ca154f811776a8cbbeaea515c3b226c26036eab6484530eea28e0"}, - {file = "immutables-0.11-cp35-cp35m-win32.whl", hash = "sha256:e87bd941cb4dfa35f16e1ff4b2d99a2931452dcc9cfd788dc8fe513f3d38551e"}, - {file = "immutables-0.11-cp35-cp35m-win_amd64.whl", hash = "sha256:0aa055c745510238cbad2f1f709a37a1c9e30a38594de3b385e9876c48a25633"}, - {file = "immutables-0.11-cp36-cp36m-macosx_10_13_x86_64.whl", hash = "sha256:422c7d4c75c88057c625e32992248329507bca180b48cfb702b4ef608f581b50"}, - {file = "immutables-0.11-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:f5b93248552c9e7198558776da21c9157d3f70649905d7fdc083c2ab2fbc6088"}, - {file = "immutables-0.11-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:b268422a5802fbf934152b835329ac0d23b80b558eaee68034d45718edab4a11"}, - {file = "immutables-0.11-cp36-cp36m-win32.whl", hash = "sha256:0f07c58122e1ce70a7165e68e18e795ac5fe94d7fee3e045ffcf6432602026df"}, - {file = "immutables-0.11-cp36-cp36m-win_amd64.whl", hash = "sha256:b8fed714f1c84a3242c7184838f5e9889139a22bbdd701a182b7fdc237ca3cbb"}, - {file = "immutables-0.11-cp37-cp37m-macosx_10_13_x86_64.whl", hash = "sha256:518f20945c1f600b618fb691922c2ab43b193f04dd2d4d2823220d0202014670"}, - {file = "immutables-0.11-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:2c536ff2bafeeff9a7865ea10a17a50f90b80b585e31396c349e8f57b0075bd4"}, - {file = "immutables-0.11-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:1c2e729aab250be0de0c13fa833241a778b51390ee2650e0457d1e45b318c441"}, - {file = "immutables-0.11-cp37-cp37m-win32.whl", hash = "sha256:545186faab9237c102b8bcffd36d71f0b382174c93c501e061de239753cff694"}, - {file = "immutables-0.11-cp37-cp37m-win_amd64.whl", hash = "sha256:6b6d8d035e5888baad3db61dfb167476838a63afccecd927c365f228bb55754c"}, - {file = "immutables-0.11.tar.gz", hash = "sha256:d6850578a0dc6530ac19113cfe4ddc13903df635212d498f176fe601a8a5a4a3"}, + {file = "immutables-0.14-cp35-cp35m-macosx_10_14_x86_64.whl", hash = "sha256:860666fab142401a5535bf65cbd607b46bc5ed25b9d1eb053ca8ed9a1a1a80d6"}, + {file = "immutables-0.14-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:ce01788878827c3f0331c254a4ad8d9721489a5e65cc43e19c80040b46e0d297"}, + {file = "immutables-0.14-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:8797eed4042f4626b0bc04d9cf134208918eb0c937a8193a2c66df5041e62d2e"}, + {file = "immutables-0.14-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:33ce2f977da7b5e0dddd93744862404bdb316ffe5853ec853e53141508fa2e6a"}, + {file = "immutables-0.14-cp36-cp36m-win_amd64.whl", hash = "sha256:6c8eace4d98988c72bcb37c05e79aae756832738305ae9497670482a82db08bc"}, + {file = "immutables-0.14-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:ab6c18b7b2b2abc83e0edc57b0a38bf0915b271582a1eb8c7bed1c20398f8040"}, + {file = "immutables-0.14-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:c099212fd6504513a50e7369fe281007c820cf9d7bb22a336486c63d77d6f0b2"}, + {file = "immutables-0.14-cp37-cp37m-win_amd64.whl", hash = "sha256:714aedbdeba4439d91cb5e5735cb10631fc47a7a69ea9cc8ecbac90322d50a4a"}, + {file = "immutables-0.14-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:1c11050c49e193a1ec9dda1747285333f6ba6a30bbeb2929000b9b1192097ec0"}, + {file = "immutables-0.14-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:c453e12b95e1d6bb4909e8743f88b7f5c0c97b86a8bc0d73507091cb644e3c1e"}, + {file = "immutables-0.14-cp38-cp38-win_amd64.whl", hash = "sha256:ef9da20ec0f1c5853b5c8f8e3d9e1e15b8d98c259de4b7515d789a606af8745e"}, + {file = "immutables-0.14.tar.gz", hash = "sha256:a0a1cc238b678455145bae291d8426f732f5255537ed6a5b7645949704c70a78"}, ] importlib-metadata = [ {file = "importlib_metadata-1.6.0-py2.py3-none-any.whl", hash = "sha256:2a688cbaa90e0cc587f1df48bdc97a6eadccdcd9c35fb3f976a09e3b5016d90f"}, {file = "importlib_metadata-1.6.0.tar.gz", hash = "sha256:34513a8a0c4962bc66d35b359558fd8a5e10cd472d37aec5f66858addef32c1e"}, ] jinja2 = [ - {file = "Jinja2-2.10.3-py2.py3-none-any.whl", hash = "sha256:74320bb91f31270f9551d46522e33af46a80c3d619f4a4bf42b3164d30b5911f"}, - {file = "Jinja2-2.10.3.tar.gz", hash = "sha256:9fe95f19286cfefaa917656583d020be14e7859c6b0252588391e47db34527de"}, + {file = "Jinja2-2.11.2-py2.py3-none-any.whl", hash = "sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035"}, + {file = "Jinja2-2.11.2.tar.gz", hash = "sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0"}, ] jmespath = [ {file = "jmespath-0.9.5-py2.py3-none-any.whl", hash = "sha256:695cb76fa78a10663425d5b73ddc5714eb711157e52704d69be03b1a02ba4fec"}, @@ -726,12 +752,12 @@ markupsafe = [ {file = "MarkupSafe-1.1.1.tar.gz", hash = "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b"}, ] more-itertools = [ - {file = "more-itertools-8.2.0.tar.gz", hash = "sha256:b1ddb932186d8a6ac451e1d95844b382f55e12686d51ca0c68b6f61f2ab7a507"}, - {file = "more_itertools-8.2.0-py3-none-any.whl", hash = "sha256:5dd8bcf33e5f9513ffa06d5ad33d78f31e1931ac9a18f33d37e77a180d393a7c"}, + {file = "more-itertools-8.3.0.tar.gz", hash = "sha256:558bb897a2232f5e4f8e2399089e35aecb746e1f9191b6584a151647e89267be"}, + {file = "more_itertools-8.3.0-py3-none-any.whl", hash = "sha256:7818f596b1e87be009031c7653d01acc46ed422e6656b394b0f765ce66ed4982"}, ] packaging = [ - {file = "packaging-20.3-py2.py3-none-any.whl", hash = "sha256:82f77b9bee21c1bafbf35a84905d604d5d1223801d639cf3ed140bd651c08752"}, - {file = "packaging-20.3.tar.gz", hash = "sha256:3c292b474fda1671ec57d46d739d072bfd495a4f51ad01a055121d81e952b7a3"}, + {file = "packaging-20.4-py2.py3-none-any.whl", hash = "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181"}, + {file = "packaging-20.4.tar.gz", hash = "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8"}, ] pathspec = [ {file = "pathspec-0.8.0-py2.py3-none-any.whl", hash = "sha256:7d91249d21749788d07a2d0f94147accd8f845507400749ea19c1ec9054a12b0"}, @@ -746,20 +772,23 @@ py = [ {file = "py-1.8.1.tar.gz", hash = "sha256:5e27081401262157467ad6e7f851b7aa402c5852dbcb3dae06768434de5752aa"}, ] pydantic = [ - {file = "pydantic-1.4-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:07911aab70f3bc52bb845ce1748569c5e70478ac977e106a150dd9d0465ebf04"}, - {file = "pydantic-1.4-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:012c422859bac2e03ab3151ea6624fecf0e249486be7eb8c6ee69c91740c6752"}, - {file = "pydantic-1.4-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:61d22d36808087d3184ed6ac0d91dd71c533b66addb02e4a9930e1e30833202f"}, - {file = "pydantic-1.4-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:f863456d3d4bf817f2e5248553dee3974c5dc796f48e6ddb599383570f4215ac"}, - {file = "pydantic-1.4-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:bbbed364376f4a0aebb9ea452ff7968b306499a9e74f4db69b28ff2cd4043a11"}, - {file = "pydantic-1.4-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:e27559cedbd7f59d2375bfd6eea29a330ea1a5b0589c34d6b4e0d7bec6027bbf"}, - {file = "pydantic-1.4-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:50e4e948892a6815649ad5a9a9379ad1e5f090f17842ac206535dfaed75c6f2f"}, - {file = "pydantic-1.4-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:8848b4eb458469739126e4c1a202d723dd092e087f8dbe3104371335f87ba5df"}, - {file = "pydantic-1.4-cp38-cp38-manylinux1_i686.whl", hash = "sha256:831a0265a9e3933b3d0f04d1a81bba543bafbe4119c183ff2771871db70524ab"}, - {file = "pydantic-1.4-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:47b8db7024ba3d46c3d4768535e1cf87b6c8cf92ccd81e76f4e1cb8ee47688b3"}, - {file = "pydantic-1.4-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:51f11c8bbf794a68086540da099aae4a9107447c7a9d63151edbb7d50110cf21"}, - {file = "pydantic-1.4-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:6100d7862371115c40be55cc4b8d766a74b1d0dbaf99dbfe72bb4bac0faf89ed"}, - {file = "pydantic-1.4-py36.py37.py38-none-any.whl", hash = "sha256:72184c1421103cca128300120f8f1185fb42a9ea73a1c9845b1c53db8c026a7d"}, - {file = "pydantic-1.4.tar.gz", hash = "sha256:f17ec336e64d4583311249fb179528e9a2c27c8a2eaf590ec6ec2c6dece7cb3f"}, + {file = "pydantic-1.5.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:2a6904e9f18dea58f76f16b95cba6a2f20b72d787abd84ecd67ebc526e61dce6"}, + {file = "pydantic-1.5.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:da8099fca5ee339d5572cfa8af12cf0856ae993406f0b1eb9bb38c8a660e7416"}, + {file = "pydantic-1.5.1-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:68dece67bff2b3a5cc188258e46b49f676a722304f1c6148ae08e9291e284d98"}, + {file = "pydantic-1.5.1-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:ab863853cb502480b118187d670f753be65ec144e1654924bec33d63bc8b3ce2"}, + {file = "pydantic-1.5.1-cp36-cp36m-win_amd64.whl", hash = "sha256:2007eb062ed0e57875ce8ead12760a6e44bf5836e6a1a7ea81d71eeecf3ede0f"}, + {file = "pydantic-1.5.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:20a15a303ce1e4d831b4e79c17a4a29cb6740b12524f5bba3ea363bff65732bc"}, + {file = "pydantic-1.5.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:473101121b1bd454c8effc9fe66d54812fdc128184d9015c5aaa0d4e58a6d338"}, + {file = "pydantic-1.5.1-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:9be755919258d5d168aeffbe913ed6e8bd562e018df7724b68cabdee3371e331"}, + {file = "pydantic-1.5.1-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:b96ce81c4b5ca62ab81181212edfd057beaa41411cd9700fbcb48a6ba6564b4e"}, + {file = "pydantic-1.5.1-cp37-cp37m-win_amd64.whl", hash = "sha256:93b9f265329d9827f39f0fca68f5d72cc8321881cdc519a1304fa73b9f8a75bd"}, + {file = "pydantic-1.5.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e2c753d355126ddd1eefeb167fa61c7037ecd30b98e7ebecdc0d1da463b4ea09"}, + {file = "pydantic-1.5.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:8433dbb87246c0f562af75d00fa80155b74e4f6924b0db6a2078a3cd2f11c6c4"}, + {file = "pydantic-1.5.1-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:0a1cdf24e567d42dc762d3fed399bd211a13db2e8462af9dfa93b34c41648efb"}, + {file = "pydantic-1.5.1-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:8be325fc9da897029ee48d1b5e40df817d97fe969f3ac3fd2434ba7e198c55d5"}, + {file = "pydantic-1.5.1-cp38-cp38-win_amd64.whl", hash = "sha256:3714a4056f5bdbecf3a41e0706ec9b228c9513eee2ad884dc2c568c4dfa540e9"}, + {file = "pydantic-1.5.1-py36.py37.py38-none-any.whl", hash = "sha256:70f27d2f0268f490fe3de0a9b6fca7b7492b8fd6623f9fecd25b221ebee385e3"}, + {file = "pydantic-1.5.1.tar.gz", hash = "sha256:f0018613c7a0d19df3240c2a913849786f21b6539b9f23d85ce4067489dfacfa"}, ] pyparsing = [ {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, @@ -769,18 +798,26 @@ pytest = [ {file = "pytest-5.4.2-py3-none-any.whl", hash = "sha256:95c710d0a72d91c13fae35dce195633c929c3792f54125919847fdcdf7caa0d3"}, {file = "pytest-5.4.2.tar.gz", hash = "sha256:eb2b5e935f6a019317e455b6da83dd8650ac9ffd2ee73a7b657a30873d67a698"}, ] +pytest-html = [ + {file = "pytest-html-2.1.1.tar.gz", hash = "sha256:6a4ac391e105e391208e3eb9bd294a60dd336447fd8e1acddff3a6de7f4e57c5"}, + {file = "pytest_html-2.1.1-py2.py3-none-any.whl", hash = "sha256:9e4817e8be8ddde62e8653c8934d0f296b605da3d2277a052f762c56a8b32df2"}, +] +pytest-metadata = [ + {file = "pytest-metadata-1.9.0.tar.gz", hash = "sha256:168d203abba8cabb65cf1b5fa675b0ba60dccbf1825d147960876a7e6f7c219c"}, + {file = "pytest_metadata-1.9.0-py2.py3-none-any.whl", hash = "sha256:91d09c0e367e93c63c98461e9960833f465bff53d00ed2f8ccf680205e5053a4"}, +] pyyaml = [ - {file = "PyYAML-5.2-cp27-cp27m-win32.whl", hash = "sha256:35ace9b4147848cafac3db142795ee42deebe9d0dad885ce643928e88daebdcc"}, - {file = "PyYAML-5.2-cp27-cp27m-win_amd64.whl", hash = "sha256:ebc4ed52dcc93eeebeae5cf5deb2ae4347b3a81c3fa12b0b8c976544829396a4"}, - {file = "PyYAML-5.2-cp35-cp35m-win32.whl", hash = "sha256:38a4f0d114101c58c0f3a88aeaa44d63efd588845c5a2df5290b73db8f246d15"}, - {file = "PyYAML-5.2-cp35-cp35m-win_amd64.whl", hash = "sha256:483eb6a33b671408c8529106df3707270bfacb2447bf8ad856a4b4f57f6e3075"}, - {file = "PyYAML-5.2-cp36-cp36m-win32.whl", hash = "sha256:7f38e35c00e160db592091751d385cd7b3046d6d51f578b29943225178257b31"}, - {file = "PyYAML-5.2-cp36-cp36m-win_amd64.whl", hash = "sha256:0e7f69397d53155e55d10ff68fdfb2cf630a35e6daf65cf0bdeaf04f127c09dc"}, - {file = "PyYAML-5.2-cp37-cp37m-win32.whl", hash = "sha256:e4c015484ff0ff197564917b4b4246ca03f411b9bd7f16e02a2f586eb48b6d04"}, - {file = "PyYAML-5.2-cp37-cp37m-win_amd64.whl", hash = "sha256:4b6be5edb9f6bb73680f5bf4ee08ff25416d1400fbd4535fe0069b2994da07cd"}, - {file = "PyYAML-5.2-cp38-cp38-win32.whl", hash = "sha256:8100c896ecb361794d8bfdb9c11fce618c7cf83d624d73d5ab38aef3bc82d43f"}, - {file = "PyYAML-5.2-cp38-cp38-win_amd64.whl", hash = "sha256:2e9f0b7c5914367b0916c3c104a024bb68f269a486b9d04a2e8ac6f6597b7803"}, - {file = "PyYAML-5.2.tar.gz", hash = "sha256:c0ee8eca2c582d29c3c2ec6e2c4f703d1b7f1fb10bc72317355a746057e7346c"}, + {file = "PyYAML-5.3.1-cp27-cp27m-win32.whl", hash = "sha256:74809a57b329d6cc0fdccee6318f44b9b8649961fa73144a98735b0aaf029f1f"}, + {file = "PyYAML-5.3.1-cp27-cp27m-win_amd64.whl", hash = "sha256:240097ff019d7c70a4922b6869d8a86407758333f02203e0fc6ff79c5dcede76"}, + {file = "PyYAML-5.3.1-cp35-cp35m-win32.whl", hash = "sha256:4f4b913ca1a7319b33cfb1369e91e50354d6f07a135f3b901aca02aa95940bd2"}, + {file = "PyYAML-5.3.1-cp35-cp35m-win_amd64.whl", hash = "sha256:cc8955cfbfc7a115fa81d85284ee61147059a753344bc51098f3ccd69b0d7e0c"}, + {file = "PyYAML-5.3.1-cp36-cp36m-win32.whl", hash = "sha256:7739fc0fa8205b3ee8808aea45e968bc90082c10aef6ea95e855e10abf4a37b2"}, + {file = "PyYAML-5.3.1-cp36-cp36m-win_amd64.whl", hash = "sha256:69f00dca373f240f842b2931fb2c7e14ddbacd1397d57157a9b005a6a9942648"}, + {file = "PyYAML-5.3.1-cp37-cp37m-win32.whl", hash = "sha256:d13155f591e6fcc1ec3b30685d50bf0711574e2c0dfffd7644babf8b5102ca1a"}, + {file = "PyYAML-5.3.1-cp37-cp37m-win_amd64.whl", hash = "sha256:73f099454b799e05e5ab51423c7bcf361c58d3206fa7b0d555426b1f4d9a3eaf"}, + {file = "PyYAML-5.3.1-cp38-cp38-win32.whl", hash = "sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97"}, + {file = "PyYAML-5.3.1-cp38-cp38-win_amd64.whl", hash = "sha256:95f71d2af0ff4227885f7a6605c37fd53d3a106fcab511b8860ecca9fcf400ee"}, + {file = "PyYAML-5.3.1.tar.gz", hash = "sha256:b8eac752c5e14d3eca0e6dd9199cd627518cb5ec06add0de9d32baeee6fe645d"}, ] regex = [ {file = "regex-2020.5.14-cp27-cp27m-win32.whl", hash = "sha256:e565569fc28e3ba3e475ec344d87ed3cd8ba2d575335359749298a0899fe122e"}, @@ -806,16 +843,16 @@ regex = [ {file = "regex-2020.5.14.tar.gz", hash = "sha256:ce450ffbfec93821ab1fea94779a8440e10cf63819be6e176eb1973a6017aff5"}, ] requests = [ - {file = "requests-2.22.0-py2.py3-none-any.whl", hash = "sha256:9cf5292fcd0f598c671cfc1e0d7d1a7f13bb8085e9a590f48c010551dc6c4b31"}, - {file = "requests-2.22.0.tar.gz", hash = "sha256:11e007a8a2aa0323f5a921e9e6a2d7e4e67d9877e85773fba9ba6419025cbeb4"}, + {file = "requests-2.23.0-py2.py3-none-any.whl", hash = "sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee"}, + {file = "requests-2.23.0.tar.gz", hash = "sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6"}, ] 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"}, ] six = [ - {file = "six-1.14.0-py2.py3-none-any.whl", hash = "sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c"}, - {file = "six-1.14.0.tar.gz", hash = "sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a"}, + {file = "six-1.15.0-py2.py3-none-any.whl", hash = "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"}, + {file = "six-1.15.0.tar.gz", hash = "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259"}, ] starlette = [ {file = "starlette-0.12.9.tar.gz", hash = "sha256:c2ac9a42e0e0328ad20fe444115ac5e3760c1ee2ac1ff8cdb5ec915c4a453411"}, @@ -848,12 +885,12 @@ typed-ast = [ {file = "typed_ast-1.4.1.tar.gz", hash = "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b"}, ] urllib3 = [ - {file = "urllib3-1.25.7-py2.py3-none-any.whl", hash = "sha256:a8a318824cc77d1fd4b2bec2ded92646630d7fe8619497b142c84a9e6f5a7293"}, - {file = "urllib3-1.25.7.tar.gz", hash = "sha256:f3c5fd51747d450d4dcf6f923c81f78f811aab8205fda64b0aba34a4e48b0745"}, + {file = "urllib3-1.25.9-py2.py3-none-any.whl", hash = "sha256:88206b0eb87e6d677d424843ac5209e3fb9d0190d0ee169599165ec25e9d9115"}, + {file = "urllib3-1.25.9.tar.gz", hash = "sha256:3018294ebefce6572a474f0604c2021e33b3fd8006ecd11d62107a5d2a963527"}, ] uvicorn = [ - {file = "uvicorn-0.11.3-py3-none-any.whl", hash = "sha256:0f58170165c4495f563d8224b2f415a0829af0412baa034d6f777904613087fd"}, - {file = "uvicorn-0.11.3.tar.gz", hash = "sha256:6fdaf8e53bf1b2ddf0fe9ed06079b5348d7d1d87b3365fe2549e6de0d49e631c"}, + {file = "uvicorn-0.11.5-py3-none-any.whl", hash = "sha256:50577d599775dac2301bac8bd5b540d19a9560144143c5bdab13cba92783b6e7"}, + {file = "uvicorn-0.11.5.tar.gz", hash = "sha256:596eaa8645b6dbc24d6610e335f8ddf5f925b4c4b86fdc7146abb0bf0da65d17"}, ] uvloop = [ {file = "uvloop-0.14.0-cp35-cp35m-macosx_10_11_x86_64.whl", hash = "sha256:08b109f0213af392150e2fe6f81d33261bb5ce968a288eb698aad4f46eb711bd"}, diff --git a/pyproject.toml b/pyproject.toml index 9a499654..ec8c3459 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "httprunner" -version = "3.0.5" +version = "3.0.6" description = "One-stop solution for HTTP(S) testing." license = "Apache-2.0" readme = "README.md" @@ -30,16 +30,21 @@ include = ["docs/CHANGELOG.md"] [tool.poetry.dependencies] python = "^3.6" requests = "^2.22.0" -requests-toolbelt = "^0.9.1" pyyaml = "^5.1.2" jinja2 = "^2.10.3" -filetype = "^1.0.5" pydantic = "^1.4" loguru = "^0.4.1" jmespath = "^0.9.5" black = "^19.10b0" pytest = "^5.4.2" -allure-pytest = "^2.8.15" +pytest-html = "^2.1.1" +allure-pytest = {version = "^2.8.16", optional = true} +requests-toolbelt = {version = "^0.9.1", optional = true} +filetype = {version = "^1.0.7", optional = true} + +[tool.poetry.extras] +allure = ["allure-pytest"] # poetry install -E allure +upload = ["requests-toolbelt", "filetype"] # poetry install -E upload [tool.poetry.dev-dependencies] coverage = "^4.5.4" diff --git a/docs/examples/demo-klook/__init__.py b/tests/__init__.py similarity index 100% rename from docs/examples/demo-klook/__init__.py rename to tests/__init__.py diff --git a/docs/examples/demo-klook/utils/__init__.py b/tests/app/__init__.py similarity index 100% rename from docs/examples/demo-klook/utils/__init__.py rename to tests/app/__init__.py diff --git a/httprunner/app/routers/debug_test.py b/tests/app/debug_test.py similarity index 100% rename from httprunner/app/routers/debug_test.py rename to tests/app/debug_test.py diff --git a/httprunner/cli_test.py b/tests/cli_test.py similarity index 97% rename from httprunner/cli_test.py rename to tests/cli_test.py index a344866c..3ddd0efb 100644 --- a/httprunner/cli_test.py +++ b/tests/cli_test.py @@ -43,6 +43,6 @@ class TestCli(unittest.TestCase): pytest.main( [ "-s", - "examples/postman_echo/request_methods/request_with_variables_test.py", + "examples/postman_echo/request_methods/request_with_testcase_reference_test.py", ] ) diff --git a/tests/compat_test.py b/tests/compat_test.py new file mode 100644 index 00000000..c44cd2a7 --- /dev/null +++ b/tests/compat_test.py @@ -0,0 +1,190 @@ +import os +import unittest + +from httprunner import compat, exceptions + + +class TestCompat(unittest.TestCase): + def test_convert_jmespath(self): + + self.assertEqual(compat.convert_jmespath("content.abc"), "body.abc") + self.assertEqual(compat.convert_jmespath("json.abc"), "body.abc") + self.assertEqual( + compat.convert_jmespath("headers.Content-Type"), 'headers."Content-Type"' + ) + self.assertEqual( + compat.convert_jmespath('headers."Content-Type"'), 'headers."Content-Type"' + ) + self.assertEqual( + compat.convert_jmespath("body.data.buildings.0.building_id"), + "body.data.buildings[0].building_id", + ) + with self.assertRaises(exceptions.FileFormatError): + compat.convert_jmespath("2.buildings.0.building_id") + + def test_convert_extractors(self): + self.assertEqual( + compat.convert_extractors( + [{"varA": "content.varA"}, {"varB": "json.varB"}] + ), + {"varA": "body.varA", "varB": "body.varB"}, + ) + self.assertEqual( + compat.convert_extractors([{"varA": "content.0.varA"}]), + {"varA": "body[0].varA"}, + ) + self.assertEqual( + compat.convert_extractors({"varA": "content.0.varA"}), + {"varA": "body[0].varA"}, + ) + + def test_convert_validators(self): + self.assertEqual( + compat.convert_validators( + [{"check": "content.abc", "assert": "eq", "expect": 201}] + ), + [{"check": "body.abc", "assert": "eq", "expect": 201}], + ) + self.assertEqual( + compat.convert_validators([{"eq": ["content.abc", 201]}]), + [{"eq": ["body.abc", 201]}], + ) + self.assertEqual( + compat.convert_validators([{"eq": ["content.0.name", 201]}]), + [{"eq": ["body[0].name", 201]}], + ) + + def test_ensure_testcase_v3_api(self): + api_content = { + "name": "get with params", + "request": { + "method": "GET", + "url": "/get", + "params": {"foo1": "bar1", "foo2": "bar2"}, + "headers": {"User-Agent": "HttpRunner/3.0"}, + }, + "extract": [{"varA": "content.varA"}, {"user_agent": "headers.User-Agent"}], + "validate": [{"eq": ["content.varB", 200]}, {"lt": ["json.0.varC", 0]}], + } + self.assertEqual( + compat.ensure_testcase_v3_api(api_content), + { + "config": {"name": "get with params"}, + "teststeps": [ + { + "name": "get with params", + "request": { + "method": "GET", + "url": "/get", + "params": {"foo1": "bar1", "foo2": "bar2"}, + "headers": {"User-Agent": "HttpRunner/3.0"}, + }, + "extract": { + "varA": "body.varA", + "user_agent": 'headers."User-Agent"', + }, + "validate": [ + {"eq": ["body.varB", 200]}, + {"lt": ["body[0].varC", 0]}, + ], + } + ], + }, + ) + + def test_ensure_testcase_v3(self): + testcase_content = { + "config": {"name": "xxx", "base_url": "https://httpbin.org"}, + "teststeps": [ + { + "name": "get with params", + "request": { + "method": "GET", + "url": "/get", + "params": {"foo1": "bar1", "foo2": "bar2"}, + "headers": {"User-Agent": "HttpRunner/3.0"}, + }, + "extract": [ + {"varA": "content.varA"}, + {"user_agent": "headers.User-Agent"}, + ], + "validate": [ + {"eq": ["content.varB", 200]}, + {"lt": ["json.0.varC", 0]}, + ], + } + ], + } + self.assertEqual( + compat.ensure_testcase_v3(testcase_content), + { + "config": {"name": "xxx", "base_url": "https://httpbin.org"}, + "teststeps": [ + { + "name": "get with params", + "request": { + "method": "GET", + "url": "/get", + "params": {"foo1": "bar1", "foo2": "bar2"}, + "headers": {"User-Agent": "HttpRunner/3.0"}, + }, + "extract": { + "varA": "body.varA", + "user_agent": 'headers."User-Agent"', + }, + "validate": [ + {"eq": ["body.varB", 200]}, + {"lt": ["body[0].varC", 0]}, + ], + } + ], + }, + ) + + def test_ensure_cli_args(self): + args1 = ["examples/postman_echo/request_methods/hardcode.yml", "--failfast"] + self.assertEqual( + compat.ensure_cli_args(args1), + ["examples/postman_echo/request_methods/hardcode.yml"], + ) + + args2 = ["examples/postman_echo/request_methods/hardcode.yml", "--save-tests"] + self.assertEqual( + compat.ensure_cli_args(args2), + ["examples/postman_echo/request_methods/hardcode.yml"], + ) + self.assertTrue( + os.path.isfile("examples/postman_echo/request_methods/conftest.py") + ) + + args3 = [ + "examples/postman_echo/request_methods/hardcode.yml", + "--report-file", + "report.html", + ] + self.assertEqual( + compat.ensure_cli_args(args3), + [ + "examples/postman_echo/request_methods/hardcode.yml", + "--html", + "report.html", + "--self-contained-html", + ], + ) + + args4 = [ + "examples/postman_echo/request_methods/hardcode.yml", + "--failfast", + "--save-tests", + "--report-file", + "report.html", + ] + self.assertEqual( + compat.ensure_cli_args(args4), + [ + "examples/postman_echo/request_methods/hardcode.yml", + "--html", + "report.html", + "--self-contained-html", + ], + ) diff --git a/docs/examples/demo-klook/utils/setup_hooks.py b/tests/ext/__init__.py similarity index 100% rename from docs/examples/demo-klook/utils/setup_hooks.py rename to tests/ext/__init__.py diff --git a/tests/ext/har2case/__init__.py b/tests/ext/har2case/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/httprunner/ext/har2case/core_test.py b/tests/ext/har2case/core_test.py similarity index 98% rename from httprunner/ext/har2case/core_test.py rename to tests/ext/har2case/core_test.py index 22ec06e4..329dde57 100644 --- a/httprunner/ext/har2case/core_test.py +++ b/tests/ext/har2case/core_test.py @@ -2,10 +2,10 @@ import os from httprunner.ext.har2case.core import HarParser from httprunner.ext.har2case.utils import load_har_log_entries -from httprunner.ext.har2case.utils_test import TestUtils +from tests.ext.har2case.utils_test import TestHar2CaseUtils -class TestHar(TestUtils): +class TestHar(TestHar2CaseUtils): def setUp(self): self.har_path = os.path.join(os.path.dirname(__file__), "data", "demo.har") self.har_parser = HarParser(self.har_path) diff --git a/httprunner/ext/har2case/data/demo-quickstart.har b/tests/ext/har2case/data/demo-quickstart.har similarity index 100% rename from httprunner/ext/har2case/data/demo-quickstart.har rename to tests/ext/har2case/data/demo-quickstart.har diff --git a/httprunner/ext/har2case/data/demo.har b/tests/ext/har2case/data/demo.har similarity index 100% rename from httprunner/ext/har2case/data/demo.har rename to tests/ext/har2case/data/demo.har diff --git a/httprunner/ext/har2case/utils_test.py b/tests/ext/har2case/utils_test.py similarity index 89% rename from httprunner/ext/har2case/utils_test.py rename to tests/ext/har2case/utils_test.py index 38e34dd5..658ec3ae 100644 --- a/httprunner/ext/har2case/utils_test.py +++ b/tests/ext/har2case/utils_test.py @@ -5,7 +5,7 @@ import unittest from httprunner.ext.har2case import utils -class TestUtils(unittest.TestCase): +class TestHar2CaseUtils(unittest.TestCase): @staticmethod def create_har_file(file_name, content): file_path = os.path.join( @@ -24,7 +24,7 @@ class TestUtils(unittest.TestCase): self.assertIn("response", log_entries[0]) def test_load_har_log_key_error(self): - empty_json_file_path = TestUtils.create_har_file( + empty_json_file_path = TestHar2CaseUtils.create_har_file( file_name="empty_json", content={} ) with self.assertRaises(SystemExit): @@ -32,7 +32,9 @@ class TestUtils(unittest.TestCase): os.remove(empty_json_file_path) def test_load_har_log_empty_error(self): - empty_file_path = TestUtils.create_har_file(file_name="empty", content="") + empty_file_path = TestHar2CaseUtils.create_har_file( + file_name="empty", content="" + ) with self.assertRaises(SystemExit): utils.load_har_log_entries(empty_file_path) os.remove(empty_file_path) diff --git a/httprunner/loader_test.py b/tests/loader_test.py similarity index 100% rename from httprunner/loader_test.py rename to tests/loader_test.py diff --git a/httprunner/ext/make/make_test.py b/tests/make_test.py similarity index 67% rename from httprunner/ext/make/make_test.py rename to tests/make_test.py index 7145807c..cabb27ed 100644 --- a/httprunner/ext/make/make_test.py +++ b/tests/make_test.py @@ -1,6 +1,6 @@ import unittest -from httprunner.ext.make import main_make, convert_testcase_path +from httprunner.make import main_make, convert_testcase_path, make_files_cache_set class TestLoader(unittest.TestCase): @@ -12,6 +12,34 @@ class TestLoader(unittest.TestCase): "examples/postman_echo/request_methods/request_with_variables_test.py", ) + def test_make_testcase_with_ref(self): + path = [ + "examples/postman_echo/request_methods/request_with_testcase_reference.yml" + ] + make_files_cache_set.clear() + testcase_python_list = main_make(path) + self.assertEqual(len(testcase_python_list), 2) + self.assertIn( + "examples/postman_echo/request_methods/request_with_testcase_reference_test.py", + testcase_python_list, + ) + + with open( + "examples/postman_echo/request_methods/request_with_testcase_reference_test.py" + ) as f: + content = f.read() + self.assertIn( + """ +from examples.postman_echo.request_methods.request_with_functions_test import ( + TestCaseRequestWithFunctions as RequestWithFunctions, +) +""", + content, + ) + self.assertIn( + '"testcase": RequestWithFunctions,', content, + ) + def test_make_testcase_folder(self): path = ["examples/postman_echo/request_methods/"] testcase_python_list = main_make(path) @@ -56,8 +84,9 @@ class TestLoader(unittest.TestCase): def test_make_testsuite(self): path = ["examples/postman_echo/request_methods/demo_testsuite.yml"] + make_files_cache_set.clear() testcase_python_list = main_make(path) - self.assertEqual(len(testcase_python_list), 2) + self.assertEqual(len(testcase_python_list), 3) self.assertIn( "examples/postman_echo/request_methods/demo_testsuite_yml/request_with_functions_test.py", testcase_python_list, @@ -66,3 +95,7 @@ class TestLoader(unittest.TestCase): "examples/postman_echo/request_methods/demo_testsuite_yml/request_with_testcase_reference_test.py", testcase_python_list, ) + self.assertIn( + "examples/postman_echo/request_methods/request_with_functions_test.py", + testcase_python_list, + ) diff --git a/httprunner/parser_test.py b/tests/parser_test.py similarity index 100% rename from httprunner/parser_test.py rename to tests/parser_test.py diff --git a/httprunner/runner_test.py b/tests/runner_test.py similarity index 100% rename from httprunner/runner_test.py rename to tests/runner_test.py diff --git a/httprunner/ext/scaffold/scaffold_test.py b/tests/scaffold_test.py similarity index 89% rename from httprunner/ext/scaffold/scaffold_test.py rename to tests/scaffold_test.py index b85c1d48..ee21cf51 100644 --- a/httprunner/ext/scaffold/scaffold_test.py +++ b/tests/scaffold_test.py @@ -3,10 +3,10 @@ import shutil import subprocess import unittest -from httprunner.ext.scaffold import create_scaffold +from httprunner.scaffold import create_scaffold -class TestUtils(unittest.TestCase): +class TestScaffold(unittest.TestCase): def test_create_scaffold(self): project_name = "projectABC" create_scaffold(project_name) diff --git a/httprunner/utils_test.py b/tests/utils_test.py similarity index 92% rename from httprunner/utils_test.py rename to tests/utils_test.py index 252b33f0..6558325c 100644 --- a/httprunner/utils_test.py +++ b/tests/utils_test.py @@ -1,4 +1,3 @@ -import io import os import unittest @@ -89,3 +88,13 @@ class TestUtils(unittest.TestCase): def test_print_info(self): info_mapping = {"a": 1, "t": (1, 2), "b": {"b1": 123}, "c": None, "d": [4, 5]} utils.print_info(info_mapping) + + def test_sort_dict_by_custom_order(self): + self.assertEqual( + list( + utils.sort_dict_by_custom_order( + {"C": 3, "D": 2, "A": 1, "B": 8}, ["A", "D"] + ).keys() + ), + ["A", "D", "C", "B"], + )