지난 여름, 스프링을 시작하며 나 홀로 테스트코드를 작성했던 적이 있다.
@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 강의 등을 보면서 테스트코드를 작성했던 것 같다. 위 코드의 문제점을 떠올려보자.
이제 현재 프로젝트에서 구현 중인 Springboot 서버 JUnit5 테스트코드에 Mockito를 도입한 것에 대해 이야기하고자 한다.
내가 구현 중인 프로젝트의 주로 요구사항은 다음과 같다.
@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가 필요했는지는 뒤에 설명하겠다.
@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;
}
}
UserService의 테스트코드에서 의존하는 클래스는 UserService, UserRepository, FollowRepository가 존재하며, 이를 다음과 같은 어노테이션으로 세팅한다.
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@InjectMocks
private UserService userService;
@Mock
private UserRepository userRepository;
@Mock
private FollowRepository followRepository;
}
이제 내가 구현한 UserService를 살펴보고, Mockito의 주요 메서드를 활용하여 테스트코드를 작성해보자.
실제 운용하는 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의 설명은 약간의 주석으로 대신하고, 이를 바로 테스트코드로 옮겨보겠다.
나는 크게 2가지 메서드를 사용하였고, 그 외 다양한 메서드가 존재한다.
@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));
}
첫번째로 작성한 테스트 메서드를 예시로 들어보자.
만약 user.setId(1L)이 없으면 어떻게 될까?
id가 정수형이 아니라 null이다.(이것때문에 몇 시간동안 삽질에 빠졌다.)
현재 Repository는 실제 DB가 아닌 Mock 모의 객체이기에, save() 메서드를 실행해도 id가 자동으로 생성되지 않는 것이다.
따라서 setId를 통해 테스트 시 별도로 id를 추가해주어야 했다.
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));
}
}
테스트가 잘 이루어졌고 모두 성공하였다.
다음 게시물은 Controller(API)의 테스트코드에 대해 다루어보자!
(좋아요는 큰 힘이 됩니다!)