Skip to content

Commit fbc672c

Browse files
authored
(#89) 핵심 피드백 루프 unit 테스트 보강
* test: 핵심 피드백 루프 unit 테스트 보강 - 기존 domain/service 테스트의 예외 분기와 경계값 검증 보강 - batch 피드백 루프 unit 테스트 신규 추가 - GitHub infra 및 전체 테스트 스위트 검증 추가 * test: UTC 환경에서도 GitHubTokenPoolTest가 안정적으로 통과하도록 수정 - Asia/Seoul 고정 zone 상수를 테스트에 재사용 - threshold 회전 케이스를 현재 시각 대신 고정된 미래 시각으로 검증 - UTC 환경으로 clean test를 재현해 CI 실패 원인 제거 * test: PR 리뷰 피드백 기반 테스트 검증 강화
1 parent fc85948 commit fbc672c

44 files changed

Lines changed: 2820 additions & 0 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package com.gitranker.api.batch.listener;
2+
3+
import com.gitranker.api.domain.user.UserRepository;
4+
import org.junit.jupiter.api.DisplayName;
5+
import org.junit.jupiter.api.Test;
6+
import org.junit.jupiter.api.extension.ExtendWith;
7+
import org.mockito.InjectMocks;
8+
import org.mockito.Mock;
9+
import org.mockito.junit.jupiter.MockitoExtension;
10+
import org.springframework.batch.core.JobExecution;
11+
import org.springframework.batch.core.JobInstance;
12+
import org.springframework.batch.core.JobParameters;
13+
import org.springframework.batch.core.StepExecution;
14+
import org.springframework.batch.core.scope.context.ChunkContext;
15+
import org.springframework.batch.core.scope.context.StepContext;
16+
import org.springframework.test.util.ReflectionTestUtils;
17+
18+
import static org.assertj.core.api.Assertions.assertThat;
19+
import static org.mockito.Mockito.times;
20+
import static org.mockito.Mockito.verify;
21+
import static org.mockito.Mockito.when;
22+
23+
@ExtendWith(MockitoExtension.class)
24+
class BatchProgressListenerTest {
25+
26+
@InjectMocks
27+
private BatchProgressListener listener;
28+
29+
@Mock
30+
private UserRepository userRepository;
31+
32+
@Test
33+
@DisplayName("beforeChunk initializes total count once from repository")
34+
void initializesTotalCountOnlyOnce() {
35+
when(userRepository.count()).thenReturn(42L, 100L);
36+
37+
listener.beforeChunk(chunkContext("DailyScoreRecalculationJob", 0));
38+
listener.beforeChunk(chunkContext("DailyScoreRecalculationJob", 0));
39+
40+
verify(userRepository, times(1)).count();
41+
assertThat(ReflectionTestUtils.getField(listener, "totalCount")).isEqualTo(42);
42+
assertThat(ReflectionTestUtils.getField(listener, "lastLoggedPercentage")).isEqualTo(0);
43+
}
44+
45+
@Test
46+
@DisplayName("afterChunk does nothing when total count is zero")
47+
void ignoresAfterChunkWhenNothingInitialized() {
48+
listener.afterChunk(chunkContext("DailyScoreRecalculationJob", 25));
49+
50+
assertThat(ReflectionTestUtils.getField(listener, "totalCount")).isEqualTo(0);
51+
assertThat(ReflectionTestUtils.getField(listener, "lastLoggedPercentage")).isEqualTo(0);
52+
}
53+
54+
@Test
55+
@DisplayName("afterChunk rounds progress down to the nearest ten percent interval")
56+
void updatesProgressAtInterval() {
57+
ReflectionTestUtils.setField(listener, "totalCount", 100);
58+
ReflectionTestUtils.setField(listener, "lastLoggedPercentage", 0);
59+
60+
listener.afterChunk(chunkContext("DailyScoreRecalculationJob", 27));
61+
assertThat(ReflectionTestUtils.getField(listener, "lastLoggedPercentage")).isEqualTo(20);
62+
63+
listener.afterChunk(chunkContext("DailyScoreRecalculationJob", 29));
64+
assertThat(ReflectionTestUtils.getField(listener, "lastLoggedPercentage")).isEqualTo(20);
65+
66+
listener.afterChunk(chunkContext("DailyScoreRecalculationJob", 31));
67+
assertThat(ReflectionTestUtils.getField(listener, "lastLoggedPercentage")).isEqualTo(30);
68+
}
69+
70+
@Test
71+
@DisplayName("reset clears cached progress state")
72+
void resetsCachedProgressState() {
73+
ReflectionTestUtils.setField(listener, "totalCount", 100);
74+
ReflectionTestUtils.setField(listener, "lastLoggedPercentage", 40);
75+
76+
listener.reset();
77+
78+
assertThat(ReflectionTestUtils.getField(listener, "totalCount")).isEqualTo(0);
79+
assertThat(ReflectionTestUtils.getField(listener, "lastLoggedPercentage")).isEqualTo(0);
80+
}
81+
82+
private ChunkContext chunkContext(String jobName, long writeCount) {
83+
JobExecution jobExecution = new JobExecution(new JobInstance(1L, jobName), new JobParameters());
84+
StepExecution stepExecution = jobExecution.createStepExecution("scoreRecalculationStep");
85+
stepExecution.setWriteCount(writeCount);
86+
return new ChunkContext(new StepContext(stepExecution));
87+
}
88+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package com.gitranker.api.batch.listener;
2+
3+
import com.gitranker.api.batch.metrics.BatchMetrics;
4+
import com.gitranker.api.domain.user.UserRepository;
5+
import org.junit.jupiter.api.DisplayName;
6+
import org.junit.jupiter.api.Test;
7+
import org.junit.jupiter.api.extension.ExtendWith;
8+
import org.mockito.InjectMocks;
9+
import org.mockito.Mock;
10+
import org.mockito.junit.jupiter.MockitoExtension;
11+
import org.springframework.batch.core.BatchStatus;
12+
import org.springframework.batch.core.JobExecution;
13+
import org.springframework.batch.core.JobInstance;
14+
import org.springframework.batch.core.JobParameters;
15+
import org.springframework.batch.core.StepExecution;
16+
17+
import java.time.LocalDateTime;
18+
19+
import static org.mockito.Mockito.never;
20+
import static org.mockito.Mockito.verify;
21+
import static org.mockito.Mockito.when;
22+
23+
@ExtendWith(MockitoExtension.class)
24+
class GitHubCostListenerTest {
25+
26+
@InjectMocks
27+
private GitHubCostListener listener;
28+
29+
@Mock
30+
private UserRepository userRepository;
31+
32+
@Mock
33+
private BatchProgressListener progressListener;
34+
35+
@Mock
36+
private BatchMetrics batchMetrics;
37+
38+
@Test
39+
@DisplayName("beforeJob resets progress listener and counts users")
40+
void resetsProgressAndCountsUsersBeforeJob() {
41+
JobExecution jobExecution = new JobExecution(new JobInstance(1L, "DailyScoreRecalculationJob"), new JobParameters());
42+
when(userRepository.count()).thenReturn(150L);
43+
44+
listener.beforeJob(jobExecution);
45+
46+
verify(progressListener).reset();
47+
verify(userRepository).count();
48+
}
49+
50+
@Test
51+
@DisplayName("afterJob records completed metrics from score recalculation step")
52+
void recordsSuccessMetrics() {
53+
JobExecution jobExecution = new JobExecution(new JobInstance(1L, "DailyScoreRecalculationJob"), new JobParameters());
54+
StepExecution stepExecution = jobExecution.createStepExecution("scoreRecalculationStep");
55+
stepExecution.setReadCount(100);
56+
stepExecution.setWriteCount(92);
57+
stepExecution.setProcessSkipCount(3);
58+
stepExecution.setWriteSkipCount(2);
59+
stepExecution.setFilterCount(5);
60+
jobExecution.setStatus(BatchStatus.COMPLETED);
61+
jobExecution.setStartTime(LocalDateTime.of(2026, 4, 16, 10, 0, 0));
62+
jobExecution.setEndTime(LocalDateTime.of(2026, 4, 16, 10, 0, 2));
63+
64+
listener.afterJob(jobExecution);
65+
66+
verify(batchMetrics).recordJobCompleted(2000L);
67+
verify(batchMetrics, never()).recordJobFailed(org.mockito.ArgumentMatchers.anyLong());
68+
verify(batchMetrics).recordItemsProcessed(92);
69+
verify(batchMetrics).recordItemsSkipped(5);
70+
}
71+
72+
@Test
73+
@DisplayName("afterJob records failed metrics and ignores non-score steps")
74+
void recordsFailureMetricsAndIgnoresOtherSteps() {
75+
JobExecution jobExecution = new JobExecution(new JobInstance(1L, "DailyScoreRecalculationJob"), new JobParameters());
76+
StepExecution stepExecution = jobExecution.createStepExecution("cleanupStep");
77+
stepExecution.setReadCount(20);
78+
stepExecution.setWriteCount(18);
79+
stepExecution.setFilterCount(1);
80+
jobExecution.setStatus(BatchStatus.FAILED);
81+
82+
listener.afterJob(jobExecution);
83+
84+
verify(batchMetrics).recordJobFailed(0L);
85+
verify(batchMetrics, never()).recordJobCompleted(org.mockito.ArgumentMatchers.anyLong());
86+
verify(batchMetrics).recordItemsProcessed(0);
87+
verify(batchMetrics).recordItemsSkipped(0);
88+
}
89+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package com.gitranker.api.batch.listener;
2+
3+
import com.gitranker.api.domain.failure.BatchFailureLogService;
4+
import com.gitranker.api.domain.user.User;
5+
import com.gitranker.api.global.error.ErrorType;
6+
import com.gitranker.api.global.error.exception.GitHubApiNonRetryableException;
7+
import com.gitranker.api.global.error.exception.GitHubApiRetryableException;
8+
import com.gitranker.api.support.TestFixtures;
9+
import org.junit.jupiter.api.DisplayName;
10+
import org.junit.jupiter.api.Test;
11+
import org.junit.jupiter.api.extension.ExtendWith;
12+
import org.mockito.InjectMocks;
13+
import org.mockito.Mock;
14+
import org.mockito.junit.jupiter.MockitoExtension;
15+
16+
import static org.mockito.Mockito.verify;
17+
18+
@ExtendWith(MockitoExtension.class)
19+
class UserScoreCalculationSkipListenerTest {
20+
21+
private static final String JOB_NAME_DAILY_SCORE = "DailyScoreRecalculationJob";
22+
private static final String UNKNOWN_USER_ID = "UNKNOWN_USER";
23+
24+
@InjectMocks
25+
private UserScoreCalculationSkipListener listener;
26+
27+
@Mock
28+
private BatchFailureLogService batchFailureLogService;
29+
30+
@Test
31+
@DisplayName("read skip stores retryable error metadata for unknown user")
32+
void logsRetryableReadSkip() {
33+
listener.onSkipInRead(new GitHubApiRetryableException(ErrorType.GITHUB_API_TIMEOUT, "timeout"));
34+
35+
verify(batchFailureLogService).saveFailureLog(
36+
JOB_NAME_DAILY_SCORE,
37+
UNKNOWN_USER_ID,
38+
ErrorType.GITHUB_API_TIMEOUT,
39+
"[READ_PHASE] error.github.api-timeout: timeout"
40+
);
41+
}
42+
43+
@Test
44+
@DisplayName("process skip stores default error type for generic failures")
45+
void logsGenericProcessSkip() {
46+
User user = TestFixtures.user("alice");
47+
48+
listener.onSkipInProcess(user, new IllegalStateException("boom"));
49+
50+
verify(batchFailureLogService).saveFailureLog(
51+
JOB_NAME_DAILY_SCORE,
52+
"alice",
53+
ErrorType.DEFAULT_ERROR,
54+
"[PROCESS_PHASE] boom"
55+
);
56+
}
57+
58+
@Test
59+
@DisplayName("write skip stores non-retryable GitHub error for the target user")
60+
void logsNonRetryableWriteSkip() {
61+
User user = TestFixtures.user("alice");
62+
63+
listener.onSkipInWrite(user, new GitHubApiNonRetryableException(ErrorType.GITHUB_USER_NOT_FOUND));
64+
65+
verify(batchFailureLogService).saveFailureLog(
66+
JOB_NAME_DAILY_SCORE,
67+
"alice",
68+
ErrorType.GITHUB_USER_NOT_FOUND,
69+
"[WRITE_PHASE] error.github.user-not-found"
70+
);
71+
}
72+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package com.gitranker.api.batch.metrics;
2+
3+
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
4+
import org.junit.jupiter.api.DisplayName;
5+
import org.junit.jupiter.api.Test;
6+
7+
import static org.assertj.core.api.Assertions.assertThat;
8+
9+
class BatchMetricsTest {
10+
11+
@Test
12+
@DisplayName("successful job recording increments success counter and timer")
13+
void recordsCompletedJob() {
14+
SimpleMeterRegistry registry = new SimpleMeterRegistry();
15+
BatchMetrics metrics = new BatchMetrics(registry);
16+
17+
metrics.recordJobCompleted(1250);
18+
19+
assertThat(registry.get("batch_jobs_completed_total").counter().count()).isEqualTo(1.0);
20+
assertThat(registry.get("batch_job_duration").tag("status", "success").timer().count()).isEqualTo(1L);
21+
assertThat(registry.get("batch_job_duration").tag("status", "success").timer().totalTime(java.util.concurrent.TimeUnit.MILLISECONDS))
22+
.isEqualTo(1250.0);
23+
}
24+
25+
@Test
26+
@DisplayName("failed job recording increments failure counter and timer")
27+
void recordsFailedJob() {
28+
SimpleMeterRegistry registry = new SimpleMeterRegistry();
29+
BatchMetrics metrics = new BatchMetrics(registry);
30+
31+
metrics.recordJobFailed(500);
32+
33+
assertThat(registry.get("batch_jobs_failed_total").counter().count()).isEqualTo(1.0);
34+
assertThat(registry.get("batch_job_duration").tag("status", "failure").timer().count()).isEqualTo(1L);
35+
assertThat(registry.get("batch_job_duration").tag("status", "failure").timer().totalTime(java.util.concurrent.TimeUnit.MILLISECONDS))
36+
.isEqualTo(500.0);
37+
}
38+
39+
@Test
40+
@DisplayName("item counters accumulate processed and skipped counts")
41+
void recordsProcessedAndSkippedItems() {
42+
SimpleMeterRegistry registry = new SimpleMeterRegistry();
43+
BatchMetrics metrics = new BatchMetrics(registry);
44+
45+
metrics.recordItemsProcessed(12);
46+
metrics.recordItemsProcessed(8);
47+
metrics.recordItemsSkipped(3);
48+
metrics.recordItemsSkipped(2);
49+
50+
assertThat(registry.get("batch_items_processed_total").counter().count()).isEqualTo(20.0);
51+
assertThat(registry.get("batch_items_skipped_total").counter().count()).isEqualTo(5.0);
52+
}
53+
}

0 commit comments

Comments
 (0)