Skip to content

Commit

Permalink
feat: Support for GraphQL tests for the pytest runner
Browse files Browse the repository at this point in the history
Ref: #649
  • Loading branch information
Stranger6667 committed Sep 27, 2020
1 parent 1574751 commit d1463a7
Show file tree
Hide file tree
Showing 5 changed files with 109 additions and 38 deletions.
2 changes: 2 additions & 0 deletions docs/changelog.rst
Expand Up @@ -10,6 +10,7 @@ Added
~~~~~

- Stateful testing via Open API links for the ``pytest`` runner. `#616`_
- Support for GraphQL tests for the ``pytest`` runner. `#649`_

Fixed
~~~~~
Expand Down Expand Up @@ -1371,6 +1372,7 @@ Fixed
.. _#658: https://github.com/schemathesis/schemathesis/issues/658
.. _#656: https://github.com/schemathesis/schemathesis/issues/656
.. _#651: https://github.com/schemathesis/schemathesis/issues/651
.. _#649: https://github.com/schemathesis/schemathesis/issues/649
.. _#647: https://github.com/schemathesis/schemathesis/issues/647
.. _#641: https://github.com/schemathesis/schemathesis/issues/641
.. _#640: https://github.com/schemathesis/schemathesis/issues/640
Expand Down
18 changes: 5 additions & 13 deletions docs/graphql.rst
Expand Up @@ -3,17 +3,12 @@
GraphQL
=======

Schemathesis provides basic capabilities for testing GraphQL-based applications.
The current support is limited to creating Hypothesis strategies for tests and crafting appropriate network requests.

**NOTE**: This area is in active development - more features will be added soon.
Schemathesis provides capabilities for testing GraphQL-based applications.
The current support is limited to Python tests - CLI support is in the works.

Usage
~~~~~

At the moment, there is no direct integration with pytest, and to generate GraphQL queries, you need to manually
pass strategies to Hypothesis's ``given`` decorator.

.. code:: python
import schemathesis
Expand All @@ -22,22 +17,19 @@ pass strategies to Hypothesis's ``given`` decorator.
"https://bahnql.herokuapp.com/graphql"
)
@given(case=schema.query.as_strategy())
@schema.parametrize()
@settings(deadline=None)
def test(case):
response = case.call()
assert response.status_code < 500, response.content
case.validate_response(response)
This test will load GraphQL schema from ``https://bahnql.herokuapp.com/graphql`` and will generate queries for it.
In the test body, ``case`` instance provides only one method - ``call`` that will run a proper network request to the
application under test.
This test will load GraphQL schema from ``https://bahnql.herokuapp.com/graphql``, generate queries for it, send them to the server, and verify responses.

Limitations
~~~~~~~~~~~

Current GraphQL support does **NOT** include the following:

- Direct pytest integration;
- CLI integration;
- Custom scalar types support (it will produce an error);
- Mutations;
Expand Down
77 changes: 56 additions & 21 deletions src/schemathesis/specs/graphql/schemas.py
@@ -1,44 +1,79 @@
from functools import partial
from typing import Any, Callable, Dict, Generator, List, Optional, Tuple, cast
from urllib.parse import urlsplit

import attr
import graphql
import requests
from hypothesis import strategies as st
from hypothesis.strategies import SearchStrategy
from hypothesis_graphql import strategies as gql_st

from ...checks import not_a_server_error
from ...hooks import HookDispatcher
from ...models import Case, CheckFunction, Endpoint
from ...schemas import BaseSchema
from ...stateful import Feedback
from ...utils import GenericResponse


@attr.s() # pragma: no mutate
class GraphQLCase:
path: str = attr.ib() # pragma: no mutate
data: str = attr.ib() # pragma: no mutate
class GraphQLCase(Case):
def as_requests_kwargs(self, base_url: Optional[str] = None) -> Dict[str, Any]:
base_url = self._get_base_url(base_url)
return {"method": self.method, "url": base_url, "json": {"query": self.body}}

def call(self) -> requests.Response:
return requests.post(self.path, json={"query": self.data})
def as_werkzeug_kwargs(self, headers: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
final_headers = self.headers.copy() if self.headers is not None else {}
if headers:
final_headers.update(headers)
return {
"method": self.method,
"path": self.endpoint.schema.get_full_path(self.formatted_path),
"headers": final_headers,
"query_string": self.query,
"json": {"query": self.body},
}


@attr.s(slots=True) # pragma: no mutate
class GraphQLQuery:
path: str = attr.ib() # pragma: no mutate
schema: graphql.GraphQLSchema = attr.ib() # pragma: no mutate

def as_strategy(self) -> st.SearchStrategy[GraphQLCase]:
constructor = partial(GraphQLCase, path=self.path)
return st.builds(constructor, data=gql_st.query(self.schema))
def validate_response(
self,
response: GenericResponse,
checks: Tuple[CheckFunction, ...] = (not_a_server_error,),
) -> None:
return super().validate_response(response, checks)


@attr.s() # pragma: no mutate
class GraphQLSchema(BaseSchema):
schema: graphql.GraphQLSchema = attr.ib(init=False) # pragma: no mutate

def __attrs_post_init__(self) -> None:
self.schema = graphql.build_client_schema(self.raw_schema)
def get_full_path(self, path: str) -> str:
return self.base_path

@property # pragma: no mutate
def verbose_name(self) -> str:
return "GraphQL"

@property
def query(self) -> GraphQLQuery:
return GraphQLQuery(path=self.location or "", schema=self.schema)
def base_path(self) -> str:
if self.base_url:
return urlsplit(self.base_url).path
return self._get_base_path()

def _get_base_path(self) -> str:
return cast(str, urlsplit(self.location).path)

def get_all_endpoints(self) -> Generator[Endpoint, None, None]:
yield Endpoint(
base_url=self.location, path=self.base_path, method="POST", schema=self, definition=None # type: ignore
)

def get_case_strategy(
self, endpoint: Endpoint, hooks: Optional[HookDispatcher] = None, feedback: Optional[Feedback] = None
) -> SearchStrategy:
constructor = partial(GraphQLCase, endpoint=endpoint)
schema = graphql.build_client_schema(self.raw_schema)
return st.builds(constructor, body=gql_st.query(schema))

def get_strategies_from_examples(self, endpoint: Endpoint) -> List[SearchStrategy[Case]]:
return []

def get_hypothesis_conversion(self, endpoint: Endpoint, location: str) -> Optional[Callable]:
return None
29 changes: 25 additions & 4 deletions test/specs/graphql/test_basic.py
Expand Up @@ -9,6 +9,11 @@ def graphql_schema(graphql_endpoint):
return schemathesis.graphql.from_url(graphql_endpoint)


@pytest.fixture
def graphql_strategy(graphql_schema):
return graphql_schema["/graphql"]["POST"].as_strategy()


def test_raw_schema(graphql_schema):
assert graphql_schema.raw_schema["__schema"]["types"][1] == {
"kind": "OBJECT",
Expand Down Expand Up @@ -44,13 +49,29 @@ def test_raw_schema(graphql_schema):


@pytest.mark.hypothesis_nested
def test_query_strategy(graphql_schema):
strategy = graphql_schema.query.as_strategy()

@given(case=strategy)
def test_query_strategy(graphql_strategy):
@given(case=graphql_strategy)
@settings(max_examples=10)
def test(case):
response = case.call()
assert response.status_code < 500

test()


@pytest.mark.filterwarnings("ignore:.*method is good for exploring strategies.*")
def test_get_code_to_reproduce(graphql_endpoint, graphql_strategy):
case = graphql_strategy.example()
assert case.get_code_to_reproduce() == f"requests.post('{graphql_endpoint}', json={{'query': {repr(case.body)}}})"


@pytest.mark.filterwarnings("ignore:.*method is good for exploring strategies.*")
def test_as_werkzeug_kwargs(graphql_strategy):
case = graphql_strategy.example()
assert case.as_werkzeug_kwargs() == {
"method": "POST",
"path": "/graphql",
"query_string": None,
"json": {"query": case.body},
"headers": {},
}
21 changes: 21 additions & 0 deletions test/specs/graphql/test_pytest.py
@@ -0,0 +1,21 @@
def test_basic_pytest_graphql(testdir, graphql_endpoint):
testdir.make_test(
f"""
schema = schemathesis.graphql.from_url('{graphql_endpoint}')
@schema.parametrize()
@settings(max_examples=10)
def test_(request, case):
request.config.HYPOTHESIS_CASES += 1
assert case.path == "/graphql"
assert "patron" in case.body
response = case.call()
assert response.status_code == 200
case.validate_response(response)
""",
)
result = testdir.runpytest("-v", "-s")
result.assert_outcomes(passed=1)
result.stdout.re_match_lines(
[r"test_basic_pytest_graphql.py::test_\[POST:/graphql\] PASSED", r"Hypothesis calls: 10"]
)

0 comments on commit d1463a7

Please sign in to comment.