diff --git a/README.md b/README.md index 03882c3..ae287a3 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,6 @@ # pytest-httpdbg -A pytest plugin to record HTTP(S) requests with stack trace. - -![](ui.png) +A pytest plugin for recording HTTP(S) requests and saving them in your test report. ## installation @@ -10,45 +8,30 @@ A pytest plugin to record HTTP(S) requests with stack trace. pip install pytest-httpdbg ``` -## usage - -### pytest custom options - -``` - --httpdbg record HTTP(S) requests - --httpdbg-dir=HTTPDBG_DIR - save httpdbg traces in a directory - --httpdbg-no-clean do not clean the httpdbg directory - --httpdbg-initiator=HTTPDBG_INITIATOR - add a new initiator (package) for httpdbg -``` -### option httpdbg - -Enables the record of the HTTP requests. +## Allure report -### option httpdbg-dir +If you use the [allure-pytest](https://pypi.org/project/allure-pytest/) plugin to generate an [Allure](https://allurereport.org/docs/pytest/) report, you can use [pytest-httpdbg](https://pypi.org/project/pytest-httpdbg/) to include HTTP request traces in your test report without any code modifications. -Indicates where to save the log files. +All you need to do is add the `--httpdbg-allure` option to your pytest command line: -### option httpdbg-no-clean - -Does not clean existing log files if the log directory is not empty. +``` +pytest ../httpdbg-docs/examples/ --alluredir=./allure-results --httpdbg-allure +``` -### option http-initiator +If an HTTP request is made by the test (or within a fixture, during the setup or teardown phase), the request will be saved in the Allure report under a step called `httpdbg`. -An initiator is the function/method that is at the origin of the HTTP requests. By default, we already support some packages but you can add your own initiators. +![](https://github.com/cle-b/pytest-httpdbg/blob/main/pytest-httpdbg-allure-0.8.0.png?raw=true) -To add a new package in the list of initiators, you can use the `http-initiator` command line argument. -You can use any package as an initiator, this is not limited to HTTP requests. +## Custom test report -## test report +You can add HTTP traces to any test report of your choice. To do this, you can use the HTTP traces saved by the plugin in Markdown format. -When the test is finished (teardown step included), one log file in markdown format is written. The path to this log file is stashed in the item when the test starts (before the setup step), even if the file not exists yet. +When a test finishes (including the teardown step), a log file in Markdown format is generated. The path to this log file is stored in the test item when the test starts (before the setup step), even if the file does not yet exist. ### pytest-html -You can copy the following code in your top-level `conftest.py` to include the logs into your pytest-html report. +You can copy the following code in your top-level `conftest.py` to include the logs into your `pytest-html` report. ```python import os @@ -82,6 +65,36 @@ This example works if you use the same directory for the html test report file a If this is not the case, you must adapt it to your configuration. +![](https://github.com/cle-b/pytest-httpdbg/blob/main/ui.png?raw=true) + +## pytest command line options + +``` +reporting: + + --httpdbg record HTTP(S) requests + --httpdbg-dir=HTTPDBG_DIR save httpdbg traces in a directory + --httpdbg-no-clean do not clean the httpdbg directory + + --httpdbg-allure save HTTP(S) traces into the allure report + --httpdbg-no-headers save the HTTP headers + --httpdbg-no-binary do not save the HTTP payload if it's a binary content + --httpdbg-only-on-failure save the HTTP requests only if the test failed + + --httpdbg-initiator=HTTPDBG_INITIATOR add a new initiator (package) for httpdbg + +``` + +## httpdbg + +This plugin is based on the [httpdbg](https://pypi.org/project/httpdbg/) Python tool. You can use it to trace all HTTP requests in your tests and view them in a more detailed user interface using the `pyhttpdbg` command. + +``` +pyhttpdbg -m pytest -v examples/ +``` + +![](https://github.com/cle-b/pytest-httpdbg/blob/main/httpdbg-pytest-1.2.1.png?raw=true) + ## documentation https://httpdbg.readthedocs.io/en/latest/pytest/ diff --git a/httpdbg-pytest-1.2.1.png b/httpdbg-pytest-1.2.1.png new file mode 100644 index 0000000..56f2300 Binary files /dev/null and b/httpdbg-pytest-1.2.1.png differ diff --git a/pytest-httpdbg-allure-0.8.0.png b/pytest-httpdbg-allure-0.8.0.png new file mode 100644 index 0000000..8af71db Binary files /dev/null and b/pytest-httpdbg-allure-0.8.0.png differ diff --git a/pytest_httpdbg/__init__.py b/pytest_httpdbg/__init__.py index 3455bfb..1b99f50 100644 --- a/pytest_httpdbg/__init__.py +++ b/pytest_httpdbg/__init__.py @@ -1,4 +1,4 @@ # -*- coding: utf-8 -*- from pytest_httpdbg.plugin import httpdbg_record_filename # noqa F401 -__version__ = "0.7.0" +__version__ = "0.8.0" diff --git a/pytest_httpdbg/plugin.py b/pytest_httpdbg/plugin.py index f3fe923..eec01af 100644 --- a/pytest_httpdbg/plugin.py +++ b/pytest_httpdbg/plugin.py @@ -2,6 +2,7 @@ import glob import os import time +import traceback from typing import Optional import pytest @@ -62,17 +63,54 @@ def record_to_md(record, initiators): def pytest_addoption(parser): - parser.addoption("--httpdbg", action="store_true", help="record HTTP(S) requests") - parser.addoption( + + reporting_group = parser.getgroup("reporting") + + # mode custom + reporting_group.addoption( + "--httpdbg", action="store_true", help="record HTTP(S) requests" + ) + + reporting_group.addoption( "--httpdbg-dir", type=str, default="", help="save httpdbg traces in a directory" ) - parser.addoption( + + reporting_group.addoption( "--httpdbg-no-clean", action="store_true", default=False, help="do not clean the httpdbg directory", ) - parser.addoption( + + # mode allure + reporting_group.addoption( + "--httpdbg-allure", + action="store_true", + help="save HTTP(S) traces into the allure report", + ) + + reporting_group.addoption( + "--httpdbg-no-headers", + action="store_true", + default=False, + help="save the HTTP headers", + ) + + reporting_group.addoption( + "--httpdbg-no-binary", + action="store_true", + default=False, + help="do not save the HTTP payload if it's a binary content", + ) + + reporting_group.addoption( + "--httpdbg-only-on-failure", + action="store_true", + default=False, + help="save the HTTP requests only if the test failed", + ) + + reporting_group.addoption( "--httpdbg-initiator", action="append", help="add a new initiator (package) for httpdbg", @@ -80,9 +118,11 @@ def pytest_addoption(parser): def pytest_configure(config): - # add a flag to indicates to HTTPDBG to not set specific initiator - if config.option.httpdbg: - os.environ["HTTPDBG_PYTEST_PLUGIN"] = "1" + + if config.option.httpdbg is True and config.option.httpdbg_allure is True: + pytest.exit( + "Error: --httpdbg and --httpdbg-allure are mutually exclusive. Please specify only one." + ) # clean logs directory httpdbg_dir = config.option.httpdbg_dir @@ -124,3 +164,152 @@ def pytest_runtest_protocol(item: pytest.Item, nextitem: Optional[pytest.Item]): f.write(f"{record_to_md(record, records.initiators)}\n") else: yield + + +# Allure mode: HTTP requests are recorded throughout the entire session and +# saved in the Allure report at the test level. +def pytest_sessionstart(session): + if session.config.option.httpdbg_allure: + session.httpdbg_recorder = httprecord( + initiators=session.config.option.httpdbg_initiator + ) + session.httpdbg_records = session.httpdbg_recorder.__enter__() + + +def pytest_sessionfinish(session, exitstatus): + if session.config.option.httpdbg_allure: + session.httpdbg_recorder.__exit__(None, None, None) + + +def get_allure_attachment_type_from_content_type(content_type: str): + try: + import allure + + for attachment_type in allure.attachment_type: + if attachment_type.mime_type == content_type: + return attachment_type + except ImportError: + pass + return None + + +def req_resp_steps(label, req, save_headers, save_binary_payload): + try: + import allure + + # we generate the payload first because we do not want to add a step + # if there is no headers and no payload to save + content = req.preview + payload = None + if content.get("text"): + payload = content.get("text") + elif save_binary_payload: + payload = req.content + + if save_headers or payload: + with allure.step(label): + if save_headers: + allure.attach( + req.rawheaders.decode("utf-8"), + name="headers", + attachment_type=allure.attachment_type.TEXT, + ) + if payload: + attachment_type = get_allure_attachment_type_from_content_type( + content.get("content_type") + ) + allure.attach( + payload, name="payload", attachment_type=attachment_type + ) + except ImportError: + pass + + +@pytest.hookimpl(hookwrapper=True) +def pytest_runtest_makereport(item, call): + + outcome = yield + report = outcome.get_result() + + if item.config.option.httpdbg_allure: + # we keep the information about the status of the test for all phases + item.passed = getattr(item, "passed", True) and report.passed + + if report.when == "teardown": + if (not item.config.option.httpdbg_only_on_failure) or (not item.passed): + try: + import allure + + with allure.step("httpdbg"): + + records = item.session.httpdbg_records + + for record in records: + + label = "" + + if record.response.status_code: + label += f"{record.response.status_code} " + + if record.request.method: + label += f"{record.request.method} " + + if record.request.uri: + url = record.request.uri + else: + url = record.url + if len(url) > 200: + url = url[:100] + "..." + url[-97:] + ex = ( + (str(type(record.exception)) + " ") + if record.exception is not None + else "" + ) + label += f"{ex}{url}" + + if record.tag: + label += f" (from {record.tag})" + + with allure.step(label): + details = record.url + details += f"\n\nstatus: {record.response.status_code} {record.response.message}" + details += f"\n\nstart: {record.tbegin.isoformat()}" + details += f"\nend: {record.last_update.isoformat()}" + + if record.initiator_id in records.initiators: + details += f"\n\n{records.initiators[record.initiator_id].short_stack}" + + if record.exception is not None: + details += ( + f"\n\nException: {type(record.exception)}\n" + ) + details += "".join( + traceback.format_exception( + type(record.exception), + record.exception, + record.exception.__traceback__, + ) + ) + + allure.attach( + details, + name="details", + attachment_type=allure.attachment_type.TEXT, + ) + + req_resp_steps( + "request", + record.request, + not item.config.option.httpdbg_no_headers, + not item.config.option.httpdbg_no_binary, + ) + req_resp_steps( + "response", + record.response, + not item.config.option.httpdbg_no_headers, + not item.config.option.httpdbg_no_binary, + ) + except ImportError: + pass + + item.session.httpdbg_records.reset() diff --git a/requirements-dev.txt b/requirements-dev.txt index 65cef8b..a4bc010 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -8,4 +8,5 @@ Werkzeug<2.1 flask>2 pytest-httpbin pytest-html -pytest-xdist \ No newline at end of file +pytest-xdist +allure-pytest \ No newline at end of file diff --git a/tests/test_allure.py b/tests/test_allure.py new file mode 100644 index 0000000..34beef0 --- /dev/null +++ b/tests/test_allure.py @@ -0,0 +1,433 @@ +# -*- coding: utf-8 -*- +import json + +import pytest + +confest_py = """ + import pytest + import requests + from pytest_httpdbg import httpdbg_record_filename + + @pytest.fixture(scope="session") + def fixture_session(httpbin): + requests.get(httpbin.url + "/get?setupsession") + yield + requests.get(httpbin.url + "/get?teardownsession") + + + @pytest.fixture() + def fixture_function(httpbin, fixture_session): + requests.get(httpbin.url + "/get?setupfunction") + yield + requests.get(httpbin.url + "/get?teardownfunction") + """ + + +def test_mode_mutual_exclusion(pytester): + pytester.makeconftest(confest_py) + + pytester.makepyfile( + """ + import requests + + def test_get(httpbin, fixture_session, fixture_function): + requests.get(httpbin.url + "/get") + """ + ) + + result = pytester.runpytest("--httpdbg", "--httpdbg-allure") + + result.stderr.fnmatch_lines( + [ + "*mutually exclusive. Please specify only one*", + ] + ) + + assert result.ret == pytest.ExitCode.INTERNAL_ERROR + + +def test_mode_allure(pytester, tmp_path): + pytester.makeconftest(confest_py) + + pytester.makepyfile( + """ + import requests + + def test_get(httpbin, fixture_session, fixture_function): + requests.get(httpbin.url + "/get") + + def test_post(httpbin, fixture_session, fixture_function): + requests.post(httpbin.url + "/post", json={"a":"b"}) + """ + ) + + result = pytester.runpytest("--httpdbg-allure", f"--alluredir={tmp_path}") + + result.assert_outcomes(passed=2) + + result_files = list(tmp_path.glob("*result.json")) + + assert len(result_files) == 2 + + for result_file in result_files: + + with open(result_file) as f: + result = json.load(f) + + if result["name"] == "test_get": + + httpdbg_steps = [] + for step in result["steps"]: + if step["name"] == "httpdbg": + httpdbg_steps = step["steps"] + assert len(httpdbg_steps) == 4 + + step = httpdbg_steps[0] + assert step["name"] == "200 GET /get?setupsession (from fixture_session)" + step = httpdbg_steps[1] + assert step["name"] == "200 GET /get?setupfunction (from fixture_function)" + step = httpdbg_steps[2] + assert step["name"] == "200 GET /get" + step = httpdbg_steps[3] + assert ( + step["name"] == "200 GET /get?teardownfunction (from fixture_function)" + ) + + else: + + httpdbg_steps = [] + for step in result["steps"]: + if step["name"] == "httpdbg": + httpdbg_steps = step["steps"] + assert len(httpdbg_steps) == 4 + + step = httpdbg_steps[0] + assert step["name"] == "200 GET /get?setupfunction (from fixture_function)" + step = httpdbg_steps[1] + assert step["name"] == "200 POST /post" + step = httpdbg_steps[2] + assert ( + step["name"] == "200 GET /get?teardownfunction (from fixture_function)" + ) + step = httpdbg_steps[3] + assert step["name"] == "200 GET /get?teardownsession (from fixture_session)" + + +def test_mode_allure_only_on_failure(pytester, tmp_path): + pytester.makeconftest(confest_py) + + pytester.makepyfile( + """ + import requests + + def test_pass(httpbin): + requests.get(httpbin.url + "/get") + + def test_fail(httpbin): + requests.post(httpbin.url + "/post", json={"a":"b"}) + assert False + """ + ) + + result = pytester.runpytest( + "--httpdbg-allure", f"--alluredir={tmp_path}", "--httpdbg-only-on-failure" + ) + + result.assert_outcomes(passed=1, failed=1) + + result_files = list(tmp_path.glob("*result.json")) + + assert len(result_files) == 2 + + for result_file in result_files: + + with open(result_file) as f: + result = json.load(f) + + httpdbg_steps = [] + for step in result.get("steps", []): + if step["name"] == "httpdbg": + httpdbg_steps = step["steps"] + + if result["name"] == "test_pass": + assert len(httpdbg_steps) == 0 + else: + assert result["name"] == "test_fail" + assert len(httpdbg_steps) == 1 + + +def get_attachments_req_resp(result_file, name): + with open(result_file) as f: + result = json.load(f) + + httpdbg_steps = [] + for step in result.get("steps", []): + if step["name"] == "httpdbg": + httpdbg_steps = step["steps"] + assert len(httpdbg_steps) == 1 + + filename_request = None + filename_response = None + for step in httpdbg_steps[0]["steps"]: + if step["name"] == "request": + for attachment in step["attachments"]: + if attachment["name"] == name: + filename_request = attachment["source"] + if step["name"] == "response": + for attachment in step["attachments"]: + if attachment["name"] == name: + filename_response = attachment["source"] + + return filename_request, filename_response + + +def test_mode_allure_with_headers(pytester, tmp_path): + + pytester.makepyfile( + """ + import requests + + def test_pass(httpbin): + requests.get(httpbin.url + "/get") + """ + ) + + result = pytester.runpytest("--httpdbg-allure", f"--alluredir={tmp_path}") + + result.assert_outcomes(passed=1) + + result_files = list(tmp_path.glob("*result.json")) + + assert len(result_files) == 1 + + headers_filename_request, headers_filename_response = get_attachments_req_resp( + result_files[0], "headers" + ) + + with open(tmp_path / headers_filename_request) as f: + assert "GET /get HTTP/1.1" in f.read() + + with open(tmp_path / headers_filename_response) as f: + assert "HTTP/1.1 200 OK" in f.read() + + +def test_mode_allure_without_headers(pytester, tmp_path): + + pytester.makepyfile( + """ + import requests + + def test_pass(httpbin): + requests.get(httpbin.url + "/get") + """ + ) + + result = pytester.runpytest( + "--httpdbg-allure", f"--alluredir={tmp_path}", "--httpdbg-no-headers" + ) + + result.assert_outcomes(passed=1) + + result_files = list(tmp_path.glob("*result.json")) + + assert len(result_files) == 1 + + headers_filename_request, headers_filename_response = get_attachments_req_resp( + result_files[0], "headers" + ) + + assert headers_filename_request is None + assert headers_filename_response is None + + +def test_mode_allure_payload(pytester, tmp_path): + + pytester.makepyfile( + """ + import requests + + def test_get(httpbin): + requests.get(httpbin.url + "/get") + + def test_post(httpbin): + requests.post(httpbin.url + "/post", data="hello") + + def test_binary(httpbin): + requests.get(httpbin.url + "/bytes/56") + + """ + ) + + result = pytester.runpytest("--httpdbg-allure", f"--alluredir={tmp_path}") + + result.assert_outcomes(passed=3) + + result_files = list(tmp_path.glob("*result.json")) + + assert len(result_files) == 3 + + for result_file in result_files: + + with open(result_file) as f: + test_name = json.load(f)["name"] + + if test_name == "test_get": + get_payload_request, get_payload_response = get_attachments_req_resp( + result_file, "payload" + ) + + if test_name == "test_post": + post_payload_request, post_payload_response = get_attachments_req_resp( + result_file, "payload" + ) + + if test_name == "test_binary": + binary_payload_request, binary_payload_response = get_attachments_req_resp( + result_file, "payload" + ) + + assert get_payload_request is None + with open(tmp_path / get_payload_response) as f: + payload = f.read() + assert "User-Agent" in payload + assert "/get" in payload + + with open(tmp_path / post_payload_request) as f: + payload = f.read() + assert "hello" in payload + with open(tmp_path / post_payload_response) as f: + payload = f.read() + assert "User-Agent" in payload + assert "/post" in payload + + assert binary_payload_request is None + with open(tmp_path / binary_payload_response, "rb") as f: + assert len(f.read()) == 56 + + +def test_mode_allure_payload_no_binary(pytester, tmp_path): + + pytester.makepyfile( + """ + import requests + + def test_get(httpbin): + requests.get(httpbin.url + "/get") + + def test_post(httpbin): + requests.post(httpbin.url + "/post", data="hello") + + def test_binary(httpbin): + requests.get(httpbin.url + "/bytes/56") + """ + ) + + result = pytester.runpytest( + "--httpdbg-allure", f"--alluredir={tmp_path}", "--httpdbg-no-binary" + ) + + result.assert_outcomes(passed=3) + + result_files = list(tmp_path.glob("*result.json")) + + assert len(result_files) == 3 + + for result_file in result_files: + + with open(result_file) as f: + test_name = json.load(f)["name"] + + if test_name == "test_get": + get_payload_request, get_payload_response = get_attachments_req_resp( + result_file, "payload" + ) + + if test_name == "test_post": + post_payload_request, post_payload_response = get_attachments_req_resp( + result_file, "payload" + ) + + if test_name == "test_binary": + binary_payload_request, binary_payload_response = get_attachments_req_resp( + result_file, "payload" + ) + + assert get_payload_request is None + assert get_payload_response is not None + + assert post_payload_request is not None + assert post_payload_response is not None + + assert binary_payload_request is None + assert binary_payload_request is None + + +def test_mode_allure_details(pytester, tmp_path): + + pytester.makepyfile( + """ + import requests + + def test_get(httpbin): + requests.get("http://127.0.0.1" + ":8345" + "/get") + """ + ) + + result = pytester.runpytest("--httpdbg-allure", f"--alluredir={tmp_path}") + + result.assert_outcomes(failed=1) + + result_files = list(tmp_path.glob("*result.json")) + + assert len(result_files) == 1 + + with open(result_files[0]) as f: + result = json.load(f) + + filename_payload = None + for step in result.get("steps", []): + if step["name"] == "httpdbg": + for attachment in step["steps"][0]["attachments"]: + if attachment["name"] == "details": + filename_payload = attachment["source"] + + with open(tmp_path / filename_payload) as f: + payload = f.read() + assert "http://127.0.0.1:8345/get" in payload + assert 'requests.get("http://127.0.0.1" + ":8345" + "/get")' in payload + + +def test_mode_allure_no_step_if_empty(pytester, tmp_path): + + pytester.makepyfile( + """ + import requests + + def test_binary(httpbin): + requests.get(httpbin.url + "/bytes/56") + """ + ) + + result = pytester.runpytest( + "--httpdbg-allure", + f"--alluredir={tmp_path}", + "--httpdbg-no-headers", + "--httpdbg-no-binary", + ) + + result.assert_outcomes(passed=1) + + result_files = list(tmp_path.glob("*result.json")) + + assert len(result_files) == 1 + + sub_steps = "should be None" + + with open(result_files[0]) as f: + result = json.load(f) + + for step in result.get("steps", []): + if step["name"] == "httpdbg": + sub_steps = step["steps"][0].get("steps") + + assert sub_steps is None