2025.12.01 - [JAVA/Java 실습] - [Java] 테스트 코드 개념 정리하기 를 통해 테스트 코드의 필요성과 개념에 대해 정리했었다.
오늘은 그에 이어 두번째 특강 내용을 기반으로 테스트 코드에 대한 깊이있는 이해를 가져가보고자 한다.
오늘 정리하고자 하는 개념은 아래와 같다.
- 비동기 통신 (WebSocket) 검증 방법 학습
- CI 환경에서 자동으로 테스트가 실행되도록 구축
1. WebSocket 테스트 환경 설정
웹소켓 테스트에서는 @SpringBootTest 어노테이션을 필요로 한다.
@SpringBootTest(webEnvironment = RANDOM_PORT) 가 필요한가?
- WebSocket은 실제 서버-클라이언트 연결이 필요
- RANDOM_PORT로 실제 내장 서버를 띄워서 테스트
- 포트 충돌 방지
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class WebSocketIntegrationTest {
@LocalServerPort
private int port;
private String wsUrl;
private WebSocketStompClient stompClient;
WebSocketStompClient 설정
- SockJsClient : WebSocket을 지원하지 않는 브라우저 대비 폴백 메커니즘
- WebSocketStompClient : STOMP 프로토콜 사용
- MappingJackson2MessageConverter : 메시지를 JSON으로 변환
@BeforeEach
void setUp() {
wsUrl = "http://localhost:" + port + "/ws-stomp";
// SockJS 클라이언트 설정
SockJsClient sockJsClient = new SockJsClient(
List.of(new WebSocketTransport(new StandardWebSocketClient()))
);
// STOMP 클라이언트 설정
stompClient = new WebSocketStompClient(sockJsClient);
stompClient.setMessageConverter(new MappingJackson2MessageConverter());
}
2. 시나리오 : 채팅방 입장 및 메시지 전송
2-1. Connection Test : 핸드쉐이크 및 소켓 연결 성공 검증
- connectAsync() : 비동기로 연결 시도
- get(3, TimeUnit.SECONDS) : 최대 3초 대기하여 동기화
- isConnected(): 연결 상태 검증
@Test
@DisplayName("WebSocket 연결 성공 테스트")
void testWebSocketConnection() throws Exception {
// given
StompSession session = stompClient
.connectAsync(wsUrl, new StompSessionHandlerAdapter() {})
.get(3, TimeUnit.SECONDS);
// then
assertThat(session.isConnected()).isTrue();
// cleanup
session.disconnect();
}
2-2. Subscribe & Send : 특정 Topic 구독 후 메시지 발행
- subscribe() : 특정 destination 구독
- send() : 메시지 발행
- StompFrameHandler : 수신 메시지 처리
2-3. 비동기 응답 검증
메시지를 보냈는데, 테스트 코드가 응답을 받기 전에 끝난다. → Async 문제
👉 BlockingQueue를 사용하여 응답 대기 패턴 구현
- messageQueue.offer(message) : 메시지를 Queue에 추가
- messageQueue.poll(3, TimeUnit.SECONDS) : 최대 3초간 대기하며 메시지를 가져옴
- 메시지가 없으면 null 반환
- 비동기 응답을 동기적으로 검증할 수 있게 해줌
@Test
@DisplayName("채팅 메시지 전송 및 수신 테스트")
void testChatMessage() throws Exception {
// given
String roomId = "test-room-2";
String testMessage = "안녕하세요!";
BlockingQueue<ChatMessageDto> messageQueue = new LinkedBlockingQueue<>();
StompSession session = stompClient
.connectAsync(wsUrl, new StompSessionHandlerAdapter() {})
.get(3, TimeUnit.SECONDS);
session.subscribe("/topic/chat/room/" + roomId, new StompFrameHandler() {
@Override
public Type getPayloadType(StompHeaders headers) {
return ChatMessageDto.class;
}
@Override
public void handleFrame(StompHeaders headers, Object payload) {
messageQueue.offer((ChatMessageDto) payload);
}
});
// when
ChatMessageDto chatMessage = new ChatMessageDto();
chatMessage.setType(ChatMessageDto.MessageType.TALK);
chatMessage.setRoomId(roomId);
chatMessage.setSender("홍길동");
chatMessage.setMessage(testMessage);
session.send("/app/chat/message", chatMessage);
// then
ChatMessageDto receivedMessage = messageQueue.poll(3, TimeUnit.SECONDS);
assertThat(receivedMessage).isNotNull();
assertThat(receivedMessage.getType()).isEqualTo(ChatMessageDto.MessageType.TALK);
assertThat(receivedMessage.getMessage()).isEqualTo(testMessage);
assertThat(receivedMessage.getSender()).isEqualTo("홍길동");
// cleanup
session.disconnect();
}
2-4. 예외 케이스 테스트
잘못된 목적지(Destionation)로 전송
@Test
@DisplayName("잘못된 목적지로 메시지 전송 시 수신되지 않음")
void testInvalidDestination() throws Exception {
// given
String correctRoomId = "room-1";
String wrongRoomId = "room-2";
BlockingQueue<ChatMessageDto> messageQueue = new LinkedBlockingQueue<>();
StompSession session = stompClient
.connectAsync(wsUrl, new StompSessionHandlerAdapter() {})
.get(3, TimeUnit.SECONDS);
// room-1 구독
session.subscribe("/topic/chat/room/" + correctRoomId, new StompFrameHandler() {
@Override
public Type getPayloadType(StompHeaders headers) {
return ChatMessageDto.class;
}
@Override
public void handleFrame(StompHeaders headers, Object payload) {
messageQueue.offer((ChatMessageDto) payload);
}
});
// when - room-2로 메시지 전송
ChatMessageDto message = new ChatMessageDto();
message.setType(ChatMessageDto.MessageType.TALK);
message.setRoomId(wrongRoomId);
message.setSender("테스터");
message.setMessage("이 메시지는 수신되지 않아야 함");
session.send("/app/chat/message", message);
// then - 메시지가 수신되지 않아야 함 (타임아웃)
ChatMessageDto receivedMessage = messageQueue.poll(2, TimeUnit.SECONDS);
assertThat(receivedMessage).isNull();
// cleanup
session.disconnect();
}
테스트 자동화와 CI/CD
4-1. CI (지속적 통합)이란 무엇인가?
CI(Continuous Integration)의 정의
- 여러 개발자가 작성한 코드를 자주 통합하는 개발 방법론
- 자동화된 빌드와 테스트를 통해 코드 품질 유지
로컬에서 성공했지만 CI에서 실패하는 이유
- 환경 변수 차이 (DB 설정, Secret Key emd)
- 시간대 / 로케일 차이
- 의존성 버전 차이
4-2. GitHub Actions로 테스트 자동화하기
Workflow 파일(.yml) 기본 구조
.github/workflows/test.yml 파일 생성
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main, develop ]
jobs:
test:
runs-on: ubuntu-latest
steps:
# 1. 코드 체크아웃
- name: Checkout code
uses: actions/checkout@v4
# 2. JDK 17 설정
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
cache: 'gradle'
# 3. Gradle 실행 권한 부여
- name: Grant execute permission for gradlew
run: chmod +x gradlew
# 4. 테스트 실행
- name: Run tests with Gradle
run: ./gradlew test
# 5. 테스트 결과 리포트 업로드 (실패 시에만)
- name: Upload test results
if: failure()
uses: actions/upload-artifact@v4
with:
name: test-results
path: build/reports/tests/test/
Step 구성
1. Checkout: 코드 가져오기
- uses: actions/checkout@v4
2. JDK Setup : Java 17 설치 및 Gradle 캐시
- uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
cache: 'gradle'
3. Build & Test : Gradle로 테스트 실행
- run: ./gradlew test
권한 및 환경 변수 처리
GitHub Secrets 설정 :
- Repository → Settings → Secrets and variables → Actions
- New repository secret 클릭
- 예 : JWT_SECRET_KEY 추가
Workflow에서 사용 :
- name: Run tests with Gradle
env:
JWT_SECRET_KEY: ${{ secrets.JWT_SECRET_KEY }}
run: ./gradlew test
application-test.yml 활용 :
# src/test/resources/application-test.yml
spring:
datasource:
url: jdbc:h2:mem:testdb;MODE=MySQL
driver-class-name: org.h2.Driver
username: sa
password:
jpa:
hibernate:
ddl-auto: create-drop
properties:
hibernate:
format_sql: true
show_sql: true
jwt:
secret:
key: ${JWT_SECRET_KEY:test-secret-key-for-ci}
4-3. 테스트 결과 리포팅 & Jacoco
테스트 실패 시 로그 확인하는 법
- Actions 탭 → 실패한 워크플로우 ㅋㄹ릭
- 실패한 Step 확장
- 에러 메시지 및 스택 트레이스 확인
> Task :test FAILED
com.sparta.testcode.domain.chat.integration.WebSocketIntegrationTest > testChatMessage FAILED
java.lang.AssertionError: expected: <안녕하세요!> but was: <null>
Jacoco를 활용한 테스트 커버리지 ( 보통의 경우 80% 수준까지를 목표로 설정 )
1. build.gradle에 Jacoco 플러그인 추가 :
plugins {
id 'jacoco'
}
jacoco {
toolVersion = "0.8.12"
}
test {
finalizedBy jacocoTestReport
}
jacocoTestReport {
dependsOn test
reports {
xml.required = true
html.required = true
csv.required = false
}
}
jacocoTestCoverageVerification {
violationRules {
rule {
limit {
minimum = 0.70
}
}
}
}
2. Workflow에서 커버리지 리포트 생성 :
- name: Generate test coverage report
if: success()
run: ./gradlew jacocoTestReport
- name: Upload coverage report
if: success()
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: build/reports/jacoco/test/
3. 커버리지 확인 :
- Actions → 성공한 워크플로우 → Artifacts → coverage-report 다운로드
- index.html 파일 브라우저로 열기
'JAVA > Java 실습' 카테고리의 다른 글
| [Java] 테스트 코드 개념 정리하기 (0) | 2025.12.01 |
|---|---|
| [자바 알고리즘] 프로그래머스 두 개 뽑아서 더하기 (0) | 2025.11.10 |
| [JAVA Algorithm] 커머스 과제 알고리즘 기능 구현 (0) | 2025.10.13 |
| [Java Algorithm] 거스름돈 문제 풀이 예시 따라가보기 (0) | 2025.09.29 |
| [JAVA 실습] 커머스 과제 (심화) (0) | 2025.09.24 |