[스프링 부트 - 1] Spring Boot Repository and Service Layer Testing
요새 많은 회사에서 REST 한 방식의 API 사용을 선호합니다.
그 이유는 마이크로서비스 아키텍처 도입을 하게 되면서 Front Side 와 BackEnd 사이드를 완전히 분리해버리는 것이지요.
먼저 서비스 레이어를 만들어보고 테스트를 해보겠습니다.
1. 의존성 추가
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
testImplementation 'com.h2database:h2'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
testImplementation 'com.h2database:h2'
'org.springframework.boot:spring-boot-starter-web' : 웹 관련 모듈
'org.springframework.boot:spring-boot-starter-data-jpa' : jpa 관련 모듈
'com.h2database:h2' : embedded 데이터베이스 사용, 테스트 환경에서도 사용
실습 진행을 위해서 다음과 같이 의존성을 추가합니다.
2. h2 연결 확인
데이터베이스에 간단한 데이터를 넣고, 다시 조회해서 그 값이 넣은 값이랑 일치하는 테스트를 해보겠습니다.
2.1 User Entity 생성
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
@Entity
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
public class User {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
private String email;
private String password;
}
2.1.1 @Entity
spring-boot-starter-data-jpa
의존성을 추가하고 @Entity
를 붙이면 테이블과 자바 클래스가 매핑이 됩니다.
2.1.2 @Id
테이블의 식별자로 설정해줍니다. primary key 가 되겠죠.
2.1.3 @GeneratedValue
전략은 GenerationType.AUTO 로 해줍니다. 데이터베이스에 넣을때 자동으로 관리해줍니다.
2.1.4 @AllArgsConstructor , @NoArgsConstructor
모든 인자를 타입의 생성자를 만들어줍니다.
인자가 없는 생성자를 만들어줍니다.
2.2 UserRepository 생성
import com.plee.auth.domain.User;
import org.springframework.data.repository.CrudRepository;
public interface UserRepository extends CrudRepository<User, Long> {
Optional<User> findByEmail(String email);
}
데이터베이스에 일관된 메소드로 접근 가능하게 Spring 에서 제공해주는 추상화 인터페이스입니다.
2.3 RepositoryTest 생성
import com.plee.auth.domain.User;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import java.util.Optional;
@ExtendWith(SpringExtension.class)
@DataJpaTest
@ActiveProfiles(value = "dev")
public class UserRepositoryTest {
@Autowired
private UserRepository userRepository;
@Test
public void saveUserTest() {
User user = new User(null,"name", "password");
User savedUser = userRepository.save(user);
Assertions.assertEquals(user.getEmail(), savedUser.getEmail(),
"saveUserTest");
Assertions.assertEquals(user.getPassword(), savedUser.getPassword(),
"saveUserTest");
}
@Test
public void findByEmailSuccessTest() {
User user = new User(null, "name1", "password");
User savedUser = userRepository.save(user);
Optional<User> userFindByEmail = userRepository.findByEmail(user.getEmail());
userFindByEmail.ifPresent(value -> Assertions.assertEquals(savedUser.getEmail(), value.getEmail()));
}
@Test
public void findByEmailFailureTest() {
Optional<User> userFindByEmail = userRepository.findByEmail("not exist email");
Assertions.assertEquals(Optional.empty(), userFindByEmail);
}
@Test
public void idStrategyTest() {
User user1 = new User(null, "name1", "password");
User user2 = new User(null,"name2", "password");
User savedUser1 = userRepository.save(user1);
User savedUser2 = userRepository.save(user2);
Assertions.assertEquals(1, Math.abs(savedUser1.getId() - savedUser2.getId()));
}
}
유저를 생성하고 저장하는 테스트를 해보겠습니다.
2.3.1 @DataJpaTest
처음 등장하는 어노테이션입니다.
이 어노테이션을 통해서 JPA 관련 테스트 설정만 불러올 수 있습니다.
Datasource 설정이라던가, JPA 관련 테스트를 수행할 수 있습니다.
2.3.2 saveUserTest
새로운 사용자를 생성하고 저장된 사용자와 동일한지 확인합니다.
2.3.3 findByNameTest
UserRepository 를 생성하면서 메소드를 추가해줬는데, 이 메소드가 저장된 이름으로 잘 찾아오는지 테스트 합니다.
2.3.4 idStrategyTest
id 생성을 Database 가 관리하도록 맡겼죠. 따라서 연속으로 저장된 두 개의 데이터는 id 값의 차이가 1이 될겁니다.
물론 프로덕션 환경에서 적절한 Isolation 이 지정되지 않는다면 테스트가 올바르게 동작하지 않을수는 있습니다.
user1을 save 하던 도중 누군가가 다른 user 를 저장한다면 차이는 2가 될 수도 있습니다.
3. UserService
먼저 Service Layer 를 만들어보죠
3.1 UserService Interface
import com.plee.auth.domain.User;
public interface UserService {
User add(User user);
User get(Long id);
User update(User user);
boolean delete(Long id);
User findByEmail(String email);
}
추후 컨트롤러가 다양한 UserService 를 사용할 수 있도록 인터페이스를 생성합니다. 사실 이러한 서비스의 경우 변화가 크게 없지만 그래도 인터페이스를 만드는 습관을 들여 의존성을 줄이는 것은 유지 보수에서 매우 중요한 역할을 합니다.
3.2 UserServiceImpl
import com.plee.auth.domain.User;
import com.plee.auth.exception.UserExistedException;
import com.plee.auth.exception.UserNotFoundException;
import com.plee.auth.repository.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class UserServiceImpl implements UserService{
final boolean DELETE_SUCCESS = true;
final boolean DELETE_FAILED = false;
private UserRepository userRepository;
@Autowired
public UserServiceImpl(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public User add(User user) {
if (userRepository.findByEmail(user.getEmail()).isPresent())
throw new UserExistedException("User is already joined : "+user.getEmail());
else
return userRepository.save(user);
}
@Override
public User get(Long id) {
return userRepository.findById(id).orElseThrow(
() -> new UserNotFoundException("delete: User not found by : " + id));
}
@Override
public User update(User user) {
if (userRepository.findById(user.getId()).isPresent())
return userRepository.save(user);
else
throw new UserNotFoundException("update: User not found by : " + user.getId());
}
@Override
public boolean delete(Long id) {
userRepository.deleteById(id);
if (!userRepository.findById(id).isPresent())
return DELETE_SUCCESS;
else
return DELETE_FAILED;
}
@Override
public User findByEmail(String email) {
return userRepository.findByEmail(email).orElseThrow(
() -> new UserNotFoundException("findbyEmail: User not found by : " + email));
}
}
UserRepository 를 주입받아 각 메소드를 구현해줍니다.
3.2.1 CustomException
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
@ResponseStatus(HttpStatus.CONFLICT)
public class UserExistedException extends RuntimeException{
public UserExistedException(String message) {
super(message);
}
}
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
@ResponseStatus(HttpStatus.NOT_FOUND)
public class UserNotFoundException extends RuntimeException{
public UserNotFoundException(String message) {
super(message);
}
}
서비스 레이어에서 사용할 Exception을 만듭니다.
@ResponseStatus 는 추후에 컨트롤러 테스트에서 에러가 발생하는 경우 어떤 HttpStatus 를 반환할 것인가를 정의합니다.
3.3 UserServiceImplTest
일반적으로 각 레이어를 테스트할때는 해당하는 클래스가 가지는 역할에 대해서 고민해봐야 합니다.
서비스 레이어는 일반적으로 다음과 같은 역할을 가집니다.
- 비즈니스 로직 ( 이 부분에서는 여러 책에서 도메인이 비즈니스 로직의 책임자다 하는데, 토비의 스프링에서 비즈니스 로직은 서비스 레이어가 담당하는게 좋다. 어플리케이션이 충분히 처리할 수 있는 성능이 됨에도 불구하고 데이터베이스에 넘기는건 데이터베이스의 부하를 키우는 꼴이 된다 라고 이해했습니다.)
- 트랜잭션 관리 주체
몇 가지가 더 있을 수 있겠지만, 저는 위 두개라고 생각합니다.
먼저 비즈니스 로직에 대한 유닛 테스트를 진행합니다.
3.3.1 UserServiceImpl Unit Test 를 위한 의존성 변경
UserServiceImpl 은 UserRepository 에 대한 의존성을 가지고 있습니다.
하지만 유닛 테스트는 해당하는 메소드에 집중해서 테스트해야 합니다. 다른 의존성을 직접 주입해서 테스트 하는 것을 지양합니다.
따라서 이러한 의존성이 있을때 사용할 수 있는 라이브러리가 있습니다.
3.3.1.1 Mockito
테스트를 할 때 목 객체로 테스트를 돕는 라이브러리입니다.
스프링이 기본적으로 제공하는 라이브러리 버전에 이슈가 있습니다. 따라서 의존성을 변경합니다.
testImplementation 'org.junit.jupiter:junit-jupiter-api:5.6.1'
testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.6.1'
기존의 5.2 대 버전에 이슈가 있습니다.
3.3.2 UserServiceImplTest
import com.plee.auth.domain.User;
import com.plee.auth.exception.UserNotFoundException;
import com.plee.auth.repository.UserRepository;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Optional;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
public class UserServiceImplTest {
@Mock
private UserRepository userRepository;
@InjectMocks
private UserService userService = new UserServiceImpl(this.userRepository);
@Test
void addUserSuccessfully() {
final User user = new User(null, "name", "password");
given(userRepository.findByEmail(user.getEmail())).willReturn(Optional.empty());
given(userRepository.save(user)).willReturn(user);
User savedUser = userService.add(user);
Assertions.assertNotNull(savedUser);
verify(userRepository).findByEmail(anyString());
verify(userRepository).save(any(User.class));
}
@Test
void addUserFailure() {
final User user = new User(1L, "name", "password");
given(userRepository.findByEmail(user.getEmail())).willReturn(Optional.of(user));
Assertions.assertThrows(UserNotFoundException.class, () -> userService.add(user));
verify(userRepository, never()).save(any(User.class));
}
@Test
void getUserSuccessfully() {
final User user = new User(1L, "name", "password");
given(userRepository.findById(user.getId())).willReturn(Optional.of(user));
User userByGet = userService.get(user.getId());
Assertions.assertEquals(user, userByGet);
verify(userRepository).findById(anyLong());
}
@Test
void getUserFailure() {
given(userRepository.findById(anyLong())).willReturn(Optional.empty());
Assertions.assertThrows(UserNotFoundException.class, () -> userService.get(anyLong()));
verify(userRepository).findById(anyLong());
}
@Test
void updateSuccessfully() {
final User user = new User(1L, "name", "password");
given(userRepository.findById(user.getId())).willReturn(Optional.of(user));
given(userRepository.save(user)).willReturn(user);
User updatedUser = userService.update(user);
Assertions.assertNotNull(updatedUser);
verify(userRepository).save(any(User.class));
}
@Test
void updateFailure() {
final User user = new User(1L, "name", "password");
given(userRepository.findById(anyLong())).willReturn(Optional.empty());
Assertions.assertThrows(UserNotFoundException.class, () -> userService.update(user));
verify(userRepository).findById(anyLong());
}
@Test
void deleteSuccessfully() {
final Long id = 1L;
userService.delete(id);
verify(userRepository, times(1)).deleteById(anyLong());
verify(userRepository, times(1)).findById(anyLong());
}
@Test
void findByEmailSuccessfully() {
final User user = new User(1L, "name", "password");
given(userRepository.findByEmail(user.getEmail())).willReturn(Optional.of(user));
User userByEmail = userService.findByEmail(user.getEmail());
Assertions.assertEquals(user, userByEmail);
verify(userRepository).findByEmail(any(String.class));
}
@Test
void findByEmailFailure() {
final String email = "email";
given(userRepository.findByEmail(email)).willReturn(Optional.empty());
Assertions.assertThrows(UserNotFoundException.class, () -> userService.findByEmail(email));
verify(userRepository).findByEmail(any(String.class));
}
}
3.3.2.1 Mock 객체를 활용하여 테스트
코드의 이해를 돕기 위해 몇가지 설명을 덧붙이겠습니다.
@Mock
목 객체를 생성합니다.
@InjectMock
의존성 주입이 필요한 목 객체를 생성합니다.
3.2.2.2 코드를 보면서 사용된 메소드를 살펴보겠습니다.
@Test
void addUserSuccessfully() {
final User user = new User(null, "name", "password");
given(userRepository.findByEmail(user.getEmail())).willReturn(Optional.empty());
given(userRepository.save(user)).willReturn(user);
User savedUser = userService.add(user);
Assertions.assertNotNull(savedUser);
verify(userRepository).findByEmail(anyString());
verify(userRepository).save(any(User.class));
}
given
메소드에 대한 조건을 걸 때 사용합니다.
given(userRepository.findByEmail(user.getEmail())) : userRepository.findByEmail(user.getEmail()) 이 호출되면
willReturn(Optional.empty()) : Optional.empty() 를 반환
verify(userRepository).findByEmail(anyString()); : userRepository 가 findByEmail을 호출했는지 검증
3.3.3 유닛 테스트
기존의 UserRepository 를 고려하지 않는 것은 서비스 레이어만을 위한 테스트를 작성하기 위함입니다.
UserRepository 가 정상적으로 동작한다고 가정하고, 서비스 레이어 테스트를 구현하는 것이죠.
이렇게 고립된 유닛 테스트를 진행해야 비로소 온전한 비즈니스 로직 테스트에 집중할 수 있는 것입니다.
다음 포스팅에서 이어가겠습니다.
Author And Source
이 문제에 관하여([스프링 부트 - 1] Spring Boot Repository and Service Layer Testing), 우리는 이곳에서 더 많은 자료를 발견하고 링크를 클릭하여 보았다 https://velog.io/@devsh/스프링-부트-1-Spring-Boot-Repository-and-Service-Layer-Testing저자 귀속: 원작자 정보가 원작자 URL에 포함되어 있으며 저작권은 원작자 소유입니다.
우수한 개발자 콘텐츠 발견에 전념 (Collection and Share based on the CC Protocol.)