From ccd5ca84c3e724ff3aef4f83f2b20292189497fe Mon Sep 17 00:00:00 2001 From: Jihyun3478 Date: Fri, 7 Mar 2025 15:24:07 +0900 Subject: [PATCH 1/5] =?UTF-8?q?test:=20Scraper=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scraping/tests/test_main.py | 119 ++++++++++++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 scraping/tests/test_main.py diff --git a/scraping/tests/test_main.py b/scraping/tests/test_main.py new file mode 100644 index 0000000..847473b --- /dev/null +++ b/scraping/tests/test_main.py @@ -0,0 +1,119 @@ +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +import uuid + +from users.models import User +from scraping.main import Scraper + + +class TestScraper: + @pytest.fixture + def scraper(self): + """Scraper 인스턴스 생성""" + return Scraper(group_range=range(1, 10), max_connections=10) + + @pytest.fixture + def user(self, db): + """테스트용 User 객체 생성""" + return User.objects.create( + velog_uuid=uuid.uuid4(), + access_token="encrypted-access-token", + refresh_token="encrypted-refresh-token", + group_id=1, + email="test@example.com", + is_active=True, + ) + + @patch("scraping.main.AESEncryption") + @pytest.mark.asyncio + async def test_update_old_tokens(self, mock_aes, scraper, user): + """토큰 만료로 인한 토큰 업데이트 테스트""" + mock_encryption = mock_aes.return_value + mock_encryption.decrypt.side_effect = lambda token: f"decrypted-{token}" + mock_encryption.encrypt.side_effect = lambda token: f"encrypted-{token}" + + new_tokens = { + "access_token": "new-access-token", + "refresh_token": "new-refresh-token", + } + + with patch.object(user, "asave", new_callable=AsyncMock) as mock_asave: + result = await scraper.update_old_tokens(user, mock_encryption, new_tokens) + + assert result is True + mock_asave.assert_called_once() + assert user.access_token == "encrypted-new-access-token" + assert user.refresh_token == "encrypted-new-refresh-token" + + @patch("scraping.main.Post.objects.bulk_create", new_callable=AsyncMock) + @pytest.mark.asyncio + async def test_bulk_insert_posts(self, mock_bulk_create, scraper, user): + """Post 객체를 일정 크기의 배치로 나눠서 삽입 테스트""" + posts_data = [ + { + "id": f"post-{i}", + "title": f"Title {i}", + "url_slug": f"slug-{i}", + "released_at": "2025-03-07", + } + for i in range(50) + ] + + result = await scraper.bulk_insert_posts(user, posts_data, batch_size=10) + + assert result is True + mock_bulk_create.assert_called() + assert mock_bulk_create.call_count == 5 + + @patch("scraping.main.sync_to_async", new_callable=MagicMock) + @pytest.mark.asyncio + async def test_update_daily_statistics(self, mock_sync_to_async, scraper): + """PostDailyStatistics 업데이트 또는 생성 테스트""" + post_data = {"id": "post-123"} + stats_data = {"data": {"getStats": {"total": 100}}, "likes": 5} + + mock_sync_to_async.return_value = AsyncMock() + + await scraper.update_daily_statistics(post_data, stats_data) + + mock_sync_to_async.assert_called() + + @patch("scraping.main.fetch_post_stats") + @pytest.mark.asyncio + async def test_fetch_post_stats_limited(self, mock_fetch, scraper): + """세마포어를 적용한 fetch_post_stats + 엄격한 재시도 로직 추가 테스트""" + mock_fetch.side_effect = [None, None, {"data": {"getStats": {"total": 100}}}] + + result = await scraper.fetch_post_stats_limited( + "post-123", "token-1", "token-2" + ) + + assert result is not None + mock_fetch.assert_called() + assert mock_fetch.call_count == 3 + + @patch("scraping.main.fetch_velog_user_chk") + @patch("scraping.main.fetch_all_velog_posts") + @patch("scraping.main.AESEncryption") + @pytest.mark.asyncio + async def test_process_user( + self, mock_aes, mock_fetch_posts, mock_fetch_user_chk, scraper, user + ): + """scraping 메인 비즈니스로직, 유저 데이터를 전체 처리 테스트""" + mock_encryption = mock_aes.return_value + mock_encryption.decrypt.side_effect = lambda token: f"decrypted-{token}" + mock_encryption.encrypt.side_effect = lambda token: f"encrypted-{token}" + + mock_fetch_user_chk.return_value = ( + {"access_token": "new-token"}, + {"data": {"currentUser": {"username": "testuser"}}}, + ) + mock_fetch_posts.return_value = [] + + with patch.object( + scraper, "update_old_tokens", new_callable=AsyncMock + ) as mock_update_tokens: + await scraper.process_user(user, MagicMock()) + + mock_update_tokens.assert_called_once() From 1641fde55c7be8a9467e28e01bac095ab80d76cf Mon Sep 17 00:00:00 2001 From: Nuung Date: Sat, 8 Mar 2025 10:16:14 +0900 Subject: [PATCH 2/5] =?UTF-8?q?modify:=20batch=20=EC=97=90=20env=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80,=20sample=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/test-ci.yaml | 10 ++++++++++ scraping/tests/test_sample.py | 6 ------ 2 files changed, 10 insertions(+), 6 deletions(-) delete mode 100644 scraping/tests/test_sample.py diff --git a/.github/workflows/test-ci.yaml b/.github/workflows/test-ci.yaml index 200ec0f..41d0804 100644 --- a/.github/workflows/test-ci.yaml +++ b/.github/workflows/test-ci.yaml @@ -45,6 +45,16 @@ jobs: echo "SECRET_KEY=adsfgdsftrgdfsvdf" >> .env echo "DEBUG=True" >> .env echo "DATABASE_URL=sqlite:///db.sqlite3" >> .env + echo "AES_KEY_0=${{ secrets.AES_KEY_0 }}" >> .env + echo "AES_KEY_1=${{ secrets.AES_KEY_1 }}" >> .env + echo "AES_KEY_2=${{ secrets.AES_KEY_2 }}" >> .env + echo "AES_KEY_3=${{ secrets.AES_KEY_3 }}" >> .env + echo "AES_KEY_4=${{ secrets.AES_KEY_4 }}" >> .env + echo "AES_KEY_5=${{ secrets.AES_KEY_5 }}" >> .env + echo "AES_KEY_6=${{ secrets.AES_KEY_6 }}" >> .env + echo "AES_KEY_7=${{ secrets.AES_KEY_7 }}" >> .env + echo "AES_KEY_8=${{ secrets.AES_KEY_8 }}" >> .env + echo "AES_KEY_9=${{ secrets.AES_KEY_9 }}" >> .env - name: Run migrations run: poetry run python manage.py migrate diff --git a/scraping/tests/test_sample.py b/scraping/tests/test_sample.py deleted file mode 100644 index c6ae8a8..0000000 --- a/scraping/tests/test_sample.py +++ /dev/null @@ -1,6 +0,0 @@ -def add_nums(a: int, b: int) -> int: - return a + b - - -def test_add_nums() -> None: - assert add_nums(1, 2) == 3 From 1b2be1ddf56ecb37c19588110d82afd671716b41 Mon Sep 17 00:00:00 2001 From: Jihyun3478 Date: Sun, 9 Mar 2025 17:20:09 +0900 Subject: [PATCH 3/5] =?UTF-8?q?modify:=20=EB=A9=94=EC=84=9C=EB=93=9C?= =?UTF-8?q?=EB=AA=85=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scraping/tests/test_main.py | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/scraping/tests/test_main.py b/scraping/tests/test_main.py index 847473b..eb3730e 100644 --- a/scraping/tests/test_main.py +++ b/scraping/tests/test_main.py @@ -1,9 +1,11 @@ from unittest.mock import AsyncMock, MagicMock, patch +from django.db import transaction import pytest import uuid from users.models import User +from posts.models import Post from scraping.main import Scraper @@ -27,8 +29,8 @@ def user(self, db): @patch("scraping.main.AESEncryption") @pytest.mark.asyncio - async def test_update_old_tokens(self, mock_aes, scraper, user): - """토큰 만료로 인한 토큰 업데이트 테스트""" + async def test_update_old_tokens_success(self, mock_aes, scraper, user): + """토큰 업데이트 성공 테스트""" mock_encryption = mock_aes.return_value mock_encryption.decrypt.side_effect = lambda token: f"decrypted-{token}" mock_encryption.encrypt.side_effect = lambda token: f"encrypted-{token}" @@ -48,8 +50,8 @@ async def test_update_old_tokens(self, mock_aes, scraper, user): @patch("scraping.main.Post.objects.bulk_create", new_callable=AsyncMock) @pytest.mark.asyncio - async def test_bulk_insert_posts(self, mock_bulk_create, scraper, user): - """Post 객체를 일정 크기의 배치로 나눠서 삽입 테스트""" + async def test_bulk_insert_posts_success(self, mock_bulk_create, scraper, user): + """Post 객체 배치 분할 삽입 성공 테스트""" posts_data = [ { "id": f"post-{i}", @@ -68,8 +70,8 @@ async def test_bulk_insert_posts(self, mock_bulk_create, scraper, user): @patch("scraping.main.sync_to_async", new_callable=MagicMock) @pytest.mark.asyncio - async def test_update_daily_statistics(self, mock_sync_to_async, scraper): - """PostDailyStatistics 업데이트 또는 생성 테스트""" + async def test_update_daily_statistics_success(self, mock_sync_to_async, scraper): + """데일리 통계 업데이트 또는 생성 성공 테스트""" post_data = {"id": "post-123"} stats_data = {"data": {"getStats": {"total": 100}}, "likes": 5} @@ -81,8 +83,8 @@ async def test_update_daily_statistics(self, mock_sync_to_async, scraper): @patch("scraping.main.fetch_post_stats") @pytest.mark.asyncio - async def test_fetch_post_stats_limited(self, mock_fetch, scraper): - """세마포어를 적용한 fetch_post_stats + 엄격한 재시도 로직 추가 테스트""" + async def test_fetch_post_stats_limited_success(self, mock_fetch, scraper): + """fetch_post_stats 성공 테스트""" mock_fetch.side_effect = [None, None, {"data": {"getStats": {"total": 100}}}] result = await scraper.fetch_post_stats_limited( @@ -97,10 +99,10 @@ async def test_fetch_post_stats_limited(self, mock_fetch, scraper): @patch("scraping.main.fetch_all_velog_posts") @patch("scraping.main.AESEncryption") @pytest.mark.asyncio - async def test_process_user( + async def test_process_user_success( self, mock_aes, mock_fetch_posts, mock_fetch_user_chk, scraper, user ): - """scraping 메인 비즈니스로직, 유저 데이터를 전체 처리 테스트""" + """유저 데이터 전체 처리 성공 테스트""" mock_encryption = mock_aes.return_value mock_encryption.decrypt.side_effect = lambda token: f"decrypted-{token}" mock_encryption.encrypt.side_effect = lambda token: f"encrypted-{token}" From abd467c99bc52b84bcc945c0ab7a6fbb438eb8eb Mon Sep 17 00:00:00 2001 From: Jihyun3478 Date: Sun, 9 Mar 2025 17:22:46 +0900 Subject: [PATCH 4/5] =?UTF-8?q?test:=20=EC=8B=A4=ED=8C=A8=20=EC=BC=80?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scraping/main.py | 4 ++ scraping/tests/test_main.py | 119 ++++++++++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+) diff --git a/scraping/main.py b/scraping/main.py index ebf98ec..24e5890 100644 --- a/scraping/main.py +++ b/scraping/main.py @@ -36,6 +36,10 @@ async def update_old_tokens( """토큰 만료로 인한 토큰 업데이트""" current_access_token = aes_encryption.decrypt(user.access_token) current_refresh_token = aes_encryption.decrypt(user.refresh_token) + + if current_access_token is None or current_refresh_token is None: + return False + try: # 복호화된 토큰과 새 토큰을 비교 if new_user_cookies["access_token"] != current_access_token: diff --git a/scraping/tests/test_main.py b/scraping/tests/test_main.py index eb3730e..bbcd775 100644 --- a/scraping/tests/test_main.py +++ b/scraping/tests/test_main.py @@ -47,6 +47,63 @@ async def test_update_old_tokens_success(self, mock_aes, scraper, user): mock_asave.assert_called_once() assert user.access_token == "encrypted-new-access-token" assert user.refresh_token == "encrypted-new-refresh-token" + mock_encryption.decrypt.assert_any_call("encrypted-access-token") + mock_encryption.decrypt.assert_any_call("encrypted-refresh-token") + + @patch("scraping.main.AESEncryption") + @pytest.mark.asyncio + async def test_update_old_tokens_no_change(self, mock_aes, scraper, user): + """토큰 업데이트 없음 테스트""" + mock_encryption = mock_aes.return_value + mock_encryption.decrypt.side_effect = lambda token: token + mock_encryption.encrypt.side_effect = lambda token: f"encrypted-{token}" + + new_tokens = { + "access_token": "encrypted-access-token", + "refresh_token": "encrypted-refresh-token", + } + + with patch.object(user, "asave", new_callable=AsyncMock) as mock_asave: + result = await scraper.update_old_tokens(user, mock_encryption, new_tokens) + + assert result is False + mock_asave.assert_not_called() + + + @patch("scraping.main.AESEncryption") + @pytest.mark.asyncio + async def test_update_old_tokens_expired_failure(self, mock_aes, scraper, user): + """토큰이 만료되었을 때 업데이트 실패 테스트""" + mock_encryption = mock_aes.return_value + mock_encryption.decrypt.side_effect = lambda token: f"decrypted-{token}" + mock_encryption.encrypt.side_effect = lambda token: f"encrypted-{token}" + + new_tokens = { + "access_token": "decrypted-encrypted-access-token", + "refresh_token": "decrypted-encrypted-refresh-token", + } + + with patch.object(user, "asave", new_callable=AsyncMock) as mock_asave: + result = await scraper.update_old_tokens(user, mock_encryption, new_tokens) + + assert result is False + mock_asave.assert_not_called() + + @patch("scraping.main.AESEncryption") + @pytest.mark.asyncio + async def test_update_old_tokens_with_mocked_decryption_failure(self, mock_aes, scraper, user): + """복호화가 제대로 되지 않았을 경우 업데이트 실패 테스트""" + mock_encryption = mock_aes.return_value + mock_encryption.decrypt.side_effect = lambda token: None + mock_encryption.encrypt.side_effect = lambda token: f"encrypted-{token}" + + new_tokens = {"access_token": "valid_token", "refresh_token": "valid_token"} + + with patch.object(user, "asave", new_callable=AsyncMock) as mock_asave: + result = await scraper.update_old_tokens(user, mock_encryption, new_tokens) + + assert result is False + mock_asave.assert_not_called() @patch("scraping.main.Post.objects.bulk_create", new_callable=AsyncMock) @pytest.mark.asyncio @@ -68,6 +125,26 @@ async def test_bulk_insert_posts_success(self, mock_bulk_create, scraper, user): mock_bulk_create.assert_called() assert mock_bulk_create.call_count == 5 + @patch("scraping.main.Post.objects.bulk_create", side_effect=Exception("DB 에러")) + @pytest.mark.asyncio + async def test_bulk_insert_posts_failure(self, mock_bulk_create, scraper, user): + """Post 객체 배치 분할 삽입 실패 테스트""" + posts_data = [ + { + "id": f"post-{i}", + "title": f"Title {i}", + "url_slug": f"slug-{i}", + "released_at": "2025-03-07", + } + for i in range(10) + ] + + result = await scraper.bulk_insert_posts(user, posts_data, batch_size=5) + + assert result is False + mock_bulk_create.assert_called() + + @patch("scraping.main.sync_to_async", new_callable=MagicMock) @pytest.mark.asyncio async def test_update_daily_statistics_success(self, mock_sync_to_async, scraper): @@ -95,6 +172,19 @@ async def test_fetch_post_stats_limited_success(self, mock_fetch, scraper): mock_fetch.assert_called() assert mock_fetch.call_count == 3 + @patch("scraping.main.fetch_post_stats", new_callable=AsyncMock) + @pytest.mark.asyncio + async def test_fetch_post_stats_limited_failure(self, mock_fetch, scraper): + """fetch_post_stats 실패 테스트""" + mock_fetch.side_effect = [None, None, None] + + result = await scraper.fetch_post_stats_limited( + "post-123", "token-1", "token-2" + ) + + assert result is None + assert mock_fetch.call_count == 3 + @patch("scraping.main.fetch_velog_user_chk") @patch("scraping.main.fetch_all_velog_posts") @patch("scraping.main.AESEncryption") @@ -119,3 +209,32 @@ async def test_process_user_success( await scraper.process_user(user, MagicMock()) mock_update_tokens.assert_called_once() + + @pytest.mark.django_db(transaction=True) + async def test_process_user_failure_rollback(scraper, user): + """유저 데이터 처리 실패 시 롤백 확인 테스트""" + mock_session = AsyncMock() + + with patch("myapp.scraper.fetch_velog_user_chk", side_effect=Exception("Failed to fetch user data")): + try: + async with transaction.atomic(): + await scraper.process_user(user, mock_session) + except Exception: + pass + + assert not Post.objects.filter(user=user).exists() + + @pytest.mark.django_db(transaction=True) + async def test_process_user_partial_failure_rollback(scraper, user): + """통계 업데이트 중 실패 시 롤백 확인 테스트""" + mock_session = AsyncMock() + + with patch("myapp.scraper.fetch_post_stats_limited", side_effect=Exception("Faile to fetch post stats_limited failed")): + try: + async with transaction.atomic(): + await scraper.process_user(user, mock_session) + except Exception: + pass + + assert Post.objects.filter(user=user).exists() + assert not any(post.statistics for post in Post.objects.filter(user=user)) From e958b9db5aca37e441802010e922bdc3d34465d2 Mon Sep 17 00:00:00 2001 From: Jihyun3478 Date: Sun, 9 Mar 2025 19:38:17 +0900 Subject: [PATCH 5/5] =?UTF-8?q?modify:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scraping/tests/test_main.py | 93 +++++++++++++++++++++++++++++++------ 1 file changed, 78 insertions(+), 15 deletions(-) diff --git a/scraping/tests/test_main.py b/scraping/tests/test_main.py index bbcd775..c3e1c71 100644 --- a/scraping/tests/test_main.py +++ b/scraping/tests/test_main.py @@ -69,7 +69,6 @@ async def test_update_old_tokens_no_change(self, mock_aes, scraper, user): assert result is False mock_asave.assert_not_called() - @patch("scraping.main.AESEncryption") @pytest.mark.asyncio async def test_update_old_tokens_expired_failure(self, mock_aes, scraper, user): @@ -91,7 +90,9 @@ async def test_update_old_tokens_expired_failure(self, mock_aes, scraper, user): @patch("scraping.main.AESEncryption") @pytest.mark.asyncio - async def test_update_old_tokens_with_mocked_decryption_failure(self, mock_aes, scraper, user): + async def test_update_old_tokens_with_mocked_decryption_failure( + self, mock_aes, scraper, user + ): """복호화가 제대로 되지 않았을 경우 업데이트 실패 테스트""" mock_encryption = mock_aes.return_value mock_encryption.decrypt.side_effect = lambda token: None @@ -144,19 +145,49 @@ async def test_bulk_insert_posts_failure(self, mock_bulk_create, scraper, user): assert result is False mock_bulk_create.assert_called() + @pytest.mark.asyncio + async def test_update_daily_statistics_success(self, scraper): + """데일리 통계 업데이트 또는 생성 성공 테스트""" + post_data = {"id": "post-123"} + stats_data = {"data": {"getStats": {"total": 100}}, "likes": 5} + + with patch( + "scraping.main.sync_to_async", new_callable=MagicMock + ) as mock_sync_to_async: + mock_async_func = AsyncMock() + mock_sync_to_async.return_value = mock_async_func + + await scraper.update_daily_statistics(post_data, stats_data) + + mock_sync_to_async.assert_called() + mock_async_func.assert_called_once() + + for call_args in mock_sync_to_async.call_args_list: + args, kwargs = call_args + + assert callable(args[0]) + + if kwargs: + assert "post-123" in str(kwargs.get("post_data", "")) + assert 100 in str(kwargs.get("stats_data", "")) @patch("scraping.main.sync_to_async", new_callable=MagicMock) @pytest.mark.asyncio - async def test_update_daily_statistics_success(self, mock_sync_to_async, scraper): - """데일리 통계 업데이트 또는 생성 성공 테스트""" + async def test_update_daily_statistics_exception(self, mock_sync_to_async, scraper): + """데일리 통계 업데이트 실패 테스트""" post_data = {"id": "post-123"} stats_data = {"data": {"getStats": {"total": 100}}, "likes": 5} - mock_sync_to_async.return_value = AsyncMock() + mock_async_func = AsyncMock(side_effect=Exception("Database error")) + mock_sync_to_async.return_value = mock_async_func - await scraper.update_daily_statistics(post_data, stats_data) + try: + await scraper.update_daily_statistics(post_data, stats_data) + except Exception: + pass mock_sync_to_async.assert_called() + mock_async_func.assert_called_once() @patch("scraping.main.fetch_post_stats") @pytest.mark.asyncio @@ -172,6 +203,29 @@ async def test_fetch_post_stats_limited_success(self, mock_fetch, scraper): mock_fetch.assert_called() assert mock_fetch.call_count == 3 + for call_args in mock_fetch.call_args_list: + args, kwargs = call_args + assert "post-123" in str(args) or "post-123" in str(kwargs) + assert ( + "token-1" in str(args) + or "token-1" in str(kwargs) + or "token-2" in str(args) + or "token-2" in str(kwargs) + ) + + @patch("scraping.main.fetch_post_stats") + @pytest.mark.asyncio + async def test_fetch_post_stats_limited_max_retries(self, mock_fetch, scraper): + """최대 재시도 횟수 초과 테스트""" + mock_fetch.return_value = None + + result = await scraper.fetch_post_stats_limited( + "post-123", "token-1", "token-2" + ) + + assert result is None + assert mock_fetch.call_count >= 3 + @patch("scraping.main.fetch_post_stats", new_callable=AsyncMock) @pytest.mark.asyncio async def test_fetch_post_stats_limited_failure(self, mock_fetch, scraper): @@ -210,26 +264,35 @@ async def test_process_user_success( mock_update_tokens.assert_called_once() + @patch("scraping.main.transaction.atomic") @pytest.mark.django_db(transaction=True) - async def test_process_user_failure_rollback(scraper, user): + async def test_process_user_failure_rollback(self, mock_atomic, scraper, user): """유저 데이터 처리 실패 시 롤백 확인 테스트""" mock_session = AsyncMock() - - with patch("myapp.scraper.fetch_velog_user_chk", side_effect=Exception("Failed to fetch user data")): + mock_atomic.side_effect = ( + transaction.atomic + ) # 실제 트랜잭션을 패치한 형태로 유지 + + with patch( + "scraping.main.fetch_velog_user_chk", + side_effect=Exception("Failed to fetch user data"), + ): try: - async with transaction.atomic(): - await scraper.process_user(user, mock_session) + await scraper.process_user(user, mock_session) except Exception: pass - assert not Post.objects.filter(user=user).exists() + assert Post.objects.filter(user=user).count() == 0 @pytest.mark.django_db(transaction=True) - async def test_process_user_partial_failure_rollback(scraper, user): + async def test_process_user_partial_failure_rollback(self, scraper, user): """통계 업데이트 중 실패 시 롤백 확인 테스트""" mock_session = AsyncMock() - - with patch("myapp.scraper.fetch_post_stats_limited", side_effect=Exception("Faile to fetch post stats_limited failed")): + + with patch( + "scraping.main.fetch_post_stats_limited", + side_effect=Exception("Failed to fetch post stats limited"), + ): try: async with transaction.atomic(): await scraper.process_user(user, mock_session)