[Springboot] 스프링 JUnit5에서 Mockito로 Service 계층 테스트코드 작성하기

winluck·2023년 10월 26일
0

Springboot

목록 보기
8/18


지난 여름, 스프링을 시작하며 나 홀로 테스트코드를 작성했던 적이 있다.

TagServiceTest

@SpringBootTest
@Transactional
class TagServiceTest {

    @Autowired
    TagService tagService;
    @Autowired
    UserTagRepository userTagRepository;
    @Autowired
    ArticleTagRepository articleTagRepository;
    @Autowired
    TagRepository tagRepository;
    @Autowired
    UserRepository userRepository;
    @Autowired
    ArticleRepository articleRepository;

    @BeforeEach
    void init(){
        userTagRepository.deleteAll();
        articleTagRepository.deleteAll();
        tagRepository.deleteAll();
        userRepository.deleteAll();
        articleRepository.deleteAll();
    }

    // 태그 생성
    @Test
    void 태그생성(){
        // given
        String tagName = "tag1";

        // when
        Long tagId = tagService.createTag(tagName);

        // then
        assertEquals(tagRepository.findById(tagId).get().getTagName(), tagName);
    }


    // 태그명 중복 검증
    @Test
    void 태그명중복검증() throws TagException{
        // given
        String tagName = "tag1";
        String tagName2 = "tag1";

        // when
        tagService.createTag(tagName);

        // then
        assertThrows(TagException.class, () -> tagService.createTag(tagName2));
    }

    // 태그 삭제
    @Test
    void 태그삭제(){
        // given
        String tagName = "tag1";
        Long tagId = tagService.createTag(tagName);

        // when
        tagService.deleteTag(tagId);

        // then
        assertEquals(0, tagRepository.findAll().size());
    }

    // 태그 구독
    @Test
    void 태그구독(){
        // given
        String tagName = "tag1";
        Long tagId = tagService.createTag(tagName);
        Long userId = userRepository.save(User.createUser("test", "test", null)).getId();

        // when
        tagService.subscribeTag(userId, tagId);

        // then
        assertEquals(1, userTagRepository.findAll().size());
    }

    // 태그 구독 취소
    @Test
    void 태그구독취소(){
        // given
        String tagName = "tag1";
        Long tagId = tagService.createTag(tagName);
        Long userId = userRepository.save(User.createUser("test", "test", null)).getId();

        // when
        tagService.subscribeTag(userId, tagId);
        tagService.unsubscribeTag(userId, tagId);

        // then
        assertEquals(0, userTagRepository.findAll().size());
    }

    // 태그 목록 조회
    @Test
    void 태그목록조회(){
        // given
        User user = User.createUser("test", "test", null);
        Long userId = userRepository.save(user).getId();

        String tagName = "tag1";
        String tagName2 = "tag2";
        tagService.createTag(tagName);
        tagService.createTag(tagName2);

        userTagRepository.save(UserTag.createUserTag(tagRepository.findByTagName(tagName).get(), user));

        // when
        Page<ResponseTagDto> tags = tagService.getTagList(userId, PageRequest.of(0, 10));

        // then
        assertEquals(2, tags.getTotalElements());
    }

    // 태그 검색
    @Test
    void 태그검색(){
        // given
        User user = User.createUser("test", "test", null);
        Long userId = userRepository.save(user).getId();
        String tagName = "tag1";
        String tagName2 = "tag2";
        String tagName3 = "tag3";
        tagService.createTag(tagName);
        tagService.createTag(tagName2);
        tagService.createTag(tagName3);

        // when
        Page<ResponseTagDto> tags = tagService.searchTag(userId, "tag", PageRequest.of(0, 10));

        // then
        assertEquals(3, tags.getTotalElements());
    }

    // 특정 태그가 포함된 게시물 목록 조회
    @Test
    void 특정태그가포함된게시물목록조회(){
        // given
        User user = userRepository.save(User.createUser("test", "test", null));
        Article article = Article.createArticle("test", "test");
        article.setUser(user);

        List<String> tags = new ArrayList<>();
        tags.add("tag1");
        Tag tag = tagRepository.save(Tag.createTag("tag1"));
        Article article1 = articleRepository.save(article);

        // when
        tagService.addTagToArticle(article1.getId(), tags);

        // then
        assertEquals(1, tagService.getArticleListByTag(tag.getId()).size());
    }

    // 특정 태그를 구독하는 유저 목록 조회
    @Test
    void 특정태그를구독하는유저목록조회(){
        // given
        User user  = userRepository.save(User.createUser("test", "test", null));
        Tag tag = tagRepository.save(Tag.createTag("tag1"));

        // when
        tagService.subscribeTag(user.getId(), tag.getId());

        // then
        assertEquals(1, tagService.getUserListByTag(tag.getId()).size());
    }
}

스프링 JPA 강의 등을 보면서 테스트코드를 작성했던 것 같다. 위 코드의 문제점을 떠올려보자.

  1. 서버와 연결된 실 DB로 테스트코드를 실행하고 있어, 테스트코드의 실행이 실제 DB의 데이터에 영향을 주는 상황이 발생하고 있다.
  2. 테스트코드가 각 메서드마다 중복되는 내용이 존재하며, 재사용성이 좋지 않다.
  3. 그 외 DisplayName 등을 사용하지 않으며, 메서드명이 한글이다.

이제 현재 프로젝트에서 구현 중인 Springboot 서버 JUnit5 테스트코드에 Mockito를 도입한 것에 대해 이야기하고자 한다.

Mockito란?

  • Java에서 실제가 아닌 모의(mock) 객체를 생성하고 이를 사용하는 데 도움을 주는 라이브러리이다.
  • 주로 Unit Test에서 의존성을 가진 클래스(Service, Repository 등)를 테스트할 때 사용한다.

JPA Entity

내가 구현 중인 프로젝트의 주로 요구사항은 다음과 같다.

  • 기본적으로 앱을 사용하는 User Entity가 존재
  • User는 자신이 섭취한 음식을 객체화하여 영양함량과 음식명 등을 관리
  • User는 개인정보에 따라 적절한 기준섭취량이 기본적으로 배정되고, 직접 수정 가능
  • 유저가 유저를 팔로우하는 기능의 구현을 위해 유저를 FK이자 복합키로 갖는 Follow Entity가 존재

User

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class User {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "user_id")
    private Long id;

    private String name;

    @JsonIgnore
    private String keyCode;

    private String image;

    private int height; 

    private int weight; 

    private int gender;

    private int age; 

    private int type;

    private BaseNutrition baseNutrition;

    @OneToMany(mappedBy = "user", cascade = {CascadeType.PERSIST, CascadeType.REMOVE})
    private List<Food> foods = new ArrayList<>();

    @OneToMany(mappedBy = "user", cascade = {CascadeType.PERSIST, CascadeType.REMOVE})
    private List<FavoriteFood> favoriteFoods = new ArrayList<>();

    // 생성 메서드
    public static User createUser(String name, String image, String keyCode, int height, int weight, int gender, int age, BaseNutrition baseNutrition) {
        User user = new User();
        user.name = name;
        user.image = image;
        user.keyCode = keyCode;
        user.height = height;
        user.weight = weight;
        user.gender = gender;
        user.age = age;
        user.baseNutrition = baseNutrition;
        user.type = UserTypeUtil.decideUserType(gender, age);
        return user;
    }

    // 회원정보 수정
    public void updateUser(String name, int height, int weight, int age, boolean autoUpdate) {
        this.name = name;
        this.height = height;
        this.weight = weight;
        this.age = age;
        if(autoUpdate) {
            this.type = UserTypeUtil.decideUserType(this.gender, this.age);
        }
    }

    // 회원 기준섭취량 직접 수정
    public void updateBaseNutrition(BaseNutrition baseNutrition) {
        this.baseNutrition = baseNutrition;
    }

	// 테스트코드용 메서드
    public void setId(Long id) { 
        this.id = id;
    }
}

정적 생성 메서드를 활용하였고, 테스트코드를 위해 setId를 추가하였다. 왜 setId가 필요했는지는 뒤에 설명하겠다.

Follow

@NoArgsConstructor
@IdClass(Follow.PK.class) // 복합키를 위한 어노테이션
@Getter
@Table(uniqueConstraints = {
        @UniqueConstraint(columnNames = {"to_user", "from_user"})
}) // 중복 팔로우 방지
@Entity
public class Follow { // A -> B 방향의 팔로우를 관리

    @Id
    @Column(name = "to_user", insertable = false, updatable = false)
    private Long toUser; // B

    @Id
    @Column(name = "from_user", insertable = false, updatable = false)
    private Long fromUser; // A

    public static Follow makeFollow(Long toUser, Long fromUser) {
        Follow follow = new Follow();
        follow.toUser = toUser;
        follow.fromUser = fromUser;
        return follow;
    }

    public static class PK implements Serializable { // 복합키를 위한 클래스
        Long toUser;
        Long fromUser;
    }
}

UserServiceTest (구현 전)

UserService의 테스트코드에서 의존하는 클래스는 UserService, UserRepository, FollowRepository가 존재하며, 이를 다음과 같은 어노테이션으로 세팅한다.

@ExtendWith(MockitoExtension.class)
class UserServiceTest {

    @InjectMocks
    private UserService userService;

    @Mock
    private UserRepository userRepository;

    @Mock
    private FollowRepository followRepository;

}
  • @ExtendWith(MockitoExtension.class): JUnit5 Test 프레임워크를 확장하는 MockitoExtension을 활성화하며, 이를 통해 Mockito 어노테이션을 사용하여 Mock 객체를 생성하고 주입할 수 있게 된다.
  • @InjectMocks: UserService 객체를 외부에서 생성 및 주입하여, 테스트코드에서 사용할 수 있도록 만들어준다.
  • @Mock: Mock 모의 객체를 생성하고 주입하며, 주로 테스트할 Service가 의존하는 Repository에 붙인다. 즉 UserRepository 및 FollowRepository 클래스의 모의 객체를 생성하고 테스트코드에서 활용할 수 있도록 도와준다.

이제 내가 구현한 UserService를 살펴보고, Mockito의 주요 메서드를 활용하여 테스트코드를 작성해보자.

UserService

실제 운용하는 UserService 코드의 일부이다.

@RequiredArgsConstructor
@Service
public class UserService {

    private final UserRepository userRepository;
    private final FollowRepository followRepository;

    // 회원정보 저장
    @Transactional
    public Long saveUser(CreateUserDto createUserDto) {
        if (userRepository.existsByName(createUserDto.getName()))
            throw new UserException(ResponseCode.USER_NAME_ALREADY_EXIST);
        if(userRepository.existsByKeyCode(createUserDto.getKeyCode()))
            throw new UserException(ResponseCode.USER_ALREADY_EXIST);

        int type = UserTypeUtil.decideUserType(createUserDto.getGender(), createUserDto.getAge());
        List<Integer> standard = UserTypeUtil.getStanardByUserType(type); // 유저 타입에 따른 기본 기준섭취량 조회
        BaseNutrition baseNutrition = BaseNutrition.createNutrition(standard.get(0), standard.get(2), createUserDto.getWeight(), standard.get(1)); // 단백질은 자신 체중 기준으로 계산
        User user = User.createUser(createUserDto.getName(), createUserDto.getImage(), createUserDto.getKeyCode(), createUserDto.getHeight(), createUserDto.getWeight(), createUserDto.getGender(), createUserDto.getAge(), baseNutrition);
        return userRepository.save(user).getId();
    }

    // 회원정보 조회
    @Transactional(readOnly = true)
    public ResponseUserDto getUserInfo(Long userId) {
        User user = getUserById(userId);
        return ResponseUserDto.from(user);
    }

    // 회원정보 수정
    @Transactional
    public void updateUserInfo(UpdateUserDto updateUserDto) {
        User user = getUserById(updateUserDto.getUserId());
        user.updateUser(updateUserDto.getName(), updateUserDto.getHeight(), updateUserDto.getWeight(), updateUserDto.getAge(), updateUserDto.isAutoUpdateNutrition());
        userRepository.save(user);
    }

    // 회원 탈퇴
    @Transactional
    public void deleteUser(Long userId) {
        validateUser(userId);
        userRepository.deleteById(userId);
    }

    // 회원의 친구 검색 결과 조회
    @Transactional(readOnly = true)
    public List<ResponseSearchUserDto> searchUser(Long hostId, String name) {
        validateUser(hostId);
        List<User> users = new ArrayList<>(userRepository.findAllByNameContaining(name));
        users.removeIf(user -> user.getId().equals(hostId)); // 검색 결과에서 자기 자신은 제외 (removeIf 메서드는 ArrayList에만 존재)
        return users.stream()
                .map(user -> ResponseSearchUserDto.of(user.getId(), user.getName(), user.getImage(), followRepository.existsByFromUserAndToUser(hostId, user.getId()))).collect(Collectors.toList());
    }

    // 회원이 특정 회원 팔로우
    @Transactional
    public void followUser(Long userId, Long followId) {
        validateUser(userId);
        validateUser(followId);
        // 이미 팔로우 중인 경우
        if (followRepository.existsByFromUserAndToUser(userId, followId))
            throw new UserException(ResponseCode.FOLLOWED_ALREADY);
        followRepository.save(Follow.makeFollow(userId, followId));
    }
    
    // 그 외 다양한 추가 메서드 존재 (생략됨)

    private void validateUser(Long userId) {
        if (!userRepository.existsById(userId))
            throw new UserException(ResponseCode.USER_NOT_FOUND);
    }

    private User getUserById(Long userId) {
        return userRepository.findById(userId)
                .orElseThrow(() -> new UserException(ResponseCode.USER_NOT_FOUND));
    }
}

UserService의 설명은 약간의 주석으로 대신하고, 이를 바로 테스트코드로 옮겨보겠다.

Mockito 주요 메서드

나는 크게 2가지 메서드를 사용하였고, 그 외 다양한 메서드가 존재한다.

  • given: 주입한 Mock 객체에 대해 특정 조건에서 특정 메서드를 실행하면 정해진 결과가 나온다고 가정(지정)할 수 있다. 이를 기반으로 "A일 때, B가 나오는가?" 방식의 테스트 작성이 가능하다.
  • verify: 주입한 Mock 객체의 특정 메서드 호출 횟수나 반환값 등을 검증하기 위해 사용하였다.
    @DisplayName("회원정보 저장")
    @Test
    void saveUser() {
        // Given
        CreateUserDto createUserDto = CreateUserDto.of("test", "profile.jpg", "testPassword", 0, 75, 1, 25);
        BaseNutrition baseNutrition = BaseNutrition.createNutrition(2000, 300, 80, 80);
        User user = User.createUser(createUserDto.getName(), createUserDto.getImage(), createUserDto.getKeyCode(), createUserDto.getHeight(), createUserDto.getWeight(), createUserDto.getGender(), createUserDto.getAge(), baseNutrition);
        user.setId(1L); 

        given(userRepository.existsByName("test")).willReturn(false);
        given(userRepository.save(any(User.class))).willReturn(user);

        // When
        Long id = userService.saveUser(createUserDto); 

        // Then
        assertEquals(1L, id);
        verify(userRepository, times(1)).save(any(User.class));
    }

첫번째로 작성한 테스트 메서드를 예시로 들어보자.

  • given(userRepository.existsByName("test")).willReturn(false);
    test라는 이름을 가진 사용자가 존재하지 않는다고 설정한다.
  • given(userRepository.save(any(User.class))).willReturn(user);
    어떤 User class를 save하든, 그 반환값은 무조건 user로 설정한다.
  • verify(userRepository, times(1)).save(any(User.class));
    saveUser() 메서드를 실행했을 때, userRepository.save()가 1번 호출되었는지 검증한다.

중요: Mock과 save()

만약 user.setId(1L)이 없으면 어떻게 될까?
id가 정수형이 아니라 null이다.(이것때문에 몇 시간동안 삽질에 빠졌다.)

현재 Repository는 실제 DB가 아닌 Mock 모의 객체이기에, save() 메서드를 실행해도 id가 자동으로 생성되지 않는 것이다.
따라서 setId를 통해 테스트 시 별도로 id를 추가해주어야 했다.

UserServiceTest

Mock을 사용해 작성을 마친 테스트코드의 일부이다.

Mock 객체를 다루는 것에 익숙하지 않을 경우, 아래의 given, verify의 여러 활용 구도를 익히면 도움이 될 것이다.

@ExtendWith(MockitoExtension.class)
class UserServiceTest {

    @InjectMocks
    private UserService userService;

    @Mock
    private UserRepository userRepository;

    @Mock
    private FollowRepository followRepository;

    @DisplayName("회원정보 저장")
    @Test
    void saveUser() {
        // Given
        CreateUserDto createUserDto = CreateUserDto.of("test", "profile.jpg", "testPassword", 0, 75, 1, 25);
        BaseNutrition baseNutrition = BaseNutrition.createNutrition(2000, 300, 80, 80);
        User user = User.createUser(createUserDto.getName(), createUserDto.getImage(), createUserDto.getKeyCode(), createUserDto.getHeight(), createUserDto.getWeight(), createUserDto.getGender(), createUserDto.getAge(), baseNutrition);
        user.setId(1L);

        given(userRepository.existsByName("test")).willReturn(false);
        given(userRepository.save(any(User.class))).willReturn(user);

        // When
        Long id = userService.saveUser(createUserDto); 

        // Then
        assertEquals(1L, id);
        verify(userRepository, times(1)).save(any(User.class));
    }

    @DisplayName("회원정보 조회")
    @Test
    void getUserInfo() {
        // Given
        Long userId = 1L;
        User user = User.createUser("test", "profile.jpg", "keycode123", 175, 70, 0, 30, BaseNutrition.createNutrition(2000, 300, 80, 80));
        given(userRepository.findById(userId)).willReturn(Optional.of(user)); // findById(userId) 실행 시 user가 반환값으로 설정

        // When
        ResponseUserDto result = userService.getUserInfo(userId);

        // Then
        assertEquals("test", result.getName());
        assertEquals(30, result.getAge());
    }

    @DisplayName("회원정보 수정")
    @Test
    void updateUserInfo() {
        // given
        UpdateUserDto updateUserDto = UpdateUserDto.of(1L, "update", 180, 75, 25, false);
        User user = User.createUser("test", "profile.jpg", "keycode123", 175, 70, 0, 30, BaseNutrition.createNutrition(2000, 300, 80, 80));
        given(userRepository.findById(updateUserDto.getUserId())).willReturn(Optional.of(user));

        // when
        userService.updateUserInfo(updateUserDto);


        // Then
        assertEquals("update", user.getName());
        assertEquals(180, user.getHeight());
        assertEquals(75, user.getWeight());
        assertEquals(25, user.getAge());
    }

    @DisplayName("회원 탈퇴")
    @Test
    void deleteUser() {
        // Given
        Long userId = 1L;
        given(userRepository.existsById(userId)).willReturn(true); // userId를 PK로 갖는 유저가 존재한다고 가정

        // When
        userService.deleteUser(userId);

        // Then
        verify(userRepository, times(1)).deleteById(userId);
    }

    @DisplayName("회원의 친구 검색 결과 조회")
    @Test
    void searchUser() { // setId() 메서드로 테스트 진행함
        // Given
        Long userId1 = 1L;
        Long userId2 = 2L;
        Long userId3 = 3L;
        String name = "John";

        // 사용자 목록 생성
        User user1 = User.createUser("John", "profile1.jpg", "keycode123", 175, 70, 0, 30, BaseNutrition.createNutrition(2000, 300, 80, 80));
        User user2 = User.createUser("John Doe", "profile2.jpg", "keycode456", 170, 65, 1, 35, BaseNutrition.createNutrition(2000, 300, 80, 80));
        User user3 = User.createUser("John Doo", "profile3.jpg", "keycode789", 160, 55, 1, 25, BaseNutrition.createNutrition(2000, 300, 80, 80));
        user1.setId(userId1);
        user2.setId(userId2);
        user3.setId(userId3);

        given(userRepository.existsById(userId1)).willReturn(true); // userId1을 PK로 갖는 유저가 존재한다고 가정
        given(userRepository.findAllByNameContaining(name)).willReturn(List.of(user1, user2, user3)); // 검색 메서드 반환값이 해당 리스트로 설정
        given(followRepository.existsByFromUserAndToUser(userId1, userId2)).willReturn(true); // 1이 2를 팔로우한다고 설정
        given(followRepository.existsByFromUserAndToUser(userId1, userId3)).willReturn(false); // 2가 3을 팔로우하지 않음으로 설정

        // When
        List<ResponseSearchUserDto> result = userService.searchUser(userId1, name);

        // Then
        assertEquals(2, result.size());
        assertEquals("John Doe", result.get(0).getName());
        assertTrue(result.get(0).isFollow());
        assertEquals("John Doo", result.get(1).getName());
        assertFalse(result.get(1).isFollow());
        verify(userRepository, times(1)).findAllByNameContaining(name);
    }

    @DisplayName("회원이 특정 회원 팔로우")
    @Test
    void followUser() {
        // Given
        Long userId = 1L; // 팔로우 요청을 보낸 사용자의 ID
        Long followId = 2L; // 팔로우할 사용자의 ID

        given(userRepository.existsById(userId)).willReturn(true); // userId에 해당하는 사용자가 존재함
        given(userRepository.existsById(followId)).willReturn(true); // followId에 해당하는 사용자가 존재함
        given(followRepository.existsByFromUserAndToUser(userId, followId)).willReturn(false); // 아직 팔로우 중이 아님

        // When
        userService.followUser(userId, followId);

        // Then
        verify(userRepository, times(1)).existsById(userId); // 사용자 존재 여부 확인
        verify(userRepository, times(1)).existsById(followId); // 팔로우할 사용자 존재 여부 확인
        verify(followRepository, times(1)).existsByFromUserAndToUser(userId, followId); // 이미 팔로우 중인지 확인
        verify(followRepository, times(1)).save(any(Follow.class));
    }
}

테스트가 잘 이루어졌고 모두 성공하였다.

결론

  • Mock 객체를 사용하면 첫번째로 작성했던 흙내나는(...) 테스트코드와 달리 데이터베이스에 영향을 주지 않는다.
  • given으로 대표되는 가정 -> 결론을 바탕으로 한 깔끔한 테스트가 가능해지므로 재사용성이 늘어난다.
  • verify 등 메서드가 실제 호출되었는지, 몇 번 호출되었는지까지 검증할 수 있어 꼼꼼한 테스트에도 도움이 된다.

다음 게시물은 Controller(API)의 테스트코드에 대해 다루어보자!

(좋아요는 큰 힘이 됩니다!)

profile
Discover Tomorrow

0개의 댓글