Skip to content

Commit cfdf8fc

Browse files
authored
(#53) Repository 통합 테스트 추가 (Testcontainers)
* feature: Repository 통합 테스트 추가 (Testcontainers) Testcontainers MySQL 8.0 기반의 Repository 통합 테스트 추가 - UserRepositoryIT: bulkUpdateRanking Native SQL 검증, 동점자 처리, 조회/페이징/티어 필터링 (8건) - ActivityLogRepositoryIT: 최신 로그 조회, 기준일 이전 조회, 사용자+날짜 조회, 전체 삭제 (6건) 테스트 인프라 구성: - build.gradle에 Testcontainers 의존성 추가 - test 태스크에서 *IT.class 제외 (Docker 불필요) - integrationTest 태스크 별도 등록 (Docker 필요) - CI에 integrationTest 단계 추가 * fix: Testcontainers 종료 시 불필요한 DDL 제거 및 빌드 태스크 개선 - ddl-auto를 create-drop에서 create로 변경하여 컨테이너 종료 후 테이블 DROP 시도 방지 (60초 타임아웃 + WARN/ERROR 로그 제거) - integrationTest가 check 라이프사이클에서 의도적으로 제외된 이유 주석 명시 - mustRunAfter로 test → integrationTest 실행 순서 보장
1 parent 87251d8 commit cfdf8fc

4 files changed

Lines changed: 374 additions & 4 deletions

File tree

.github/workflows/ci.yml

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,21 +33,32 @@ jobs:
3333
- name: Build with Gradle
3434
run: ./gradlew build -x test
3535

36-
- name: Run tests
36+
- name: Run unit tests
3737
run: ./gradlew test
3838

39-
- name: Upload test results
39+
- name: Run integration tests
40+
run: ./gradlew integrationTest
41+
42+
- name: Upload unit test results
4043
uses: actions/upload-artifact@v4
4144
if: always()
4245
with:
43-
name: test-results
46+
name: unit-test-results
4447
path: build/reports/tests/test/
4548
retention-days: 7
4649

50+
- name: Upload integration test results
51+
uses: actions/upload-artifact@v4
52+
if: always()
53+
with:
54+
name: integration-test-results
55+
path: build/reports/tests/integrationTest/
56+
retention-days: 7
57+
4758
- name: Upload build artifacts
4859
uses: actions/upload-artifact@v4
4960
if: success()
5061
with:
5162
name: jar-artifact
5263
path: build/libs/*.jar
53-
retention-days: 7
64+
retention-days: 7

build.gradle

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,28 @@ dependencies {
4646
testImplementation 'org.springframework.security:spring-security-test'
4747
testImplementation 'org.springframework.batch:spring-batch-test'
4848
testImplementation 'org.springframework.boot:spring-boot-starter-test'
49+
testImplementation 'org.springframework.boot:spring-boot-testcontainers'
50+
testImplementation 'org.testcontainers:junit-jupiter'
51+
testImplementation 'org.testcontainers:mysql'
4952

5053
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
5154
}
5255

56+
// 단위 테스트: *IT.java 제외 (Docker 불필요)
5357
tasks.named('test') {
5458
useJUnitPlatform()
59+
exclude '**/*IT.class'
60+
}
61+
62+
// 통합 테스트: *IT.java만 실행 (Docker/Testcontainers 필요)
63+
// check 라이프사이클에 포함하지 않음 — Docker 없는 환경에서 build가 실패하지 않도록 의도적 제외
64+
// CI에서는 별도 단계로 명시적 실행: ./gradlew integrationTest
65+
tasks.register('integrationTest', Test) {
66+
useJUnitPlatform()
67+
include '**/*IT.class'
68+
69+
mustRunAfter tasks.named('test')
70+
71+
testClassesDirs = sourceSets.test.output.classesDirs
72+
classpath = sourceSets.test.runtimeClasspath
5573
}
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
package com.gitranker.api.domain.log;
2+
3+
import com.gitranker.api.domain.user.Role;
4+
import com.gitranker.api.domain.user.User;
5+
import com.gitranker.api.domain.user.UserRepository;
6+
import com.gitranker.api.domain.user.vo.ActivityStatistics;
7+
import org.junit.jupiter.api.BeforeEach;
8+
import org.junit.jupiter.api.DisplayName;
9+
import org.junit.jupiter.api.Test;
10+
import org.springframework.beans.factory.annotation.Autowired;
11+
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
12+
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
13+
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
14+
import org.springframework.test.context.TestPropertySource;
15+
import org.testcontainers.containers.MySQLContainer;
16+
import org.testcontainers.junit.jupiter.Container;
17+
import org.testcontainers.junit.jupiter.Testcontainers;
18+
19+
import java.time.LocalDate;
20+
import java.time.LocalDateTime;
21+
import java.util.Optional;
22+
23+
import static org.assertj.core.api.Assertions.assertThat;
24+
25+
@DataJpaTest
26+
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
27+
@Testcontainers
28+
@TestPropertySource(properties = {
29+
"spring.jpa.hibernate.ddl-auto=create",
30+
"spring.batch.jdbc.initialize-schema=never"
31+
})
32+
class ActivityLogRepositoryIT {
33+
34+
@Container
35+
@ServiceConnection
36+
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
37+
.withDatabaseName("gitranker_test");
38+
39+
@Autowired
40+
private ActivityLogRepository activityLogRepository;
41+
42+
@Autowired
43+
private UserRepository userRepository;
44+
45+
private User savedUser;
46+
47+
@BeforeEach
48+
void setUp() {
49+
activityLogRepository.deleteAll();
50+
userRepository.deleteAll();
51+
52+
savedUser = userRepository.save(User.builder()
53+
.githubId(1L)
54+
.nodeId("node1")
55+
.username("testuser")
56+
.githubCreatedAt(LocalDateTime.of(2020, 1, 1, 0, 0))
57+
.role(Role.USER)
58+
.build());
59+
}
60+
61+
@Test
62+
@DisplayName("여러 로그 중 가장 최근 날짜의 로그를 조회한다")
63+
void should_findLatestLog_when_multipleLogsExist() {
64+
ActivityStatistics stats = ActivityStatistics.of(10, 2, 1, 0, 3);
65+
ActivityStatistics diff = ActivityStatistics.of(5, 1, 0, 0, 1);
66+
67+
activityLogRepository.save(ActivityLog.of(savedUser, stats, diff, LocalDate.of(2025, 1, 1)));
68+
activityLogRepository.save(ActivityLog.of(savedUser, stats, diff, LocalDate.of(2025, 1, 2)));
69+
ActivityLog latestLog = activityLogRepository.save(
70+
ActivityLog.of(savedUser, stats, diff, LocalDate.of(2025, 1, 3)));
71+
72+
ActivityLog found = activityLogRepository.getTopByUserOrderByActivityDateDesc(savedUser);
73+
74+
assertThat(found).isNotNull();
75+
assertThat(found.getActivityDate()).isEqualTo(LocalDate.of(2025, 1, 3));
76+
}
77+
78+
@Test
79+
@DisplayName("로그가 없으면 null을 반환한다")
80+
void should_returnNull_when_noLogExists() {
81+
ActivityLog found = activityLogRepository.getTopByUserOrderByActivityDateDesc(savedUser);
82+
83+
assertThat(found).isNull();
84+
}
85+
86+
@Test
87+
@DisplayName("특정 날짜 이전의 가장 최근 로그를 조회한다")
88+
void should_findBaselineLog_when_logBeforeDateExists() {
89+
ActivityStatistics stats = ActivityStatistics.of(10, 2, 1, 0, 3);
90+
ActivityStatistics diff = ActivityStatistics.of(5, 1, 0, 0, 1);
91+
92+
activityLogRepository.save(ActivityLog.of(savedUser, stats, diff, LocalDate.of(2025, 1, 1)));
93+
activityLogRepository.save(ActivityLog.of(savedUser, stats, diff, LocalDate.of(2025, 1, 10)));
94+
activityLogRepository.save(ActivityLog.of(savedUser, stats, diff, LocalDate.of(2025, 1, 20)));
95+
96+
// 1월 15일 이전의 가장 최근 로그 → 1월 10일
97+
Optional<ActivityLog> found = activityLogRepository
98+
.findTopByUserAndActivityDateLessThanOrderByActivityDateDesc(
99+
savedUser, LocalDate.of(2025, 1, 15));
100+
101+
assertThat(found).isPresent();
102+
assertThat(found.get().getActivityDate()).isEqualTo(LocalDate.of(2025, 1, 10));
103+
}
104+
105+
@Test
106+
@DisplayName("기준 날짜 이전에 로그가 없으면 빈 Optional을 반환한다")
107+
void should_returnEmpty_when_noLogBeforeDateExists() {
108+
ActivityStatistics stats = ActivityStatistics.of(10, 2, 1, 0, 3);
109+
ActivityStatistics diff = ActivityStatistics.of(5, 1, 0, 0, 1);
110+
111+
activityLogRepository.save(ActivityLog.of(savedUser, stats, diff, LocalDate.of(2025, 6, 1)));
112+
113+
// 2025년 1월 이전 → 없음
114+
Optional<ActivityLog> found = activityLogRepository
115+
.findTopByUserAndActivityDateLessThanOrderByActivityDateDesc(
116+
savedUser, LocalDate.of(2025, 1, 1));
117+
118+
assertThat(found).isEmpty();
119+
}
120+
121+
@Test
122+
@DisplayName("사용자와 날짜로 정확히 일치하는 로그를 조회한다")
123+
void should_findLog_when_userAndDateMatch() {
124+
ActivityStatistics stats = ActivityStatistics.of(50, 10, 5, 3, 8);
125+
ActivityStatistics diff = ActivityStatistics.of(5, 1, 1, 0, 2);
126+
127+
activityLogRepository.save(ActivityLog.of(savedUser, stats, diff, LocalDate.of(2025, 3, 15)));
128+
129+
Optional<ActivityLog> found = activityLogRepository.findByUserAndActivityDate(
130+
savedUser, LocalDate.of(2025, 3, 15));
131+
132+
assertThat(found).isPresent();
133+
assertThat(found.get().getCommitCount()).isEqualTo(50);
134+
assertThat(found.get().getIssueCount()).isEqualTo(10);
135+
}
136+
137+
@Test
138+
@DisplayName("사용자의 모든 로그를 삭제한다")
139+
void should_deleteAllLogs_when_deleteAllByUserCalled() {
140+
ActivityStatistics stats = ActivityStatistics.of(10, 2, 1, 0, 3);
141+
ActivityStatistics diff = ActivityStatistics.of(5, 1, 0, 0, 1);
142+
143+
activityLogRepository.save(ActivityLog.of(savedUser, stats, diff, LocalDate.of(2025, 1, 1)));
144+
activityLogRepository.save(ActivityLog.of(savedUser, stats, diff, LocalDate.of(2025, 1, 2)));
145+
146+
activityLogRepository.deleteAllByUser(savedUser);
147+
148+
assertThat(activityLogRepository.getTopByUserOrderByActivityDateDesc(savedUser)).isNull();
149+
}
150+
}

0 commit comments

Comments
 (0)