From aa79539189b12a99d12dc7c815f7670eb98ebc9f Mon Sep 17 00:00:00 2001 From: jmj Date: Fri, 21 Aug 2026 10:15:43 +0900 Subject: [PATCH] =?UTF-8?q?test(backend):=20=EC=8B=A4=EC=A0=9C=20PG=20?= =?UTF-8?q?=EC=BB=A8=ED=85=8C=EC=9D=B4=EB=84=88=EB=A1=9C=20=EB=A6=AC?= =?UTF-8?q?=ED=8F=AC=EC=A7=80=ED=86=A0=EB=A6=AC=20=EC=BF=BC=EB=A6=AC?= =?UTF-8?q?=EB=A5=BC=20=EA=B2=80=EC=A6=9D=ED=95=98=EB=8A=94=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=8A=AC=EB=9D=BC=EC=9D=B4=EC=8A=A4=20?= =?UTF-8?q?=EB=8F=84=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기본 테스트 프로파일은 DataSource·Hibernate·Flyway 오토컨피그를 제외하고 모든 리포지토리를 @MockitoBean 으로 대체한다. 그래서 지금까지 코드베이스의 어떤 @Query JPQL 도 자동 검증을 받은 적이 없다 — 문법 오류는 물론이고 "삭제된 행을 안 걸렀다" 같은 의미 결함도 CI 를 그대로 통과했다(#196 이 그랬다). @PostgresRepositoryTest 를 붙이면 실제 pgvector 컨테이너에서 돈다: - 스키마는 운영과 같은 Flyway 마이그레이션 + ddl-auto=validate → 엔티티 매핑과 마이그레이션 불일치도 컨텍스트 로딩에서 잡힌다 - 컨테이너는 JVM 당 하나 (테스트가 늘어도 기동 비용은 한 번) - @DataJpaTest 라 테스트마다 롤백 첫 사용처로 #196 에서 로컬 PG 로 수동 검증했던 케이스를 그대로 옮겼다. 필터를 되돌리면 실패하는 것을 확인했다(Expected size: 1 but was: 2). findSharedByOwner 는 삭제 세션을 포함해야 한다는 반대 방향 계약도 함께 고정한다 (탈퇴 시 토큰 회수용이라 여기 필터를 넣으면 살아있는 공유 링크가 남는다). Testcontainers 의존성은 이미 build.gradle 에 선언돼 있었고 쓰이지 않았다. junit-jupiter, spring-boot-testcontainers, spring-boot-data-jpa-test 만 추가. --- backend/CLAUDE.md | 32 +++++++- backend/build.gradle | 4 + .../domain/SessionFeedbackRepositoryTest.java | 80 +++++++++++++++++++ .../support/PostgresRepositoryTest.java | 45 +++++++++++ .../support/PostgresTestContainer.java | 58 ++++++++++++++ .../test/resources/db/testcontainers-init.sql | 3 + 6 files changed, 221 insertions(+), 1 deletion(-) create mode 100644 backend/src/test/java/com/stackup/stackup/session/domain/SessionFeedbackRepositoryTest.java create mode 100644 backend/src/test/java/com/stackup/stackup/support/PostgresRepositoryTest.java create mode 100644 backend/src/test/java/com/stackup/stackup/support/PostgresTestContainer.java create mode 100644 backend/src/test/resources/db/testcontainers-init.sql diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index cfad8448..84150e3d 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -229,12 +229,42 @@ spring.jpa.hibernate.ddl-auto=validate # Flyway 사용 → validate ## 15. 테스트 -- 단위: `*Test.java` (Spring 컨텍스트 X, 빠름) +- 단위: `*Test.java` (Spring 컨텍스트 X, 빠름) — 기본값. 대부분 여기서 끝낸다. +- **리포지토리: `*RepositoryTest.java` + `@PostgresRepositoryTest`** — 실제 PG(+pgvector) 컨테이너 (아래 §15.1) - 통합: `*IT.java` 또는 `*IntegrationTest.java` + Testcontainers (PG/RabbitMQ) - 아키텍처: `*ArchTest.java` (ArchUnit) — 의존성 방향·패키지 규칙 검증 (§16) - Builder 패턴으로 fixture (`UserBuilder.aUser()`) - 자세한 전략: [`/docs/testing-strategy.md`](../docs/testing-strategy.md) +### 15.1 `@PostgresRepositoryTest` — 쿼리 검증 + +기본 테스트 프로파일(`application-test.yml`)은 DataSource·Hibernate·Flyway 오토컨피그를 +**제외**하고 리포지토리를 전부 목으로 대체한다. 빠르지만 그 대가로 **`@Query` 의 JPQL 이 +아무 검증도 받지 못한다** — 문법 오류는 물론이고 "삭제된 행을 안 걸렀다" 같은 의미 결함도 +그대로 통과한다(실제로 통계 쿼리 5개가 그랬다. `SessionFeedbackRepositoryTest` 참고). + +`@PostgresRepositoryTest` 를 붙이면 실제 PostgreSQL 컨테이너에서 돈다: + +```java +@PostgresRepositoryTest +class SessionFeedbackRepositoryTest { + @Autowired SessionFeedbackRepository feedbackRepository; + // ... +} +``` + +- 이미지는 `pgvector/pgvector:pg17` — `infra/postgres/Dockerfile` 과 같은 계열이어야 한다 + (마이그레이션이 vector 타입·인덱스를 쓴다). **운영 PG 버전을 올리면 `PostgresTestContainer` + 의 태그도 같이 올린다.** +- 스키마는 운영과 같은 **Flyway 마이그레이션**으로 만들고 `ddl-auto: validate` 를 건다 → + 엔티티 매핑과 마이그레이션이 어긋나면 컨텍스트 로딩에서 바로 터진다. 배포 후에야 알던 + 사고를 CI 로 당기는 부수 효과. +- 컨테이너는 **JVM 당 하나**만 뜬다(`PostgresTestContainer`). 테스트가 늘어도 기동 비용은 한 번. +- `@DataJpaTest` 라 각 테스트는 트랜잭션 안에서 돌고 끝나면 롤백된다. +- CI(`ubuntu-latest`)는 Docker 가 이미 있어 별도 설정이 필요 없다. 다만 첫 실행에 이미지를 + 받으므로 백엔드 잡이 그만큼 길어진다 — **쿼리 동작을 봐야 하는 테스트에만** 쓰고, + 서비스 로직은 계속 Mockito 단위 테스트로 다룬다. + ## 16. ArchUnit — 아키텍처 룰 자동 검증 도메인 우선 패키지 구조 + 레이어 의존성 방향(§3)을 **빌드 단계에서 강제**한다. 사람의 리뷰가 놓치기 쉬운 위반을 컴파일/테스트로 차단. diff --git a/backend/build.gradle b/backend/build.gradle index 7b7a9eac..039deb5d 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -79,6 +79,10 @@ dependencies { testImplementation 'org.testcontainers:postgresql' testImplementation 'org.testcontainers:rabbitmq' + testImplementation 'org.testcontainers:junit-jupiter' + testImplementation 'org.springframework.boot:spring-boot-testcontainers' + testImplementation 'org.springframework.boot:spring-boot-data-jpa-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } diff --git a/backend/src/test/java/com/stackup/stackup/session/domain/SessionFeedbackRepositoryTest.java b/backend/src/test/java/com/stackup/stackup/session/domain/SessionFeedbackRepositoryTest.java new file mode 100644 index 00000000..a1512d26 --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/session/domain/SessionFeedbackRepositoryTest.java @@ -0,0 +1,80 @@ +package com.stackup.stackup.session.domain; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.stackup.stackup.support.PostgresRepositoryTest; +import com.stackup.stackup.user.domain.User; +import com.stackup.stackup.user.domain.UserRepository; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.PageRequest; + +/** + * 통계 쿼리는 삭제된 세션을 빼야 한다. + * + *

UserStatsService 의 총/완료 카운트는 `countByUser_IdAndDeletedFalse` 로 이미 빼고 + * 있어서, 여기서 안 빼면 같은 화면 안에서 "완료 1회"인데 추이엔 점이 2개 찍힌다. + * 무엇보다 사용자가 '기록 삭제'로 기대하는 것은 통계에서도 사라지는 것이다. + */ +@PostgresRepositoryTest +class SessionFeedbackRepositoryTest { + + @Autowired UserRepository userRepository; + @Autowired InterviewSessionRepository sessionRepository; + @Autowired SessionFeedbackRepository feedbackRepository; + + @Test + void statsQueriesExcludeDeletedSessions() { + User user = userRepository.save(User.createGithubUser(99001L, "stats-user", null, null, "t")); + + InterviewSession kept = sessionRepository.save(startedSession(user)); + InterviewSession removed = sessionRepository.save(startedSession(user)); + feedbackRepository.save(feedback(kept, 80.0)); + feedbackRepository.save(feedback(removed, 40.0)); + + // 지우기 전에는 둘 다 잡힌다 — 필터가 "아무것도 안 거르는" 상태와 구분되게. + assertThat(feedbackRepository.findRecentByOwner(user.getId(), PageRequest.of(0, 10))).hasSize(2); + assertThat(feedbackRepository.averageOverallScore(user.getId())).isEqualTo(60.0); + + removed.markDeleted(); + sessionRepository.save(removed); + + assertThat(feedbackRepository.findRecentByOwner(user.getId(), PageRequest.of(0, 10))) + .hasSize(1); + assertThat(feedbackRepository.averageOverallScore(user.getId())).isEqualTo(80.0); + assertThat(feedbackRepository.averageTechnicalAccuracy(user.getId())).isEqualTo(80.0); + assertThat(feedbackRepository.averageLogicScore(user.getId())).isEqualTo(80.0); + assertThat(feedbackRepository.averageCommunicationScore(user.getId())).isEqualTo(80.0); + } + + // 회원 탈퇴 시 공유 토큰을 회수하는 경로는 반대다 — 삭제된 세션의 토큰도 거둬야 + // 살아있는 공유 링크가 남지 않는다. 통계와 같이 필터를 걸면 안 되는 이유. + @Test + void findSharedByOwnerIncludesDeletedSessions() { + User user = userRepository.save(User.createGithubUser(99002L, "share-user", null, null, "t")); + InterviewSession removed = sessionRepository.save(startedSession(user)); + SessionFeedback shared = feedbackRepository.save(feedback(removed, 70.0)); + shared.enableShare("token-for-deleted-session"); + feedbackRepository.save(shared); + + removed.markDeleted(); + sessionRepository.save(removed); + + assertThat(feedbackRepository.findSharedByOwner(user.getId())) + .extracting(SessionFeedback::getShareToken) + .contains("token-for-deleted-session"); + } + + private InterviewSession startedSession(User user) { + InterviewSession s = InterviewSession.create( + user, "통계 검증", null, SessionMode.TECHNICAL, List.of(JobCategory.BACKEND), 5, 30, null, null); + s.start(); + return s; + } + + private SessionFeedback feedback(InterviewSession session, Double score) { + return SessionFeedback.of(session, score, score, score, score, + "강점", "약점", "[]", "[]", "[]", "[]", null); + } +} diff --git a/backend/src/test/java/com/stackup/stackup/support/PostgresRepositoryTest.java b/backend/src/test/java/com/stackup/stackup/support/PostgresRepositoryTest.java new file mode 100644 index 00000000..f75e0be0 --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/support/PostgresRepositoryTest.java @@ -0,0 +1,45 @@ +package com.stackup.stackup.support; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; + +/** + * 실제 PostgreSQL(+pgvector) 을 띄워 리포지토리 쿼리를 검증하는 테스트에 붙인다. + * + *

왜 필요한가 — 이 프로젝트의 기본 테스트 프로파일은 DataSource·Hibernate·Flyway + * 오토컨피그를 제외하고 모든 리포지토리를 목으로 대체한다. 빠르지만 그 대가로 + * **`@Query` 의 JPQL 이 아무 검증도 받지 못한다.** 문법 오류는 물론이고 + * "삭제된 행을 안 걸렀다" 같은 의미 결함도 통과한다(실제로 통계 쿼리 5개가 그랬다). + * + *

컨테이너는 클래스가 아니라 JVM 단위로 하나만 뜬다({@link PostgresTestContainer}) — + * 리포지토리 테스트가 늘어나도 기동 비용은 한 번이다. + * + *

스키마는 운영과 같은 Flyway 마이그레이션으로 만든다. `ddl-auto: validate` 라 + * 엔티티 매핑과 마이그레이션이 어긋나면 컨텍스트 로딩에서 바로 터진다 — + * 이것만으로도 배포 후에야 알게 되던 사고를 CI 로 당긴다. + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Inherited +@DataJpaTest +// 임베디드 DB 로 갈아끼우지 않는다 — 우리가 띄운 컨테이너를 그대로 쓴다. +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@ContextConfiguration(initializers = PostgresTestContainer.Initializer.class) +// application-test.yml 의 제외 목록을 이 테스트에서만 푼다(거기서 DataSource·Hibernate· +// Flyway 를 빼기 때문에, 그대로 두면 리포지토리 빈 자체가 만들어지지 않는다). +@TestPropertySource(properties = { + "spring.autoconfigure.exclude=", + "spring.jpa.hibernate.ddl-auto=validate", + "spring.flyway.enabled=true", +}) +public @interface PostgresRepositoryTest { +} diff --git a/backend/src/test/java/com/stackup/stackup/support/PostgresTestContainer.java b/backend/src/test/java/com/stackup/stackup/support/PostgresTestContainer.java new file mode 100644 index 00000000..962a918b --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/support/PostgresTestContainer.java @@ -0,0 +1,58 @@ +package com.stackup.stackup.support; + +import org.springframework.context.ApplicationContextInitializer; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.test.context.support.TestPropertySourceUtils; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.MountableFile; + +/** + * 리포지토리 테스트가 공유하는 PostgreSQL 컨테이너. + * + *

static 으로 한 번 띄우고 JVM 종료까지 재사용한다. Testcontainers 의 Ryuk 이 + * 프로세스가 죽을 때 정리하므로 stop 을 명시적으로 부르지 않는다 — 클래스마다 + * 껐다 켜면 리포지토리 테스트가 늘어날수록 CI 가 선형으로 느려진다. + * + *

이미지는 `infra/postgres` 와 같은 pgvector 계열이어야 한다. 마이그레이션이 + * vector 타입·인덱스를 쓰기 때문에 순정 postgres 이미지로는 Flyway 가 실패한다. + */ +public final class PostgresTestContainer { + + // infra/postgres/Dockerfile 과 같은 태그를 쓴다. 운영 PG 버전을 올리면 여기도 같이 올린다. + private static final DockerImageName IMAGE = DockerImageName.parse("pgvector/pgvector:pg17") + .asCompatibleSubstituteFor("postgres"); + + private static final PostgreSQLContainer CONTAINER = new PostgreSQLContainer<>(IMAGE) + .withDatabaseName("stackup") + .withUsername("stackup") + .withPassword("stackup") + // Flyway 가 돌기 전에 확장이 있어야 한다. 엔트리포인트 초기화 스크립트로 넣는다 + // (infra/postgres 가 init.sql 을 같은 자리에 복사하는 것과 같은 방식). + .withCopyFileToContainer( + MountableFile.forClasspathResource("db/testcontainers-init.sql"), + "/docker-entrypoint-initdb.d/init.sql"); + + private PostgresTestContainer() { + } + + static PostgreSQLContainer started() { + if (!CONTAINER.isRunning()) { + CONTAINER.start(); + } + return CONTAINER; + } + + public static class Initializer implements ApplicationContextInitializer { + @Override + public void initialize(ConfigurableApplicationContext context) { + PostgreSQLContainer pg = started(); + TestPropertySourceUtils.addInlinedPropertiesToEnvironment( + context, + "spring.datasource.url=" + pg.getJdbcUrl(), + "spring.datasource.username=" + pg.getUsername(), + "spring.datasource.password=" + pg.getPassword(), + "spring.datasource.driver-class-name=org.postgresql.Driver"); + } + } +} diff --git a/backend/src/test/resources/db/testcontainers-init.sql b/backend/src/test/resources/db/testcontainers-init.sql new file mode 100644 index 00000000..5ccdb746 --- /dev/null +++ b/backend/src/test/resources/db/testcontainers-init.sql @@ -0,0 +1,3 @@ +-- infra/postgres/init.sql 과 같은 내용. 마이그레이션(V*)이 pgvector 타입을 쓰므로 +-- Flyway 가 돌기 전에 확장이 있어야 한다. +CREATE EXTENSION IF NOT EXISTS vector;