Skip to content

Commit 41132fb

Browse files
committed
feat: Negative testing
Signed-off-by: Dmitry Dygalo <dmitry@dygalo.dev>
1 parent 177c814 commit 41132fb

File tree

11 files changed

+1018
-27
lines changed

11 files changed

+1018
-27
lines changed

.pre-commit-config.yaml

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,5 @@ repos:
3535
rev: v0.15.0
3636
hooks:
3737
- id: ruff-format
38-
39-
- repo: https://github.com/astral-sh/ruff-pre-commit
40-
rev: v0.15.0
41-
hooks:
42-
- id: ruff
38+
- id: ruff-check
39+
args: [--fix]

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,13 @@
55
### Added
66

77
- Support for Python 3.13 and 3.14.
8+
- Negative testing mode for generating invalid queries.
89

910
## [0.11.1] - 2024-08-06
1011

1112
### Added
1213

13-
- The `allow_null` option that controls if optional arguments may be `null`. `True` by default.
14+
- The `allow_null` option that controls if optional arguments may be `null`. `True` by default.
1415

1516
## [0.11.0] - 2023-11-29
1617

README.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,46 @@ The `hypothesis_graphql.nodes` module includes a few helpers to generate various
143143

144144
They exist because classes like `graphql.StringValueNode` can't be directly used in `map` calls due to kwarg-only arguments.
145145

146+
### Negative Testing
147+
148+
`hypothesis-graphql` supports generating **invalid** GraphQL queries for negative testing. This is useful for testing error handling and validation logic in your GraphQL server.
149+
150+
Use the `mode=Mode.NEGATIVE` parameter to generate queries that should be rejected:
151+
152+
```python
153+
from hypothesis import given
154+
from hypothesis_graphql import queries, mode
155+
import requests
156+
157+
SCHEMA = """
158+
type Query {
159+
getUser(id: Int!, name: String!): User
160+
}
161+
162+
type User {
163+
id: Int!
164+
name: String!
165+
}
166+
"""
167+
168+
169+
@given(queries(SCHEMA, mode=Mode.NEGATIVE))
170+
def test_server_rejects_invalid_queries(query):
171+
# Generates queries with violations like:
172+
# - Wrong types (String where Int expected)
173+
# - Missing required arguments
174+
# - Out-of-range integers
175+
# - Null for non-null fields
176+
# - Invalid enum values
177+
response = requests.post("http://127.0.0.1/graphql", json={"query": query})
178+
179+
# Server should reject with 400 or return errors
180+
if response.status_code == 200:
181+
assert "errors" in response.json()
182+
```
183+
184+
The `negative` parameter works with `queries()`, `mutations()`, and `from_schema()`.
185+
146186
## License
147187

148188
The code in this project is licensed under [MIT license](https://opensource.org/licenses/MIT).

src/hypothesis_graphql/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
1+
from ._strategies.mode import Mode # noqa: F401
12
from ._strategies.validation import validate_scalar_strategy # noqa: F401
23
from .strategies import from_schema, mutations, queries # noqa: F401
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import enum
2+
3+
4+
class Mode(enum.Enum):
5+
"""Generation mode for GraphQL queries.
6+
7+
POSITIVE: Generate valid queries that pass schema validation.
8+
NEGATIVE: Generate invalid queries with intentional violations
9+
(wrong types, missing required args, invalid enums, etc.)
10+
for testing error handling.
11+
"""
12+
13+
POSITIVE = enum.auto()
14+
NEGATIVE = enum.auto()
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
from hypothesis import strategies as st
2+
3+
4+
class ViolationTracker:
5+
"""Tracks violation injection state to guarantee at least one per query."""
6+
7+
def __init__(self) -> None:
8+
self.has_injected = False
9+
10+
def should_inject(self, draw: st.DrawFn) -> bool:
11+
"""Decide whether to inject a violation."""
12+
if self.has_injected:
13+
return draw(st.booleans())
14+
draw(st.just(True))
15+
return True
16+
17+
def mark_injected(self) -> None:
18+
"""Mark that a violation was actually injected."""
19+
self.has_injected = True

src/hypothesis_graphql/_strategies/primitives.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,25 @@ def _string(
2424

2525
INTEGER_STRATEGY = st.integers(min_value=MIN_INT, max_value=MAX_INT).map(nodes.Int)
2626
FLOAT_STRATEGY = st.floats(allow_infinity=False, allow_nan=False).map(nodes.Float)
27+
STRING_STRATEGY = st.text().map(_string)
2728
BOOLEAN_STRATEGY = st.booleans().map(nodes.Boolean)
2829
NULL_STRATEGY = st.just(nodes.Null)
2930

31+
# Mapping of type names to their strategies for exclusion logic
32+
_PRIMITIVE_STRATEGIES = {
33+
"Int": INTEGER_STRATEGY,
34+
"Float": FLOAT_STRATEGY,
35+
"String": STRING_STRATEGY,
36+
"Boolean": BOOLEAN_STRATEGY,
37+
"Null": NULL_STRATEGY,
38+
}
39+
40+
41+
def _except(*exclude: str) -> st.SearchStrategy[graphql.ValueNode]:
42+
# Exclude Null from wrong type violations - null is valid for nullable fields
43+
exclude_with_null = set(exclude) | {"Null"}
44+
return st.one_of(*(strategy for name, strategy in _PRIMITIVE_STRATEGIES.items() if name not in exclude_with_null))
45+
3046

3147
@lru_cache(maxsize=16)
3248
def scalar(
@@ -121,3 +137,60 @@ def list_(
121137
default: Optional[graphql.ValueNode] = None,
122138
) -> st.SearchStrategy[graphql.ListValueNode]:
123139
return maybe_default(maybe_null(strategy.map(nodes.List), nullable), default=default)
140+
141+
142+
def invalid_int() -> st.SearchStrategy[graphql.ValueNode]:
143+
return _except("Int")
144+
145+
146+
def invalid_string() -> st.SearchStrategy[graphql.ValueNode]:
147+
return _except("String")
148+
149+
150+
def invalid_float() -> st.SearchStrategy[graphql.ValueNode]:
151+
# Int coerces to Float in GraphQL, so exclude both
152+
return _except("Float", "Int")
153+
154+
155+
def invalid_boolean() -> st.SearchStrategy[graphql.ValueNode]:
156+
return _except("Boolean")
157+
158+
159+
def invalid_id() -> st.SearchStrategy[graphql.ValueNode]:
160+
return st.one_of(FLOAT_STRATEGY, BOOLEAN_STRATEGY)
161+
162+
163+
def wrong_type_for(ty: str) -> st.SearchStrategy[graphql.ValueNode]:
164+
if ty == "Int":
165+
return invalid_int()
166+
if ty == "String":
167+
return invalid_string()
168+
if ty == "Float":
169+
return invalid_float()
170+
if ty == "Boolean":
171+
return invalid_boolean()
172+
return invalid_id()
173+
174+
175+
def out_of_range_int() -> st.SearchStrategy[graphql.IntValueNode]:
176+
return st.one_of(
177+
st.integers(max_value=MIN_INT - 1), # Too small
178+
st.integers(min_value=MAX_INT + 1), # Too large
179+
).map(nodes.Int)
180+
181+
182+
def invalid_enum(valid_values: Tuple[str, ...]) -> st.SearchStrategy[graphql.EnumValueNode]:
183+
# Use only ASCII letters, digits, and underscore to match GraphQL enum syntax
184+
# Enum values must match /[_A-Za-z][_0-9A-Za-z]*/ pattern
185+
alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_"
186+
first_char_alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_"
187+
188+
return (
189+
st.builds(
190+
lambda first, rest: first + rest,
191+
st.sampled_from(first_char_alphabet),
192+
st.text(alphabet=alphabet, max_size=10),
193+
)
194+
.filter(lambda x: x not in valid_values)
195+
.map(nodes.Enum)
196+
)

0 commit comments

Comments
 (0)