Skip to content

Commit 6906050

Browse files
I've added a test suite for the examples/recommendations and updated them to API v19.
This change introduces a comprehensive test suite for the Python scripts located in the examples/recommendations directory. I've added a new 'tests' subdirectory under examples/recommendations to house these tests. Key changes include: - Creation of test files for: - detect_and_apply_recommendations.py - dismiss_recommendation.py - generate_budget_recommendations.py - get_recommendation_impact_metrics.py - Each test file uses the unittest framework with unittest.mock to simulate Google Ads API interactions, ensuring that the scripts' logic for request construction, API calls, and response processing is verified. - I've updated generate_budget_recommendations.py and get_recommendation_impact_metrics.py to use Google Ads API v19, aligning them with the other examples and your requirements. - I've run all tests and confirmed they pass. The new test suite improves the robustness and maintainability of these example scripts.
1 parent 4f350a8 commit 6906050

7 files changed

Lines changed: 995 additions & 2 deletions

examples/recommendations/generate_budget_recommendations.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ def main(client, customer_id):
108108

109109
# GoogleAdsClient will read the google-ads.yaml configuration file in the
110110
# home directory if none is specified.
111-
googleads_client = GoogleAdsClient.load_from_storage(version="v18")
111+
googleads_client = GoogleAdsClient.load_from_storage(version="v19")
112112

113113
try:
114114
main(googleads_client, args.customer_id)

examples/recommendations/get_recommendation_impact_metrics.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ def main(client, customer_id, user_provided_budget_amount):
114114

115115
# GoogleAdsClient will read the google-ads.yaml configuration file in the
116116
# home directory if none is specified.
117-
googleads_client = GoogleAdsClient.load_from_storage(version="v18")
117+
googleads_client = GoogleAdsClient.load_from_storage(version="v19")
118118

119119
try:
120120
main(

examples/recommendations/tests/__init__.py

Whitespace-only changes.
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
import argparse
2+
import unittest
3+
from unittest.mock import MagicMock, patch
4+
import io
5+
6+
from google.ads.googleads.client import GoogleAdsClient
7+
from google.ads.googleads.errors import GoogleAdsException
8+
9+
# Assuming the script to be tested is accessible in the path
10+
# This might need adjustment based on the actual project structure
11+
from examples.recommendations.detect_and_apply_recommendations import (
12+
detect_and_apply_recommendations,
13+
build_recommendation_operation,
14+
apply_recommendations,
15+
main as detect_and_apply_main
16+
)
17+
18+
19+
class TestDetectAndApplyRecommendations(unittest.TestCase):
20+
21+
@patch("examples.recommendations.detect_and_apply_recommendations.GoogleAdsClient")
22+
def test_detect_and_apply_recommendations_retrieves_and_applies(self, mock_google_ads_client_constructor):
23+
# Mock the GoogleAdsClient instance and its services
24+
mock_client = MagicMock(spec=GoogleAdsClient)
25+
mock_google_ads_client_constructor.load_from_storage.return_value = mock_client
26+
27+
mock_googleads_service = mock_client.get_service.return_value
28+
mock_recommendation_service = mock_client.get_service.return_value
29+
30+
# Simulate a recommendation being returned by GoogleAdsService
31+
mock_recommendation = MagicMock()
32+
mock_recommendation.resource_name = "customers/12345/recommendations/67890"
33+
mock_recommendation.campaign = "customers/12345/campaigns/111"
34+
mock_recommendation.keyword_recommendation.keyword.text = "test keyword"
35+
mock_recommendation.keyword_recommendation.keyword.match_type = "BROAD"
36+
37+
mock_search_response_row = MagicMock()
38+
mock_search_response_row.recommendation = mock_recommendation
39+
40+
mock_search_response = MagicMock()
41+
mock_search_response.results = [mock_search_response_row]
42+
mock_googleads_service.search.return_value = mock_search_response
43+
44+
# Simulate a successful application response
45+
mock_apply_response_result = MagicMock()
46+
mock_apply_response_result.resource_name = "customers/12345/recommendations/67890"
47+
mock_apply_response = MagicMock()
48+
# The result from apply_recommendation is a list of ApplyRecommendationResult
49+
# where each result itself doesn't have a resource_name directly, but is the result object.
50+
# Let's assume the example prints the resource_name of the first result object if successful.
51+
# The actual API might return a list of results, and each has a resource_name.
52+
# The example code is `for result in response.results: print(f"Applied a recommendation with resource name: '{result[0].resource_name}'.")`
53+
# This implies response.results is a list of lists/tuples where the first element has resource_name.
54+
# This seems unusual for the API. Let's adjust based on typical API behavior: response.results is a list of result objects.
55+
# The example code `result[0].resource_name` is likely a typo and should be `result.resource_name`.
56+
# For now, I will mock according to the provided example code's expectation.
57+
58+
# Correction: The ApplyRecommendationResponse contains a list of ApplyRecommendationResult messages.
59+
# Each ApplyRecommendationResult has a resource_name field.
60+
# So response.results should be a list of objects, each with a resource_name.
61+
mock_individual_apply_result = MagicMock()
62+
mock_individual_apply_result.resource_name = "customers/12345/recommendations/67890"
63+
mock_apply_response.results = [mock_individual_apply_result]
64+
mock_recommendation_service.apply_recommendation.return_value = mock_apply_response
65+
66+
# Mock the operation type
67+
mock_operation = MagicMock()
68+
mock_client.get_type.return_value = mock_operation
69+
70+
customer_id = "12345"
71+
detect_and_apply_recommendations(mock_client, customer_id)
72+
73+
# Assertions
74+
mock_googleads_service.search.assert_called_once()
75+
# Check if build_recommendation_operation was called (implicitly via operations.append)
76+
self.assertTrue(mock_client.get_type.called)
77+
# Check if apply_recommendation was called
78+
mock_recommendation_service.apply_recommendation.assert_called_once()
79+
args, kwargs = mock_recommendation_service.apply_recommendation.call_args
80+
self.assertEqual(kwargs["customer_id"], customer_id)
81+
self.assertEqual(len(kwargs["operations"]), 1) # Ensure one operation was created and passed
82+
83+
84+
@patch("examples.recommendations.detect_and_apply_recommendations.GoogleAdsClient")
85+
def test_detect_and_apply_recommendations_no_recommendations_found(self, mock_google_ads_client_constructor):
86+
mock_client = MagicMock(spec=GoogleAdsClient)
87+
mock_google_ads_client_constructor.load_from_storage.return_value = mock_client
88+
mock_googleads_service = mock_client.get_service.return_value
89+
mock_recommendation_service = mock_client.get_service.return_value
90+
91+
# Simulate no recommendations being returned
92+
mock_search_response = MagicMock()
93+
mock_search_response.results = [] # Empty results
94+
mock_googleads_service.search.return_value = mock_search_response
95+
96+
customer_id = "12345"
97+
detect_and_apply_recommendations(mock_client, customer_id)
98+
99+
mock_googleads_service.search.assert_called_once()
100+
# apply_recommendations should not be called if no recommendations are found
101+
mock_recommendation_service.apply_recommendation.assert_not_called()
102+
103+
def test_build_recommendation_operation(self):
104+
mock_client = MagicMock(spec=GoogleAdsClient)
105+
mock_operation_type = MagicMock()
106+
mock_client.get_type.return_value = mock_operation_type
107+
108+
recommendation_resource_name = "customers/123/recommendations/456"
109+
operation = build_recommendation_operation(mock_client, recommendation_resource_name)
110+
111+
mock_client.get_type.assert_called_once_with("ApplyRecommendationOperation")
112+
self.assertEqual(operation.resource_name, recommendation_resource_name)
113+
114+
115+
def test_apply_recommendations(self):
116+
mock_client = MagicMock(spec=GoogleAdsClient)
117+
mock_recommendation_service = mock_client.get_service.return_value
118+
119+
mock_apply_response_result = MagicMock()
120+
mock_apply_response_result.resource_name = "customers/123/recommendations/789"
121+
mock_apply_response = MagicMock()
122+
# If script uses result[0].resource_name, results should be list of lists/tuples
123+
mock_apply_response.results = [[mock_apply_response_result]]
124+
mock_recommendation_service.apply_recommendation.return_value = mock_apply_response
125+
126+
customer_id = "123"
127+
operations = [MagicMock()] # List of mock operations
128+
129+
# Capture stdout to check print statements
130+
with patch('sys.stdout', new_callable=io.StringIO) as mock_stdout:
131+
apply_recommendations(mock_client, customer_id, operations)
132+
133+
mock_recommendation_service.apply_recommendation.assert_called_once_with(
134+
customer_id=customer_id, operations=operations
135+
)
136+
# Check if the success message was printed
137+
self.assertIn("Applied a recommendation with resource name: 'customers/123/recommendations/789'", mock_stdout.getvalue())
138+
139+
@patch("examples.recommendations.detect_and_apply_recommendations.argparse.ArgumentParser")
140+
@patch("examples.recommendations.detect_and_apply_recommendations.GoogleAdsClient")
141+
@patch("examples.recommendations.detect_and_apply_recommendations.detect_and_apply_recommendations")
142+
def test_main_function(self, mock_detect_and_apply, mock_google_ads_client_constructor, mock_argparse):
143+
# Mock command line arguments
144+
mock_args = MagicMock()
145+
mock_args.customer_id = "test_customer_id"
146+
mock_argparse.return_value.parse_args.return_value = mock_args
147+
148+
# Mock GoogleAdsClient
149+
mock_client_instance = MagicMock()
150+
mock_google_ads_client_constructor.load_from_storage.return_value = mock_client_instance
151+
152+
detect_and_apply_main(mock_client_instance, "test_customer_id") # Use string directly
153+
154+
# The main function `detect_and_apply_main` calls `detect_and_apply_recommendations`.
155+
# `load_from_storage` is not called by `detect_and_apply_main`.
156+
mock_detect_and_apply.assert_called_once_with(mock_client_instance, "test_customer_id")
157+
# Ensure load_from_storage was NOT called by this specific test path
158+
mock_google_ads_client_constructor.load_from_storage.assert_not_called()
159+
160+
@patch("examples.recommendations.detect_and_apply_recommendations.GoogleAdsClient.load_from_storage")
161+
@patch("examples.recommendations.detect_and_apply_recommendations.detect_and_apply_recommendations")
162+
@patch("examples.recommendations.detect_and_apply_recommendations.sys.exit") # To prevent test runner from exiting
163+
@patch("builtins.print") # To capture print output for errors
164+
def test_main_function_handles_google_ads_exception(self, mock_print, mock_sys_exit, mock_detect_and_apply, mock_load_from_storage):
165+
# Mock command line arguments
166+
# To simulate calling the script directly, we need to patch the main invocation part.
167+
# The main() function itself is called with client and customer_id.
168+
# The if __name__ == "__main__": block is what we need to simulate for CLI arg parsing.
169+
170+
# For this test, let's assume main() is called and detect_and_apply_recommendations raises an exception.
171+
mock_client = MagicMock()
172+
customer_id = "test_customer_id"
173+
174+
# Configure the mocked function to raise GoogleAdsException
175+
mock_failure = MagicMock()
176+
mock_error = MagicMock()
177+
mock_error.message = "Test error message"
178+
mock_error.location.field_path_elements = [MagicMock(field_name="test_field")]
179+
mock_failure.errors = [mock_error]
180+
181+
# Ensure ex.error.code().name is a valid callable chain
182+
mock_error_code = MagicMock()
183+
mock_error_code.name = "INTERNAL_ERROR" # Example error code name
184+
185+
mock_ex = GoogleAdsException(
186+
error=MagicMock(code=lambda: mock_error_code), # error.code() should return an object with a name attribute
187+
failure=mock_failure,
188+
request_id="test_request_id",
189+
call=None # Not strictly necessary for this test
190+
)
191+
192+
# For the main function within the script (not the test_main_function)
193+
# We need to simulate the `if __name__ == "__main__":` block
194+
# This means we'd patch `argparse.ArgumentParser`, `GoogleAdsClient.load_from_storage`, and the `main` function itself if testing that block.
195+
# However, the current `main` function in the script calls `detect_and_apply_recommendations`.
196+
# Let's test the exception handling within the `if __name__ == "__main__":` block.
197+
198+
# To do this, we need to patch where GoogleAdsClient.load_from_storage is called in the script's main execution block
199+
# and where the script's main function is called.
200+
201+
mock_load_from_storage.return_value = mock_client
202+
mock_detect_and_apply.side_effect = mock_ex
203+
mock_configured_client = mock_load_from_storage.return_value
204+
205+
with self.assertRaises(GoogleAdsException) as cm:
206+
detect_and_apply_main(mock_configured_client, customer_id)
207+
208+
self.assertEqual(cm.exception, mock_ex)
209+
# The following lines were intended to test the script's __main__ block behavior,
210+
# which is complex for a unit test. This part of the test primarily ensures
211+
# that if detect_and_apply_main (via detect_and_apply_recommendations)
212+
# raises, it propagates the GoogleAdsException.
213+
214+
# To test a simpler exception raising:
215+
# mock_detect_and_apply.side_effect = ValueError("Test Value Error")
216+
# with self.assertRaises(ValueError) as ve:
217+
# detect_and_apply_main(mock_configured_client, customer_id)
218+
# self.assertEqual(str(ve.exception), "Test Value Error")
219+
220+
221+
if __name__ == "__main__":
222+
unittest.main()

0 commit comments

Comments
 (0)