Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion backend/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)을 **빌드 단계에서 강제**한다. 사람의 리뷰가 놓치기 쉬운 위반을 컴파일/테스트로 차단.
Expand Down
4 changes: 4 additions & 0 deletions backend/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/**
* 통계 쿼리는 삭제된 세션을 빼야 한다.
*
* <p>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);
}
}
Original file line number Diff line number Diff line change
@@ -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) 을 띄워 리포지토리 쿼리를 검증하는 테스트에 붙인다.
*
* <p>왜 필요한가 — 이 프로젝트의 기본 테스트 프로파일은 DataSource·Hibernate·Flyway
* 오토컨피그를 제외하고 모든 리포지토리를 목으로 대체한다. 빠르지만 그 대가로
* **`@Query` 의 JPQL 이 아무 검증도 받지 못한다.** 문법 오류는 물론이고
* "삭제된 행을 안 걸렀다" 같은 의미 결함도 통과한다(실제로 통계 쿼리 5개가 그랬다).
*
* <p>컨테이너는 클래스가 아니라 JVM 단위로 하나만 뜬다({@link PostgresTestContainer}) —
* 리포지토리 테스트가 늘어나도 기동 비용은 한 번이다.
*
* <p>스키마는 운영과 같은 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 {
}
Original file line number Diff line number Diff line change
@@ -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 컨테이너.
*
* <p>static 으로 한 번 띄우고 JVM 종료까지 재사용한다. Testcontainers 의 Ryuk 이
* 프로세스가 죽을 때 정리하므로 stop 을 명시적으로 부르지 않는다 — 클래스마다
* 껐다 켜면 리포지토리 테스트가 늘어날수록 CI 가 선형으로 느려진다.
*
* <p>이미지는 `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<ConfigurableApplicationContext> {
@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");
}
}
}
3 changes: 3 additions & 0 deletions backend/src/test/resources/db/testcontainers-init.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- infra/postgres/init.sql 과 같은 내용. 마이그레이션(V*)이 pgvector 타입을 쓰므로
-- Flyway 가 돌기 전에 확장이 있어야 한다.
CREATE EXTENSION IF NOT EXISTS vector;
Loading