Skip to content

Commit f3c6647

Browse files
authored
Merge pull request #24 from jpstroop/mock-response-doc-update
Mock response consistency
2 parents f6b183e + e9316e8 commit f3c6647

File tree

50 files changed

+661
-290
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

50 files changed

+661
-290
lines changed

docs/DEVELOPMENT.md

Lines changed: 72 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -195,15 +195,82 @@ All resource mocks are in the root [conftest.py](tests/conftest.py).
195195

196196
### Response Mocking
197197

198-
The test suite uses consistent patterns for mocking API responses:
198+
The test suite uses the `mock_response_factory` fixture from `tests/conftest.py`
199+
to create consistent, configurable mock responses. This is the required pattern
200+
for all tests that need to mock HTTP responses.
201+
202+
#### Standard Mock Response Pattern
203+
204+
```python
205+
def test_some_endpoint(resource, mock_oauth_session, mock_response_factory):
206+
"""Test description."""
207+
# Define expected data first
208+
expected_data = {"data": "test"}
209+
210+
# Create mock response using the factory
211+
mock_response = mock_response_factory(200, expected_data)
212+
213+
# Assign to oauth.request.return_value
214+
resource.oauth.request.return_value = mock_response
215+
216+
# Call the method under test
217+
result = resource.some_method()
218+
219+
# Assert against expected_data, not mock_response.json.return_value
220+
assert result == expected_data
221+
```
222+
223+
#### Response Factory Examples
199224

200225
```python
201-
mock_response = Mock()
202-
mock_response.json.return_value = {"data": "test"}
203-
mock_response.headers = {"content-type": "application/json"}
204-
mock_response.status_code = 200
226+
# Success response with data
227+
mock_response = mock_response_factory(200, {"data": "test"})
228+
229+
# Response with custom headers
230+
mock_response = mock_response_factory(
231+
status_code=200,
232+
json_data={"data": "test"},
233+
headers={"custom-header": "value"}
234+
)
235+
236+
# Delete/no content response (204)
237+
mock_response = mock_response_factory(204)
238+
239+
# Error response
240+
mock_response = mock_response_factory(
241+
400,
242+
{"errors": [{"errorType": "validation", "message": "Error message"}]}
243+
)
244+
245+
# Non-JSON response (XML)
246+
mock_response = mock_response_factory(
247+
200,
248+
headers={"content-type": "application/vnd.garmin.tcx+xml"},
249+
content_type="application/vnd.garmin.tcx+xml"
250+
)
251+
mock_response.text = "<xml>content</xml>"
205252
```
206253

254+
#### Parameter Validation Pattern
255+
256+
For tests that only need to verify parameter validation or endpoint construction
257+
(not response handling), it's acceptable to use the following alternative
258+
pattern:
259+
260+
```python
261+
def test_validation(resource):
262+
"""Test parameter validation."""
263+
resource._make_request = Mock()
264+
resource.some_method(param="value")
265+
resource._make_request.assert_called_once_with(
266+
"endpoint/path", params={"param": "value"}, user_id="-", debug=False
267+
)
268+
```
269+
270+
This approach provides a clean, standardized way to create mock responses with
271+
the desired status code, data, and headers. All test files must use one of these
272+
patterns.
273+
207274
## OAuth Callback Implementation
208275

209276
The OAuth callback mechanism is implemented using two main classes:

standardize_mock_responses.py

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Script to standardize mock_response usage in test files.
4+
5+
This script finds test files that use direct mock_response manipulation and
6+
converts them to use mock_response_factory instead.
7+
"""
8+
9+
# Standard library imports
10+
import os
11+
from pathlib import Path
12+
import re
13+
import sys
14+
from typing import List
15+
from typing import Tuple
16+
17+
18+
def find_pattern_in_file(file_path: str, pattern: str) -> List[str]:
19+
"""Find all matches of pattern in the given file."""
20+
with open(file_path, "r") as f:
21+
content = f.read()
22+
return re.findall(pattern, content, re.MULTILINE)
23+
24+
25+
def transform_file(file_path: str, dry_run: bool = False) -> bool:
26+
"""Transform a file to use mock_response_factory."""
27+
with open(file_path, "r") as f:
28+
content = f.read()
29+
30+
# Backup original content
31+
original_content = content
32+
33+
# Pattern 1: def test_*(mock_response) -> def test_*(mock_response_factory)
34+
# Find all test function definitions
35+
test_pattern = r"(def\s+test_\w+\([^)]*)(,\s*mock_response)(\s*[,)].*)"
36+
37+
def param_replacement(match):
38+
before = match.group(1)
39+
param = match.group(2).replace("mock_response", "mock_response_factory")
40+
after = match.group(3)
41+
return f"{before}{param}{after}"
42+
43+
content = re.sub(test_pattern, param_replacement, content)
44+
45+
# Pattern 2: mock_response.json.return_value = {...}
46+
pattern2 = r"([ \t]*)mock_response\.json\.return_value\s*=\s*({[^;]*}|\[[^;]*\])"
47+
48+
def replacement2(match):
49+
indent = match.group(1)
50+
data = match.group(2).strip()
51+
# Ensure data has balanced braces
52+
open_braces = data.count("{") - data.count("}")
53+
open_brackets = data.count("[") - data.count("]")
54+
55+
if open_braces != 0 or open_brackets != 0:
56+
# Skip this match as it has unbalanced braces or brackets
57+
return match.group(0)
58+
59+
return f"{indent}mock_response = mock_response_factory(\n{indent} 200, \n{indent} {data}\n{indent})"
60+
61+
content = re.sub(pattern2, replacement2, content)
62+
63+
# Pattern 3: mock_response.status_code = 204
64+
pattern3 = r"([ \t]*)mock_response\.status_code\s*=\s*(\d+)"
65+
66+
def replacement3(match):
67+
indent = match.group(1)
68+
status_code = match.group(2)
69+
return f"{indent}mock_response = mock_response_factory({status_code})"
70+
71+
content = re.sub(pattern3, replacement3, content)
72+
73+
# Pattern 4: We're disabling this pattern for now as it was causing issues
74+
# The goal was to change order of assignments (response assignment should come before oauth assignment)
75+
# but it was causing syntax errors
76+
"""
77+
pattern4 = r'([ \t]*)(.*?)\.oauth(?:\.|\w+\.)*request\.return_value\s*=\s*mock_response\n([ \t]*)mock_response\s*=\s*mock_response_factory'
78+
79+
def replacement4(match):
80+
indent1 = match.group(1)
81+
obj = match.group(2)
82+
indent2 = match.group(3)
83+
return f"{indent2}mock_response = mock_response_factory\n{indent1}{obj}.oauth.request.return_value = mock_response"
84+
85+
content = re.sub(pattern4, replacement4, content)
86+
"""
87+
88+
# Pattern 5: Fix cases where test was updated to use mock_response_factory as parameter
89+
# but still uses mock_response in the body
90+
pattern5 = r"def\s+test_\w+\([^)]*mock_response_factory[^)]*\).*?(?=\n\s*def|\Z)"
91+
92+
def fix_mock_response_usage(match):
93+
test_func = match.group(0)
94+
# If the function uses mock_response_factory but also has direct mock_response usage
95+
if (
96+
"mock_response.json.return_value =" in test_func
97+
or "mock_response.status_code =" in test_func
98+
):
99+
# Add a mock_response declaration at the beginning of the function body
100+
# Find the first indented line after the function def
101+
lines = test_func.split("\n")
102+
for i, line in enumerate(lines):
103+
if i > 0 and line.strip() and not line.strip().startswith("#"):
104+
indent = re.match(r"(\s*)", line).group(1)
105+
# Insert the mock_response assignment after docstring (if any)
106+
for j in range(i, len(lines)):
107+
if not lines[j].strip().startswith('"""') and not lines[
108+
j
109+
].strip().startswith("'''"):
110+
lines.insert(j, f"{indent}mock_response = mock_response_factory(200)")
111+
break
112+
break
113+
return "\n".join(lines)
114+
return test_func
115+
116+
content = re.sub(pattern5, fix_mock_response_usage, content, flags=re.DOTALL)
117+
118+
# Pattern 6: Replace assert result == mock_response.json.return_value with assert result == expected_data
119+
pattern6 = r"([ \t]*assert\s+.*?==\s*)mock_response\.json\.return_value"
120+
121+
def fix_assertions(match):
122+
before_part = match.group(1)
123+
return f"{before_part}mock_response.json()"
124+
125+
content = re.sub(pattern6, fix_assertions, content)
126+
127+
# If no changes were made, return False
128+
if content == original_content:
129+
return False
130+
131+
# Write the changes back to the file
132+
if not dry_run:
133+
with open(file_path, "w") as f:
134+
f.write(content)
135+
136+
return True
137+
138+
139+
def find_files_to_transform() -> List[Tuple[str, bool]]:
140+
"""Find all test files that need to be transformed."""
141+
test_dir = Path("tests")
142+
# Allow specifying a subdirectory
143+
if len(sys.argv) > 1 and not sys.argv[1].startswith("--"):
144+
test_dir = Path(sys.argv[1])
145+
print(f"Searching in directory: {test_dir}")
146+
147+
result = []
148+
verbose = "--verbose" in sys.argv
149+
150+
for root, _, files in os.walk(test_dir):
151+
for file in files:
152+
if file.endswith(".py") and file.startswith("test_"):
153+
file_path = os.path.join(root, file)
154+
155+
# First check for any mention of mock_response
156+
has_mock_response = bool(find_pattern_in_file(file_path, r"mock_response"))
157+
158+
if not has_mock_response:
159+
continue
160+
161+
# More detailed patterns
162+
# Check if file uses mock_response directly as parameter
163+
uses_mock_response = bool(
164+
find_pattern_in_file(
165+
file_path, r"def\s+test_\w+\([^)]*,\s*mock_response\s*[,)]"
166+
)
167+
)
168+
169+
# Check if file directly manipulates mock_response
170+
manipulates_mock_response = bool(
171+
find_pattern_in_file(
172+
file_path, r"mock_response\.(json\.return_value|status_code)\s*="
173+
)
174+
)
175+
176+
# Check if file uses mock_response.json.return_value in assertions
177+
uses_return_value_in_assertions = bool(
178+
find_pattern_in_file(
179+
file_path, r"assert\s+.*?=.*?mock_response\.json\.return_value"
180+
)
181+
)
182+
183+
# Check if file uses mock_response_factory
184+
uses_factory = bool(
185+
find_pattern_in_file(file_path, r"mock_response\s*=\s*mock_response_factory")
186+
)
187+
188+
# Determine if file needs transformation
189+
needs_transform = (
190+
uses_mock_response or manipulates_mock_response
191+
) and not uses_factory
192+
193+
# Also flag files that use factory but still use mock_response.json.return_value in assertions
194+
if uses_factory and uses_return_value_in_assertions and not needs_transform:
195+
needs_transform = True
196+
197+
if verbose or needs_transform:
198+
print(f"File: {file_path}")
199+
print(f" Has mock_response: {has_mock_response}")
200+
print(f" Uses as parameter: {uses_mock_response}")
201+
print(f" Manipulates: {manipulates_mock_response}")
202+
print(f" Uses in assertions: {uses_return_value_in_assertions}")
203+
print(f" Uses factory: {uses_factory}")
204+
print(f" Needs transform: {needs_transform}")
205+
206+
if needs_transform:
207+
result.append((file_path, needs_transform))
208+
209+
return result
210+
211+
212+
def main():
213+
"""Main entry point."""
214+
dry_run = "--dry-run" in sys.argv
215+
files = find_files_to_transform()
216+
217+
print(f"Found {len(files)} files that need to be transformed.")
218+
if dry_run:
219+
print("Running in dry-run mode. No files will be modified.")
220+
221+
for file_path, _ in files:
222+
print(f"Transforming {file_path}...", end="")
223+
transformed = transform_file(file_path, dry_run)
224+
print(" TRANSFORMED" if transformed else " SKIPPED (no changes)")
225+
226+
print("\nDone!")
227+
print(f"Transformed {len(files)} files.")
228+
229+
if dry_run:
230+
print("\nRun without --dry-run to apply changes.")
231+
232+
233+
if __name__ == "__main__":
234+
main()

tests/fitbit_client/resources/active_zone_minutes/test_get_azm_timeseries.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,13 @@
33
"""Tests for the get_azm_timeseries endpoint."""
44

55

6-
def test_get_azm_timeseries_with_today_date(azm_resource, mock_response):
6+
def test_get_azm_timeseries_with_today_date(azm_resource, mock_response_factory):
77
"""Test using 'today' as the date parameter"""
8-
mock_response.json.return_value = {"activities-active-zone-minutes": []}
8+
expected_data = {"activities-active-zone-minutes": []}
9+
mock_response = mock_response_factory(200, expected_data)
910
azm_resource.oauth.request.return_value = mock_response
1011
result = azm_resource.get_azm_timeseries_by_date(date="today")
11-
assert result == mock_response.json.return_value
12+
assert result == expected_data
1213
azm_resource.oauth.request.assert_called_once_with(
1314
"GET",
1415
"https://api.fitbit.com/1/user/-/activities/active-zone-minutes/date/today/1d.json",

tests/fitbit_client/resources/active_zone_minutes/test_get_azm_timeseries_by_date.py

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@
1313
from fitbit_client.resources._constants import Period
1414

1515

16-
def test_get_azm_timeseries_by_date_success(azm_resource, mock_response):
16+
def test_get_azm_timeseries_by_date_success(azm_resource, mock_response_factory):
1717
"""Test successful retrieval of AZM time series by date with default period"""
18-
mock_response.json.return_value = {
18+
expected_data = {
1919
"activities-active-zone-minutes": [
2020
{
2121
"dateTime": "2025-02-01",
@@ -28,9 +28,10 @@ def test_get_azm_timeseries_by_date_success(azm_resource, mock_response):
2828
}
2929
]
3030
}
31+
mock_response = mock_response_factory(200, expected_data)
3132
azm_resource.oauth.request.return_value = mock_response
3233
result = azm_resource.get_azm_timeseries_by_date(date="2025-02-01")
33-
assert result == mock_response.json.return_value
34+
assert result == expected_data
3435
azm_resource.oauth.request.assert_called_once_with(
3536
"GET",
3637
"https://api.fitbit.com/1/user/-/activities/active-zone-minutes/date/2025-02-01/1d.json",
@@ -41,12 +42,13 @@ def test_get_azm_timeseries_by_date_success(azm_resource, mock_response):
4142
)
4243

4344

44-
def test_get_azm_timeseries_by_date_explicit_period(azm_resource, mock_response):
45+
def test_get_azm_timeseries_by_date_explicit_period(azm_resource, mock_response_factory):
4546
"""Test successful retrieval of AZM time series by date with explicit ONE_DAY period"""
46-
mock_response.json.return_value = {"activities-active-zone-minutes": []}
47+
expected_data = {"activities-active-zone-minutes": []}
48+
mock_response = mock_response_factory(200, expected_data)
4749
azm_resource.oauth.request.return_value = mock_response
4850
result = azm_resource.get_azm_timeseries_by_date(date="2025-02-01", period=Period.ONE_DAY)
49-
assert result == mock_response.json.return_value
51+
assert result == expected_data
5052
azm_resource.oauth.request.assert_called_once_with(
5153
"GET",
5254
"https://api.fitbit.com/1/user/-/activities/active-zone-minutes/date/2025-02-01/1d.json",
@@ -57,12 +59,13 @@ def test_get_azm_timeseries_by_date_explicit_period(azm_resource, mock_response)
5759
)
5860

5961

60-
def test_get_azm_timeseries_by_date_with_user_id(azm_resource, mock_response):
62+
def test_get_azm_timeseries_by_date_with_user_id(azm_resource, mock_response_factory):
6163
"""Test getting AZM time series for a specific user"""
62-
mock_response.json.return_value = {"activities-active-zone-minutes": []}
64+
expected_data = {"activities-active-zone-minutes": []}
65+
mock_response = mock_response_factory(200, expected_data)
6366
azm_resource.oauth.request.return_value = mock_response
6467
result = azm_resource.get_azm_timeseries_by_date(date="2025-02-01", user_id="123ABC")
65-
assert result == mock_response.json.return_value
68+
assert result == expected_data
6669
azm_resource.oauth.request.assert_called_once_with(
6770
"GET",
6871
"https://api.fitbit.com/1/user/123ABC/activities/active-zone-minutes/date/2025-02-01/1d.json",

0 commit comments

Comments
 (0)