ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • TDD 기반 테스트 코드 도입기
    Develop/Spring 2025. 7. 18. 17:00

    TL;DR

    소프트웨어의 안정성을 높이고, 유지보수를 용이하게 하며, 궁극적으로는 총 개발 비용과 시간을 줄이기 위해 테스트 코드를 도입합니다.

     

     

    들어가며

     

     

     

    테스트 코드를 그래서 왜 짜야되는데?

     


    테스트 코드를 작성 하는 이유

     

    1. 안정성 확보

    새로운 기능 추가나 코드 수정 시, 기존 기능이 고장 나는 것을 방지하는 안전망 역할을 합니다. 이를 통해 언제든 자신감을 갖고 코드를 변경할 수 있습니다.

     

    2. 자동화 된 테스트로 시간 절약 가능

    Postman을 이용한 수동 API 테스트는 매번 직접 API를 호출하고 결과를 눈으로 확인해야 합니다. 코드가 복잡해지고 수정이 잦아질수록, 모든 API를 일일이 테스트하는 것은 매우 비효율적입니다.

    테스트 코드를 작성하면 이 모든 과정을 자동화하여, 단 몇 초 만에 수백 개의 테스트를 실행할 수 있습니다.

    기능 추가나 수정 후에도 테스트 코드 실행만으로 전체 기능의 안정성을 빠르고 간단하게 검증할 수 있습니다.

    초기에 드는 시간은 장기적으로 더 큰 시간을 절약해 주는 현명한 투자입니다.

     

     

     

    3. 더 나은 설계 유도

    "테스트하기 어려운 코드는 잘못 설계된 코드일 가능성이 높다"는 말이 있습니다. 테스트 코드를 작성하는 과정은 자연스럽게 더 좋은 코드 설계를 유도합니다.

    • 관심사 분리(Separation of Concerns): 테스트를 쉽게 하려면 거대한 함수나 클래스를 단일 책임(Single Responsibility)을 갖는 작은 단위로 나누게 됩니다. 이는 코드의 결합도(Coupling)를 낮추고 응집도(Cohesion)를 높여 재사용성과 유지보수성을 향상시킵니다.
    • 명확한 인터페이스: 테스트는 특정 기능의 입력(Input)과 기대하는 출력(Output)을 명확하게 정의하는 과정입니다. 이로 인해 해당 기능의 역할과 책임이 더 명확해집니다.

     

    4. 살아있는 문서

    잘 작성된 테스트 코드는 그 자체로 가장 정확하고 항상 최신 상태를 유지하는 문서가 됩니다.

    • 기능 명세서: 테스트 코드의 이름과 내용을 보면, 해당 기능이 어떤 역할을 하고 어떻게 사용해야 하는지 쉽게 파악할 수 있습니다.
    • 신뢰성: 일반적인 문서(e.g., Wiki, Word)는 코드가 변경될 때 함께 업데이트되지 않아 신뢰도가 떨어지기 쉽습니다. 하지만 테스트 코드는 코드 변경 후 실패할 경우 즉시 알려주므로 항상 최신 상태를 유지합니다.

     

     

    TDD(Test-Driven Development)란 무엇인가?

    TDD(테스트 주도 개발)는 실제 코드를 작성하기 전에 실패하는 테스트 코드를 먼저 작성하는 개발 방법론입니다. 즉, "코드를 어떻게 짤 것인가"가 아니라 "코드가 어떤 조건을 만족해야 하는가"에 대한 테스트부터 정의하고 개발을 시작하는 방식입니다.

    이는 전통적인 개발 방식(코드 작성 → 테스트)의 순서를 완전히 뒤집은 것으로, 테스트가 전체 개발 과정을 주도(Drive)해 나간다는 점이 핵심입니다.

     

    TDD의 Red-Green-Refactor 사이클

    TDD는 아래의 세 단계를 반복하며 개발을 진행합니다. 이 사이클은 TDD의 심장과도 같습니다.

    ❤️ 1단계: Red (실패하는 테스트 작성)

    • 새롭게 구현할 기능에 대한 테스트 코드를 먼저 작성합니다.
    • 이 시점에는 실제 기능 코드가 없으므로 이 테스트는 반드시 실패해야 합니다.
    • 테스트가 실패하는 것을 확인함으로써, 우리는 테스트 코드가 올바르게 동작하고 있음을 증명할 수 있습니다.

    ✅ 2단계: Green (테스트를 통과하는 코드 작성)

    • 앞서 작성한 테스트를 통과시키는 것만을 목표로, 가장 빠르고 간단한 방법으로 실제 코드를 작성합니다.
    • 이 단계에서는 코드의 아름다움이나 효율성보다는 오직 테스트 통과에만 집중합니다.

    ♻️ 3단계: Refactor (코드 개선)

    • 테스트가 통과하는 상태(안전망)를 확보했으므로, 이제 코드의 구조를 개선합니다.
    • 중복 코드를 제거하고, 가독성을 높이며, 더 나은 설계로 코드를 정리합니다.
    • 리팩토링 후에도 테스트가 계속 통과하는지 확인하여, 기능은 그대로 유지하면서 코드의 품질만 높였음을 보장합니다.

    개발자는 이 Red-Green-Refactor 사이클을 매우 짧은 주기로 반복하며 점진적으로 소프트웨어를 완성해 나갑니다.

     

    TDD  장점 

    높은 품질과 안정성: 모든 코드에는 그에 맞는 테스트가 존재하므로, 코드의 안정성과 신뢰도가 매우 높습니다.

     

    설계 개선: 테스트 가능한 코드를 작성하는 과정에서 자연스럽게 객체의 역할과 책임이 명확해지고, 유연한 설계가 만들어집니다.

     

    디버깅 시간 단축: 버그가 발생하면 어느 테스트에서 문제가 생겼는지 바로 알 수 있어, 원인 파악이 매우 빠르고 쉽습니다.

     

    개발 집중력 향상: 지금 무엇을 해야 하는지(실패하는 테스트를 통과시키는 것) 목표가 명확해져 개발에 더 집중할 수 있습니다.

     

     

     

     

    TDD 도입시 우려상황

    높은 학습 곡선: 기존의 개발 방식과 생각의 흐름이 완전히 달라, 익숙해지는 데 시간과 노력이 필요합니다.

     

    초기 개발 속도: TDD에 익숙하지 않은 경우, 테스트를 먼저 작성하는 것이 번거롭게 느껴져 초기 개발 속도가 더디다고 느낄 수 있습니다.

    (하지만 장기적으로는 디버깅 및 유지보수 시간을 줄여 총 개발 시간은 단축됩니다.)

     

    무엇을 테스트할 것인가?: 어떤 것을 테스트해야 하고, 어디까지 테스트해야 하는지에 대한 기준을 세우기 어려울 수 있습니다.

     

    기존 프로젝트 적용의 어려움: 테스트를 고려하지 않고 작성된 기존의 거대한 코드(레거시 코드)에 TDD를 적용하기는 매우 어렵습니다.

     

     

     

     

    결론

    테스트 코드 작성은 언뜻 보기엔 시간을 더 들이는 불필요한 작업처럼 보일 수 있습니다. 하지만 장기적인 관점에서 보면, 프로젝트의 안정성, 유지보수성, 그리고 코드 품질을 높이는 필수적인 투자입니다.

     그러나 테스트 코드는 안정성, 자동화된 테스트, 더 나은 설계유도, 그리고 살아있는 문서라는 강력한 이점을 제공합니다. 이러한 장점들을 고려하면, 테스트 코드 작성이 왜 중요한지 명확히 알 수 있습니다.

     

    이어서 테스트 코드를 작성 해 보겠습니다.

     


    테스트 코드 작성

    Junit과 Assert를 사용하여 테스트를 진행합니다.

     

    AAA Pattern

    AAA Pattern은 테스트 코드의 가독성을 높이고 코드를 이해하기 쉽게 해줍니다.

     

    • Arrange : 테스팅 환경과 값을 정의함
    • Act : 테스트 되어야할 코드를 실행함
    • Assert : 실행 결과값을 평가함 / 예상되어야 하는 결과 혹은 값에 부합하는지 비교
      @Test
      @DisplayName("Test")
      void test() {
          // Arrange
    
          // Act
    
          // Assert
      }

     

    TDD 도입

    @RequiredArgsConstructor
    @RestController
    @RequestMapping("/api/v1/point")
    public class PointV1Controller implements PointV1ApiSpec {
    
        private final PointFacade pointFacade;
    
        @PostMapping("/charge")
        @Override
        public ApiResponse<ChargeResponse> charge(@RequestBody final PointRequest pointRequest) {
            ChargeResponse response = ChargeResponse.from(pointFacade.charge(pointRequest.toCommand()));
            return ApiResponse.success(response);
        }
    }
    @RequiredArgsConstructor
    @Service
    public class PointService {
        private final PointRepository pointRepository;
    
        @Transactional
        public PointModel charge(final PointCommand command) {
            PointModel pointModel = pointRepository.findByUsersId(command.userId())
                    .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, " 해당 [id = " + command.userId() + "]의 포인트가 존재하지 않습니다."));
    
            pointModel.charge(command.balance());
    
            return pointRepository.save(pointModel);
        }
    }

     

     

    유닛 테스트

    @DisplayName("포인트 모델을 생성할 때, ")
    @Nested
    class Create {
    
        @DisplayName("0 이하의 정수로 포인트를 충전 시 실패한다.")
        @Test
        void failWhenChargingWithZeroOrNegativeAmount() {
            // arrange
            Long userId = 0L;
            Long balance = -1L;
    
            // act
            CoreException result = assertThrows(CoreException.class, () -> new PointModel(userId, balance));
    
            // assert
            assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST);
        }
    }

     

    통합 테스트

    @SpringBootTest
    public class PointServiceIntegrationTest {
    
        @Autowired
        private PointService pointService;
    
        @Autowired
        private PointJpaRepository pointJpaRepository;
    
        @Autowired
        private DatabaseCleanUp databaseCleanUp;
    
        @AfterEach
        void tearDown() {
            databaseCleanUp.truncateAllTables();
        }
    
        @DisplayName("해당 ID의 회원이 존재하면, 보유 포인트를 반환한다.")
        @Test
        void returnsPoints_whenUserExists() {
        // arrange
        Long userId = 1L;
        Long balance = 1000L;
    
        PointModel pointModel = pointJpaRepository.save(new PointModel(userId, balance));
    
        // act
        PointModel result = pointService.getPoint(pointModel.getUserId());
    
        // assert
        assertAll(
                () -> assertThat(result.getBalance()).isEqualTo(balance)
        );
        }
    
        @DisplayName("해당 ID의 회원이 존재하지 않을 경우, null 이 반환된다.")
        @Test
        void shouldThrowException_whenUserDoesNotExist() {
        // arrange
        Long nonExistentUserId = 999L;
        Long balance = 1000L;
    
        PointModel pointModel = pointJpaRepository.save(new PointModel(nonExistentUserId, balance));
    
        // act and assert
        assertThrows(CoreException.class, () -> pointService.getPoint(nonExistentUserId));
        }
    }

     

    E2E 테스트

    @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
    public class PointV1ApiE2ETest {
    
        private final TestRestTemplate testRestTemplate;
        private final DatabaseCleanUp databaseCleanUp;
        private final PointJpaRepository pointJpaRepository;
    
        @Autowired
        public PointV1ApiE2ETest(
                TestRestTemplate testRestTemplate,
                DatabaseCleanUp databaseCleanUp,
                PointJpaRepository pointJpaRepository
    
        ) {
            this.testRestTemplate = testRestTemplate;
            this.databaseCleanUp = databaseCleanUp;
            this.pointJpaRepository = pointJpaRepository;
    
        }
    
        @AfterEach
        void tearDown() {
            databaseCleanUp.truncateAllTables();
        }
    
        @DisplayName("POST /api/v1/charge")
        @Nested
        class Post {
    
            @Test
            @DisplayName("존재하는 유저가 1000원을 충전할 경우, 충전된 보유 총량을 응답으로 반환한다.")
            void chargePoint_success() {
                // arrange
                Long userId = 1L;
                Long initialBalance = 500L;
                Long chargeAmount = 1000L;
    
                pointJpaRepository.save(new PointModel(userId, initialBalance));
    
                PointRequest pointRequest = new PointRequest(userId, chargeAmount);
                HttpEntity<PointRequest> requestEntity = new HttpEntity<>(pointRequest);
    
                String requestUrl = "/api/v1/point/charge";
    
                // act
                ParameterizedTypeReference<ApiResponse<ChargeResponse>> responseType = new ParameterizedTypeReference<>() {
                };
                ResponseEntity<ApiResponse<ChargeResponse>> response =
                        testRestTemplate.exchange(requestUrl, HttpMethod.POST, requestEntity, responseType);
    
                // assert
                ChargeResponse responseData = response.getBody().data();
    
                assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
                assertThat(response.getBody()).isNotNull();
                assertThat(response.getBody().data().balance()).isEqualTo(initialBalance + chargeAmount);
            }
    
            @Test
            @DisplayName("존재하지 않는 유저로 요청할 경우, 404 Not Found 응답을 반환한다.")
            void chargePoint_fail_when_user_not_found() {
                // arrange
                long nonExistentUserId = 999L;
                long chargeAmount = 1000L;
    
                PointRequest pointRequest = new PointRequest(nonExistentUserId, chargeAmount);
                HttpEntity<PointRequest> requestEntity = new HttpEntity<>(pointRequest);
    
                String requestUrl = "/api/v1/point/charge";
    
                // act
                ParameterizedTypeReference<ApiResponse<ChargeResponse>> responseType = new ParameterizedTypeReference<>() {
                };
    
                ResponseEntity<ApiResponse<ChargeResponse>> response = testRestTemplate.exchange(
                        requestUrl,
                        HttpMethod.POST,
                        requestEntity,
                        responseType
                );
    
                // assert
                assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
            }
        }
    }

     

     

    마지막으로 실제 Spring 컨텍스트를 로드하여 테스트를 수행해보겠습니다.

     

     

    마치며

    이번 프로젝트를 통해 TDD와 단위 테스트의 중요성을 체감하고 있습니다. 하지만 아직 개념을 완전히 숙달하지는 못했습니다.

    테스트 주도 개발(TDD)을 통해 비즈니스 로직을 구현하며, 예외 상황과 경계값을 먼저 테스트하는 방식이 코드의 안정성과 신뢰도를 높인다는 점을 이론적으로 이해했습니다. 다만, 이를 실제 프로젝트에 효과적으로 적용하기 위해서는 더 깊이 있는 학습과 훈련이 필요함을 느낍니다.

     

    참고

    https://tech.kakaopay.com/post/implementing-tdd-in-practical-applications/

     

    실전에서 TDD하기 | 카카오페이 기술 블로그

    TDD가 무엇인지 모르는 사람은 없습니다. 그런데 왜 하는 사람은 얼마 없을까요?

    tech.kakaopay.com

     

    댓글

Designed by Tistory.