728x90

테스트를 해보자~!

 

 

처음에는 @SpringBootTest를 이용해 실제 데이터베이스를 조회해서 값을 가져오며 테스트를 해봤다.

그러다 사용하고 있는 Mapper가 잘 동작되는지를 시작으로 Mock을 이용해 테스트를 하게 되었다.

이후 Mock 테스트를 하며 알게 된 것인데,

내가 Mock 객체의 특징을 잘모르고 쓰고 있었구나를 깨달았다. 가짜 객체..그려그려.. 딱 요렇게만 생각했다.

그래서 나름의 깨달음을 토대로, 테스트 하기 전 생각해야할 점들을 적어보았다.

 

먼저 공부했던 개념들을 짧게 정리해보겠다.

테스트하면 단위테스트, 통합테스트를 주로 말하는데 둘의 차이가 뭘까?

 

단위 테스트와 통합테스트의 구분을 나는

 

메소드 단위를 테스트 : 단위테스트

기능 단위를 테스트 : 통합테스트

 

로 얼추 분류하고 있었다. 좀 더 큰 범위가 통합테스트가 아닐까? 하는 막연한 생각을 갖고 있었다.

 

스스로도 분류기준이 애매하다는 찜찜함이 있었다. 

 

찾아본 개념부터 정리하면

- 단위 테스트 Unit Test

: 프로그램의 각 부분을 테스트로 언제든지 수행이 가능해야 한다.

: 외부 종속성과 관련없이 테스트를 진행한다.

- 통합 테스트 Integration Test

: 소프트웨어 모듈간의 통합을 테스트하는 것으로 

: 코드가 외부종속성과 올바르게 작동하는지 확인한다.

 

즉, 분류기준은 외부 종속성과 관련이 있냐, 없냐에 있었다.

 

값이 일치한지 여부를 따지는 메서드를 테스트하고자 할때

@SpringBootTest 를 통한 테스트에서는 실제 데이터베이스의 값을 조회해 비교했다면 

Mock 테스트는 값이 일치한다는 상황을 가정하고 테스트를 한다. 실제 데이터베이스의 값이 필요없는 것이다. (외부 종속성과 관련없다.)

 

결론적으로 SpringBootTest는 단위테스트에 적합하지 않고,통합테스트에 적합하다.

 

개발자는 단위테스트를 통해 자신이 짠 코드를 검증한 후에 통합테스트를 진행한다. 

 

그렇다면 단위테스트에 대해 알아보고 사용해보자

이처럼 종속성없이 테스트를 하기 위해 실제 객체를 사용하지 않고 가짜 객체를 사용하며 테스트하는 것을 

Test Double이라고 한다. Test Double 은 여러 종류가 있다. 이중 많이 사용 되는 것이 Mock객체이다. 

 

(testdouble은 주로 단위 테스트에서 사용되지만, 목적에 따라 다양한 테스트 수준에서 활용될 수 있습니다. 예를 들어, 단위 테스트 외에도 통합 테스트나 시스템 테스트에서도 사용될 수 있습니다. 라고 =chatpgpt가 알려줬다. )

 

Test Double 의 종류를 간단히 살펴보자. (참고)

  • Dummy (사전적 정의: 쓰레기)
    • 사용하지 않는 객체
    • 인스턴스화된 객체가 필요하지만 기능은 필요하지 않은 경우 사용
    • 동작하지 않아도 영향 끼치지 않음
  • Fake (사전적 정의: 가짜)
    • 복잡한 로직이 있는 외부객체동작을 단순화해서 구현한 객체
    • 예로 실제로는 DB를 연결해 테스트해야하지만 이를 단순히 구현한 FakeUserRepository를 만들어 테스트할 수 있다.
  • Stub (사전적 정의: 쓰다 남은 토막)
    • 인터페이스 또는 기본 클래스가 최소한으로 구현된 상태를 제공하는 객체
    • Dummy객체가 실제로 동작하는 것처럼보이게 만들어놓은 객체
    • Mockito프레임워크도 Stub와 같은 역할을 함.
  • Spy (사전적 정의: 염탐하다)
    • Stub객체용으로도 쓰고 호출된 내용을 확인하고자 기록하는 용으로도 쓴다.
    • 실제 객체로도 동작할 수 있다.
    • Mockito 프레임워크의 verify() 메서드가 같은 역할을 한다. 확인용
  • Mock (사전적 정의: 모조품)
    • 호출에 대한 결과값을 미리 적어두고 그 내용에 따라 동작하도록 프로그래밍하는 객체이다.

어떤 상황에서 어떤 것으로 할지 테스트의 목적에 따라 선택해야한다. (코드 참고 )

 

 

<Mock 테스트 하기 전 생각해야할 점들>

1. 무엇을 테스트하고 싶은가?

Mock 객체를 사용한 테스트 뿐 아니라 다른 테스트를 행할 때도 마찬가지로 파악하고 넘어가야하는 지점이다.

어떤 것을 테스트하고 싶은지 정해야한다. 단순히 메서드를 테스트한다고 생각하지 않고

"어떤 것을 실행해서 어떤 결과값을 얻고자 하는지"를 정해야한다. 

2. 어떤 결과값을 얻고 싶은가?

위에도 나와 있듯이 Mock 객체는 " 호출에 대한 결과값을 미리 적어두고 그 내용에 따라 동작하도록 프로그래밍하는 객체"

이기 때문에,

 내 코드가 어떤 결과값을 도출할지를 알고 있어야한다.

3. 결과값이 나오기 위해 필요한 것은?

내가 얻고자 하는 바, 즉 기대하는 결과를 안다면 그 결과가 나오기 위한 객체를 알고 원하는 상황에 맞게 설정해주어야한다.

 

 

이제 세 가지를 생각하고 테스트 코드를 짜보자. 테스트 코드를 짜기 위해 나의 코드를 먼저 살펴보겠다.

 

( 코드 수정하였음! )

 

<로그인 컨트롤러이다.> loginForm 객체로 값을 받으면 유저가 맞는지 확인하기 위해

loginService의 checkLoginForm메서드로 넘긴다.

   @PostMapping("/login")
    public String login(@Valid @ModelAttribute("loginForm") LoginForm loginForm, BindingResult bindingResult, HttpServletResponse response, RedirectAttributes redirectAttributes) {
        if (bindingResult.hasErrors()) {
            log.error("bindingResult.hasErrors-[GetMapping/login]");
            return "/loginForm";

        }

        Optional<User> user = loginService.checkLoginForm(loginForm);

        if (user.isEmpty()) {

            bindingResult.reject("loginFail", "일치하는 회원정보가 없습니다. 다시 시도해주세요");
            return "/loginForm";

        }

        //Cookie
        Cookie idCookie = new Cookie("user_id", String.valueOf(user.get().getUserId()));
        response.addCookie(idCookie);


        redirectAttributes.addAttribute("user_id", user.get().getUserId());

        return "redirect:/closet/{user_id}";


    }

 

<loginService 는 컨트롤러에서 loginForm 값을 넘겨받은 서비스이다.>

checkLoginForm 메서드 안에서 

loginUserService객체의 getUserByEmail메서드로 넘겨받은 loginForm의 이메일 주소를 넘겨

이메일 주소와 같은 유저가 있는지 찾는다. 

그 반환값에 따라 User가 있는 경우, 그 유저의 비밀번호와 loginForm의 비밀번호가 일치한지 확인한다.

일치하면 User를 넘기고 없다면 null을 반환한다.

@Slf4j
@Service
@RequiredArgsConstructor
public class LoginService {

    
    private final LoginUserService loginUserService;

    public Optional<User> checkLoginForm(LoginForm loginForm) {
        String email = loginForm.getEmail();
        User userEmail = User.builder().email(email).build();

        String password = loginForm.getPassword();

        Optional<User> userByEmail = loginUserService.getUserByEmail(userEmail);

        if (userByEmail.isPresent()) {
            return Optional.ofNullable(userByEmail.filter(m -> m.getPassword().equals(password)).orElse(null));

        } else {
            throw new NoSuchElementException("User not found");


        }


    }


}

 

<LoginUserService 는 userMapper를 통해 조회 후 결과값을 반환한다.>

@Service
@RequiredArgsConstructor
public class LoginUserService {
    private final UserMapper userMapper;

    public Optional<User> getUserByEmail(User user) {

        return Optional.ofNullable(userMapper.getByEmail(user.getEmail()));
    }
}

 

 

로그인 로직은 이렇다. 

 

1. 여기서 내가 테스트하고 싶은 것은 

loginService 객체가 유효한 loginForm을 받았을 경우이다. 그 경우 이메일도 일치하고 비밀번호도 일치해야한다. 

 

2. 기대하는 결과는, loginForm의 이메일을 넘겨받은

loginUserService의 getUserByEmail 메서드가 

해당하는 이메일과 비밀번호를 잘 조회해서 User값을 문제없이 응답하는 것이다.

 

3. 그러면 기대하는 결과가 나오기 위해서는 loginUserService객체가 필요하다.

 

일단 완성된 테스트 코드를 보자.

public class LoginServiceTest {


    @Mock
    private LoginUserService loginUserService;
    @InjectMocks
    private LoginService loginService;

    private LoginForm loginForm;
    private User user;

    @BeforeEach
    public void init() {
        MockitoAnnotations.openMocks(this);
        loginForm = LoginForm.builder().email("so@naver.com").password("1234").build();
        user = User.builder().email(loginForm.getEmail()).password(loginForm.getPassword()).build();
    }

    @Test
    @DisplayName("checkLoginForm메서드 : 유저의 이메일이 맞고 이메일의 비밀번호도 맞다")
    void checkLoginFormTest() {
        //given
        //loginUserService가 email에 맞는 user값을 반환했다고 가정하면
        when(loginUserService.getUserByEmail(any())).thenReturn(Optional.of(user));

        //when
        //checkLoginForm 를 호출했을 때
        Optional<User> result = loginService.checkLoginForm(loginForm);

        //then
        //값이 있고 이메일과 비밀번호도 같아야한다.
        assertThat(result).isNotNull();
        assertThat(result.get().getEmail().equals("so@naver.com"));
        assertThat(result.get().getPassword().equals("1234"));


    }




}

 

 

나는 명시적으로 메서드 호출 전에 호출하기 위해  BeforeEach를 사용해 

 MockitoAnnotations.openMocks(this); 로 Mock객체를 만들어주었다. 

 

이거 대신 이 어노테이션을 사용해도 똑같이 필요한 mock객체들을 초기화해서 가져오는 역할을 한다.

(@ExtendWith(MockitoExtension.class)를 사용하는 것이 JUnit 5에 더 적합한 방법이라고 함. )

@ExtendWith(MockitoExtension.class)
class LoginServiceTest {
}

 

기본적으로 Mockito를 사용할 때는 목 객체를 생성하고 초기화 하는 과정이 필요한데

 

@Mock으로 선언할 때 사용한다.  MockitoExtension이 이를 감지해 테스트 메서드 실행 전에 초기화를 수행한다. 

@InjectMocks 을 사용해 필요한 목 객체를 주입받는 객체도 초기화 한다. 

 

내 코드로 보자면, @Mock으로 선언한 LoginUserService 목 객체를 @InjectMocks로 선언한 LoginService에 주입합니다.

그러면 필요한 객체를 가져왔으니 테스트 메서드를 보면, (LoginService가 받는 loginForm을 만들고 타입에 맞게 User로도 변환해주었다. )

 

//given

내가 원하는 결과를 얻기 위해서는 LoginUserService에서 문제없이 User값을 반환해야한다. 그렇게 했다고 가정하고

 

//when

loginService의 checkLoginForm를 실행하면 응답값User가 나온다.

 

//then

그러면 그 User는 입력한 User의 이메일과 비밀번호 값이 동일할 것이다. 

 

 

여기서 내가 많이 헤맨 부분은 given 이었다. 원하는 결과값을 얻기 위해 필요한 상황을 제시해주어야한다.

 

given이 when으로 되어있는 기존 코드에서 통일된 given을 사용하는 BDD(행동 주도 개발, Behavior-Driven Development) 로도 작성해보았다.

       //given
        //loginUserService가 email에 맞는 user값을 반환했다고 가정하면
        given(loginUserService.getUserByEmail(any(User.class))).willReturn(Optional.of(user));

        //when
        //checkLoginForm 를 호출했을 때
        Optional<User> result = loginService.checkLoginForm(loginForm);

        //then
        //값이 있고 이메일과 비밀번호도 같아야한다.
        assertThat(result).isNotNull();
        assertThat(result.get().getEmail().equals("so@naver.com"));
        assertThat(result.get().getPassword().equals("1234"));

        then(loginUserService).should().getUserByEmail(any(User.class));

 

 

그렇다면 지금 checkLoginForm가 잘 동작하는지 확인하기 위해서 

LoginUserService 객체가 올바르게 반환한다! 전제조건을 깔았는데,

 

LoginUserService 객체의 메소드를 테스트하는 코드도 짜봤다. 

1. 테스트하고자 하는것 LoginUserService이 유효한 user값을 받았을 경우이다. 

2. 기대하는 결과는 mapper를 통해 유효하는 user가 검증되었다면 user값을 반환해야한다.

3. 필요한 객체는 userMapper이다.

 

@Slf4j
@ExtendWith(MockitoExtension.class) //초기화로 mock 객체 가져옴
public class LoginUserServiceTest {

    @Mock
    private UserMapper userMapper;

    @InjectMocks
    private LoginUserService loginUserService;
    

    @Test
    void testGetUserByEmail() {
        //Given
        String userEmail = "test@example.com";
        String password = "123";
        User user = User.builder().email(userEmail).password(password).build();
        when(userMapper.getByEmail(userEmail)).thenReturn(user); //조회해서 값이 있다고 가정했을 때

        //when
        Optional<User> result = loginUserService.getUserByEmail(user);

        //Then
        assertThat(result.isPresent()); //값이 있다고 나온다.
        assertEquals(userEmail, result.get().getEmail());
        assertEquals(password, result.get().getPassword());

        //userMapper 목 객체에 대해 getByEmail메서드가 정확히 1번 호출되었는지 검증
        verify(userMapper, times(1)).getByEmail(userEmail);


    }
}

 

 

사실 이렇게만 보면 Mock테스트는

메서드 내에서 필요한 객체들이 문제없이 반환값을 잘 가져왔을 때 

테스트하고자하는 메서드가 잘 동작하는지 확인하는 용도일뿐이다.

 

그래도 테스트 코드를 짜다보면 코드의 문제점을 발견하고 고치게 된다. 

특히 예외테스트가 중요하다고 한다!

 

나도 테스트를 하면서 많은 리팩토링을 거쳤다. 

1. 단일책임 원칙으로 필요한 메서드만 담긴 서비스 객체 만듦.

기존 코드에서는

UserService에서 User값을 조회하고 검증하는 여러개의 메소드를 가진 UserSerivce를 주입했었다.

하지만 이렇게 될 경우 사용하지 않는 메소드들도 모두 주입된다고 하여 

UserLoginService를 따로 만들어서 처리하였다. 

2. 예외테스트를 하면서 예외의 경우 로직 추가 및 optional추가

 

기존 코드에서는

loginUserService.getUserByEmail(userEmail); 는  Optional로 처리하였지만

였고 그 결과값이 존재할때 null이 아닌경우, 해당하는 비밀번호가 있을 경우 return User타입으로 반환을 하였었는데

 

return userByEmail.filter(m -> m.getPassword().equals(password)).orElse(null);

그대로 optional로 반환하였다.

Optional<User> userByEmail = loginUserService.getUserByEmail(userEmail);

if (userByEmail.isPresent()) {
   // 같은 코드임 return Optional.ofNullable(userByEmail.filter(m -> m.getPassword().equals(password)).orElse(null));
    return userByEmail.filter(m -> m.getPassword().equals(password));

} else {
    throw new NoSuchElementException("User not found");


}

 

 

테스트코드는 다음과 같다. 

    @Test
    @DisplayName("조회한 이메일이 없을 때 checkLoginForm 실패")
    void uncheckLoginFormTest() {
    
     	// Given: Mock 객체에 예외를 발생시키도록 설정
        when(loginUserService.getUserByEmail(any())).thenReturn(Optional.empty());
        
        // When: 특정 메서드를 호출 () -> 메서드 호출하면
        // Then: 예외가 발생하는지 확인 NoSuchElementException이 나온다.

        assertThrows(NoSuchElementException.class, () ->
                loginService.checkLoginForm(loginForm)

        );


    }

 

예외테스트가 훨씬 어려운 느낌이다.. 공부 많이 해야지..

 

그리고 처음에 테스트하면서 테스트 코드에 given에

thenReturn에 null값을 넣어놓고

왜 값이 없을 때 else 로 빠져서 NosuchElementException이 나오는게 아니라

userByEmail.isPresent()에 멈춰서 nullpointException이 나지? 라며 계속 헤맸다. 

 

Optional을 검증하는 메서드ispresent를 할 건데 그 값에 null인 값이 설정돼서 그랬다.

 

단위테스트 재밌기도 한데 방향을 놓치면 엄청 헤매거나 의미없는 테스트코드만 만들 수가 있다.

공부 더 해보자 이얏호~!

728x90

+ Recent posts