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
14 changes: 14 additions & 0 deletions .github/workflows/cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,23 @@ jobs:
java-version: "21"
cache: gradle

- name: Check Docker availability
run: docker info

- name: Build & Test
run: ./gradlew clean generateProto test build

- name: Upload test artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: cd-test-artifacts
if-no-files-found: warn
path: |
build/test-results/test/**
build/reports/tests/test/**
build/testcontainers-logs/**

- name: Login to GHCR
uses: docker/login-action@v3
with:
Expand Down
14 changes: 14 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,19 @@ jobs:
java-version: "21"
cache: gradle

- name: Check Docker availability
run: docker info

- name: Build & Test
run: ./gradlew clean generateProto test build

- name: Upload test artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: ci-test-artifacts
if-no-files-found: warn
path: |
build/test-results/test/**
build/reports/tests/test/**
build/testcontainers-logs/**
46 changes: 46 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# 저장소 가이드라인

## 프로젝트 구조 및 모듈 구성

* DDD 성격의 레이어를 둔 Java 21 / Spring Boot 3 프로젝트.
* 도메인 로직: `.../domain/**` (schedule, dependency, statistics, member, datetime, converters, events).
* 애플리케이션 서비스: `.../application/**` 에서 유스케이스를 오케스트레이션하며, 얇고 트랜잭션 경계 중심으로 유지.
* 인터페이스: `.../interfaces/web` 컨트롤러 + `.../interfaces/dto` DTO, 공용 헬퍼는 `.../interfaces/utils`.
* 인프라스트럭처: `.../infrastructure/config|repository|web` 에 영속성/어댑터/설정 배치.
* 리소스와 설정은 `src/main/resources` 에 위치(로컬은 `application.yml`). 테스트는 패키지 구조를 동일하게 미러링.

## 빌드, 테스트, 개발 명령어

* `./gradlew clean build` — 컴파일, 테스트 실행, 아티팩트 생성.
* `./gradlew test` — JUnit 5 테스트 스위트 실행(로컬/CI 기본 검증 작업).
* `./gradlew bootRun` — `application.yml` 로컬 설정으로 API 실행.
* `./gradlew bootJar` — 배포 가능한 실행 JAR 생성이 필요할 때 사용.

## 코딩 스타일 및 네이밍 컨벤션

* 4칸 들여쓰기와 표준 Java 컨벤션 사용; 가능한 범위에서 `final`/불변성 선호.
* 레이어 역할이 드러나도록 네이밍(`*Service`, `*Controller`, `*Repository`, `*Response`/`*Request` DTOs).
* 이미 사용 중인 Lombok을 보일러플레이트 제거에 활용하며, 생성자/`@Builder` 사용 패턴은 기존 코드와 일관되게 유지.
* Querydsl은 리포지토리와 함께 두되, 쿼리는 컨트롤러/서비스가 아니라 인프라 레이어에 둔다.
* 문서화 주석은 Javadoc 스타일로 작성하고, 공개 API 메서드에만 적용.
* openapi/Swagger 주석은 컨트롤러 메서드 및 DTO에 추가.
* 도메인 예외 발생 시 예외 클래스 자체로 타입화 (ProductNotFoundException, InvalidStockException), 도메인 레이어가 인터페이스 레이어에 의존하지 않도록 유의

## 테스트 가이드라인

* 프레임워크: JUnit 5, Spring Test, Mockito(JUnit Jupiter 통합 포함).
* 단위 테스트는 도메인/서비스 인접 위치에 두고, 클래스명은 `*Test`, 메서드명은 행위 중심(예: `shouldCreateScheduleWhen...`).
* 도메인 규칙(의존성 사이클, patch 동작)과 리포지토리 쿼리를 커버하고, 외부 경계는 목으로 처리.
* PR 열기 전 `./gradlew test` 실행. 샘플 데이터/설정 추가는 필요한 경우에만.

## 커밋 및 PR 가이드라인

* 히스토리에 사용된 Conventional Commit 스타일 준수(예: `feat: ...`, `docs: ...`, `fix: ...`), 제목은 간결하게.
* PR 설명에는 what/why, 연결된 이슈, 테스트 결과(`./gradlew test` 출력) 포함. 스크린샷은 API 문서/UI 변경에만.
* 스키마/설정 변경(`application.yml`, DB URL/DDL)은 명시하고, 필요 시 마이그레이션/롤백 노트 제공.
* 시크릿 커밋 금지. DB 크리덴셜/외부 키는 환경변수나 프로파일로 오버라이드하고, `application.yml` 은 로컬 개발 기준으로 정상 동작하도록 유지.

## 보안 및 설정 팁

* 로컬 DB 기본값은 `src/main/resources/application.yml` 에 두고, 실제 배포에서는 환경변수/프로파일로 민감값을 오버라이드.
* 로컬이 아닌 환경에서는 `ddl-auto` 를 보수적으로(`validate`/`none`) 유지하고, 스키마 설정 변경 전 팀과 조율.
2 changes: 2 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ dependencies {
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
testImplementation 'org.testcontainers:junit-jupiter'
testImplementation 'org.testcontainers:rabbitmq'

// MySQL
implementation 'com.mysql:mysql-connector-j'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# RabbitMQ Testcontainers 통합 테스트 런북

## 목적

- RabbitMQ Testcontainers 기반 통합 테스트를 로컬/CI에서 동일한 방식으로 실행하고, 실패 시 빠르게 원인을 진단한다.

## 사전 점검

1. Docker 상태 확인

```bash
docker info
```

2. Java/Gradle 확인

```bash
java -version
./gradlew --version
```

3. 테스트 프로파일 확인

- `src/test/resources/application-test.yml`이 로드되는지 확인한다.

## 실행 방법

1. 단일 통합 테스트 실행

```bash
./gradlew test --tests "*ScheduleStateRabbitPublishIntegrationTest"
```

2. 전체 테스트 실행

```bash
./gradlew test
```

## Docker 미가용 정책

- 테스트 코드는 Docker 미가용 시 `Assumptions.assumeTrue`로 skip 처리한다.
- skip 사유는 테스트 로그에 출력된다.
- CI에서는 테스트 전 `docker info`를 실행해 환경 문제를 조기 감지한다.

## 실패 시 1차 진단 순서

1. Docker 동작 확인: `docker info`
2. Testcontainers 로그 확인: `build/testcontainers-logs/rabbitmq-container.log`
3. 테스트 리포트 확인:
- `build/test-results/test`
- `build/reports/tests/test`
4. RabbitMQ 설정 확인:
- exchange: `task.schedule.direct`
- routing key: `schedule.state.started`, `schedule.state.completed`, `schedule.state.canceled`
- `@DynamicPropertySource` 주입 값(host/port/username/password)
5. 큐 바인딩/정리 확인:
- 테스트 큐 생성 및 바인딩
- purge/drain 후 검증 시작 여부

## flaky 판정 기준

- 동일 커밋에서 동일 테스트를 3회 재실행했을 때 1회 이상 timeout/수신 실패가 발생하면 flaky 후보로 분류한다.
- flaky 후보는 아래 항목을 우선 점검한다.
- 비동기 타임아웃 상수 적정성
- 큐 정리 누락 여부
- CI 러너의 Docker/RabbitMQ 리소스 상태

## CI 아티팩트

- 테스트 실패 시 아래 산출물을 보존한다.
- `build/test-results/test/**`
- `build/reports/tests/test/**`
- `build/testcontainers-logs/**`
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# Testcontainers 기반 통합 테스트 체크리스트

> 목적: 로컬/CI 환경에서 RabbitMQ Testcontainers 통합 테스트를 안정적으로 수행하기 위한 구현/운영 체크리스트를 제공한다.

## 적용 범위

- [x] 적용 대상: RabbitMQ Testcontainers 통합 테스트
- [x] 비적용 범위: 순수 단위 테스트, Mock-only 테스트, RabbitMQ 외 컨테이너(MySQL/Redis/Kafka)

## 사전 준비

- [x] Docker 실행 가능 여부 확인 절차를 문서화 (`docker info`)했다
- [x] 테스트 의존성 확인 (`build.gradle`의 `org.testcontainers:junit-jupiter`, `org.testcontainers:rabbitmq`)
- [x] 테스트 프로파일 분리 확인 (`src/test/resources/application-test.yml`)
- [x] Java 21 런타임 일치 확인 (로컬/CI)
- [x] CI 러너 Docker 접근 권한 점검 단계를 워크플로에 추가했다

## 테스트 설계

- [x] 통합 테스트 경계를 메시징 발행(RabbitMQ)으로 명시했다
- [x] 검증 포인트를 비즈니스 결과(상태 전환별 이벤트 발행/미발행) 기준으로 정의했다
- [x] 테스트 데이터 격리 전략을 반영했다 (테스트별 동적 큐명)
- [x] 시간 의존 로직은 고정 시각(`BASE_TIME`)을 사용한다
- [x] 실패 케이스를 포함한다 (권한 불일치, 잘못된 상태 전이, 라우팅 미바인딩)

## 구현

- [x] `@SpringBootTest` + `@ActiveProfiles("test")` 기준을 지킨다
- [x] 공통 베이스에서 `@DynamicPropertySource`로 런타임 프로퍼티를 주입한다
- [x] 공통 베이스에서 컨테이너 생명주기(start/stop)를 관리한다
- [x] Docker 미가용 환경은 `Assumptions.assumeTrue`로 skip 처리하고 사유를 로그에 남긴다
- [x] 테스트 리소스(큐/바인딩) 초기화/정리(선언, purge/drain, delete)를 명시적으로 수행한다
- [x] 비동기 검증 타임아웃 상수를 표준화했다 (`MESSAGE_TIMEOUT_MS`, `NO_MESSAGE_TIMEOUT_MS`)
- [x] 테스트 간 상태 누수 방지 절차를 적용했다 (DB 삭제, 큐 purge, `DomainEvents` clear)
- [x] 컨테이너 로그를 `build/testcontainers-logs/rabbitmq-container.log`로 수집한다

## 실행 및 확인

- [x] 단일 통합 테스트 실행 방법을 문서화한다

```bash
./gradlew test --tests "*ScheduleStateRabbitPublishIntegrationTest"
```

- [x] 전체 테스트 실행 방법을 문서화한다

```bash
./gradlew test
```

- [x] 실패 시 1차 진단 순서를 런북에 명시했다 (Docker -> 컨테이너 로그 -> 바인딩/프로퍼티)
- [x] flaky 판단 기준(동일 커밋 재실행 편차/타임아웃 빈도)을 런북에 명시했다

## CI 파이프라인

- [x] CI에서 Docker 가용성 점검(`docker info`)을 수행한다
- [x] 기존 `./gradlew ... test ...` 흐름에 통합 테스트를 포함한다
- [x] 테스트 실패 시 리포트/로그 아티팩트를 보존한다
- [x] Gradle 캐시(`actions/setup-java`의 `cache: gradle`)를 유지한다

## 현재 저장소 기준 메모

- [x] Testcontainers 적용 범위는 RabbitMQ 중심이다
- [x] 참조 테스트 클래스:
`src/test/java/me/gg/pinit/pinittask/infrastructure/events/schedule/ScheduleStateRabbitPublishIntegrationTest.java`
- [x] 공통 베이스 클래스: `src/test/java/me/gg/pinit/pinittask/infrastructure/events/support/RabbitMqTestcontainersSupport.java`
- [x] 관련 의존성: `build.gradle`
- [x] 테스트 프로파일: `src/test/resources/application-test.yml`
- [x] 실행/진단 런북: `docs/006 Testcontainers 통합 테스트/rabbitmq-testcontainers-runbook.md`

## 완료 기준

- [x] 신규 RabbitMQ 통합 테스트 추가 시 본 체크리스트만으로 PR 준비가 가능하다
- [x] 로컬/CI 공통 실패 원인 진단 절차가 문서에 포함되어 있다
- [x] 실제 케이스(RabbitMQ 상태 이벤트 발행 테스트)에 즉시 적용 가능하다
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package me.gg.pinit.pinittask.infrastructure.events;

import me.gg.pinit.pinittask.domain.events.DomainEvent;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.amqp.rabbit.core.RabbitTemplate;

import java.util.List;

import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;

@ExtendWith(MockitoExtension.class)
class RabbitEventPublisherTest {

@Mock
RabbitTemplate rabbitTemplate;

RabbitEventPublisher rabbitEventPublisher;

@BeforeEach
void setUp() {
rabbitEventPublisher = new RabbitEventPublisher(
rabbitTemplate,
List.of(new MappedEventMapper())
);
}

@Test
void publish_withMappedEvent_sendsMessage() {
MappedEvent event = new MappedEvent(10L);

rabbitEventPublisher.publish(event);

verify(rabbitTemplate).convertAndSend("exchange.test", "routing.test", "payload-10");
}

@Test
void publish_withoutMapper_doesNothing() {
rabbitEventPublisher.publish(new UnmappedEvent());

verifyNoInteractions(rabbitTemplate);
}

private record MappedEvent(Long id) implements DomainEvent {
}

private record UnmappedEvent() implements DomainEvent {
}

private static class MappedEventMapper implements AmqpEventMapper<MappedEvent> {
@Override
public Class<MappedEvent> eventType() {
return MappedEvent.class;
}

@Override
public String exchange() {
return "exchange.test";
}

@Override
public String routingKey() {
return "routing.test";
}

@Override
public Object payload(MappedEvent event) {
return "payload-" + event.id();
}
}
}
Loading