[TIL] 241115 테스트코드

MONA·2024년 11월 15일

나혼공

목록 보기
30/92

개발이 어느 정도 마무리 되고, 명세대로 테스트 코드를 작성하게 되었다.
지금까지 개발 중 테스트는 Postman으로 보내고, DB를 확인하는 방법으로 진행했었다.
테스트 기반 개발도 해보고 싶었는데, 이번에 테스트에 대해서 알아보고 다음 개발때는 한번 해봐도 좋을 것 같다.

테스트

종류

일반적으로 단위 테스트, 통합 테스트, 종단 간 테스트로 나눌 수 있다.
1. 단위 테스트 (Unit Test)

  • 개별 메소드나 함수의 기능을 테스트
  • 외부 의존성을 Mock 객체로 대체해 개별 코드의 로직만 검증
  • 서비스 레이어의 특정 메서드가 예상된 데이터를 반환하는지, 예외를 제대로 던지는지 등을 테스트
  • JUnit, Mockito 등의 라이브러리를 주로 사용
  1. 통합 테스트 (Ingration Test)
  • 서비스와 데이터베이스, 외부 API 등 여러 모듈이 상호작용할 때 전체적인 동작이 올바른지 확인
  • 실제 데이터베이스에 대한 의존성을 가지고 실행하거나 H2같은 인메모리 데이터베이스를 사용
  • Spring Boot에서는 @SpringBootTest 를 사용해 전체 애플리케이션 컨텍스트를 로드하고 테스트할 수 있음
  1. 종단 간 테스트 (End-to-End Test)
  • 애플리케이션의 요청부터 응답까지 전체적인 흐름을 테스트
  • 사용자의 행동을 시뮬레이션 하는 방식으로 실제 서비스가 사용자 요청을 처리하는 방식을 검증
  • Postman, Rest Assured 등을 사용해 API 테스트를 진행할 수 있음

테스트 코드의 필요성

  1. 신뢰성
  • 코드를 변경하거나 새로운 기능을 추가할 때 기존 기능이 제대로 동작하는지 확인할 수 있음
  • 코드의 수정으로 인해 생길 수 있는 부작용을 조기에 발견 가능
  1. 유지보수성
  • 테스트 코드는 코드의 동작을 문서화하는 역할도 함
  • 코드 변경이 필요할 시 테스트 코드가 그 변경의 결과를 예측하고 확인할 수 있도록 도움
  1. 개발 속도 향상
  • 수동 테스트보다 자동화된 테스트 코드가 있으면 빠른 검증이 가능
  • 오류 발생 시 즉시 알려주기 때문에 디버깅 시간도 줄어듦
  1. 품질 보증
  • 테스트 코드를 통해 애플리케이션이 예상대로 동작함을 보장할 수 있음
  • 사용자에게 더 신뢰할 수 있는 제품을 제공

테스트 코드 작성 방법

각 단계(단위 테스트, 통합 테스트, 종단 간 테스트)별로 검증해야 할 범위와 방법이 다름

  1. 단위 테스트
  • @Test 어노테이션을 이용해 테스트 메소드를 정의
  • Given-When-Then 패턴으로 작성
    • Given: 테스트에 필요한 사전 조건 설정
    • When: 테스트하려는 동작 실행
    • Then: 예상 결과 검증
  1. 통합 테스트
  • @SpringBootTest 어노테이션을 사용해 애플리케이션 컨텍스트를 로드하여 통합 테스트 수행 가능
  1. 종단 간 테스트
  • Rest Assured, Postman, 또는 Selenium을 사용하여 HTTP 요청을 보내고 응답을 확인할 수 있음
  • API 엔드포인트에 대한 HTTP 요청을 보내고 응답을 검증

종단 간 테스트는 계속 해왔던 것이고, Junit과 Mockito를 사용해서 단위 테스트를 진행해 볼 것이다

Junit

  • 자바 애플리케이션에서 가장 널리 사용되는 테스트 프레임워크 중 하나
  • 단위 테스트를 쉽게 작성하고 실행할 수 있게 지원
  • 메소드별로 테스트 코드를 작성하고 코드의 개별 단위가 정상 동작하는지를 검증할 수 있음

Junit의 주요 어노테이션

  • @Test: 테스트 메소드를 표시함. 이 어노테이션이 붙은 메소드는 테스트로 인식되며 실행됨.
  • @BeforeEach: 각 테스트 메소드가 실행되기 전에 수행할 초기화 코드가 있는 메소드를 정의할 때 사용함. 테스트의 독립성을 보장하기 위해 사용됨.
  • @AfterEach: 각 테스트가 끝난 후 실행되는 메소드를 정의함. 주로 리소스 정리 작업을 수행할 때 사용됨.
  • @BeforeAll: 모든 테스트가 실행되기 전에 한 번만 수행될 설정 작업을 정의함.
  • @AfterAll: 모든 테스트가 끝난 후 한 번만 실행될 작업을 정의함.
  • @Disabled: 특정 테스트 메소드를 비활성화할 때 사용함.

Mockito

  • 자바 애플리케이션에서 단위 테스트를 작성할 때 널리 사용되는 Mocking 프레임워크
  • Mock 객체를 만들어 메소드 호출을 시뮬레이션하고, 실제 객체 대신 가짜 객체로 테스트 환경을 구성할 수 있게 해줌
  • 이로 인해 객체 간의 의존성을 분리하고 개별적인 메소드나 클래스의 동작을 검증할 수 있음

Mockito의 주요 개념

  1. Mock 객체
  • 실제 객체처럼 동작하지만 메소드의 반환값이나 동작을 미리 지정할 수 있음
  • 실제 비즈니스 로직을 포함하지 않고 특정 동작을 모방하여 테스트 코드의 안정성을 높이는 데 사용
  1. Stubbing
  • Mock 객체의 특정 메소드 호출에 대해 원하는 반환값을 미리 지정하는 작업
  • 실제 메소드를 호출하지 않고도 원하는 테스트 시나리오를 만들 수 있음
  1. Verification
  • 특정 메소드가 호출되었는지, 호출 횟수는 몇 번인지 등을 검증하는 작업
  • 메소드가 예상대로 실행되었는지 검증할 때 유용

주요 기능 및 사용방법

  1. Mock 객체 생성
@Mock
private UserRepository userRepository;
// 또는 아래 방법 사용
UserRepository userRepository = Mockito.mock(UserRepository.class);
  1. Stubbing
when(userRepository.findByEmail("test@example.com")).thenReturn(Optional.of(new User()));
  1. Verification
verify(userRepository).save(any(User.class));
verify(userRepository, times(1)).save(any(User.class)); // 호출 횟수도 검증 가능
  1. Argument Matchers
    메소드의 매개변수 값을 특정하지 않고도 호출 여부를 검증할 수 있도록 하는 기능
    any(), anyString(), anyInt() 등 다양한 Matcher 사용
when(userRepository.findByEmail(anyString())).thenReturn(Optional.of(new User()));
verify(userRepository).findByEmail(anyString());
  1. void 메소드의 예외 처리
    doThrow() 사용
doThrow(new RuntimeException()).when(userRepository).delete(any(User.class));

Mockito를 사용했을 때의 장점

1.의존성 분리

  • 복잡한 의존성을 가진 객체를 테스트할 때, 의존성을 분리하고 테스트할 수 있어 독립적인 단위 테스트가 가능함
  1. 테스트 속도 향상
  • 실제 데이터베이스나 네트워크 요청을 사용할 필요 없이 가짜 객체로 테스트할 수 있어 빠른 속도로 테스트를 실행할 수 있음
  1. 코드 커버리지 증가
  • 여러 가지 경우에 대해 Mocking으로 다양한 테스트 시나리오를 만들 수 있으므로 코드 커버리지를 높이는 데 도움이 됨

작성하기!

  1. 의존성 추가
testImplementation 'org.junit.jupiter:junit-jupiter:5.7.0'
  1. 테스트하려는 서비스 클래스명 위에 커서를 두고 command + n > test

이렇게 알아서 만들어준다.

  1. 테스트 코드 작성
    회원가입 메서드에 대한 테스트 코드를 작성하려 한다.
public ResponseDto createUser(CreateUserRequestDto request) {

        Point latLngPoint = geometryFactory.createPoint(new Coordinate(request.getLng(), request.getLat()));
        String socialProvider = "NONE";
        String password = request.getPassword();
        // 이메일 중복확인
        if(userRepository.findByEmail(request.getEmail()).isPresent()) {
            return new ResponseDto<>(-1, "중복된 이메일입니다", null);
        }
        // 닉네임 중복확인
        if(userRepository.findByNickname(request.getNickname()).isPresent()) {
            return new ResponseDto<>(-1, "중복된 닉네임입니다", null);
        }
        // 카카오 회원가입일 경우
        if(request.getKakaoId() != null) {
            socialProvider = "KAKAO";
            // 비밀번호 랜덤 생성
            password = UUID.randomUUID().toString();
        }
        // 일반 회원가입인데 비밀번호가 없는 경우
        if(request.getKakaoId() == null && password == null) {
            return new ResponseDto<>(-1, "비밀번호가 없습니다", null);
        }

        // 비밀번호 암호화
        String encodedPassword = passwordEncoder.encode(password);

        P_user user = P_user.builder()
                .email(request.getEmail())
                .password(encodedPassword)
                .nickname(request.getNickname())
                .phone(request.getPhone())
                .birth(request.getBirth())
                .use_yn(true)
                .role(UserRoleEnum.CUSTOMER)
                .imageProfile(request.getImageProfile())
                .latLng(latLngPoint)
                .address(request.getAddress())
                .kakaoId(request.getKakaoId())
                .socialProvider(socialProvider)
                .build();

        userRepository.save(user);
        return new ResponseDto<>(1, "회원가입이 완료되었습니다", null);

    }

이 기능에 대해 테스트해야 할 주요 시나리오는 다음과 같다.
1. 이메일 중복
2. 닉네임 중복
3. 카카오 회원가입: 카카오 ID가 있는 경우 랜덤 비밀번호가 생성되는지 확인
4. 비밀번호 없음: 일반 회원인데 비밀번호가 없는 경우
5. 정상적인 회원 가입: 모든 필드가 유효한 경우
테스트를 위해 userRepository, passwordEncoder, geometryFactory 등을 Mocking하여 사용한다.

이메일 중복 상황 테스트

@Test
    void createUserTest_EmailDuplicate() {
        // Given
        CreateUserRequestDto request = CreateUserRequestDto.builder()
                .email("test@test.com")
                .password("password1234")
                .nickname("nickname")
                .phone("phone")
                .birth(LocalDate.parse("2000-01-01"))
                .imageProfile("imageProfileUrl")
                .lat(12.123124f)
                .lng(123.123124f)
                .address("상세주소")
                .kakaoId(null)
                .build();
        when(userRepository.findByEmail(request.getEmail())).thenReturn(Optional.of(new P_user()));
[;''
        // When
        ResponseDto response = userService.createUser(request);

        // Then
        assertEquals(-1, response.getCode());
        assertEquals("중복된 이메일입니다", response.getMsg());
    }
  • Given
    • Mocking 설정: when(userRepository.findByEmail(request.getEmail())).thenReturn(Optional.of(new P_user()));가 정상적으로 동작하여, 이메일 중복 상황이 시뮬레이션됨
  • When
    • userService.createUser(request) 호출 시, findByEmail이 Mock 설정대로 Optional.of(new P_user())를 반환함
  • Then
    • assertEquals(-1, response.getCode()): ResponseDto 객체의 code 값이 예상대로 -1로 설정되었음을 확인
    • assertEquals("중복된 이메일입니다", response.getMsg()): 오류 메시지가 정확히 "중복된 이메일입니다"로 반환되었음을 확인

이런 식으로 테스트 하면 된다.

닉네임 중복 테스트

@Test
    void createUserTest_NicknameDuplicate() {
        // Given
        CreateUserRequestDto request = CreateUserRequestDto.builder()
                .email("test@test.com")
                .password("password1234")
                .nickname("nickname")
                .phone("010-1234-5678")
                .birth(LocalDate.parse("2000-01-01"))
                .imageProfile("imageProfileUrl")
                .lat(12.123124f)
                .lng(123.123124f)
                .address("상세주소")
                .kakaoId(null)
                .build();

        // Mocking: 이메일은 중복되지 않음
        when(userRepository.findByEmail(request.getEmail())).thenReturn(Optional.empty());

        // Mocking: 닉네임이 중복되는 상황 시뮬레이션
        when(userRepository.findByNickname(request.getNickname())).thenReturn(Optional.of(new P_user()));

        // When
        ResponseDto response = userService.createUser(request);

        // Then
        assertEquals(-1, response.getCode());
        assertEquals("중복된 닉네임입니다", response.getMsg());
    }

카카오 회원가입 성공

@Test
    void createUserTest_KakaoSignupSuccess() {
        // Given
        CreateUserRequestDto request = CreateUserRequestDto.builder()
                .email("test@test.com")
                .password(null)  // 비밀번호는 랜덤으로 생성
                .nickname("kakaoUser")
                .phone("010-5678-1234")
                .birth(LocalDate.parse("1998-03-17"))
                .imageProfile("kakaoProfile.png")
                .lat(37.5665f)
                .lng(126.9780f)
                .address("상세주소")
                .kakaoId(123456789L)  // 카카오 ID 설정
                .build();

        // Mocking: 이메일과 닉네임 중복 없음
        when(userRepository.findByEmail(request.getEmail())).thenReturn(Optional.empty());
        when(userRepository.findByNickname(request.getNickname())).thenReturn(Optional.empty());

        // Mocking: 비밀번호 암호화
        String randomPassword = UUID.randomUUID().toString(); // 생성된 랜덤 비밀번호
        String encodedPassword = "encodedRandomPassword";    // 암호화된 비밀번호
        when(passwordEncoder.encode(randomPassword)).thenReturn(encodedPassword);

        // When
        ResponseDto response = userService.createUser(request);

        // Then
        assertEquals(1, response.getCode());
        assertEquals("회원가입이 완료되었습니다", response.getMsg());

        // Verify: passwordEncoder.encode(...) 호출 확인
        verify(passwordEncoder).encode(randomPassword);

        // Verify: 저장된 사용자 정보 확인
        verify(userRepository).save(argThat(user ->
                user.getEmail().equals("kakao@test.com") &&
                        user.getNickname().equals("kakaoUser") &&
                        user.getKakaoId().equals(123456789L) &&
                        user.getSocialProvider().equals("KAKAO") &&
                        user.getPassword().equals(encodedPassword) // 암호화된 비밀번호 확인
        ));
    }


생각없이 쓰다가 전달 객체랑 검증 객체 이메일을 다르게 써서 에러 발생.
잘 동작하는지 확인한거임 아무튼 그럼.

인코딩된 비밀번호 때문에 테스트가 실패했다.

  • 테스트 코드에서 randomPassword을 명시적으로 정의했지만, 서비스 로직에서 새로운 UUID를 생성하여 사용하고 있음
  • 테스트 코드에서 Mocking한 when(passwordEncoder.encode())은 테스트 코드의 randomPassword와 연결되어 있지만 서비스 로직에서는 다른 UUID 값을 passwordEncoder.encode()에 전달함
  • passwordEncoder.encode()가 호출될 때 당연히 Mocking된 값과 다른 값이 전달됨
  • Stubbing argument mismatch 에러 발생

해결: 어차피 쓰지 않을 비밀번호이기에 전달된 값과는 상관없이 호출 여부만 검증함
Mockito의 anyString() 사용

  • anyString(): Mockito의 Argument Matcher 중 하나로, 메서드 호출 시 특정 타입의 인자가 어떤 값으로든 전달될 수 있음을 나타내기 위해 사용됨. String 타입의 값이 어떤 것이든 허용함

수정된 테스트 코드

@Test
    void createUserTest_KakaoSignupSuccess() {
        // Given
        String encodedPassword = "encodedRandomPassword";
        CreateUserRequestDto request = CreateUserRequestDto.builder()
                .email("test@test.com")
                .password(null)  // 비밀번호는 랜덤으로 생성
                .nickname("kakaoUser")
                .phone("010-5678-1234")
                .birth(LocalDate.parse("1998-03-17"))
                .imageProfile("kakaoProfile.png")
                .lat(37.5665f)
                .lng(126.9780f)
                .address("상세주소")
                .kakaoId(123456789L)  // 카카오 ID 설정
                .build();

        // Mocking: 이메일과 닉네임 중복 없음, 비밀번호는 존재여부 확인
        when(userRepository.findByEmail(request.getEmail())).thenReturn(Optional.empty());
        when(userRepository.findByNickname(request.getNickname())).thenReturn(Optional.empty());
        when(passwordEncoder.encode(anyString())).thenReturn(encodedPassword);

        // When
        ResponseDto response = userService.createUser(request);

        // Then
        assertEquals(1, response.getCode());
        assertEquals("회원가입이 완료되었습니다", response.getMsg());

        // Verify: 저장된 사용자 정보 확인
        verify(userRepository).save(argThat(user ->
                user.getEmail().equals("test@test.com") &&
                        user.getNickname().equals("kakaoUser") &&
                        user.getKakaoId().equals(123456789L) &&
                        user.getSocialProvider().equals("KAKAO") &&
                        user.getPassword().equals(encodedPassword)
        ));
    }

일반 회원가입 성공

@Test
    void createUserTest_Success() {
        // Given
        CreateUserRequestDto request = CreateUserRequestDto.builder()
                .email("test@test.com")
                .password("password")  // 일반 유저의 비밀번호
                .nickname("nickname")
                .phone("010-1234-5678")
                .birth(LocalDate.parse("1990-01-01"))
                .imageProfile("profile.png")
                .lat(37.5665f)
                .lng(126.9780f)
                .address("상세주소")
                .kakaoId(null)  // 카카오 ID 없음
                .build();

        // Mocking: 이메일과 닉네임 중복 없음
        when(userRepository.findByEmail(request.getEmail())).thenReturn(Optional.empty());
        when(userRepository.findByNickname(request.getNickname())).thenReturn(Optional.empty());

        // Mocking: 비밀번호 암호화
        String password = "password";
        when(passwordEncoder.encode("password")).thenReturn(password);

        // When
        ResponseDto response = userService.createUser(request);

        // Then
        assertEquals(1, response.getCode());
        assertEquals("회원가입이 완료되었습니다", response.getMsg());

        // Verify: 저장된 사용자 정보 확인
        verify(passwordEncoder).encode("password"); // 일반 유저 비밀번호 인코딩 검증
        verify(userRepository).save(argThat(user ->
                user.getEmail().equals("test@test.com") &&
                        user.getNickname().equals("nickname") &&
                        user.getKakaoId() == null &&
                        user.getSocialProvider().equals("NONE") &&
                        user.getPassword().equals(password) // 암호화된 비밀번호 저장 확인
        ));
    }

조건문이 많으면 테스트 코드 짜기가 번거롭다는걸 느꼈다..

다 작성하고 보니 CreateUserRequestDto를 생성하는 부분이 번복되어서 따로 분리하였다.

private CreateUserRequestDto createRequest(String email, String password, String nickname, Long kakaoId) {
        return CreateUserRequestDto.builder()
                .email(email)
                .password(password)
                .nickname(nickname)
                .phone("010-1234-5678")
                .birth(LocalDate.parse("1990-01-01"))
                .imageProfile("profile.png")
                .lat(37.5665f)
                .lng(126.9780f)
                .address("상세주소")
                .kakaoId(kakaoId)
                .build();
    }

그리고 카카오 로그인 때문에 password 필드를 notnull 설정해두지 않아서 에지 케이스로 추가 작성해줬다.

비밀번호가 Null인 경우

@Test
    void createUser_WhenPasswordIsNullForGeneralUser() {
        // Given
        CreateUserRequestDto request = createRequest("test@test.com", null, "nickname", null);

        // Mocking: 이메일과 닉네임 중복 없음
        when(userRepository.findByEmail(request.getEmail())).thenReturn(Optional.empty());
        when(userRepository.findByNickname(request.getNickname())).thenReturn(Optional.empty());

        // When
        ResponseDto response = userService.createUser(request);

        // Then
        assertEquals(-1, response.getCode());
        assertEquals("비밀번호가 없습니다", response.getMsg());
    }
profile
고민고민고민

0개의 댓글