🔌 SPARTA/Courses

스탠다드반 9회차: TDD와 단위 테스트

eunjiom 2026. 3. 5. 17:35

단위 테스트와 GWT 패턴

1. 기존

1. `Application.java`의 재생 버튼(▶)을 눌러 스프링 부트 서버를 켠다.
2. 포스트맨(Postman)을 열고 JSON 바디를 한 땀 한 땀 작성한다.
3. `Send` 버튼을 누르고 응답을 확인한다.
4. "어라? 에러 나네?" 코드를 수정하고 1번부터 다시 시작한다.

 

2. 수동 테스트 의존하면 안 되는 이유

  • 회귀 버그: 한 코드를 수정하며 공통 모듈을 건드리는 바람에 다른 코드에서 버그가 생김 > postman을 이용하면 일일이 다시 실행해서 확인해봐야 함
  • 느린 피드백 루프: 스프링 부트가 켜지는데 오래 걸림 / 에러 하나 고치고 확인하는데 오래 걸림
  • 문서화 부재: 남의 코드를 보고 수정해야 하는데 확인하기 어려움

3. 단위 테스트

 

1) 개념

  • 개발자의 의도대로 정확히 작동하는지 코드로 코드를 검증하는 자동화된 과정
  • 함수 단위로 테스트

2) Assertion (단언 / 검증)

  • Assertion: 내가 예상한 결과(Expected)와 실제 실행된 결과(Actual)가 일치하는지 단호하게 따져 묻는 것
  • 일반 코드: int result = calculator.add(1, 2); (실행하고 끝)
  • 테스트 코드 (Assertion): assertThat(result).isEqualTo(3); (결과가 3이 아니면 테스트 실패 처리!)
  • 주로 AssertJ라는 라이브러리의 assertThat() 메서드를 사용

3) Given-When-Then (GWT) 패턴

  •  Given (준비)
    • 테스트를 실행하기 위한 '환경과 데이터'를 세팅하는 단계입니다.
    • "어떤 상황이 주어졌을 때"
    • 예: "홍길동이라는 유저 객체를 만들고, 나이를 20살로 세팅한다."
  • When (실행)
    • 우리가 실제로 테스트하고자 하는 그 '메서드'를 호출하는 단계입니다.
    • "그 행동을 실행하면"
    • 예: "유저의 성인 여부를 확인하는 isAdult() 메서드를 호출한다."
  • Then (검증)
    • When 단계에서 나온 결과값을 앞서 배운 Assertion을 통해 검증하는 단계입니다.
    • "이러한 결과가 나와야만 한다!"
    • 예: "반환된 결과값이 true인지 검증한다. (assertThat(result).isTrue())"

Mockito를 활용한 AuthService 테스트

1. 서비스(Service)는 리포지토리(Repository)가 필요한 이유

  • AuthService의 login() 메서드가 작동하려면 데이터베이스에서 유저 정보를 꺼내와야 함
  • MemberRepository에 강하게 의존
  • 진짜 DB 붙이면 테스트 속도 저하, 데이터 오염

2. Mocito

 

1) 역할

  • DB 연결 없이, 마치 DB가 있는 것처럼 AuthService만 테스트
  • 주연 배우 (AuthService): 우리가 진짜로 연기력(로직)을 평가하고 싶은 테스트의 주인공입니다
  • 위험한 액션 씬 (DB 조회): 시간도 오래 걸리고 데이터 오염의 위험이 있는 작업입니다
  • 대역 배우 (Mock Repository): 주연 배우를 대신해 액션 씬을 처리해 주는 '가짜 객체'입니다

2) Mockito의 3대장 용어

  • @Mock (가짜 객체 생성)
  • @InjectMocks (가짜 객체 주입)
  • Stubbing (스텁 / 훈련시키기) : given() 또는 when().thenReturn() 이라는 메서드로 훈련

3. TDD : 실패하는 테스트를 먼저 작성

 

1) 로그인 실패 시나리오

  • 이메일이 없는 경우 예외가 터져야 한다!
  • 비밀번호가 틀린 경우 예외가 터져야 한다!

2) 실행

  • 실제 로직이 없으니 테스트는 새빨간 에러(RED)를 뿜어내며 실패
  • 빨간불을 초록불(GREEN)로 바꾸기 위해 다음 스텝에서 실제 서비스 로직을 채워 넣을 것

3) 실습

// Mockito 대역 배우들을 사용하겠다고 선언합니다!
@ExtendWith(MockitoExtension.class)
class AuthServiceTest {

    // 1. 대역 배우 캐스팅
    @Mock
    private MemberRepository memberRepository;

    @Mock
    private PasswordEncoder passwordEncoder;

    @Mock
    private JwtTokenProvider jwtTokenProvider;

    // 2. 주연 배우에게 대역 배우들 주입
    @InjectMocks
    private AuthService authService;

    @Test
    @DisplayName("로그인 실패 - 존재하지 않는 회원")
    void login_fail_user_not_found() {
        // given: 상황 세팅 (존재하지 않는 유저네임으로 로그인 시도)
        LoginRequest request = new LoginRequest("not_found_user", "password123!");

        // 핵심! 대역 배우 훈련(Stubbing)시키기
        // "누가 memberRepository.findByUsername()을 호출하든, 비어있는 Optional을 리턴해라!"
        // Stubbing: 실제DB에 간 척
        given(memberRepository.findByUsername(anyString())).willReturn(Optional.empty());

        // when & then: authService.login()을 실행했을 때, MemberException이 "터져야만" 테스트 통과!
        assertThatThrownBy(() -> authService.login(request))
                .isInstanceOf(MemberException.class)
                .hasMessage(MemberErrorCode.INVALID_CREDENTIALS.getMessage());
    }

 

TDD, 로그인 API 서비스 로직 완성

 

1. 실제 개발자의 TDD 논리 프로세스

  • "첫 번째 테스트(존재하지 않는 회원)를 통과시켜야 해."
  • "두 번째 테스트(비밀번호 불일치)를 통과시켜야 해."
  • "마지막, 진짜 로그인 성공 로직 완성하기."

2. TDD 실습

 

1) 존재하지 않는 회원 거르기

더보기
더보기
더보기
  • 테스트 코드 작성 (Red) 
    // AuthServiceTest.java 내부에 추가
    @Test
    @DisplayName("로그인 실패 - 존재하지 않는 회원 ")
    void login_fail_user_not_found() {
        // given: 없는 유저네임으로 요청
        LoginRequest request = new LoginRequest("not_found_user", "password123!");

        // 대역 배우 훈련: "누가 조회하든 무조건 비어있는 Optional을 뱉어라!"
        given(memberRepository.findByUsernameOrEmailOrPhone(anyString(), anyString(), anyString())).willReturn(Optional.empty());

        // when & then: 예외가 터져야 성공! 예외 코드도 우리가 지정한 에러코드인지 확인
        assertThatThrownBy(() -> authService.login(request))
                .isInstanceOf(MemberException.class)
                .hasMessage(MemberErrorCode.INVALID_CREDENTIALS.getMessage());
    }
    • 서비스 로직 구현(Green)
    // AuthService.java 내부에 추가
    @Transactional
    public AuthTokens login(LoginRequest request) {
        // 요구사항: username, email, phone 셋 중 하나를 선택해 로그인
        String loginId = request.username();

        // 1. 유저 조회 (없으면 예외를 뻥! 던집니다. 이걸로 Step 1 테스트가 통과됩니다!)
        Member member = memberRepository.findByUsernameOrEmailOrPhone(loginId, loginId, loginId)
                .orElseThrow(() -> new MemberException(MemberErrorCode.INVALID_CREDENTIALS));

        return null; // 일단 컴파일을 위해 null 리턴
    }

2) 비밀번호 불일치 거르기

더보기
더보기
더보기
  • 테스트 코드 작성 (Red)
    // AuthServiceTest.java 내부에 추가
    @Test
    @DisplayName("로그인 실패 - 비밀번호 불일치 시 없는 회원과 동일한 예외 반환")
    void login_fail_invalid_password() {
        // given: DB에 유저는 있지만, 입력한 비밀번호가 틀림
        LoginRequest request = new LoginRequest("test_user", "wrong_password");
        Member mockMember = Member.builder().username("test_user").password("encoded_correct_password").build();
        ReflectionTestUtils.setField(mockMember, "id", 1L);

        // 대역 배우 훈련 1: 유저 조회는 성공하게 만듦
        given(memberRepository.findByUsernameOrEmailOrPhone(
                eq(request.username()), eq(request.username()), eq(request.username())))
                .willReturn(Optional.of(mockMember));

        // 대역 배우 훈련 2: 비밀번호 검사기(PasswordEncoder)는 무조건 false를 뱉게 만듦
        given(passwordEncoder.matches(eq(request.password()), eq(mockMember.getPassword())))
                .willReturn(false);

        // when & then: 예외가 터지는지 확인
        assertThatThrownBy(() -> authService.login(request))
                .isInstanceOf(MemberException.class)
                .hasMessage(MemberErrorCode.INVALID_CREDENTIALS.getMessage());
    }
  • 서비스 로직 구현(Green)
    // AuthService.java 의 login 메서드에 이어서 작성
        // 2. 비밀번호 불일치 시 예외 던지기 (이걸로 Step 3 테스트가 통과됩니다!)
        if (!passwordEncoder.matches(request.password(), member.getPassword())) {
            throw new MemberException(MemberErrorCode.INVALID_CREDENTIALS);
        }
  •  

3) 로그인 성공(토큰 발급)

더보기
더보기
더보기
  • 테스트 코드 작성 (Red)
    // AuthServiceTest.java 내부에 추가
    @Test
    @DisplayName("로그인 성공 - 토큰 페어 정상 발급")
    void login_success() {
        // given: 모든 것이 완벽한 상황 세팅
        LoginRequest request = new LoginRequest("test_user", "correct_password");
        /* ... mockMember 생성 및 repository, passwordEncoder Stubbing (위와 동일하여 생략) ... */
        given(passwordEncoder.matches(eq(request.password()), eq(mockMember.getPassword())))
                .willReturn(true);

        // 대역 배우 훈련 3: 토큰 발급기(JwtTokenProvider)가 가짜 토큰을 뱉도록 훈련
        given(jwtTokenProvider.createAccessToken(eq(1L), anyString())).willReturn("mock.access.token");
        given(jwtTokenProvider.createRefreshToken(eq(1L))).willReturn("mock.refresh.token");

        // when
        AuthTokens tokens = authService.login(request);

        // then: 토큰이 잘 받아와졌는지 검증
        assertThat(tokens).isNotNull();
        assertThat(tokens.accessToken()).isEqualTo("mock.access.token");
        assertThat(tokens.refreshToken()).isEqualTo("mock.refresh.token");

        // 핵심! 행동 검증 (Behavior Verification)
        // 토큰 발급기의 메서드가 "정말로 1번씩 호출되었는가?"를 따져 묻는 현업 필수 검증 기법입니다!
        then(jwtTokenProvider).should().createAccessToken(eq(1L), anyString());
        then(jwtTokenProvider).should().createRefreshToken(eq(1L));
    }
  • 서비스 로직 구현 (Green)
    // AuthService.java 의 login 메서드에 이어서 작성
        // 3. 토큰 발급 및 DB 저장 (이걸로 대망의 성공 테스트가 통과됩니다!)
        String accessToken = jwtTokenProvider.createAccessToken(member.getId(), member.getRole().name());
        String refreshToken = jwtTokenProvider.createRefreshToken(member.getId());

        // Refresh Token DB 저장 로직 수행
        member.updateRefreshToken(refreshToken);

        return new AuthTokens(accessToken, refreshToken);

3. TDD & Mockito 심화

 

1) assertThat과 then().should() 다른 점

  • assertThat (상태 검증 / 결과값 검증): 최종 결과물(데이터)을 평가하는 것 / tokens.accessToken()이 "mock..."과 일치하는지
  • then().should() (행동 검증): 내부 메서드가 호출된 횟수와 과정을 감시하는 것

2) TDD는 Red -> Green -> Refactor / Refactor 단계에선 하는 것

  • 통과시킨 코드를 '아름답고 객체지향적으로 다듬는 과정

3) Mock 통과했는데 실제 DB에서 터짐

  • 통합 테스트(Integration Test, @SpringBootTest)를 추가로 작성
  • 사용자의 API 요청부터 DB 저장까지의 '전체 흐름(Full-Cycle)'을 한 번 더 검증
  • 단위 테스트: 빠르고 가벼움 (전체 테스트의 70~80% 비중)
  • 통합 테스트: 느리고 무거움, 하지만 실제 환경과 가장 유사함 (20~30% 비중)

4) when 대신 given 쓰는 이유 (BDDMockito) : 가독성

 

[Refactor] 성능과 프론트엔드 친화성 둘 다 잡기