Skip to content

Commit 4afca35

Browse files
authored
[25.08.01 / TASK-209] Test - 주간 트렌드 분석 및 주간 사용자 게시글 분석 배치 단위테스트 구현 (#39)
* modify: 트렌드 분석 성공 로그 추가 * test: 주간 트렌드 분석 배치 단위테스트 구현 * test: 주간 사용자 게시글 분석 배치 단위테스트 구현 * refactor: 본문 데이터 수집 성공 케이스 제거 * test: TokenExpiredError에 대한 테스트 케이스 추가 * modify: TokenExpiredError에 대한 테스트 케이스 수정 * modify: side_impact가 아닌 return_value를 사용하도록 수정 * refactor: fetch, analyze, save로 클래스 각각 분리 * refactor: conftest 파일 분리 * refactor: 사용하지 않는 import 제거 * modify: 기존 fixture를 재사용하도록 수정 * refactor: 루트 conftest 리팩토링 * refactor: 루트 conftest 리팩토링
1 parent 035a0de commit 4afca35

File tree

10 files changed

+506
-1
lines changed

10 files changed

+506
-1
lines changed

insight/tasks/weekly_trend_analysis.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ async def _analyze_data(
149149
trending_summary=trending_items, trend_analysis=trend_analysis
150150
)
151151

152+
self.logger.info("Trend analysis completed: %s items", len(trending_items))
152153
return [result] # 주간 트렌드는 하나의 결과만 생성
153154

154155
except Exception as e:

insight/tests/conftest.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import sys
22
import uuid
3-
from unittest.mock import MagicMock
3+
from datetime import datetime
4+
from unittest.mock import MagicMock, AsyncMock
45

56
import pytest
67
from django.conf import settings
@@ -240,3 +241,40 @@ def empty_insight_weekly_trend(db):
240241
return WeeklyTrend.objects.create(
241242
week_start_date=week_start, week_end_date=week_end, insight={}
242243
)
244+
245+
@pytest.fixture
246+
def mock_post():
247+
"""테스트용 게시글 목록 응답 (get_trending_posts 용)"""
248+
return MagicMock(
249+
id="abc123",
250+
title="test title",
251+
views=100,
252+
likes=10,
253+
user=MagicMock(username="tester"),
254+
thumbnail="thumbnail",
255+
url_slug="test",
256+
)
257+
258+
@pytest.fixture
259+
def mock_post_detail():
260+
"""테스트용 게시글 본문 응답 (get_post 용)"""
261+
return MagicMock(body="test content")
262+
263+
@pytest.fixture
264+
def mock_context(mock_post, mock_post_detail):
265+
"""VelogClient 및 날짜 mock을 포함한 컨텍스트"""
266+
mock_velog_client = AsyncMock()
267+
mock_velog_client.get_trending_posts.return_value = [mock_post]
268+
mock_velog_client.get_post.return_value = mock_post_detail
269+
270+
mock_context = MagicMock()
271+
mock_context.velog_client = mock_velog_client
272+
mock_context.week_start.date.return_value = "2025-07-21"
273+
mock_context.week_end.date.return_value = "2025-07-27"
274+
mock_context.week_end = datetime(2025, 7, 27)
275+
return mock_context
276+
277+
@pytest.fixture
278+
def trending_post_data(mock_post, mock_post_detail):
279+
from insight.tasks.weekly_trend_analysis import TrendingPostData
280+
return TrendingPostData(post=mock_post, body=mock_post_detail.body)
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import pytest
2+
3+
4+
@pytest.fixture
5+
def analyzer():
6+
from insight.tasks.weekly_trend_analysis import WeeklyTrendAnalyzer
7+
return WeeklyTrendAnalyzer(trending_limit=1)
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
from unittest.mock import MagicMock, patch
2+
3+
import pytest
4+
from insight.models import WeeklyTrendInsight, TrendAnalysis
5+
6+
7+
@pytest.mark.asyncio
8+
@pytest.mark.usefixtures("mock_setup_django")
9+
class TestWeeklyTrendAnalyze:
10+
@patch("insight.tasks.weekly_trend_analysis.analyze_trending_posts")
11+
async def test_analyze_data_success(
12+
self, mock_llm, analyzer, trending_post_data, sample_weekly_trend_insight
13+
):
14+
"""LLM 분석 성공 테스트"""
15+
mock_llm.return_value = sample_weekly_trend_insight.to_json_dict()
16+
17+
context = MagicMock()
18+
with patch.object(analyzer, "logger") as mock_logger:
19+
result = await analyzer._analyze_data(
20+
[trending_post_data], context
21+
)
22+
23+
assert len(result) == 1
24+
insight = result[0]
25+
assert insight.trend_analysis.hot_keywords == ["Python", "Django", "React"]
26+
assert insight.trending_summary[0].summary == "Django 백엔드와 React 프론트엔드를 연결하는 방법"
27+
mock_logger.info.assert_called()
28+
29+
@patch("insight.tasks.weekly_trend_analysis.analyze_trending_posts")
30+
async def test_analyze_data_with_summary_length_mismatch_fallback(
31+
self, mock_llm, analyzer, trending_post_data, sample_trending_items
32+
):
33+
"""LLM이 반환한 요약 개수가 원본보다 적을 때, 누락된 항목이 fallback 처리되는지 테스트"""
34+
mock_llm.return_value = WeeklyTrendInsight(
35+
trending_summary=[sample_trending_items[0]],
36+
trend_analysis=TrendAnalysis(hot_keywords=[], title_trends="", content_trends="", insights=""),
37+
).to_json_dict()
38+
39+
mock_context = MagicMock()
40+
with patch.object(analyzer, "logger") as mock_logger:
41+
result = await analyzer._analyze_data(
42+
[trending_post_data, trending_post_data], mock_context
43+
)
44+
mock_logger.info.assert_called()
45+
46+
assert len(result[0].trending_summary) == 2
47+
48+
@patch("insight.tasks.weekly_trend_analysis.analyze_trending_posts")
49+
async def test_analyze_data_failure(
50+
self, mock_llm, analyzer, trending_post_data
51+
):
52+
"""LLM 분석 중 예외 발생 시, 예외가 로깅되고 다시 전파되는지 테스트"""
53+
mock_llm.side_effect = Exception("LLM Error")
54+
55+
with patch.object(analyzer, "logger") as mock_logger:
56+
with pytest.raises(Exception):
57+
await analyzer._analyze_data([trending_post_data], MagicMock())
58+
mock_logger.error.assert_called()
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
from unittest.mock import patch
2+
3+
import pytest
4+
5+
6+
@pytest.mark.asyncio
7+
@pytest.mark.usefixtures("mock_setup_django")
8+
class TestWeeklyTrendFetch:
9+
async def test_fetch_data_when_fail_get_post_detail(
10+
self, analyzer, mock_context
11+
):
12+
"""게시글 본문 조회 실패 시, body 없이 기본 데이터로 대체되는지 테스트"""
13+
mock_context.velog_client.get_post.side_effect = Exception(
14+
"fetch error"
15+
)
16+
17+
with patch.object(analyzer, "logger") as mock_logger:
18+
result = await analyzer._fetch_data(mock_context)
19+
20+
assert len(result) == 1
21+
assert result[0].body == ""
22+
mock_logger.warning.assert_called()
23+
24+
async def test_fetch_data_failure_with_empty_body(
25+
self, analyzer, mock_context
26+
):
27+
"""게시글 본문이 비었을 경우, warning 로그 출력 확인 테스트"""
28+
mock_context.velog_client.get_post.return_value.body = ""
29+
30+
with patch.object(analyzer, "logger") as mock_logger:
31+
result = await analyzer._fetch_data(mock_context)
32+
33+
assert result[0].body == ""
34+
mock_logger.warning.assert_called_with(
35+
"Post %s has empty body", "abc123"
36+
)
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
from datetime import date, datetime
2+
from unittest.mock import MagicMock, patch
3+
4+
import pytest
5+
6+
7+
@pytest.mark.asyncio
8+
@pytest.mark.usefixtures("mock_setup_django")
9+
class TestWeeklyTrendSave:
10+
@patch("insight.tasks.weekly_trend_analysis.WeeklyTrend.objects.create")
11+
async def test_save_results_success(
12+
self, mock_create, analyzer, mock_context
13+
):
14+
"""분석 결과 저장 성공 테스트"""
15+
trending_item = MagicMock()
16+
trending_item.to_dict.return_value = {"title": "test"}
17+
18+
trend_analysis = MagicMock()
19+
trend_analysis.to_dict.return_value = {"insights": "Good"}
20+
21+
result = MagicMock(
22+
trending_summary=[trending_item], trend_analysis=trend_analysis
23+
)
24+
25+
with patch.object(analyzer, "logger") as mock_logger:
26+
await analyzer._save_results([result], mock_context)
27+
28+
mock_create.assert_called_once_with(
29+
week_start_date="2025-07-21",
30+
week_end_date=date(2025, 7, 27),
31+
insight={
32+
"trending_summary": [{"title": "test"}],
33+
"trend_analysis": {"insights": "Good"},
34+
},
35+
is_processed=False,
36+
processed_at=datetime(2025, 7, 27),
37+
)
38+
mock_logger.info.assert_called()
39+
40+
@patch(
41+
"insight.tasks.weekly_trend_analysis.WeeklyTrend.objects.create",
42+
side_effect=Exception("DB error"),
43+
)
44+
async def test_save_results_failure(
45+
self, mock_create, analyzer, mock_context
46+
):
47+
"""DB 저장 중 예외 발생 시, 로그 출력 및 예외 전파되는지 테스트"""
48+
result = MagicMock(
49+
trending_summary=[MagicMock(to_dict=lambda: {"title": "test"})],
50+
trend_analysis=MagicMock(to_dict=lambda: {"insights": "fail"}),
51+
)
52+
53+
with patch.object(analyzer, "logger") as mock_logger:
54+
with pytest.raises(Exception):
55+
await analyzer._save_results([result], mock_context)
56+
mock_logger.error.assert_called()
57+
58+
async def test_save_results_when_results_empty(
59+
self, analyzer, mock_context
60+
):
61+
"""분석 결과가 없을 경우, DB 저장 로직이 호출되지 않는지 테스트"""
62+
with patch(
63+
"insight.tasks.weekly_trend_analysis.WeeklyTrend.objects.create"
64+
) as mock_create:
65+
await analyzer._save_results([], mock_context)
66+
mock_create.assert_not_called()
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import pytest
2+
3+
4+
@pytest.fixture
5+
def analyzer_user():
6+
from insight.tasks.weekly_user_trend_analysis import UserWeeklyAnalyzer
7+
return UserWeeklyAnalyzer()
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
from unittest.mock import MagicMock, patch
2+
3+
import pytest
4+
5+
from insight.models import WeeklyUserStats
6+
7+
8+
@pytest.mark.asyncio
9+
@pytest.mark.usefixtures("mock_setup_django")
10+
class TestWeeklyUserTrendAnalyze:
11+
@patch("insight.tasks.weekly_user_trend_analysis.Post.objects")
12+
@patch("insight.tasks.weekly_user_trend_analysis.PostDailyStatistics.objects")
13+
async def test_calculate_user_weekly_total_stats_success(
14+
self, mock_stats, mock_posts, analyzer_user, mock_context
15+
):
16+
"""사용자 주간 전체 통계 계산 성공 테스트"""
17+
mock_posts.filter.return_value.values_list.return_value = [1, 2]
18+
mock_posts.filter.return_value.count.return_value = 1
19+
mock_stats.filter.return_value.values.return_value = [
20+
{
21+
"post_id": 1,
22+
"date": mock_context.week_start,
23+
"daily_view_count": 10,
24+
"daily_like_count": 5,
25+
},
26+
{
27+
"post_id": 1,
28+
"date": mock_context.week_end,
29+
"daily_view_count": 15,
30+
"daily_like_count": 10,
31+
},
32+
]
33+
34+
stats = await analyzer_user._calculate_user_weekly_total_stats(
35+
1, mock_context
36+
)
37+
assert isinstance(stats, WeeklyUserStats)
38+
assert stats.posts == 1
39+
assert stats.views == 5
40+
assert stats.likes == 5
41+
assert stats.new_posts == 1
42+
43+
@patch("insight.tasks.weekly_user_trend_analysis.Post.objects")
44+
@patch("insight.tasks.weekly_user_trend_analysis.PostDailyStatistics.objects")
45+
async def test_calculate_user_weekly_total_stats_missing_stats(
46+
self, mock_stats, mock_posts, analyzer_user, mock_context
47+
):
48+
"""통계가 누락된 경우, 조회수와 좋아요 수가 0으로 처리되는지 테스트"""
49+
mock_posts.filter.return_value.values_list.return_value = [1]
50+
mock_posts.filter.return_value.count.return_value = 1
51+
mock_stats.filter.return_value.values.return_value = []
52+
53+
stats = await analyzer_user._calculate_user_weekly_total_stats(
54+
1, mock_context
55+
)
56+
assert stats.views == 0
57+
assert stats.likes == 0
58+
59+
@patch("insight.tasks.weekly_user_trend_analysis.Post.objects")
60+
@patch("insight.tasks.weekly_user_trend_analysis.PostDailyStatistics.objects")
61+
async def test_calculate_user_weekly_total_stats_ignores_negative_diff(
62+
self, mock_stats, mock_posts, analyzer_user, mock_context
63+
):
64+
"""조회수나 좋아요 수가 감소한 경우, 0으로 처리하여 음수 결과를 방지하는지 테스트"""
65+
mock_posts.filter.return_value.values_list.return_value = [1]
66+
mock_posts.filter.return_value.count.return_value = 1
67+
mock_stats.filter.return_value.values.return_value = [
68+
{
69+
"post_id": 1,
70+
"date": mock_context.week_start,
71+
"daily_view_count": 200,
72+
"daily_like_count": 100,
73+
},
74+
{
75+
"post_id": 1,
76+
"date": mock_context.week_end,
77+
"daily_view_count": 180,
78+
"daily_like_count": 90,
79+
},
80+
]
81+
82+
stats = await analyzer_user._calculate_user_weekly_total_stats(
83+
1, mock_context
84+
)
85+
assert stats.views == 0
86+
assert stats.likes == 0
87+
88+
@patch("insight.tasks.weekly_user_trend_analysis.analyze_user_posts")
89+
async def test_analyze_user_posts_success(
90+
self, mock_analyze, analyzer_user, sample_trend_analysis, sample_trending_items
91+
):
92+
"""사용자 게시글 분석 성공 테스트"""
93+
mock_post = MagicMock(
94+
title="test", thumbnail="", url_slug="slug", body="내용"
95+
)
96+
mock_analyze.return_value = {
97+
"trending_summary": [sample_trending_items[0].to_dict()],
98+
"trend_analysis": sample_trend_analysis.to_dict(),
99+
}
100+
101+
trending_items, trend_analysis = await analyzer_user._analyze_user_posts_with_llm(
102+
[mock_post], "user"
103+
)
104+
105+
assert len(trending_items) == 1
106+
assert trend_analysis is not None
107+
assert trend_analysis.hot_keywords == sample_trend_analysis.hot_keywords
108+
109+
@patch(
110+
"insight.tasks.weekly_user_trend_analysis.analyze_user_posts",
111+
side_effect=Exception("LLM 실패"),
112+
)
113+
async def test_analyze_user_posts_failure_returns_fallback(
114+
self, mock_llm, analyzer_user
115+
):
116+
"""LLM 분석 실패 시, [분석 실패] 요약과 None 분석 결과를 반환하는지 테스트"""
117+
mock_post = MagicMock(
118+
title="post1", thumbnail="", url_slug="slug", body="내용"
119+
)
120+
items, trend = await analyzer_user._analyze_user_posts_with_llm(
121+
[mock_post], "tester"
122+
)
123+
124+
assert len(items) == 1
125+
assert items[0].summary == "[분석 실패]"
126+
assert trend is None
127+
128+
@patch("insight.tasks.weekly_user_trend_analysis.UserWeeklyAnalyzer._create_user_reminder")
129+
async def test_analyze_user_data_without_new_posts_creates_reminder(
130+
self, mock_reminder, analyzer_user, mock_context
131+
):
132+
"""신규 게시글이 없는 사용자의 경우, 리마인더 생성 로직이 동작하는지 테스트"""
133+
user_data = MagicMock()
134+
user_data.user_id = 1
135+
user_data.username = "tester"
136+
user_data.weekly_new_posts = []
137+
user_data.weekly_total_stats = WeeklyUserStats(
138+
posts=0, new_posts=0, views=0, likes=0
139+
)
140+
141+
mock_reminder.return_value = MagicMock(title="최근 글", days_ago=5)
142+
143+
insight = await analyzer_user._analyze_user_data(
144+
user_data, mock_context
145+
)
146+
assert insight.user_weekly_reminder.title == "최근 글"
147+
mock_reminder.assert_called_once()

0 commit comments

Comments
 (0)