SpringBoot 단위(Unit) 테스트(feat. Mock)

KDG: First things first!·2024년 9월 12일
0

테스트 코드

목록 보기
1/2


프로그램을 개발하고 서비스를 제공함에 있어 테스트 코드는 단순히 버그를 잡는 것을 넘어서 소프트웨어의 전반적인 품질을 보장하는 데 필수적인 도구이다.

지금부터 테스트 코드 어떻게 작성해야 하는지 알아보자.



Given-When-Then 패턴이란

Given : 주어진 전제 조건을 정의하고 테스트 실행을 위한 준비

"어떤 배경이나 상태가 주어졌을 때"

When : 테스트하려는 메서드나 기능을 실행하는 과정

"어떤 행동(메서드)를 실행하면"

Then : 메서드나 기능이 실행된 후 예상되는 결과가 나오는지 확인

"예상한 결과와 실제 결과가 일치하는지 확인"


[예시]

@Test
void userLogin_successful() {
    // given
    User user = new User("john@example.com", "password123");
    userService.saveUser(user);

    // when
    boolean result = userService.login("john@example.com", "password123");

    // then
    assertTrue(result, "로그인 성공해야 함");
}



JUnit5란


JUnit5란 자바에서 사용되는 최신 단위 테스트 프레임워크로 JUnit Platform, JUnit Jupiter,JUnit Vintage가 포함된다.


org.junit.jupiter 패키지를 포함하며, @Test, @BeforeEach, @AfterEach, @BeforeAll, @AfterAll 등의 어노테이션을 제공한다.

  • @Test: 테스트 메서드를 표시한다.

  • @BeforeEach: 각 테스트 메서드 실행 전에 수행될 작업을 정의한다.

  • @AfterEach: 각 테스트 메서드 실행 후에 수행될 작업을 정의한다.

  • @BeforeAll: 모든 테스트 메서드가 실행되기 전에 한 번만 수행될 작업을 정의한다.

  • @AfterAll: 모든 테스트 메서드가 실행된 후에 한 번만 수행될 작업을 정의한다.

  • @Disabled: 특정 테스트 메서드나 클래스의 실행을 비활성화한다.


  • @ParameterizedTest: 같은 테스트 메서드를 여러 가지 입력 데이터로 반복 실행할 수 있다.

  • @ExtendWith: JUnit Jupiter의 확장 기능을 사용하여 테스트를 확장하거나 테스트 환경을 설정할 수 있다.

  • @Nested: 테스트 클래스 내에서 중첩된 클래스를 정의하여 테스트 계층 구조를 구성할 수 있습니다.




Mock

Mockito는 자바용 모킹 프레임워크로, 테스트 코드에서 객체의 동작을 모방(mock)하여 테스트의 정확성을 높이고, 외부 의존성으로부터 독립적인 단위 테스트를 작성할 수 있도록 도와준다. Mockito를 사용하면 테스트하려는 객체의 동작을 설정하고, 그 객체가 다른 객체와 상호작용하는 방식을 검증할 수 있다.


※ Mock = 가짜

Mockito 특징

  • Mockito는 Mock 객체를 쉽게 만들고, 관리하고, 검증할 수 있는 방법을 제공하는 프레임워크이다.

  • 통합테스트인 @SpringBootTest 와 다르게 별도 환경, 구성에 대한 제약 사항이 없기 때문에 매우 빠르게 동작한다.




Unit Test

**단위 테스트(Unit Test) : 단위 테스트는 개별 구성 요소(단위, 유닛)를 독립적으로 검증하는 테스트이다.
여기서 '단위'는 보통 메서드(함수), 클래스 등의 가장 작은 테스트 가능한 코드를 의미한다. 유닛 테스트의 주요 목적은 각 코드가 의도한 대로 작동하는지 확인하는 것이다.

단위 테스트에서는 실제 객체를 사용하지 않고 Mocking을 사용함으로써 의존성이 적고 빠르게 테스트를 할 수 있다.


FIRST 원칙

  • Fast: 유닛 테스트는 빨라야 함

  • Isolated: 테스트는 각 테스트간에 독립적으로 실행해야함

  • Repeatable: 테스트는 환경에 상관없이 실행할 때마다 같은 결과를 만들어야 함.

  • Self-validating: 테스트는 명확히 성공/실패로 구분하여 테스트 자체가 결과를 검증할 수 있어야함.

  • Timely: 테스트는 개발간에 즉시 작성해야 함. 대표적으로 TDD 방법론이 있음. (쉽지 않음)




Mocking


Mocking(모킹)은 단위 테스트(Unit Testing)에서 실제 객체 대신 가짜 객체(Mock)를 사용하여 테스트를 수행하는 기법이다.

이를 통해 테스트하려는 코드가 의존하는 객체나 외부 리소스를 실제로 사용하지 않고도 해당 객체의 동작을 흉내 내거나 제어할 수 있다.



[Mock을 사용한 단위 테스트 예시]


@ExtendWith(MockitoExtension.class)
public class MovieServiceTest {
    @Mock // 모의 객체
    MovieRepository movieRepository;

    @InjectMocks // 모의 객체를 주입받을 클래스
    MovieService movieService;

    @Test
    @DisplayName("영화 단건조회 테스트")
    public void getMovieTest() {
        // given
        int movieId = 1;
        Movie movie = new Movie(
            new Director("ahn"),
            List.of(new Actor("park"), new Actor("kim"))
        );

        given(movieRepository.findById(anyLong())).willReturn(Optional.of(movie));

        // when
        MovieResponse result = movieService.getMovie(movieId);

        // then
        assertNotNull(result);
    }
}


@ExtendWith(MockitoExtension.class) : Mocking할 클래스 위에 붙이면 Mocking 기능을 제공해준다.

@Mock: 테스트에서 사용될 실제 객체를 흉내내는 가짜 객체로 해당 객체가 가지고 있는 메서드를 모방 하여 사용하기 위한 용도이다.

위 예시에서 @Mock을 사용하여 movieRepository를 모의 객체로 만들어서, 실제 데이터베이스 접근 없이 movieRepository의 findById 메서드의 동작을 가정하고 테스트를 진행할 수 있다.


@InjectMocks: @Mock으로 만들어진 인스턴스들을 해당 클래스에 자동으로 주입해준다.

Mock 객체를 주입하여 실제 객체의 의존성을 자동으로 설정해준다. 즉, Mock이 아닌 실제 객체를 의미한다.

따라서 @InjectMocks 대상 객체에는 mock에 넣어주는 any() 가 아닌 실제 자원을 넣어줘야 한다.
(any는 mock에만 사용 가능하다.)



given


@Test
public void 영화_단건조회() {
    // given
    long movieId = 1L;
    given(movieRepository.findById(anyLong())).willReturn(Optional.of(movie));
    
    ...
}

any~ : given에서 자원을 주어줄 때, mock에 어떤 값이든(any) 할당하는 값

any(), anyLong(), anyString(), anyList(), anySet(), anyMap() 등이 존재한다.


given(..Mock의 메서드 호출..) : Mockito에서 특정 메서드가 호출되었을 때를 의미하며, 주로 그 메서드의 반환값을 설정하는 데 사용된다.

BDDMockito의 스타일로 주로 사용되며, "주어진 조건에서"를 의미한다.


willReturn(Optional.of(객체 이름)) : given에서의 메서드가 호출되었을 때 반환할 값을 설정한다.

여기서는 null이 나오지 않도록 Optional.of(movie)를 반환하도록 설정하였다.
null값이 나오게 하고 싶으면 OPtional의 empty() 메서드를 사용하면 된다.



when


@Test
@DisplayName("영화 단건조회 테스트")
public void getMovieTest() {
    ...

    // when
    MovieResponse result = movieService.getMovie(movieId);

    ...
}

@InjectMocks 대상 객체는 mock 객체가 아니라 실제 객체이기 때문에 @InjectMocks 대상은 테스트하려는 실제 객체를 생성하고, 그 객체의 의존성들만 mocking된 객체로 대체된다.

따라서 @InjectMocks 대상 객체에는 mock에 넣어주는 any() 가 아닌 실제 자원을 넣어줘야 한다는 점을 꼭 기억해야 한다.
(any는 mock에만 사용 가능)



then

@Test
@DisplayName("영화 삭제 실패")
public void getMovieTest() {
    ...

    // then
    assertEquals("entity null error", exception.getMessage());
    verify(movieRepository, times(0)).delete(any(Movie.class));
}

assert: 테스트의 결과를 검증하는 데 사용하며, 테스트 예상한 결과와 실제 결과와 비교하여 테스트성공/실패를 결정한다.


verify: 특정 메소드가 호출되었는지, 호출 횟수는 몇 번인지, 호출 순서는 어떤지 등의 부가적인 요소를 검증하는 데 사용된다.



throws


@Test
public void 영화단건_조회() {
    // given
    long movieId = 1L;
    given(movieRepository.findById(anyLong())).willReturn(Optional.empty());

    // when
    NullPointerException exception = assertThrows(NullPointerException.class, () -> movieService.getMovie(movieId));

    // then
    assertEquals("null error", exception.getMessage());
}

assertThrows : 예외 처리로 인한 에러가 제대로 의도한대로 발생하는지 확인한다.

assertThrows(발생 에러 클래스 이름.class, () -> 호출한 실제 메서드) 형식이다.

assertEquals("정의한 에러 메시지", 정의한 에러 객체 이름.getMessage())로 각 상황에 정의한 에러 메시지가 제대로 발생하는지 확인할 수 있다.



void method


@Test
public void 영화_삭제() {
    // given
    long movieId = 1L;
    Movie movie = new Movie("재밌는영화", 2002);

    given(movieRepository.findById(anyLong())).willReturn(Optional.of(movie));
    doNothing().when(movieRepository).delete(any(Movie.class));

    // when
    movieService.removeMovie(movieId);

    // then
    verify(movieRepository, times(1)).delete(any(Movie.class));
}

doNothing(): Mockito에서 특정 메서드가 호출되었을 때, 아무 동작도 하지 않도록 설정하는 메서드이다.

예를 들어, 보통 메서드를 호출하면 무언가 실행되지만, doNothing()은 그 메서드를 호출했을 때 아무런 행동이 일어나지 않도록 설정한다.

이 기능은 주로 반환형이 없는 void 메서드를 테스트할 때 유용하다. 특히 삭제, 업데이트 또는 외부 시스템 호출처럼 상태 변화나 부작용이 있을 수 있는 메서드들을 테스트할 때, 실제로 수행되지 않도록 방지할 수 있다.

void는 반환형이 없기 때문에 반환값을 확인하기 힘들어 verify와 함께 핵심 자원이 호출 되었는지 정도 확인해주면 충분하다.


when(Mock 객체).메서드(객체): 테스트에서 모의(Mock)된 객체의 메서드를 호출할 때, 해당 메서드의 동작을 지정한다.

위의 예시에서는 movieRepository.delete() 메서드가 호출될 때의 동작을 지정한다.



Spy

@Spy: Mockito에서 사용하는 어노테이션으로, 객체의 일부 메서드는 실제로 실행하면서 나머지는 모의(mock)할 수 있는 기능을 제공한다.
즉, 부분적으로 객체를 모의할 수 있는 방법이다.

(@Spy를 지정하면 실제 해당 객체가 들어온다. 하지만 필요에 따라 given()처럼 특정 메서드 호출 시 모의 동작을 지정할 수 있다.)


[Spy 사용 예시 1]


@ExtendWith(MockitoExtension.class)
public class MovieServiceTest {

    @Spy
    private MovieRepository movieRepository = new MovieRepository();  // 실제 객체로 생성됨

    @InjectMocks
    private MovieService movieService;

    @Test
    public void testSpyExample() {
        // given: 실제 객체의 findById() 메서드를 모의(mock) 설정
        Movie mockMovie = new Movie("Spy Movie");
        doReturn(Optional.of(mockMovie)).when(movieRepository).findById(1L);

        // when: 실제 delete() 메서드가 호출됨
        movieService.deleteMovie(1L);

        // then: findById()는 모의 동작에 따라 실행되고, delete()는 실제 동작함
        verify(movieRepository).delete(mockMovie);
    }
}

[Spy 사용 예시 2]


@ExtendWith(MockitoExtension.class)
public class UserServiceTest {
    @Mock
    private UserRepository userRepository;
    @Spy
    private PasswordEncoder passwordEncoder;
    @InjectMocks
    private UserService userService;

    @Test
    public void changePassword_비밀번호_변경_검증() {
        // given
        long userId = 1L;
        User user = new User("test@a.com" , passwordEncoder.encode("Asd12345") , UserRole.ADMIN);
        UserChangePasswordRequest userChangePasswordRequest = new UserChangePasswordRequest("Asd12345" , "Asd12345");

        given(userRepository.findById(anyLong())).willReturn(Optional.of(user));

        // when
        InvalidRequestException exception = assertThrows(InvalidRequestException.class, () -> userService.changePassword(userId , userChangePasswordRequest));

        // then
        assertEquals("새 비밀번호는 기존 비밀번호와 같을 수 없습니다." , exception.getMessage());
    }
}

위의 예시는 유저가 비밀번호를 변경할 때의 코드를 검증하는 spy를 사용한 테스트 코드이다.

유저의 비밀번호를 검증하기 위해서는 입력한 비밀번호를 인코딩해주는 PasswordEncoder 클래스의 encode() 메서드를 호출하여 사용하여야 하는데 만약 단순히 @Mock를 이용하여 Mock 객체로 PasswordEncoder를 주입한다면 encode() 메서드가 작동하지 않아 테스트 검증이 불가능하다.

따라서 PasswordEncoder는 @Spy를 통해 spy로 지정하면 PasswordEncoder의 메서드 중 하나인 encode() 메서드는 실제 메서드의 기능을 수행하도록 할 수 있다.



@Spy vs @Mock

@Mock: 객체의 모든 메서드가 모의되며, 기본적으로 아무런 동작을 하지 않다. 즉, 객체의 내부 로직이 실행되지 않고, 원하는 동작을 명시적으로 설정해야 할 때 사용한다.

@Spy: 실제 객체의 일부 메서드는 그대로 실행하면서, 특정 메서드는 모의할 수 있다. 즉, 객체의 내부 로직이 실행되는 부분적인 모의가 가능하다.


@Spy는 실제 동작이 필요한 메서드가 있는 경우에 유용하다.

예를 들어, 일부 메서드만 Mock으로 사용하고, 나머지는 실제로 실행해야 하는 경우에 사용하면 좋다. 이 방식은 복잡한 로직을 가진 객체를 테스트하면서, 특정 부분만 모의하고 나머지 로직은 그대로 검증하고자 할 때 적합하다.



Nested

@Nested는 JUnit 5에서 제공하는 애너테이션으로, 테스트 클래스 내에 또 다른 테스트 클래스를 중첩하여 테스트 계층 구조를 만들 때 사용한다. 이를 통해 더 나은 가독성, 구조화된 테스트 코드, 그리고 테스트 그룹화를 얻을 수 있다.


import org.junit.jupiter.api.Nested;

@ExtendWith(MockitoExtension.class)
public class MovieServiceTest {
    ...

    @Nested
    class GetMovieTest {
        @Test
        public void 영화단건조회_조회결과없음() {
            ...
        }

        @Test
        public void 영화단건조회_정상조회() {
            ...
        }
    }


    @Nested
    class DeleteMovieTest {
        @Test
        public void 영화삭제_조회결과없음() {
            ...
        }

        @Test
        public void 영화삭제_정상동작() {
            ...
        }
    }
}

Nested 특징

  • @Nested 중첩 클래스를 통해 테스트 묶음 단위 설정이 가능하다.

  • 서로 관련 있는 Unit 테스트간 묶음을 통해 테스트 코드 가독성을 증가시킬 수 있다.

  • 묶음 단위 내에서의 순서를 통해 테스트 시나리오 작성이 가능하다.

profile
알고리즘, 자료구조 블로그: https://gyun97.github.io/

0개의 댓글