[테스트] - 서비스 계층의 단위 테스트 방법을 살펴보자

yeom yaloo·2024년 3월 24일
0

백엔드 관련 지식

목록 보기
5/7

Service Layer Test

📌 참고 자료

mockito 사용법(mockito usage) :: JDM's Blog
Mockito features in Korean

🎭 Service Layer란?

Service Layer?

1. 비지니스 로직 분리와 중앙화?

  • 비즈니스 로직은 말 그대로 비즈니스에서 일어나는 "일 처리 규칙"을 의미해요. 이것은 주로 컴퓨터 프로그램이나 소프트웨어에 적용돼요. 예를 들어, 은행 소프트웨어의 경우, 고객이 계좌에서 돈을 인출할 때, 계좌에 충분한 잔액이 있는지 확인하는 규칙이 비즈니스 로직의 한 예시 입니다.
  • 그럼 우리는 왜 비지니스 로직을 분리할까요? 이 이유는 컨트롤러나 영속성 계층에서 이를 처리하게 되면 코드가 길어지고 복잡해지는 문제가 있어서 유지보수가 어려워져요. 그래서 우리는 이를 분리하고 중앙화 하여, 코드의 가독성과 유지보수성이 향상되고 개발 효율성을 높이게 해요

2. 필요성

  • 각 계층의 역할 명확화를 위한 서비스 계층
    • ****바로 위의 역할에 연장되는 느낌인데, Controller는 사용자의 요청을 처리하고 응답을 생성하는 역할을, Persistence Layer는 DB와의 상호작용을 담당하는 역할을, Service Layer는 비즈니스 로직과 트랜잭션 관리를 수행하는 역할을 하게 돼요. 이렇게 각 계층의 역할이 분명해지면 코드의 가독성과 유지보수성이 향상됩니다.
  • 트랜잭션 관리를 서비스 계층에서 진행하는 이유
    • 각 핵심 기능마다 데이터의 일관성과 무결성을 보장하기 위해 트랜잭션을 관리해요. 하나의 기능을 처리하기 위해 여러 Dao 혹은 Service를 통해 데이터를 다뤄야 할 때, 중간에 어떤 계기로 인해 기능이 중단되어 한 단위 기능의 원자성이 파괴되는 것을 예방하기 위함이에요. 또한, 기능 단위로 트랜잭션을 관리할 수 있어서 트랜잭션의 경계를 더 명확히 할 수 있어요.
    • Controller에서 관리하기엔, 각 트랜잭션의 단위가 너무 커지고 비즈니스 로직 처리와 트랜잭션 처리가 동시에 이루어지지 않아서 유지보수의 어려움이 생길 수 있어요. 또한, Transactional은 AOP를 통해 구현되는데, Controller는 보통 인터페이스가 없어요(보통의 레포지토리나 서비스와 다르게 컨트롤러는 인터페이스 → 구현체 구조로 이뤄지지 않음을 뜻해요). AOP는 Dynamic proxy를 사용해서 인터페이스가 필요하기에, Controller에 Tracsantional을 적용하기엔 어려움이 따를 수 있어요.
    • Persistence Layer에서 트랜잭션을 관리하기엔, 이 또한 비즈니스 로직과 데이터의 영속성과 관련된 작업이 혼재될 수 있어요. 그리고 또한 트랜잭션 범위의 한계가 너무 좁아져서 각각의 기능 단위를 관리하는 Service Layer에서 트랜잭션 처리를 하기 어렵게 돼요.
  • 코드 재사용성 촉진
    • 서비스 계층은 비즈니스 로직을 캡슐화하므로 애플리케이션의 여러 부분에서 코드 재사용성을 높여줘요. 예를 들어 여러 컨트롤러가 동일한 서비스를 사용하여 공통 작업을 수행하여 코드 중복을 줄일 수 있는 것이 코드 재사용성을 높여주는 예시가 됩니다.

Service Layer 일반적인 코드 구조

1. 예시 서비스 코드 - 인터페이스

public interface OrederService {

		ResponseDTO findById(Long id);
		ResponseDTO createOrder(RequestDTO request);
}

2. 예시 서비스 코드 - 구현체

@RequiredArgsConstructor
@Service
@Transactional(readOnly = true)
public class OrederService {
		
		private final OrederRepository orderRepository; 
		
		public ResponseDTO findById(Long id){
				Order order = orderRepository.findById(id);
				
				// ResponseDTO내의 entity를 DTO로 변환해서 반환하는 static 메서드
				return ResponseDTO.fromEntity(order);
		}
		
		/**
			해당 작업이 데이터 생성과 관련된 작업이기 때문에 @Transactional(readOnly=true)가
			아닌 @Transational 애너테이션이 사용되어야 한다.
		*/
		@Transactional // 해당 메서드 레벨의 애너테이션이 더 우선순위로 동작한다.
		public ResponseDTO createOrder(RequestDTO request){
				Order order = orderRepository.findByOrderId(request.getId());
				
				//해당 아이디에 해당하는 주문 객체가 없으면 생성가능 
				if(Objects.isNull(order)){
						Oder newOrder = Order.createOrder(request);
						orderRepository.save(newOrder);
						
						return ResponseDTO.fromEntity(newOrder);
				}
				
				throw new AlreadyOrderException("이미 해당 주문이 존재합니다.");
		}

}
  • @Service
    • 서비스 레이어에 해당 클래스가 서비스임을 선언할 때 사용하는 애너테이션이에요.
    • 잘 아는 @Bean , @Component 등을 조금 더 구체적으로 선언하고자 할 때 @Service 라고 쓰는 용도예요.
    • 해당 애너테이션을 붙여주게 되면 스프링 컨테이너가 이를 관리하게 됩니다.
  • @Transactional
    • readOnly 속성을 클래스 레벨에 붙여주기 되면 해당 속성이 모든 서비스 클래스 내 메서드에 모두 적용 돼요.
    • 해당 속성을 붙여주게 됨으로써 검색 쿼리를 더 빠르게 진행할 수 있어요.
    • 검색에 사용하는 메서드가 아닌 경우라면 @Transactional 애너테이션을 해당 메서드 레벨에 붙여주면 돼요.
    • 메서드 레벨에 그냥 @Transaction 애너테이션을 붙여주게 되면 우선순위가 메서드→클래스 순서라 클래스 레벨에 붙어 있는 속성은 덮어지게 돼요.

🧩 단위 테스트 원칙과 단위 테스트를 위한 Mockito 프레임워크

단위 테스트 F.I.R.S.T 원칙

단위테스트를 잘 짜기 위한 원칙으로 로버트 C. 마틴의 클린코드에서 확인할 수 있어요.

F - Fast (빠르게)

테스트는 빠르게 진행되어야 해요.

I - Independent (독립적으로)

각 테스트는 서로 의존적이지 않아야 해요. 독립적으로 진행 해야 하고 어떤 순서로 실행해도 잘 실행되어야 해요. 테스트가 의존적으로 진행될 경우에 하나의 테스트가 실패했을 때 나머지도 잇달아 실패하게 되기 때문에 원인을 찾아내기 힘들어지고 후반 테스트 작업에 까지 영향을 미칠 수 있어서 꼭 독립적으로 진행 되어야 해요.

R - Repeatable (반복 가능하게)

테스트 코드를 작성했다면 해당 테스트 코드는 어떤 환경에서든 동일하게 동작해야 해요.

즉 실제 환경, QA 환경, 버스를 타고 가는 집으로 가는 길에 사용하는 노트북 환경에서도 실행할 수 있어야 해요.

반복 가능한 테스트는 외부 서비스나 리소스에 의존하지 않고 테스트하는 것을 의미해요. 네트워크, 개발 서버의 네트워크 환경에 상관 없이 실행되어야 하고, 단위 테스트는 외부 시스템을 테스트하지 않아야 해요.

S - Self Validating (자가검증하는)

테스트는 boolean 값으로 결과를 내야 해요. 즉, 이말은 성공 아니면 실패로 결과를 내야 한다는 것을 의미해요. 

T - Timely (적시에)

테스트 코드 작성은 기능 구현 이전에 이뤄져야 해요. 이 작업이 실제 코드 작성 후에 테스트 코드가 작성되면 실제 코드가 테스트 하기 어려워진다는 사실을 발견할 수 있어요. 그래서 테스트 작업은 대부분 기능과 관련된 코드의 작성 이전에 이뤄져야 해요.

단위 테스트를 위한 Mockito 프레임워크

1. Mockito가 필요한 경우

  • 통제된 방식으로 클래스의 기능을 독립적으로 테스트하기 위해서
  • 네트워크나 데이터베이스 연결과 같이 느린 작업에
  • 많은 종속성이 있는 경우에는 구성, 인스턴스화가 복잡하기 때문에
  • 인터페이스 껍데기만 존재 할 경우(코드 없이)
  • 코드가 부작용을 발생시키는 경우 (예 : 호출 시 전자 메일을 보내는 코드)
  • 해당 코드가 아닌 의존 관계에 있는 다른 코드에서 오류가 나는 경우

2. Mock 객체 만들기

Person 객체

public class Person {
    private String name;
    private int age;

    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public int getAge() { return age; }
    public void setAge(int age) { this.age = age; }
}
  • mock() 메소드는 목mock 객체를 만들어서 반환합니다. 예를 들어 아래와 같은 커스텀 클래스를 하나 만들었다고 가정해볼게요.

Mock 객체를 이용한 테스트 코드

@Test
public void example(){
    Person p = mock(Person.class);
    assertTrue( p != null );
}
  • 이제 mock() 메소드를 사용해봅시다.

@Mock 애너테이션 사용을 이용한 테스트 코드

@Mock
Person person;

@Test
public void example1(){
    MockitoAnnotations.initMocks(this);
    assertTrue(person != null);
}

3. 특정 조건 지정을 위한 when()

@Test
public void example(){
    Person p = mock(Person.class);
    when(p.getName()).thenReturn("JDM");
    when(p.getAge()).thenReturn(20);
    assertTrue("JDM".equals(p.getName()));
    assertTrue(20 == p.getAge());
}
  • when()은 반환해줄 값을 지정해줄 때 사용해요.

4. 예외를 던지고 싶을 때 doThrow()

@Test(expected = IllegalArgumentException.class)
public void example(){
    Person p = mock(Person.class);
    doThrow(new IllegalArgumentException()).when(p).setName(eq("JDM"));
    String name = "JDM";
    p.setName(name);
}

5. 반환 값이 void인 메서드에 when() 사용을 위한 doNothing()

@Test
public void example(){
    Person p = mock(Person.class);
    doNothing().when(p).setAge(anyInt());
    p.setAge(20);
    verify(p).setAge(anyInt());
}

6. 구문 호출 확인을 위한 verify()

@Test
public void example(){
    Person p = mock(Person.class);
    String name = "JDM";
    p.setName(name);
    // n번 호출했는지 체크
    verify(p, times(1)).setName(any(String.class)); // success
    // 호출 안했는지 체크
    verify(p, never()).getName(); // success
    verify(p, never()).setName(eq("ETC")); // success
    verify(p, never()).setName(eq("JDM")); // fail
    // 최소한 1번 이상 호출했는지 체크
    verify(p, atLeastOnce()).setName(any(String.class)); // success
    // 2번 이하 호출 했는지 체크
    verify(p, atMost(2)).setName(any(String.class)); // success
    // 2번 이상 호출 했는지 체크
    verify(p, atLeast(2)).setName(any(String.class)); // fail
    // 지정된 시간(millis)안으로 메소드를 호출 했는지 체크
    verify(p, timeout(100)).setName(any(String.class)); // success
    // 지정된 시간(millis)안으로 1번 이상 메소드를 호출 했는지 체크
    verify(p, timeout(100).atLeast(1)).setName(any(String.class)); // success
}

7. 다른 클래스를 필드로 쓰고 있는 경우엔 @InjectMocks

AuthService 코드

public class AuthService{
    private AuthDao dao;
    // some code...
    public boolean isLogin(String id){
        boolean isLogin = dao.isLogin(id);
        if( isLogin ){
            // some code...
        }
        return isLogin;
    }
}
public class AuthDao {
    public boolean isLogin(String id){ //some code ... }
}
public class AuthService{

    private AuthDao dao;
   
}
  • 해당 서비스에서는 Dao를 필드로 사용하고 있어요.
    • 이런 경우엔 setter 주입 혹은 @Autowired 주입 혹은 생성자 주입 등을 통해서 객체를 받아야 해요.
  • 테스트 작업에서 이런 경우 우리는 @InjectMocks를 사용해요.

테스트 코드

@Mock
AuthDao dao;

@InjectMocks
AuthService service;

@Test
public void example(){
    MockitoAnnotations.initMocks(this);
    when(dao.isLogin(eq("JDM"))).thenReturn(true);
    assertTrue(service.isLogin("JDM") == true);
    assertTrue(service.isLogin("ETC") == false);
}

🚲 Service Layer Test?

💡 우리는 이제 간단하게 해당 계층에 대한 테스트 작업을 진행할 거예요.

서비스 계층의 테스트를 진행할 때 가장 중요한 것은 영속성 계층의 연결 없이도 테스트가 가능해야 한다는 사실이에요. 이에 주의하고 서비스 계층의 테스트를 진행해봅시다.

이때 서비스 계층에서도 통합 테스트가 아닌 단위 테스트를 진행할 거예요. 영속성 계층에서 슬라이스 테스트를 진행하였는데, 서비스 계층에선 따로 그와 관련된 작업은 없어요. 그래서 주로 Mockito와 같은 목 객체(Mock)를 사용하여 의존성을 가짜 객체로 대체하고, 서비스 계층의 메소드들이 예상대로 작동하는지를 검증하는 식으로 진행이 돼요.

일반적인 서비스 계층의 테스트 코드

@SpringBootTest
class ArticleServiceTest {

    @Autowired
    ArticleService articleService;

    private Article article;

    @BeforeEach
    void setUp() {
        article = Article.builder()
                .contents("contents")
                .coverUrl("coverUrl")
                .title("title")
                .build();

        article = articleService.save(article);
    }

    @Test
    void 게시글_조회() {
        assertThat(articleService.findById(article.getId())).isNotNull();
    }

    @Test
    void 존재하지_않는_게시글_조회_예외처리() {
        assertThrows(IllegalArgumentException.class, () -> articleService.findById(100L));
    }

    ...
  • @SpringBootTest
    • 해당 애너테이션을 사용하면 통합 테스트를 사용하는 것과 같아요.
    • 그럼 불필요한 단위 테스트에 필요하지 않은 컨텍스트까지 모두 스캔한다는 문제점이 생기게 돼요. 이는 불필요한 비용과 시간을 들여 작업하는 것과 다름 없답니다.
    • 또한 해당 작업엔 서비스 계층 뿐만 아니라 setUp() 메서드 내에서 레포지토리까지 사용하고 있음을 알수 있어요.
    • 이는 명백하게 단위 테스트 작업이 아닌 통합 테스트와 다르지 않음을 확인할 수 있어요.
  • 우리는 불필요한 정보들을 전부 스캔하는 등의 작업 덕분에 발생하는 비용 문제 시간 문제를 해결하기 위해서 아래에서 서비스 계층에서 서비스 계층만을 위한 테스트 코드를 작성해볼 예정이에요.

2. 예시 서비스 코드

@Service
@Transactional
public class ArticleService {
    private final UserService userService;
    private final ArticleRepository articleRepository;

    public Long save(final Long userId, final ArticleDto.Request articleDto) {
        User author = userService.findById(userId);
        Article article = articleDto.toArticle(author);

        return articleRepository.save(article).getId();
    }

4. Mock 객체를 사용한 테스트 코드

import org.junit.jupiter.api.BeforeEach;
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 static org.mockito.Mockito.*;

@ExtendWith(MockitoExtension.class)
class ArticleServiceTest {
    private static final Long ARTICLE_ID = 1L;
    private static final Long USER_ID = 1L;

    @InjectMocks
    private ArticleService articleService;

    @Mock
    private UserService userService;

    @Mock
    private ArticleRepository articleRepository;

    private User user;
    private ArticleDto.Request articleRequest;
    private Article article;

    @BeforeEach
    void setUp() {
        user = new User(USER_ID, "email@gamil.com", "name", "P@ssw0rd");
        articleRequest = new ArticleDto.Request(ARTICLE_ID, "contents", "title", "coverUrl");
        article = articleRequest.toArticle(user);
    }

    @Test
    void 게시글_저장() {
        // given
        when(userService.findById(USER_ID)).thenReturn(user);
        when(articleRepository.save(article)).thenReturn(article);

        // when
        articleService.save(USER_ID, articleRequest);

        // then
        verify(articleRepository).save(article); // articleRepository.save(article)가 호출되었는지 확인
    }
}
  • @SpringBootTest
    • 해당 애너테이션이 없이도 테스트 코드가 작성됐음을 알 수 있어요 또한 따로 레포지토리와 관련해서 검증을 하지 않아도 된다는 것을 확인할 수 있어요.
  • when()
    • when(userService.findById(USER_ID)).thenReturn(user);: UserService의 findById 메서드가 USER_ID를 전달받을 때, 가짜 구현으로서 user 객체를 반환하도록 해요.
    • when(articleRepository.save(article)).thenReturn(article);: ArticleRepository의 save 메서드가 article 객체를 전달받을 때, 가짜 구현으로서 같은 article 객체를 반환하도록 지정해요
    • 서비스 테스트에서 레포지토리와 관련된 작업이 진행되는 이유는 서비스 계층과 영속성 계층 간의 상호작용을 확인하기 위해서예요. 비지니스 로직이 올바르게 작동하는지 확인하기 위해서는 이 작업이 필수적이에요.
  • @InjectMocks
    • 이 애너테이션은 목 객체(Mock)가 주입되어야 할 필드를 나타내요. 즉, 테스트 대상 객체를 생성할 때 해당 필드에 목 객체가 주입돼요. 테스트 대상 객체 안에서 이 필드에 대한 의존성 주입이 이루어져요.
  • @Mock
    • 이 애너테이션은 목 객체(Mock)를 생성해요. 테스트 대상 객체가 이 목 객체를 의존성으로 사용할 때 사용해요. 실제 객체가 아닌 가짜 객체를 생성하여 테스트 시에 사용해요.
profile
즐겁고 괴로운 개발😎

0개의 댓글