Skip to content

Commit 34cc699

Browse files
committed
feat: support Schemathesis hooks
1 parent dd297ad commit 34cc699

14 files changed

Lines changed: 334 additions & 3 deletions

README.md

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,3 +107,55 @@ class AuthExtension:
107107
Then with all API calls, will have
108108
[basic auth](https://en.wikipedia.org/wiki/Basic_access_authentication) set in the
109109
headers for all calls made to your API endpoint.
110+
111+
## Schemathesis hook support
112+
Library supports extending Schemathesis by defining hooks. Hooks allows users to customize how
113+
Schemathesis generates test data, validates responses, and handles requests through hooks, custom
114+
checks, and data generation strategies. For more details about Schemathesis hooks, refer to
115+
Schemathesis extending documentation: https://schemathesis.readthedocs.io/en/stable/guides/extending/
116+
117+
Example if there need to add custom header in each request, then it is possible to import library
118+
with hook:
119+
120+
```robotframework
121+
*** Settings ***
122+
Variables authentication.py
123+
Library SchemathesisLibrary
124+
... url=http://127.0.0.1/openapi.json
125+
... hook=${CURDIR}/hook_filter.py
126+
127+
Test Template Wrapper
128+
129+
130+
*** Test Cases ***
131+
All Tests
132+
Wrapper test_case_1
133+
134+
135+
*** Keywords ***
136+
Wrapper
137+
[Arguments] ${case}
138+
Call And Validate ${case} auth=${BASIC_AUTH_TUPLE}
139+
140+
```
141+
142+
And when hook_filter.py looks like:
143+
144+
```python
145+
import schemathesis
146+
147+
148+
global_count_count = 0
149+
150+
151+
@schemathesis.hook
152+
def filter_query(ctx, query) -> bool:
153+
method = ctx.operation.method.lower().strip()
154+
if method == "put":
155+
global global_count_count
156+
global_count_count += 1
157+
if global_count_count > 2:
158+
return False
159+
return True
160+
```
161+
Then only two test with `PUT` request are generated.

atest/library/hook_filter.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import schemathesis
2+
3+
4+
global_count_count = 0
5+
6+
7+
@schemathesis.hook
8+
def filter_query(ctx, query) -> bool:
9+
method = ctx.operation.method.lower().strip()
10+
if method == "put":
11+
global global_count_count
12+
global_count_count += 1
13+
if global_count_count > 2:
14+
return False
15+
return True

atest/library/hook_flatmap.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import schemathesis
2+
3+
4+
@schemathesis.hook
5+
def map_headers(ctx, headers):
6+
if headers is None:
7+
headers = {}
8+
headers["flatmap-headers"] = "I am here too"
9+
return headers

atest/library/hook_map.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import schemathesis
2+
3+
4+
@schemathesis.hook
5+
def map_headers(ctx, headers):
6+
if headers is None:
7+
headers = {}
8+
headers["map-headers"] = "I am here"
9+
return headers

atest/library/hooks_filter.robot

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
*** Settings ***
2+
Variables authentication.py
3+
Library SchemathesisLibrary
4+
... url=http://127.0.0.1/openapi.json
5+
... hook=${CURDIR}/hook_filter.py
6+
7+
Test Template Wrapper
8+
9+
10+
*** Test Cases ***
11+
All Tests
12+
Wrapper test_case_1
13+
14+
15+
*** Keywords ***
16+
Wrapper
17+
[Arguments] ${case}
18+
${r} = Call And Validate ${case} auth=${BASIC_AUTH_TUPLE}
19+
Log ${r.json()}

atest/library/hooks_map.robot

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
*** Settings ***
2+
Variables authentication.py
3+
Library SchemathesisLibrary
4+
... url=http://127.0.0.1/openapi.json
5+
... hook=${CURDIR}/hook_map.py
6+
... max_examples=4
7+
8+
Test Template Wrapper
9+
10+
11+
*** Test Cases ***
12+
All Tests
13+
Wrapper test_case_1
14+
15+
16+
*** Keywords ***
17+
Wrapper
18+
[Arguments] ${case}
19+
${r} = Call And Validate ${case} auth=${BASIC_AUTH_TUPLE}
20+
Log ${r.json()}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
*** Settings ***
2+
Variables authentication.py
3+
Library SchemathesisLibrary
4+
... url=http://127.0.0.1/openapi.json
5+
... hook=${CURDIR}/hook_map.py;${CURDIR}/hook_flatmap.py
6+
... max_examples=4
7+
8+
Test Template Wrapper
9+
10+
11+
*** Test Cases ***
12+
All Tests
13+
Wrapper test_case_1
14+
15+
16+
*** Keywords ***
17+
Wrapper
18+
[Arguments] ${case}
19+
${r} = Call And Validate ${case} auth=${BASIC_AUTH_TUPLE}
20+
Log ${r.json()}

atest/test/TestSuiteChecker.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from collections import defaultdict
22
from collections.abc import Callable
33
from dataclasses import dataclass
4+
from typing import TypedDict
45

56
from robot.api import ExecutionResult, ResultVisitor, logger
67
from robot.libraries.BuiltIn import BuiltIn
@@ -78,6 +79,44 @@ def _normalize_kw_name(self, name: str) -> str:
7879
return name.replace(" ", "").replace("_", "").lower()
7980

8081

82+
class ExecutionCountChecker(ResultVisitor):
83+
def __init__(self):
84+
self.test_names_counts = defaultdict(int)
85+
self.test_count = 0
86+
87+
def visit_test(self, test: TestCase):
88+
self.test_count += 1
89+
test_name = test.name.split(" ")[0]
90+
self.test_names_counts[test_name] += 1
91+
return super().visit_test(test)
92+
93+
94+
class ExecutionLogChecker(ResultVisitor):
95+
def __init__(self, match_start: str, kw_name: str = "Call And Validate"):
96+
self.match_start = match_start
97+
self.kw_name = kw_name.lower()
98+
self.test_logs = {}
99+
100+
def visit_message(self, message: Message):
101+
if not message.message.startswith(self.match_start):
102+
return super().visit_message(message)
103+
if not (
104+
message.parent
105+
and isinstance(message.parent, Keyword)
106+
and message.parent.name.lower() == self.kw_name
107+
):
108+
return super().visit_message(message)
109+
parent = message.parent
110+
while not isinstance(parent, TestCase):
111+
if not parent.parent:
112+
return super().visit_message(message)
113+
parent = parent.parent
114+
test = parent
115+
logger.debug(f"test case: {test}, message: {message.message}")
116+
self.test_logs[test.name] = message.message
117+
return super().visit_message(message)
118+
119+
81120
class TestSuiteChecker:
82121
def check_suite(self, output_xml: str, test_description: list[TestCaseData]):
83122
"""Check the test suite for test cases names."""
@@ -108,6 +147,55 @@ def check_suite(self, output_xml: str, test_description: list[TestCaseData]):
108147
self._check_test_logs(test.name, test.logs, checker.received_test_logs, should_match)
109148
logger.info(f"Checked test suite in {output_xml}")
110149

150+
def check_test_names_and_counts(
151+
self, output_xml: str, min_count: int, max_count: int, expected_names_and_counts: dict[str, int]
152+
):
153+
"""Check the test case start with correct names and has correct number of test cases."""
154+
result = ExecutionResult(output_xml)
155+
checker = ExecutionCountChecker()
156+
result.visit(checker)
157+
result.save(output_xml)
158+
logger.info(f"Checked test suite in {output_xml}")
159+
assert checker.test_count >= min_count, (
160+
f"Expected at least {min_count} tests, but found {checker.test_count}."
161+
)
162+
assert checker.test_count <= max_count, (
163+
f"Expected at most {max_count} tests, but found {checker.test_count}."
164+
)
165+
for test_name, count in expected_names_and_counts.items():
166+
assert test_name in checker.test_names_counts, (
167+
f"Expected test name {test_name} not found in {checker.test_names_counts.keys()} "
168+
)
169+
assert checker.test_names_counts[test_name] >= count, (
170+
f"Expected {count} for test name {test_name} but it was {checker.test_names_counts[test_name]}"
171+
)
172+
173+
def check_specific_test_logs(
174+
self,
175+
output_xml: str,
176+
log_message_start: str,
177+
kw_name: str = "Call And Validate",
178+
number_of_tests: int = 15,
179+
string_in_log: None | str = None,
180+
):
181+
assert number_of_tests > 0, "number_of_tests must be greater than 0"
182+
assert string_in_log, "string_in_log must be provided"
183+
result = ExecutionResult(output_xml)
184+
checker = ExecutionLogChecker(log_message_start, kw_name)
185+
result.visit(checker)
186+
result.save(output_xml)
187+
logger.info(f"Checked test suite in {output_xml}")
188+
assert len(checker.test_logs) == number_of_tests, (
189+
f"Expected {number_of_tests} tests with logs starting with '{log_message_start}', "
190+
f"but found {len(checker.test_logs)}."
191+
)
192+
for test, log in checker.test_logs.items():
193+
logger.info(f"Checking log for test '{test}': {log}")
194+
assert string_in_log in log, (
195+
f"Expected log to contain '{string_in_log}', but it was not found in log: {log} in test '{test}'."
196+
)
197+
logger.debug(f"Found logs for tests: {checker.test_logs}")
198+
111199
def _check_test_logs(
112200
self,
113201
test_name: str,

atest/test/all_cases.resource

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,3 +93,20 @@ Get All Cases Test Description With Set Headers
9393
END
9494
END
9595
RETURN ${test_description}
96+
97+
Get All Cases Test Description With Map Headers
98+
[Arguments] ${count: int}=${5}
99+
${test_description} = Get All Cases Test Description ${count}
100+
FOR ${description} IN @{test_description}
101+
VAR ${logs} = ${description}[logs]
102+
FOR ${i} ${log} IN ENUMERATE @{logs}
103+
IF $log.startswith("Case headers")
104+
Log Original log: ${log}
105+
VAR ${new_log} =
106+
... Case headers {'map-headers': 'I am here'} body 'Not set' cookies {} path parameters {}
107+
Log Modified log: ${new_log}
108+
Set List Value ${logs} ${i} ${new_log}
109+
END
110+
END
111+
END
112+
RETURN ${test_description}

atest/test/hooks_filter.robot

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
*** Settings ***
2+
Resource runner.resource
3+
Resource all_cases.resource
4+
5+
Suite Setup Run Suite
6+
7+
8+
*** Test Cases ***
9+
Check All Cases
10+
VAR &{tests} = GET=11 PUT=1
11+
Check Test Names And Counts ${LIBRARY_OUTPUT_XML} 17 18 ${tests}

0 commit comments

Comments
 (0)