[Java] 테스트 코드 개념 정리하기 2

2025. 12. 11. 19:37·JAVA/Java 실습

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 설정

  1. SockJsClient : WebSocket을 지원하지 않는 브라우저 대비 폴백 메커니즘
  2. WebSocketStompClient : STOMP 프로토콜 사용
  3. 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 설정 :

  1. Repository → Settings → Secrets and variables → Actions
  2. New repository secret 클릭
  3. 예 : 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

테스트 실패 시 로그 확인하는 법

  1. Actions 탭 → 실패한 워크플로우 ㅋㄹ릭
  2. 실패한 Step 확장
  3. 에러 메시지 및 스택 트레이스 확인
> 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
'JAVA/Java 실습' 카테고리의 다른 글
  • [Java] 테스트 코드 개념 정리하기
  • [자바 알고리즘] 프로그래머스 두 개 뽑아서 더하기
  • [JAVA Algorithm] 커머스 과제 알고리즘 기능 구현
  • [Java Algorithm] 거스름돈 문제 풀이 예시 따라가보기
stark77
stark77
하마의 IT 자기개발 이모저모, 백엔드 개발자로 거듭나기
  • stark77
    하마의 개발자 성장일기
    stark77
  • 전체
    오늘
    어제
    • 분류 전체보기
      • 컴퓨터구조와 운영체제
        • 컴퓨터구조
        • 운영체제
      • SQL 기초
      • Spring
        • 백엔드 기초
        • Spring 실습
      • JAVA
        • Java 실습
      • HTML&CSS
        • HTML&CSS 실습
      • Git&GitHub
        • Git&GitHub 실습
      • 내배캠 끄적끄적
        • Today I Learned
      • 유용한 툴 및 사이트 정리
      • 취미
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    Til
    Java 문법기초
    WebSocket
    다형성
    Github
    Spring
    백엔드 기초
    git
    백엔드 기초다지기
    thymleaf
    jsp
    JPA
    HTML&CSS
    for문
    Spriingboot
    RestTemplate
    경합조건과 교착상태
    객체지향프로그래밍
    실시간 데이터 처리
    객체지향
    Stomp
    웹소켓
    algorithm
    네트워크 기초
    프로세스와 쓰레드
    String.format
    java
    SpringSecurity
    MVC
    BEAN
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.4
stark77
[Java] 테스트 코드 개념 정리하기 2
상단으로

티스토리툴바