test_google_ads_keywords.py
1 """Google Ads _keywords.py テスト 2 3 _KeywordsMixin の list_keywords, add_keywords, remove_keyword, 4 pause_keyword, diagnose_keywords, suggest_keywords, 5 list_negative_keywords, add_negative_keywords, add_negative_keywords_to_ad_group, 6 remove_negative_keyword, get_search_terms_report のテスト。 7 """ 8 9 from __future__ import annotations 10 11 from typing import Any 12 from unittest.mock import MagicMock, patch 13 14 import pytest 15 from google.ads.googleads.errors import GoogleAdsException 16 17 from mureo.google_ads.client import GoogleAdsApiClient 18 19 20 # --------------------------------------------------------------------------- 21 # ヘルパー 22 # --------------------------------------------------------------------------- 23 24 25 def _make_client() -> GoogleAdsApiClient: 26 creds = MagicMock() 27 with patch("mureo.google_ads.client.GoogleAdsClient") as mock_gads: 28 mock_gads.return_value = MagicMock() 29 client = GoogleAdsApiClient( 30 credentials=creds, 31 customer_id="1234567890", 32 developer_token="test-token", 33 ) 34 return client 35 36 37 def _make_google_ads_exception( 38 message: str = "error", 39 attr_name: str | None = None, 40 error_name: str | None = None, 41 ) -> GoogleAdsException: 42 error = MagicMock() 43 error.message = message 44 if attr_name and error_name: 45 code_attr = MagicMock() 46 code_attr.name = error_name 47 error.error_code = MagicMock(**{attr_name: code_attr}) 48 else: 49 error.error_code = MagicMock(spec=[]) 50 failure = MagicMock() 51 failure.errors = [error] 52 exc = GoogleAdsException.__new__(GoogleAdsException) 53 exc._failure = failure 54 exc._call = MagicMock() 55 exc._request_id = "req-123" 56 type(exc).failure = property(lambda self: self._failure) 57 return exc 58 59 60 def _make_keyword_row( 61 criterion_id: int = 1, 62 text: str = "テストキーワード", 63 match_type: int = 4, # BROAD 64 status: int = 2, # ENABLED 65 approval_status: int = 3, # APPROVED 66 ) -> MagicMock: 67 row = MagicMock() 68 row.ad_group_criterion.criterion_id = criterion_id 69 row.ad_group_criterion.keyword.text = text 70 row.ad_group_criterion.keyword.match_type = match_type 71 row.ad_group_criterion.status = status 72 row.ad_group_criterion.approval_status = approval_status 73 row.campaign.id = 100 74 row.campaign.name = "テストキャンペーン" 75 row.ad_group.id = 200 76 row.ad_group.name = "テストグループ" 77 return row 78 79 80 def _make_quality_keyword_row( 81 criterion_id: int = 1, 82 text: str = "テストKW", 83 quality_score: int | None = 7, 84 system_serving_status: str = "ELIGIBLE", 85 approval_status: str = "APPROVED", 86 creative_quality_score: str = "ABOVE_AVERAGE", 87 post_click_quality_score: str = "ABOVE_AVERAGE", 88 search_predicted_ctr: str = "ABOVE_AVERAGE", 89 ) -> MagicMock: 90 row = MagicMock() 91 c = row.ad_group_criterion 92 c.criterion_id = criterion_id 93 c.keyword.text = text 94 c.keyword.match_type = 4 95 c.status = 2 96 c.approval_status = approval_status 97 c.system_serving_status = system_serving_status 98 qi = c.quality_info 99 qi.quality_score = quality_score 100 qi.creative_quality_score = creative_quality_score 101 qi.post_click_quality_score = post_click_quality_score 102 qi.search_predicted_ctr = search_predicted_ctr 103 row.campaign.id = 100 104 row.campaign.name = "テストキャンペーン" 105 row.ad_group.id = 200 106 row.ad_group.name = "テストグループ" 107 return row 108 109 110 # --------------------------------------------------------------------------- 111 # list_keywords 112 # --------------------------------------------------------------------------- 113 114 115 @pytest.mark.unit 116 class TestListKeywords: 117 @pytest.mark.asyncio 118 async def test_正常(self) -> None: 119 client = _make_client() 120 row = _make_keyword_row() 121 with patch.object(client, "_search", return_value=[row]): 122 result = await client.list_keywords() 123 assert len(result) == 1 124 125 @pytest.mark.asyncio 126 async def test_campaign_idフィルタ(self) -> None: 127 client = _make_client() 128 with patch.object(client, "_search", return_value=[]) as mock_search: 129 await client.list_keywords(campaign_id="100") 130 query = mock_search.call_args[0][0] 131 assert "campaign.id = 100" in query 132 133 @pytest.mark.asyncio 134 async def test_ad_group_idフィルタ(self) -> None: 135 client = _make_client() 136 with patch.object(client, "_search", return_value=[]) as mock_search: 137 await client.list_keywords(ad_group_id="200") 138 query = mock_search.call_args[0][0] 139 assert "adGroups/200" in query 140 141 @pytest.mark.asyncio 142 async def test_status_filterフィルタ(self) -> None: 143 client = _make_client() 144 with patch.object(client, "_search", return_value=[]) as mock_search: 145 await client.list_keywords(status_filter="ENABLED") 146 query = mock_search.call_args[0][0] 147 assert "ad_group_criterion.status = 'ENABLED'" in query 148 149 150 # --------------------------------------------------------------------------- 151 # add_keywords 152 # --------------------------------------------------------------------------- 153 154 155 @pytest.mark.unit 156 class TestAddKeywords: 157 @pytest.mark.asyncio 158 async def test_正常(self) -> None: 159 client = _make_client() 160 mock_result1 = MagicMock() 161 mock_result1.resource_name = "customers/123/adGroupCriteria/200~1" 162 mock_result2 = MagicMock() 163 mock_result2.resource_name = "customers/123/adGroupCriteria/200~2" 164 mock_response = MagicMock() 165 mock_response.results = [mock_result1, mock_result2] 166 mock_service = MagicMock() 167 mock_service.mutate_ad_group_criteria.return_value = mock_response 168 client._client.get_service.return_value = mock_service 169 client._client.get_type.return_value = MagicMock() 170 client._client.enums = MagicMock() 171 172 result = await client.add_keywords( 173 { 174 "ad_group_id": "200", 175 "keywords": [ 176 {"text": "キーワード1", "match_type": "BROAD"}, 177 {"text": "キーワード2", "match_type": "EXACT"}, 178 ], 179 } 180 ) 181 assert len(result) == 2 182 assert result[0]["resource_name"] == "customers/123/adGroupCriteria/200~1" 183 184 @pytest.mark.asyncio 185 async def test_空リスト_エラー(self) -> None: 186 client = _make_client() 187 with pytest.raises(ValueError, match="At least one keyword"): 188 await client.add_keywords( 189 { 190 "ad_group_id": "200", 191 "keywords": [], 192 } 193 ) 194 195 @pytest.mark.asyncio 196 async def test_80文字超_エラー(self) -> None: 197 client = _make_client() 198 with pytest.raises(ValueError, match="80 characters"): 199 await client.add_keywords( 200 { 201 "ad_group_id": "200", 202 "keywords": [{"text": "a" * 81}], 203 } 204 ) 205 206 @pytest.mark.asyncio 207 async def test_GoogleAdsException(self) -> None: 208 client = _make_client() 209 exc = _make_google_ads_exception("追加エラー") 210 mock_service = MagicMock() 211 mock_service.mutate_ad_group_criteria.side_effect = exc 212 client._client.get_service.return_value = mock_service 213 client._client.get_type.return_value = MagicMock() 214 client._client.enums = MagicMock() 215 216 with pytest.raises(RuntimeError, match="error occurred"): 217 await client.add_keywords( 218 { 219 "ad_group_id": "200", 220 "keywords": [{"text": "テスト"}], 221 } 222 ) 223 224 225 # --------------------------------------------------------------------------- 226 # remove_keyword 227 # --------------------------------------------------------------------------- 228 229 230 @pytest.mark.unit 231 class TestRemoveKeyword: 232 @pytest.mark.asyncio 233 async def test_正常(self) -> None: 234 client = _make_client() 235 mock_result = MagicMock() 236 mock_result.resource_name = "customers/123/adGroupCriteria/200~1" 237 mock_response = MagicMock() 238 mock_response.results = [mock_result] 239 mock_service = MagicMock() 240 mock_service.mutate_ad_group_criteria.return_value = mock_response 241 client._client.get_service.return_value = mock_service 242 client._client.get_type.return_value = MagicMock() 243 244 result = await client.remove_keyword( 245 { 246 "ad_group_id": "200", 247 "criterion_id": "1", 248 } 249 ) 250 assert result["resource_name"] == "customers/123/adGroupCriteria/200~1" 251 252 @pytest.mark.asyncio 253 async def test_不正なad_group_id(self) -> None: 254 client = _make_client() 255 with pytest.raises(ValueError, match="Invalid ad_group_id"): 256 await client.remove_keyword( 257 { 258 "ad_group_id": "abc", 259 "criterion_id": "1", 260 } 261 ) 262 263 @pytest.mark.asyncio 264 async def test_不正なcriterion_id(self) -> None: 265 client = _make_client() 266 with pytest.raises(ValueError, match="Invalid criterion_id"): 267 await client.remove_keyword( 268 { 269 "ad_group_id": "200", 270 "criterion_id": "abc", 271 } 272 ) 273 274 275 # --------------------------------------------------------------------------- 276 # pause_keyword 277 # --------------------------------------------------------------------------- 278 279 280 @pytest.mark.unit 281 class TestPauseKeyword: 282 @pytest.mark.asyncio 283 async def test_正常(self) -> None: 284 client = _make_client() 285 mock_result = MagicMock() 286 mock_result.resource_name = "customers/123/adGroupCriteria/200~1" 287 mock_response = MagicMock() 288 mock_response.results = [mock_result] 289 mock_service = MagicMock() 290 mock_service.mutate_ad_group_criteria.return_value = mock_response 291 client._client.get_service.return_value = mock_service 292 client._client.get_type.return_value = MagicMock() 293 client._client.enums = MagicMock() 294 295 result = await client.pause_keyword( 296 { 297 "ad_group_id": "200", 298 "criterion_id": "1", 299 } 300 ) 301 assert result["resource_name"] == "customers/123/adGroupCriteria/200~1" 302 303 304 # --------------------------------------------------------------------------- 305 # diagnose_keywords 306 # --------------------------------------------------------------------------- 307 308 309 @pytest.mark.unit 310 class TestDiagnoseKeywords: 311 @pytest.mark.asyncio 312 async def test_正常(self) -> None: 313 client = _make_client() 314 rows = [ 315 _make_quality_keyword_row(criterion_id=1, text="KW1", quality_score=8), 316 _make_quality_keyword_row(criterion_id=2, text="KW2", quality_score=3), 317 _make_quality_keyword_row(criterion_id=3, text="KW3", quality_score=None), 318 ] 319 320 with patch.object(client, "_search", return_value=rows): 321 result = await client.diagnose_keywords("100") 322 323 assert result["campaign_id"] == "100" 324 assert result["total_keywords"] == 3 325 dist = result["quality_score_distribution"] 326 assert dist["high_7_10"] >= 1 327 assert dist["low_1_4"] >= 1 328 assert dist["no_score"] >= 1 329 330 @pytest.mark.asyncio 331 async def test_空結果(self) -> None: 332 client = _make_client() 333 with patch.object(client, "_search", return_value=[]): 334 result = await client.diagnose_keywords("100") 335 336 assert result["total_keywords"] == 0 337 assert result["campaign_name"] == "" 338 339 @pytest.mark.asyncio 340 async def test_問題カテゴリ_low_quality_score(self) -> None: 341 client = _make_client() 342 row = _make_quality_keyword_row(quality_score=2) 343 with patch.object(client, "_search", return_value=[row]): 344 result = await client.diagnose_keywords("100") 345 346 assert len(result["issues"]["low_quality_score"]) >= 1 347 assert result["total_issues"] >= 1 348 assert len(result["recommendations"]) >= 1 349 350 @pytest.mark.asyncio 351 async def test_問題カテゴリ_rarely_served(self) -> None: 352 client = _make_client() 353 row = _make_quality_keyword_row(system_serving_status="RARELY_SERVED") 354 with patch.object(client, "_search", return_value=[row]): 355 result = await client.diagnose_keywords("100") 356 357 assert len(result["issues"]["rarely_served"]) >= 1 358 359 @pytest.mark.asyncio 360 async def test_問題カテゴリ_disapproved(self) -> None: 361 client = _make_client() 362 row = _make_quality_keyword_row(approval_status="DISAPPROVED") 363 with patch.object(client, "_search", return_value=[row]): 364 result = await client.diagnose_keywords("100") 365 366 assert len(result["issues"]["disapproved"]) >= 1 367 368 @pytest.mark.asyncio 369 async def test_問題カテゴリ_below_average_ctr(self) -> None: 370 client = _make_client() 371 row = _make_quality_keyword_row(search_predicted_ctr="BELOW_AVERAGE") 372 with patch.object(client, "_search", return_value=[row]): 373 result = await client.diagnose_keywords("100") 374 375 assert len(result["issues"]["below_average_ctr"]) >= 1 376 377 @pytest.mark.asyncio 378 async def test_問題カテゴリ_below_average_ad_relevance(self) -> None: 379 client = _make_client() 380 row = _make_quality_keyword_row(creative_quality_score="BELOW_AVERAGE") 381 with patch.object(client, "_search", return_value=[row]): 382 result = await client.diagnose_keywords("100") 383 384 assert len(result["issues"]["below_average_ad_relevance"]) >= 1 385 386 @pytest.mark.asyncio 387 async def test_問題カテゴリ_below_average_landing_page(self) -> None: 388 client = _make_client() 389 row = _make_quality_keyword_row(post_click_quality_score="BELOW_AVERAGE") 390 with patch.object(client, "_search", return_value=[row]): 391 result = await client.diagnose_keywords("100") 392 393 assert len(result["issues"]["below_average_landing_page"]) >= 1 394 395 @pytest.mark.asyncio 396 async def test_keywords上限50件(self) -> None: 397 client = _make_client() 398 rows = [_make_quality_keyword_row(criterion_id=i) for i in range(60)] 399 with patch.object(client, "_search", return_value=rows): 400 result = await client.diagnose_keywords("100") 401 402 assert result["total_keywords"] == 60 403 assert len(result["keywords"]) == 50 404 405 406 # --------------------------------------------------------------------------- 407 # suggest_keywords 408 # --------------------------------------------------------------------------- 409 410 411 @pytest.mark.unit 412 class TestSuggestKeywords: 413 @pytest.mark.asyncio 414 async def test_正常(self) -> None: 415 client = _make_client() 416 idea1 = MagicMock() 417 idea1.text = "提案KW1" 418 idea1.keyword_idea_metrics.avg_monthly_searches = 1000 419 idea1.keyword_idea_metrics.competition = "MEDIUM" 420 idea2 = MagicMock() 421 idea2.text = "提案KW2" 422 idea2.keyword_idea_metrics.avg_monthly_searches = 500 423 idea2.keyword_idea_metrics.competition = "LOW" 424 mock_response = MagicMock() 425 mock_response.results = [idea1, idea2] 426 mock_service = MagicMock() 427 mock_service.generate_keyword_ideas.return_value = mock_response 428 client._client.get_service.return_value = mock_service 429 client._client.get_type.return_value = MagicMock() 430 431 result = await client.suggest_keywords(["テスト"]) 432 assert len(result) == 2 433 assert result[0]["keyword"] == "提案KW1" 434 assert result[0]["avg_monthly_searches"] == 1000 435 436 @pytest.mark.asyncio 437 async def test_20件上限(self) -> None: 438 client = _make_client() 439 ideas = [] 440 for i in range(30): 441 idea = MagicMock() 442 idea.text = f"KW{i}" 443 idea.keyword_idea_metrics.avg_monthly_searches = 100 444 idea.keyword_idea_metrics.competition = "LOW" 445 ideas.append(idea) 446 mock_response = MagicMock() 447 mock_response.results = ideas 448 mock_service = MagicMock() 449 mock_service.generate_keyword_ideas.return_value = mock_response 450 client._client.get_service.return_value = mock_service 451 client._client.get_type.return_value = MagicMock() 452 453 result = await client.suggest_keywords(["テスト"]) 454 assert len(result) == 20 455 456 @pytest.mark.asyncio 457 async def test_DEVELOPER_TOKEN_NOT_APPROVED(self) -> None: 458 client = _make_client() 459 exc = _make_google_ads_exception( 460 attr_name="authorization_error", 461 error_name="DEVELOPER_TOKEN_NOT_APPROVED", 462 ) 463 mock_service = MagicMock() 464 mock_service.generate_keyword_ideas.side_effect = exc 465 client._client.get_service.return_value = mock_service 466 client._client.get_type.return_value = MagicMock() 467 468 with pytest.raises(ValueError, match="Basic or Standard access"): 469 await client.suggest_keywords(["テスト"]) 470 471 @pytest.mark.asyncio 472 async def test_一般的なGoogleAdsException(self) -> None: 473 client = _make_client() 474 exc = _make_google_ads_exception("一般エラー") 475 mock_service = MagicMock() 476 mock_service.generate_keyword_ideas.side_effect = exc 477 client._client.get_service.return_value = mock_service 478 client._client.get_type.return_value = MagicMock() 479 480 with pytest.raises(RuntimeError, match="error occurred"): 481 await client.suggest_keywords(["テスト"]) 482 483 484 # --------------------------------------------------------------------------- 485 # list_negative_keywords 486 # --------------------------------------------------------------------------- 487 488 489 @pytest.mark.unit 490 class TestListNegativeKeywords: 491 @pytest.mark.asyncio 492 async def test_正常(self) -> None: 493 client = _make_client() 494 row = MagicMock() 495 row.campaign_criterion.criterion_id = 1 496 row.campaign_criterion.keyword.text = "除外KW" 497 row.campaign_criterion.keyword.match_type = 4 498 499 with patch.object(client, "_search", return_value=[row]): 500 result = await client.list_negative_keywords("100") 501 assert len(result) == 1 502 503 @pytest.mark.asyncio 504 async def test_不正なcampaign_id(self) -> None: 505 client = _make_client() 506 with pytest.raises(ValueError, match="Invalid campaign_id"): 507 await client.list_negative_keywords("abc") 508 509 510 # --------------------------------------------------------------------------- 511 # add_negative_keywords 512 # --------------------------------------------------------------------------- 513 514 515 @pytest.mark.unit 516 class TestAddNegativeKeywords: 517 @pytest.mark.asyncio 518 async def test_正常(self) -> None: 519 client = _make_client() 520 mock_result = MagicMock() 521 mock_result.resource_name = "customers/123/campaignCriteria/100~1" 522 mock_response = MagicMock() 523 mock_response.results = [mock_result] 524 mock_service = MagicMock() 525 mock_service.mutate_campaign_criteria.return_value = mock_response 526 client._client.get_service.return_value = mock_service 527 client._client.get_type.return_value = MagicMock() 528 client._client.enums = MagicMock() 529 530 result = await client.add_negative_keywords( 531 { 532 "campaign_id": "100", 533 "keywords": [{"text": "除外KW", "match_type": "EXACT"}], 534 } 535 ) 536 assert len(result) == 1 537 538 @pytest.mark.asyncio 539 async def test_GoogleAdsException(self) -> None: 540 client = _make_client() 541 exc = _make_google_ads_exception("追加エラー") 542 mock_service = MagicMock() 543 mock_service.mutate_campaign_criteria.side_effect = exc 544 client._client.get_service.return_value = mock_service 545 client._client.get_type.return_value = MagicMock() 546 client._client.enums = MagicMock() 547 548 with pytest.raises(RuntimeError, match="error occurred"): 549 await client.add_negative_keywords( 550 { 551 "campaign_id": "100", 552 "keywords": [{"text": "除外KW"}], 553 } 554 ) 555 556 557 # --------------------------------------------------------------------------- 558 # add_negative_keywords_to_ad_group 559 # --------------------------------------------------------------------------- 560 561 562 @pytest.mark.unit 563 class TestAddNegativeKeywordsToAdGroup: 564 @pytest.mark.asyncio 565 async def test_正常(self) -> None: 566 client = _make_client() 567 mock_result = MagicMock() 568 mock_result.resource_name = "customers/123/adGroupCriteria/200~1" 569 mock_response = MagicMock() 570 mock_response.results = [mock_result] 571 mock_service = MagicMock() 572 mock_service.mutate_ad_group_criteria.return_value = mock_response 573 client._client.get_service.return_value = mock_service 574 client._client.get_type.return_value = MagicMock() 575 client._client.enums = MagicMock() 576 577 result = await client.add_negative_keywords_to_ad_group( 578 { 579 "ad_group_id": "200", 580 "keywords": [{"text": "除外KW", "match_type": "BROAD"}], 581 } 582 ) 583 assert len(result) == 1 584 585 @pytest.mark.asyncio 586 async def test_不正なad_group_id(self) -> None: 587 client = _make_client() 588 with pytest.raises(ValueError, match="Invalid ad_group_id"): 589 await client.add_negative_keywords_to_ad_group( 590 { 591 "ad_group_id": "abc", 592 "keywords": [{"text": "除外KW"}], 593 } 594 ) 595 596 597 # --------------------------------------------------------------------------- 598 # remove_negative_keyword 599 # --------------------------------------------------------------------------- 600 601 602 @pytest.mark.unit 603 class TestRemoveNegativeKeyword: 604 @pytest.mark.asyncio 605 async def test_正常(self) -> None: 606 client = _make_client() 607 mock_result = MagicMock() 608 mock_result.resource_name = "customers/123/campaignCriteria/100~1" 609 mock_response = MagicMock() 610 mock_response.results = [mock_result] 611 mock_service = MagicMock() 612 mock_service.mutate_campaign_criteria.return_value = mock_response 613 client._client.get_service.return_value = mock_service 614 client._client.get_type.return_value = MagicMock() 615 616 result = await client.remove_negative_keyword( 617 { 618 "campaign_id": "100", 619 "criterion_id": "1", 620 } 621 ) 622 assert result["resource_name"] == "customers/123/campaignCriteria/100~1" 623 624 @pytest.mark.asyncio 625 async def test_不正なcampaign_id(self) -> None: 626 client = _make_client() 627 with pytest.raises(ValueError, match="Invalid campaign_id"): 628 await client.remove_negative_keyword( 629 { 630 "campaign_id": "abc", 631 "criterion_id": "1", 632 } 633 ) 634 635 @pytest.mark.asyncio 636 async def test_不正なcriterion_id(self) -> None: 637 client = _make_client() 638 with pytest.raises(ValueError, match="Invalid criterion_id"): 639 await client.remove_negative_keyword( 640 { 641 "campaign_id": "100", 642 "criterion_id": "abc", 643 } 644 ) 645 646 647 # --------------------------------------------------------------------------- 648 # get_search_terms_report 649 # --------------------------------------------------------------------------- 650 651 652 @pytest.mark.unit 653 class TestGetSearchTermsReport: 654 @pytest.mark.asyncio 655 async def test_正常(self) -> None: 656 client = _make_client() 657 row = MagicMock() 658 row.search_term_view.search_term = "テスト検索語句" 659 row.metrics.impressions = 100 660 row.metrics.clicks = 10 661 row.metrics.cost_micros = 1000_000_000 662 row.metrics.conversions = 1 663 row.metrics.ctr = 0.1 664 665 with patch.object(client, "_search", return_value=[row]): 666 result = await client.get_search_terms_report() 667 assert len(result) == 1 668 669 @pytest.mark.asyncio 670 async def test_campaign_idフィルタ(self) -> None: 671 client = _make_client() 672 with patch.object(client, "_search", return_value=[]) as mock_search: 673 await client.get_search_terms_report(campaign_id="100") 674 query = mock_search.call_args[0][0] 675 assert "campaign.id = 100" in query 676 677 @pytest.mark.asyncio 678 async def test_ad_group_idフィルタ(self) -> None: 679 client = _make_client() 680 with patch.object(client, "_search", return_value=[]) as mock_search: 681 await client.get_search_terms_report(ad_group_id="200") 682 query = mock_search.call_args[0][0] 683 assert "ad_group.id = 200" in query 684 685 @pytest.mark.asyncio 686 async def test_period指定(self) -> None: 687 client = _make_client() 688 with patch.object(client, "_search", return_value=[]) as mock_search: 689 await client.get_search_terms_report(period="LAST_7_DAYS") 690 query = mock_search.call_args[0][0] 691 assert "DURING LAST_7_DAYS" in query