Skip to content

Commit

Permalink
chore: Refined generated reproduction code and shortened `X-Schemathe…
Browse files Browse the repository at this point in the history
…sis-TestCaseId` for easier debugging

Ref: #1801
  • Loading branch information
Stranger6667 committed Oct 13, 2023
1 parent 00a707d commit fab2e14
Show file tree
Hide file tree
Showing 13 changed files with 78 additions and 82 deletions.
2 changes: 2 additions & 0 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Changelog
**Changed**

- Pin ``Werkzeug`` to ``<3.0``.
- Refined generated reproduction code and shortened ``X-Schemathesis-TestCaseId`` for easier debugging. `#1801`_
- Add ``case`` as the first argument to ``AuthContext.set``. Previous calling convention is still supported. `#1788`_
- Disable the 'explain' phase in Hypothesis to improve performance. `#1808`_
- Do not display ``InsecureRequestWarning`` in CLI output if the user explicitly provided ``--request-tls-verify=false``. `#1780`_
Expand Down Expand Up @@ -3460,6 +3461,7 @@ Deprecated
.. _#1820: https://github.com/schemathesis/schemathesis/issues/1820
.. _#1808: https://github.com/schemathesis/schemathesis/issues/1808
.. _#1802: https://github.com/schemathesis/schemathesis/issues/1802
.. _#1801: https://github.com/schemathesis/schemathesis/issues/1801
.. _#1797: https://github.com/schemathesis/schemathesis/issues/1797
.. _#1788: https://github.com/schemathesis/schemathesis/issues/1788
.. _#1781: https://github.com/schemathesis/schemathesis/issues/1781
Expand Down
18 changes: 15 additions & 3 deletions src/schemathesis/cli/output/default.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,14 @@

from ... import service
from ..._compat import metadata
from ...constants import DISCORD_LINK, FLAKY_FAILURE_MESSAGE, REPORT_SUGGESTION_ENV_VAR, CodeSampleStyle, __version__
from ...constants import (
DISCORD_LINK,
FLAKY_FAILURE_MESSAGE,
REPORT_SUGGESTION_ENV_VAR,
SCHEMATHESIS_TEST_CASE_HEADER,
CodeSampleStyle,
__version__,
)
from ...experimental import GLOBAL_EXPERIMENTS
from ...models import Response, Status
from ...runner import events
Expand Down Expand Up @@ -238,6 +245,7 @@ def display_example(
click.secho(f"Run this Python code to reproduce this failure: \n\n {case.requests_code}\n", fg="red")
if context.code_sample_style == CodeSampleStyle.curl:
click.secho(f"Run this cURL command to reproduce this failure: \n\n {case.curl_code}\n", fg="red")
click.secho(f"{SCHEMATHESIS_TEST_CASE_HEADER}: {case.id}\n", fg="red")
if seed is not None:
click.secho(f"Or add this option to your command line parameters: --hypothesis-seed={seed}", fg="red")

Expand Down Expand Up @@ -291,8 +299,7 @@ def display_statistic(context: ExecutionContext, event: events.Finished) -> None
click.secho(f" - {warning}", fg="yellow")

if len(GLOBAL_EXPERIMENTS.enabled) > 0:
click.echo()
click.secho("Experimental Features:", bold=True)
click.secho("\nExperimental Features:", bold=True)
for experiment in sorted(GLOBAL_EXPERIMENTS.enabled, key=lambda e: e.name):
click.secho(f" - {experiment.verbose_name}: {experiment.description}")
click.secho(f" Feedback: {experiment.discussion_url}")
Expand All @@ -302,6 +309,11 @@ def display_statistic(context: ExecutionContext, event: events.Finished) -> None
"Please visit the provided URL(s) to share your thoughts."
)

if event.failed_count > 0:
click.echo(
f"\n{bold('Note')}: The '{SCHEMATHESIS_TEST_CASE_HEADER}' header can be used to correlate test cases with server logs for debugging."
)

if context.report is not None and not context.is_interrupted:
if isinstance(context.report, FileReportContext):
click.echo()
Expand Down
4 changes: 2 additions & 2 deletions src/schemathesis/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@
cast,
)
from urllib.parse import quote, unquote, urljoin, urlparse, urlsplit, urlunsplit
from uuid import uuid4

import curlify
import requests.auth
Expand Down Expand Up @@ -68,6 +67,7 @@
copy_response,
deprecated_property,
fast_deepcopy,
generate_random_case_id,
get_response_payload,
maybe_set_assertion_message,
)
Expand Down Expand Up @@ -118,7 +118,7 @@ class Case:
# The way the case was generated (None for manually crafted ones)
data_generation_method: Optional[DataGenerationMethod] = None
# Unique test case identifier
id: str = field(default_factory=lambda: uuid4().hex, compare=False)
id: str = field(default_factory=generate_random_case_id, compare=False)
_auth: Optional[requests.auth.AuthBase] = None

def __repr__(self) -> str:
Expand Down
2 changes: 2 additions & 0 deletions src/schemathesis/runner/serialization.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

@dataclass
class SerializedCase:
id: str
requests_code: str
curl_code: str
path_template: str
Expand All @@ -28,6 +29,7 @@ class SerializedCase:
@classmethod
def from_case(cls, case: Case, headers: Optional[Dict[str, Any]], verify: bool) -> "SerializedCase":
return cls(
id=case.id,
requests_code=case.get_code_to_reproduce(headers, verify=verify),
curl_code=case.as_curl_command(headers, verify=verify),
path_template=case.path,
Expand Down
26 changes: 24 additions & 2 deletions src/schemathesis/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import functools
import operator
import pathlib
import random
import re
import sys
import traceback
Expand Down Expand Up @@ -42,7 +43,7 @@
from werkzeug.wrappers import Response as BaseResponse

from ._compat import InferType, JSONMixin, get_signature
from .constants import USER_AGENT, DataGenerationMethod
from .constants import SCHEMATHESIS_TEST_CASE_HEADER, USER_AGENT, DataGenerationMethod
from .exceptions import SkipTest, UsageError
from .types import DataGenerationMethodInput, Filter, GenericTest, NotSet, RawAuth

Expand All @@ -58,7 +59,13 @@
# These headers are added automatically by Schemathesis or `requests`.
# Do not show them in code samples to make them more readable
IGNORED_HEADERS = CaseInsensitiveDict(
{"Content-Length": None, "Transfer-Encoding": None, "Content-Type": None, **default_headers()} # type: ignore
{
"Content-Length": None,
"Transfer-Encoding": None,
"Content-Type": None,
SCHEMATHESIS_TEST_CASE_HEADER: None,
**default_headers(),
}
)


Expand Down Expand Up @@ -534,3 +541,18 @@ def _fast_deepcopy(value: Any) -> Any:
if isinstance(value, list):
return [_fast_deepcopy(v) for v in value]
return value


CASE_ID_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
BASE = len(CASE_ID_ALPHABET)
# Separate `Random` as Hypothesis might interfere with the default one
RANDOM = random.Random()


def generate_random_case_id(length: int = 6) -> str:
number = RANDOM.randint(62 ** (length - 1), 62**length - 1)
output = ""
while number > 0:
number, rem = divmod(number, BASE)
output += CASE_ID_ALPHABET[rem]
return output
7 changes: 3 additions & 4 deletions test/cli/output/test_default.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from schemathesis import models, runner, utils
from schemathesis.cli.output import default
from schemathesis.cli.output.default import display_internal_error
from schemathesis.constants import SCHEMATHESIS_TEST_CASE_HEADER, DataGenerationMethod
from schemathesis.constants import DataGenerationMethod
from schemathesis.runner.events import Finished, InternalError
from schemathesis.runner.serialization import SerializedTestResult
from schemathesis.utils import NOT_SET
Expand Down Expand Up @@ -327,7 +327,7 @@ def test_display_single_error(capsys, swagger_20, operation, execution_context,


@pytest.mark.parametrize("verbosity", (0, 1))
def test_display_failures(swagger_20, capsys, execution_context, results_set, verbosity, response, mock_case_id):
def test_display_failures(swagger_20, capsys, execution_context, results_set, verbosity, response):
execution_context.verbosity = verbosity
# Given two test results - success and failure
operation = models.APIOperation("/api/failure", "GET", {}, base_url="http://127.0.0.1:8080", schema=swagger_20)
Expand All @@ -350,8 +350,7 @@ def test_display_failures(swagger_20, capsys, execution_context, results_set, ve
assert " GET /v1/api/failure " in out
assert "Message" in out
assert "Run this cURL command to reproduce this failure:" in out
headers = f"-H '{SCHEMATHESIS_TEST_CASE_HEADER}: {mock_case_id.hex}'"
assert f"curl -X GET {headers} http://127.0.0.1:8080/api/failure" in out
assert "curl -X GET http://127.0.0.1:8080/api/failure" in out


@pytest.mark.parametrize("show_errors_tracebacks", (True, False))
Expand Down
16 changes: 6 additions & 10 deletions test/cli/test_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@
HYPOTHESIS_IN_MEMORY_DATABASE_IDENTIFIER,
IS_PYTEST_ABOVE_54,
REPORT_SUGGESTION_ENV_VAR,
SCHEMATHESIS_TEST_CASE_HEADER,
CodeSampleStyle,
)
from schemathesis.extra._flask import run_server
Expand Down Expand Up @@ -1537,12 +1536,12 @@ def test_wsgi_app_internal_exception(testdir, cli):
result = cli.run("/schema.yaml", "--app", f"{module.purebasename}:app", "--hypothesis-derandomize")
assert result.exit_code == ExitCode.TESTS_FAILED, result.stdout
lines = result.stdout.strip().split("\n")
assert "== APPLICATION LOGS ==" in lines[41], result.stdout.strip()
assert "ERROR in app: Exception on /api/success [GET]" in lines[43]
assert "== APPLICATION LOGS ==" in lines[45], result.stdout.strip()
assert "ERROR in app: Exception on /api/success [GET]" in lines[47]
if sys.version_info >= (3, 11):
assert lines[59] == "ZeroDivisionError: division by zero"
assert lines[63] == "ZeroDivisionError: division by zero"
else:
assert lines[54] == ' raise ZeroDivisionError("division by zero")'
assert lines[58] == ' raise ZeroDivisionError("division by zero")'


@pytest.mark.parametrize("args", ((), ("--base-url",)))
Expand Down Expand Up @@ -1977,7 +1976,7 @@ def make_definition(min_items):

@pytest.mark.parametrize("extra", ("--auth='test:wrong'", "-H Authorization: Basic J3Rlc3Q6d3Jvbmcn"))
@pytest.mark.operations("basic")
def test_auth_override_on_protected_operation(cli, base_url, schema_url, extra, mock_case_id):
def test_auth_override_on_protected_operation(cli, base_url, schema_url, extra):
# See GH-792
# When the tested API operation has basic auth
# And the auth is overridden (directly or via headers)
Expand All @@ -1986,10 +1985,7 @@ def test_auth_override_on_protected_operation(cli, base_url, schema_url, extra,
assert result.exit_code == ExitCode.TESTS_FAILED, result.stdout
lines = result.stdout.splitlines()
# Then the code sample representation in the output should have the overridden value
assert (
lines[20] == f" curl -X GET -H 'Authorization: Basic J3Rlc3Q6d3Jvbmcn' "
f"-H '{SCHEMATHESIS_TEST_CASE_HEADER}: {mock_case_id.hex}' {base_url}/basic"
)
assert lines[20] == f" curl -X GET -H 'Authorization: Basic J3Rlc3Q6d3Jvbmcn' {base_url}/basic"


@pytest.mark.openapi_version("3.0")
Expand Down
25 changes: 8 additions & 17 deletions test/code_samples/test_curl.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

import schemathesis
from schemathesis import Case
from schemathesis.constants import SCHEMATHESIS_TEST_CASE_HEADER

schema = schemathesis.from_dict(app.openapi(), force_schema_version="30")

Expand All @@ -14,16 +13,13 @@
def test_as_curl_command(case: Case, headers):
command = case.as_curl_command(headers)
expected_headers = "" if not headers else " ".join(f" -H '{name}: {value}'" for name, value in headers.items())
assert (
command
== f"curl -X GET{expected_headers} -H '{SCHEMATHESIS_TEST_CASE_HEADER}: {case.id}' http://localhost/users"
)
assert command == f"curl -X GET{expected_headers} http://localhost/users"


def test_non_utf_8_body():
case = Case(operation=schema["/users"]["GET"], body=b"42\xff", media_type="application/octet-stream")
command = case.as_curl_command()
assert command == f"curl -X GET -H '{SCHEMATHESIS_TEST_CASE_HEADER}: {case.id}' -d '42�' http://localhost/users"
assert command == "curl -X GET -d '42�' http://localhost/users"


def test_explicit_headers():
Expand All @@ -32,29 +28,24 @@ def test_explicit_headers():
value = "application/json"
case = Case(operation=schema["/users"]["GET"], headers={name: value})
command = case.as_curl_command()
assert (
command
== f"curl -X GET -H '{name}: {value}' -H '{SCHEMATHESIS_TEST_CASE_HEADER}: {case.id}' http://localhost/users"
)
assert command == f"curl -X GET -H '{name}: {value}' http://localhost/users"


@pytest.mark.operations("failure")
@pytest.mark.openapi_version("3.0")
def test_cli_output(cli, base_url, schema_url, mock_case_id):
def test_cli_output(cli, base_url, schema_url):
result = cli.run(schema_url, "--code-sample-style=curl")
lines = result.stdout.splitlines()
assert "Run this cURL command to reproduce this failure: " in lines
headers = f"-H '{SCHEMATHESIS_TEST_CASE_HEADER}: {mock_case_id.hex}'"
assert f" curl -X GET {headers} {base_url}/failure" in lines
assert f" curl -X GET {base_url}/failure" in lines


@pytest.mark.operations("failure")
@pytest.mark.openapi_version("3.0")
def test_cli_output_includes_insecure(cli, base_url, schema_url, mock_case_id):
def test_cli_output_includes_insecure(cli, base_url, schema_url):
result = cli.run(schema_url, "--code-sample-style=curl", "--request-tls-verify=false")
lines = result.stdout.splitlines()
headers = f"-H '{SCHEMATHESIS_TEST_CASE_HEADER}: {mock_case_id.hex}'"
assert f" curl -X GET {headers} --insecure {base_url}/failure" in lines
assert f" curl -X GET --insecure {base_url}/failure" in lines


@pytest.mark.operations("failure")
Expand All @@ -73,5 +64,5 @@ def test_(case):
result = testdir.runpytest("-v")
result.assert_outcomes(passed=1, failed=1)
result.stdout.re_match_lines(
[r"E +Run this cURL command to reproduce this response:", rf"E + curl -X GET.+ {openapi3_base_url}/failure"]
[r"E +Run this cURL command to reproduce this response:", rf"E + curl -X GET {openapi3_base_url}/failure"]
)
31 changes: 8 additions & 23 deletions test/code_samples/test_python.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
from hypothesis import HealthCheck, given, settings

import schemathesis
from schemathesis.constants import SCHEMATHESIS_TEST_CASE_HEADER
from schemathesis.models import Case, _escape_single_quotes
from schemathesis.runner import from_schema

Expand All @@ -19,13 +18,10 @@ def openapi_case(request, swagger_20):
return operation.make_case(media_type="application/json", **kwargs)


def get_full_code(case_id, kwargs_repr=""):
def get_full_code(kwargs_repr=""):
if kwargs_repr:
kwargs_repr = f", {kwargs_repr}"
return (
f"requests.get('http://127.0.0.1:1/users', "
f"headers={{'{SCHEMATHESIS_TEST_CASE_HEADER}': '{case_id}'}}{kwargs_repr})"
)
return f"requests.get('http://127.0.0.1:1/users'{kwargs_repr})"


@pytest.mark.parametrize(
Expand All @@ -46,7 +42,7 @@ def get_full_code(case_id, kwargs_repr=""):
def test_open_api_code_sample(openapi_case, kwargs_repr):
# Custom request parts should be correctly displayed
code = openapi_case.get_code_to_reproduce()
assert code == get_full_code(openapi_case.id, kwargs_repr), code
assert code == get_full_code(kwargs_repr), code
# And the generated code should be valid Python
with pytest.raises(requests.exceptions.ConnectionError):
eval(code)
Expand Down Expand Up @@ -119,36 +115,25 @@ def test_escape_single_quotes(value, expected):
@pytest.mark.filterwarnings("ignore:.*method is good for exploring strategies.*")
def test_graphql_code_sample(graphql_url, graphql_schema, graphql_strategy):
case = graphql_strategy.example()
assert (
case.get_code_to_reproduce() == f"requests.post('{graphql_url}', "
f"headers={{'{SCHEMATHESIS_TEST_CASE_HEADER}': '{case.id}'}}, json={{'query': {repr(case.body)}}})"
)
assert case.get_code_to_reproduce() == f"requests.post('{graphql_url}', json={{'query': {repr(case.body)}}})"


@pytest.mark.operations("failure")
def test_cli_output(cli, base_url, schema_url, mock_case_id):
def test_cli_output(cli, base_url, schema_url):
result = cli.run(schema_url, "--code-sample-style=python")
lines = result.stdout.splitlines()
assert "Run this Python code to reproduce this failure: " in lines
headers = f"{{'{SCHEMATHESIS_TEST_CASE_HEADER}': '{mock_case_id.hex}'}}"
assert f" requests.get('{base_url}/failure', headers={headers})" in lines
assert f" requests.get('{base_url}/failure')" in lines


@pytest.mark.operations("failure")
def test_reproduce_code_with_overridden_headers(any_app_schema, base_url, mock_case_id):
def test_reproduce_code_with_overridden_headers(any_app_schema, base_url):
# Note, headers are essentially the same, but keys are ordered differently due to implementation details of
# real vs wsgi apps. It is the simplest solution, but not the most flexible one, though.
headers = {"X-Token": "test"}
if isinstance(any_app_schema.app, Flask):
headers = {
SCHEMATHESIS_TEST_CASE_HEADER: mock_case_id.hex,
"X-Token": "test",
}
expected = f"requests.get('http://localhost/api/failure', headers={headers})"
else:
headers = {
SCHEMATHESIS_TEST_CASE_HEADER: mock_case_id.hex,
"X-Token": "test",
}
expected = f"requests.get('{base_url}/failure', headers={headers})"

*_, after, finished = from_schema(
Expand Down
8 changes: 0 additions & 8 deletions test/conftest.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import io
import os
import uuid
from textwrap import dedent
from types import SimpleNamespace
from typing import Optional
Expand Down Expand Up @@ -870,13 +869,6 @@ def loadable_graphql_fastapi_app(testdir, graphql_path):
return f"{module.purebasename}:app"


@pytest.fixture
def mock_case_id(mocker):
case_id = uuid.uuid4()
mocker.patch("schemathesis.models.uuid4", lambda: case_id)
return case_id


@pytest.fixture(scope="session")
def is_older_subtests():
# For compatibility needs
Expand Down
Loading

0 comments on commit fab2e14

Please sign in to comment.