SY 개발일지

* 해당 포스팅에 나오는 내용과 코드들은 아직 학습중이기 때문에 완벽하지 않음을 알려드립니다.

* loop:Pak L2 Backend에서 공부하며 배운 것들과 생각한 것들을 정리한 내용입니다.

 

개요

Spring을 공부하며 다들 TDD 는 한번씩 들어봤을 것이다. 나도 TDD 를 들어봤고, 한번쯤 공부해보고 업무에 적용해보고자 하였지만 배울 수 있는 기회가 적어 그러지 못했다. 또한 업무를 하면서도 현재 코드를 유지보수 하는 데에 그치고만 있다.

이번에 TDD 를 처음 배웠고, 내가 짠 테스트 코드를 코드리뷰 받을 수 있다는 것에 설레있다. (많이 까일준비 완 ㅎ)

 

그래서 이번엔 TDD에 대해 정리해보고, 내가 마주한 에러와 해결 방안에 대해서 점진적으로 기록해보고자 한다.

TDD란 ?

TDD란, (Test-Driven-Development)의 약자로 테스트 주도 개발을 하는 것이다. 소프트웨어 개발 방법 중 하나인데, 비지니스 로직을 작성하기 전에 먼저 실패하는 코드를 작성하고, 그 테스트가 통과되도록 코드를 작성하는 개발 방법이다.

(물론 완벽하게 테스트코드를 먼저 작성한 후, 비지니스 로직을 작성한 다는 것은 매우 어렵고 어렵다!!!)

그래서 하나의 비지니스 로직에 대해 4-5개의 테스트가 존재한다면, 하나의 테스트씩 해결해나가며 비지니스 로직을 계속해서 고쳐 나가는 것이다. 여기서 주목할 점은 "한번에 완벽한 코드를 작성하는 것이 아니다"라는 것이다. 오히려 뼈대를 만들고 살을 붙여나가는 것에 가깝다!

 

그래서, 3단계로 작성한다.

1. Red : 실패하는 테스트를 작성
2. Green : 통과할 최소한의 코드만을 작성
3. Refactor : 구조 개선 및 리팩토링

내가 위에서 언급하였 듯, 한번에 완벽한 코드란 없다! 먼저 테스트가 돌아가는 코드를 작성한 후, 리팩토링 하며 이전 테스트가 모두 성공하는지 확인하면 된다.

vs. TLD (Test Last Development)

TDD는 테스트를 먼저 작성한 후, 비지니스 로직을 작성하는 것이라면, TLD는 비지니스 로직을 먼저 작성한 후 테스트를 작성하는 것이다.

TDD가 도메인/로직 중심에 적합하다면, TLD는 API/계층 설계가 먼저 필요한 상황에 적합하다고 할 수 있다.

테스트 코드 형식

테스트 종류에 대해 알아보기 전에, 먼저 테스트를 진행하는 코드 형식부터 알아보자.

@DisplayName("화면에 표시될 내용")
@Nested
class Register {

  @DisplayName("화면에 표시될 내용")
  @Test
  void returnUserInfo_whenValidInfoProvided() {
    // given/arrange

    // when/act
    
    // then/assert
  }
}

 

코드는 크게 필요한 데이터를 준비하는 given(arrange), 테스트를 실행하는 when(act), 그리고 테스트 결과를 검증하는 then(assert) 부분으로 나뉘게 된다.

나 같은 경우에는 테스트 시에 테스트할 데이터를 준비하거나, 혹은 db에 미리 있어야 하는 데이터를 저장하는 코드를 given 부분에,

API 요청을 날리거나, service를 호출하는 데이터를 준비하고 실행시키는 코드를 when 부분에,

마지막으로 응답이 내가 원하는 결과와 동일한지 체크하는 코드를 then 부분에 작성하고 있다.

 

또한 같은 단위의 테스트, 같은 함수를 실행시키지만 요청이 달라 상이한 결과를 내는 테스트를 @Nested로 묶어 하나의 클래스에 관리하고 있다. 그렇게 하면 내가 테스트하려는 로직이 어느 부분에 몰려있는지 확인할 수 있다.

테스트 종류

테스트는 크게 단위 테스트(Unit Test), 통합 테스트(Integration Test), E2E 테스트(End-to-End Test)가 있다.

  단위 테스트 통합 테스트 E2E 테스트
대상 도메인 모델
(Entity, VO, Domain service)
Service, Facade 등 Controller -> Service -> DB까지 일련의 과정
목적 데이터 정합성 및 규칙 검증 비지니스 흐름 전체 실제 HTTP 요청 단위 테스트
환경 Spring 없이 순수 JVM에서 실행
테스트 대역을 활용해 모든 의존성 대체
- @SpringBootTest
- 실제 Bean 구성
- Test DB
TestRestTemplate 등을 이용해 실제 API 요청 시뮬레이션
예시 회원가입 시, 생년월일은 "2025-01-01"의 형식이여야 한다. 회원가입 시 ID는 다른 사용자와 동일하면 안된다. 회원가입에 성공하면 사용자 정보와 함께 201 code가 날라온다.

 

단위 테스트 < 통합 테스트 < E2E 테스트 순으로 점점 더 큰 범위를 검증한다고 생각하면 쉽다.

 

예를 들어보자.

현재 회원가입을 하는 로직을 작성하고자 한다. 회원가입의 요구사항은 다음과 같다.

1. 사용자 아이디는 4~12 자리의 영어 대소문자/숫자로 이루어진 문자열이다.

2. 비밀번호는 8~16 자리의 영어 대소문자/숫자/특수문자 중 2개 이상으로 구성된 문자열이다.

3. 생년월일은 yyyy-MM-dd 의 형태여야 한다.

단위 테스트

이 중 3번째인 생년월일 기준으로 생각해보도록 하겠다. 만일 사용자가 2010년 3월 12일" 이나 "2010.3.12" 등 주어진 형태와 상이하다면 에러가 발생해야 한다. 그런 경우에는 단위테스트로 진행하면 된다. 도메인 모델 단위에서 특정 필드의 정합성을 테스트하는 것이기 때문이다.

(이 때 Spring의 @ParameterizedTest 와 @ValueSource를 사용하면 여러 경우를 한번에 테스트 가능하다.)

@DisplayName("생년월일이 yyyy-MM-dd 형식에 맞지 않으면, User 객체 생성에 실패한다.")
@ParameterizedTest
@ValueSource(strings = {
  "",
  "abcd-ef-gh",
  "abcd-01-01",
  "2025-ef-01",
  "2025-01-gh",
  "2025-1-23",
  "202-01-23",
  "2025-01-2",
  "20256-01-23",
  "2025-012-34",
  "2025-01-234",
  "-01-23",
  "2025--23",
  "2025-01-",
  "2025-13-01",
  "2025-01-32",
  "2025.01.01"
})
void throwsBadRequestException_whenInvalidBirth(String birth) {
  // arrange

  // act & assert
  assertThrows(CoreException.class, () ->
    new UserEntity("la28s5d", "password", "la28s5d@naver.com", "김소연", "소연", birth, "F")
  );
}

 

이러한 식으로 모든 도메인 단위로 단위 테스트를 모두 끝냈다.

통합 테스트

그 후, 통합 테스트를 진행할 때에는 이렇게 도메인 단위의 테스트를 할 필요가 없어졌다. 즉, 개발자는 통합테스트를 할 때 "비지니스 로직" 기준으로 테스트 코드를 작성하면 된다.

통합 테스트는 단위 테스트 보다 더 큰 블록인 비지니스 로직에 대해 테스트한다고 생각하면 쉽다.

- 회원가입을 할 때 repository.save()가 실행이 되었을까 ?

- 가입하려는 ID와 동일한 ID를 가진 사용자가 있으면 에러가 잘 나올까?

등 더 흐름에 집중하는 테스트를 한다.

@DisplayName("회원가입을 할 때,")
@Nested
class Register {
  @DisplayName("이미 가입된 ID 로 회원가입 시도 시, 실패한다.")
  @Test
  void throwsException_whenAlreadyRegisterId() {
    // arrange
    String loginId = "la28s5d";
    userRepository.save(new UserEntity(loginId, "password", "la28s5d@naver.com", "김소연", "소연", "2025-01-01", "F"));
    UserV1Dto.UserRegisterRequest request = new UserV1Dto.UserRegisterRequest(loginId, "la28s5d@naver.com", "password", "F", "2025-01-01", "소연");

    // act
    CoreException result = assertThrows(CoreException.class, () ->
      userService.register(request)
    );

    // assert
    assertAll(
      () -> assertThat(result.getErrorType()).isEqualTo(UserErrorType.DUPLICATE_LOGIN_ID)
    );
  }
}

 

위 코드와 같이, 특정 값에 대해 확인을 한다기 보다는 DB와 소통하며 비지니스 로직에 중점을 둔 코드를 작성한다.

E2E 테스트

그렇다면 단위 테스트도, 통합 테스트도 모두 완료했다고 생각해보자. 회원가입을 하는 사용자는 결국 API를 통해 회원가입에 성공하였다는 메세지가 보고싶을 것이다.

E2E 테스트는 직접 API를 보내어 "이렇게 요청을 보내면 이런 응답을 주겠지?" 하고 테스트하는 것이다.

이 때, Controller 뿐 아니라 filter와 interceptor 단위까지 테스트가 진행이 된다. (직접 API 요청을 보내는 것이기 때문에)

@DisplayName("POST /api/v1/users")
@Nested
class Register {

  private static final String ENDPOINT = "/api/v1/users";

  @DisplayName("회원 가입이 성공할 경우, 생성된 유저 정보를 응답으로 반환한다.")
  @Test
  void returnUserInfo_whenValidInfoProvided() {
    // arrange
    UserV1Dto.UserRegisterRequest request = new UserV1Dto.UserRegisterRequest(
      "la28s5d", "la28s5d@naver.com", "password", "F",  "2025-01-01", "김소연"
    );

    // act
    ParameterizedTypeReference<ApiResponse<UserV1Dto.UserResponse>> responseType = 
      new ParameterizedTypeReference<>() {};
    ResponseEntity<ApiResponse<UserV1Dto.UserResponse>> response = 
      testRestTemplate.exchange(ENDPOINT, HttpMethod.POST,  new HttpEntity<>(request), responseType);

    // assert
    assertAll(
      () -> assertTrue(response.getStatusCode().is2xxSuccessful()),
      () -> assertThat(response.getBody().data().loginId()).isEqualTo(request.loginId()),
      () -> assertThat(response.getBody().data().email()).isEqualTo(request.email()),
      () -> assertThat(response.getBody().data().birthDate()).isEqualTo(request.birthDate()),
      () -> assertThat(response.getBody().data().gender()).isEqualTo(request.gender())
    );
  }
}

 

그래서 내가 원하는 200 대의 응답이 왔는지. 데이터가 잘 저장이 되었고, 응답으로 내가 원하는 응답이 나왔는지 테스트하게 된다.

Test Doubles

테스트 더블(Test Double)이란, 테스트 대상이 실제 의존 객체를 대신해서 사용하는 '가짜 객체'를 말한다. 이를 통해 테스트가 외부 환경(DB, 네트워크, API 등)에 의존하지 않고 원하는 상황을 시뮬레이션할 수 있다.

역할 목적 사용방식 예시
Dummy 자리만 채움 (사용되지 않음) 생성자 등에서 전달 User(null, null)
Stub 고정된 응답 제공 (상태 기반) when(...).thenReturn(..) when(userRepo.findById(1L)).thenReturn(user)
Mock 호출 여부/횟수 검증 (행위 기반) verify(...) 함수가 실제로 호출되었는가
Spy 진짜 객체 감싸기+일부만 제어 spy() + doReturn() 진짜 서비스를 감싸고 일부만 stub
Fake 실제처럼 행동하는 가짜 구현체 직접 클래스 구현 InMemoryUserRepository

 

이렇게 공부하다 보니, 내가 이전에 짠 코드들은 내가 mock이라고 생각했던 것들이 모두 Stub 임을 알게 되었다 !!!

 

주로 테스트시 사용하는 Stub과 Mock, 그리고 Spy는 언제 사용하면 될까? 나 나름대로의 규칙을 만들어보았다.

(내 규칙은 정확하지 않을 수도 있어, 스스로 규칙을 세워보는 것도 좋을 것 같다 !)

Stub

Stub은 상태 기반 테스트를 위해 응답을 세팅한다. 이러한 Stub은 테스트하려는 동작이 특정 동작에 종속되어 있을 경우에 사용하면 된다.

예를 들어, 내가 문자를 보내는 API 를 만들었다고 가정해보자. 보통 이러한 서비스는 유료인데, 만일 모든 테스트에서 실제로 문제를 보내는 행위가 발생한다면 내 지갑이 매우 얇아질 것이다.

그래서, 이러한 경우에는 문자를 전송하는 로직을 실제로 실행하지 않고, "문자를 전송하는 함수가 true를 리턴했다고 해봐", 혹은 "문자 전송이 실패하여 false 를 리턴한다고 해봐." 라고 하는 등 원하는 응답만을 내뱉도록 할 수 있다.

또한 이렇게 Stub으로 해당 행위를 감싼다면 내가 테스트하려고 할 때 전제조건이 더욱 명확해지기도 한다.

@Test
void returnMessage_whenSmsSendSuccess() {
	// arrange
    String mobile = "+821012345678";
    String message = "전송 테스트";
    when(smsService.send(mobile, message)).thenReturn(true);
    
    // act
    - act에서 호출되는 서비스 내에서 smsService.send()를 호출한다면
    - smsService.send()의 응답값이 어떤지 제어할 필요 없이 원하는 응답만을 리턴하니까
    - 나는 그 응답값만을 가지고 서비스가 해당 응답값에 따라 어떻게 분기처리되는지 명확하게 테스트할 수 있다.
    
    // assert
}

 

Stub은 아래 Mock과 Spy와 달리 메서드 호출 여부는 검증하지 않는다.

Mock

mock은 테스트 대상이 의존 객체의 특정 메서드를 호출했는지, 몇 번 호출했는지 등 '행위'를 검증할 때 사용한다.

쉽게 생각하자면, 호출 여부가 검증 대상이라는 것이다.

 

예를 들어 이러한 코드가 존재한다고 해보자.

switch(no) {
    case 1 -> aa();
    case 2 -> bb();
    case 3 -> cc();
    default -> dd();
}

 

만약 no가 1이라면 aa가 1번, 2라면 bb가 1번, 3이라면 cc가 1번 그 외는 dd가 1번 호출되어야 한다.

이러한 경우 제대로 switch 문에 대한 조건에 대해 잘 동작을 하였는지 확인할 수 있다.

만약 내가 input으로 넣은 no가 2 라면 bb가 호출되어야하는데, 중간에 특정 코드로 인해 bb는 호출이 되지 않고, aa가 호출되었다면 이는 실패한 테스트라고 볼 수 있고, 개발자는 이러한 결과를 통해 중간에 로직이 잘못되었다는 것을 확인할 수 있다.

 

즉, 내부 동작은 상관 없이 단순히 호출이 몇번 되었는지 확인하고 싶을 때 사용하면 된다.

Spy

마지막으로 Spy이다.

Spy는 위치추적기이다. 나한테 위치 추적기를 달 수는 있지만 내 행위를 숨길 수 있다. 즉, Spy는 진짜 객체를 감싸서 일부 메서드만 Stub 하거나, 호출 여부를 검증할 수 있어, 실제 로직을 그대로 사용하면서 일부 메서드만 원하는 대로 제어할 수 있다.

예를 들어 위치 추적기로 확인했더니 식당에 들어갔는데 식당에서 밥먹다고 해놓고 커피만 마실 수 있다. 하지만 나 자신은 진짜이기 때문에 나 자체는 진짜이지만 일부 행위만 교란시킬 수 있다.

 

Mock과 동일한 코드로 예시를 들어보겠다.

switch(no) {
    case 1 -> aa();
    case 2 -> bb();
    case 3 -> cc();
    default -> dd();
}

 

동일하게 이러한 경우에 aa, bb, cc, dd의 호출 횟수로 내부 동작이 잘 돌아가는지 확인할 수 있다.

 

이 때, Mock으로 하지 않는 이유는, aa 등의 함수가 호출되었다는 것 뿐만 아니라 해당 함수가 제대로 작동해야하기 때문이다. 즉, Spy를 쓰면 진짜 메서드가 실행되어 실제 로직을 검증함과 동시에 필요한 메서드만 Stub 하여 특정 조건을 시뮬레이션할 수 있다.

예를 들어 이 로직을 실행하는 함수가 절대 예외를 안던진다고 가정하고 mock을 했다고 하자. 하지만 예외는 던져질 수 있고, 그럴 때, 운영에서 장애가 발생하게 된다.

 

즉, 로직은 그대로 사용하고, 그 중에서 특정 동작만 덮어씌우고 검증하고 싶을 때 사용한다.

 

그래서 행위가 발생했다는 것 뿐 만 아니라, 실제로 동작시켜 userRepository가 잘 동작하는지도 확인할 수 있다.

@SpringBootTest
class UserServiceIntegrationTest {
...

    @DisplayName("회원가입을 할 때,")
    @Nested
    class Register {
        @DisplayName("회원 가입시 User 저장이 수행된다. ( spy 검증 )")
        @Test
        void saveUserEntity_whenUserRegister() {
            // arrange
            UserV1Dto.UserRegisterRequest request = new UserV1Dto.UserRegisterRequest("la28s5d", "la28s5d@naver.com", "password", "F", "2025-01-01", "소연");

            // act
            UserEntity result = userService.register(request);
           	UserEntity repositoryUser = userRepository.findById(result.getId());

            // assert
            assertAll(
                    () -> assertThat(result).isNotNull(),
                    () -> assertThat(result.getLoginId()).isEqualTo(request.loginId()),
                    () -> assertThat(result.getEmail()).isEqualTo(request.email()),
                    () -> assertThat(result.getGender()).isEqualTo(request.gender()),
                    () -> assertThat(result.getBirthDate()).isEqualTo(request.birthDate()),
                    () -> assertThat(result.getNickname()).isEqualTo(request.nickname())
                    () -> verify(userJpaRepository).save(result), // 이 부분은 Mock과 Spy에서 둘 다 가능
                    () -> assertEquals(repositoryUser.getLoginId(), result.getLoginId())
            );
        }
    }
}

 

사실 아직도 Spy와 Mock은 헷갈리긴 한다.

내가 이해한 바로는 단순히 호출 여부나 횟수만 검증하고 싶을 때는 Mock을, 진짜 객체를 사용하면서 일부 메서드만 Stub 하면서 검증도 함께 하고 싶을 때는 Spy를 사용하면 될 것 같다.

'loop:Pak L2 Backend(2025)' 카테고리의 다른 글

[loop:Pak] Round2 - WIL  (0) 2025.07.24
[loop:Pak] Round2 - 간단한 설계 문서 만들기  (0) 2025.07.23
[loop:Pak] Round1 - WIL  (1) 2025.07.18
profile

SY 개발일지

@SY 키키

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!