[스프링 부트 - 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'

'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

일반적으로 각 레이어를 테스트할때는 해당하는 클래스가 가지는 역할에 대해서 고민해봐야 합니다.

서비스 레이어는 일반적으로 다음과 같은 역할을 가집니다.

  1. 비즈니스 로직 ( 이 부분에서는 여러 책에서 도메인이 비즈니스 로직의 책임자다 하는데, 토비의 스프링에서 비즈니스 로직은 서비스 레이어가 담당하는게 좋다. 어플리케이션이 충분히 처리할 수 있는 성능이 됨에도 불구하고 데이터베이스에 넘기는건 데이터베이스의 부하를 키우는 꼴이 된다 라고 이해했습니다.)
  2. 트랜잭션 관리 주체

몇 가지가 더 있을 수 있겠지만, 저는 위 두개라고 생각합니다.

먼저 비즈니스 로직에 대한 유닛 테스트를 진행합니다.

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 가 정상적으로 동작한다고 가정하고, 서비스 레이어 테스트를 구현하는 것이죠.

이렇게 고립된 유닛 테스트를 진행해야 비로소 온전한 비즈니스 로직 테스트에 집중할 수 있는 것입니다.

다음 포스팅에서 이어가겠습니다.

좋은 웹페이지 즐겨찾기