본문 바로가기
Spring/SpringBoot

[SpringBoot] Layer별 테스트 코드 작성하기 (2) - Service 테스트

by J4J 2024. 2. 4.
300x250
반응형

안녕하세요. J4J입니다.

 

이번 포스팅은 Layer별 테스트 코드 작성하기 두 번째인 Service 테스트하는 방법에 대해 적어보는 시간을 가져보려고 합니다.

 

 

 

이전 글

 

[SpringBoot] Layer별 테스트 코드 작성하기 (1) - JPA를 이용한 Repository 테스트

 

 

반응형

 

 

Mock 객체와 Mockito

 

service 로직과 관련된 테스트 코드를 작성하기 위해서는 먼저 mock에 대한 개념을 알고 있어야 합니다.

 

이전 글에서도 간단하게 얘기를 하기는 했지만 spring에서 테스트 코드를 작성하고 실행시킬 때 spring의 동작을 위한 모든 환경을 구성하는 상황이 발생되면 좋지 않습니다.

 

물론 통합 테스트와 같이 API의 전체 동작에 대한 흐름을 파악할 때는 당연히 구성이 되어야 하지만, 지금처럼 service layer만을 테스트할 때도 동일하게 구성이 된다면 테스트가 무거워지고 리소스 낭비를 발생시킵니다.

 

그래서 일반적으로 service 만을 위한 테스트를 구성할 때는 @SpringBootTest와 같은 어노테이션을 사용하지 않습니다.

 

 

 

결국 내용을 정리해보면 service 테스트를 구성할 때 service 동작만을 수행하는 환경이 구성된다는 얘기입니다.

 

하지만 이 방법의 문제점은 일반적으로 service에서는 repository 등과 같이 의존되는 객체들이 존재하는데 service 내부 동작을 위한 환경만 구성이 되기 때문에 의존되는 객체들의 설정이 되지 않습니다.

 

그러다 보니 테스트의 관점에서 기존 소스 코드는 에러를 발생시키게 됩니다.

 

 

 

 

이때 필요한 것이 mock 객체입니다.

 

mock이라고 하는 것은 단어 의미대로 "가짜"를 말하는 것인데 위의 예시에 대입을 해본다면 테스트 관점에서 기존 소스 코드 에러를 발생시키지 않기 위해 repository 등과 같은 의존성 객체들을 모두 가짜 객체로 생성하는 것입니다.

 

그러나 가짜 객체를 단순하게 생성하는 것 만으로는 완벽하게 해결이 되지 않습니다.

 

의존되는 객체들을 mock 객체로 대체했지만 객체들이 기존에 가지고 있던 메서드들의 실행 결과까지 담고 있지 않습니다.

 

그래서 특정 테스트 코드에서만 사용될 수 있는 가짜 결괏값까지 정의를 하여 service 테스트를 구성해 볼 수 있습니다.

 

 

 

그리고 이 모든 것을 spring에서 할 수 있도록 도와주는 것이 mockito 입니다.

 

mockito는 java에서 사용될 수 있는 테스트 프레임워크 중 하나로 spring을 이용하여 테스트 코드를 구성할 때 mock 객체 생성, mock 객체 주입, 가짜 결괏값 정의와 같은 모든 기능들을 제공해주고 있습니다.

 

그래서 mock 객체가 필요한 service layer와 같은 곳에서는 mockito의 사용이 권장되고 있습니다.

 

 

 

 

Service 테스트

 

위의 개념을 기반으로 service 테스트 코드를 작성해 보겠습니다.

 

간단하게 repository에서 조회된 데이터를 이용하여 service의 내부 로직이 올바르게 동작하는지 테스트해보겠습니다.

 

mock 객체를 활용하기 때문에 repository의 결과는 소스 코드에 작성된 것을 반환하지 않고, 테스트 내부에서 정의된 값을 반환하도록 하여 테스트가 올바르게 동작될 수 있도록 다음과 같이 코드를 작성할 수 있습니다.

 

 

 

 

[ 1. Repository 클래스 확인 ]

 

테스트 목적이기 때문에 따로 DB를 연결하지 않고 다음과 같이 임의로 만들어 보겠습니다.

 

package com.jforj.servicetest.repository;

import org.springframework.stereotype.Repository;

import java.util.Arrays;
import java.util.List;

@Repository
public class StudentRepository {

    public List<String> selectNames() {
        return Arrays.asList("repository1", "repositroy2");
    }
}

 

 

 

[ 2. Service 클래스 작성 ]

 

package com.jforj.servicetest.service;

import com.jforj.servicetest.repository.StudentRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.stream.Collectors;

@Service
@RequiredArgsConstructor
public class StudentService {

    private final StudentRepository studentRepository;

    public List<String> searchNames() {
        List<String> names = studentRepository.selectNames();
        return formatNames(names);
    }

    /**
     * name의 prefix에 service를 붙이도록 formatting 수행
     *
     * @param names 이름 목록
     * @return formatting된 이름 목록
     */
    private List<String> formatNames(List<String> names) {
        return names
                .stream()
                .map(name -> "service".concat(name))
                .collect(Collectors.toList());
    }
}

 

 

 

 

[ 3. 테스트 코드 작성 ]

 

package com.jforj.servicetest.service;

import com.jforj.servicetest.repository.StudentRepository;
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.Arrays;
import java.util.List;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.when;

// mockito 사용 설정
@ExtendWith(MockitoExtension.class)
class StudentServiceTest {

    // test에서만 사용되는 가짜 객체
    @Mock
    private StudentRepository studentRepository;

    // test에서만 사용되는 가짜 객체가 주입될 객체
    @InjectMocks
    private StudentService studentService;

    @Test
    void searchNamesTest() {
        // given
        List<String> givenNames = Arrays.asList("test1", "test2", "test3");
        // repository selectNames의 결과값을 test에서만 givenNames으로 변경
        when(studentRepository.selectNames()).thenReturn(givenNames);

        // when
        List<String> names = studentService.searchNames();

        // then
        assertEquals(3, names.size()); // 문자열 길이 검증
        for (String name : names) {
            assertFalse(name.contains("repository")); // 기존 로직에서 확인 가능한 repository 문자열 존재 검증
            assertTrue(name.contains("service")); // service 내부 로직인 formatting 수행 검증
        }
    }
}

 

 

 

[ 4. 테스트 결과 확인 ]

 

service 테스트 결과 확인

 

 

 

 

Service 테스트를 위한 커스텀 어노테이션

 

이전 글에서 repository에 대한 커스텀 어노테이션을 생성한 것처럼 service에서도 커스텀 어노테이션을 이용하여 관리해 주면 테스트 코드를 작성하기가 더 수월해질 수 있습니다.

 

물론 repository처럼 필요한 어노테이션이 많지는 않지만 프로젝트를 함께 개발하는 사람들 사이에서 더 쉬운 용어를 활용하여 테스트 코드를 작성할 수 있다면 이것만으로도 이점을 가져다줄 수 있습니다.

 

 

 

 

커스텀 어노테이션을 생성하기 전 먼저 service 테스트 코드를 작성할 때 필요한 설정들이 무엇이 있는지 확인해야 됩니다.

 

현재 기준으로 service 테스트 코드를 작성할 때 다음과 같이 설정을 위한 어노테이션이 사용되는 것을 볼 수 있습니다.

 

// mockito 사용 설정
@ExtendWith(MockitoExtension.class)
class StudentServiceTest {

   ...
   
}

 

 

 

지금 사용되는 것은 1개이지만 나중에 더 많은 어노테이션이 추가가 된다고 가정했을 때 매번 모든 service 테스트 클래스 파일에 어노테이션을 추가하는 것은 불편함을 발생시킬 수 있습니다.

 

그래서 이를 해결하기 위해서도 우리만의 service 테스트를 위한 어노테이션을 생성하여 더 편리하게 테스트 코드들을 관리해 볼 수 있습니다.

 

 

 

 

커스텀 어노테이션을 생성하는 경우 기존 소스 코드가 어떻게 변경되는지 확인해 보겠습니다.

 

[ 1. test 폴더 하위에 커스텀 어노테이션 추가 ]

 

package com.jforj.servicetest.annotation;

import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.junit.jupiter.MockitoExtension;

import java.lang.annotation.*;

// annotation이 class, interface, record 등의 선언에 사용될 수 있도록 설정
@Target(ElementType.TYPE)
// annotation이 runtime 단계에서 사용될 수 있도록 설정
@Retention(RetentionPolicy.RUNTIME)
// annotation이 javadoc 문서화에 표현될 수 있도록 설정
@Documented
// annotation이 부모 클래스에 선언되어 있을 때 자식 클래스에도 상속되도록 설정
@Inherited
// mockito 사용 설정
@ExtendWith(MockitoExtension.class)
public @interface ServiceTest {
}

 

커스텀 어노테이션 구조

 

 

 

 

[ 2. 테스트 코드 변경 ]

 

package com.jforj.servicetest.service;

import com.jforj.servicetest.annotation.ServiceTest;
import com.jforj.servicetest.repository.StudentRepository;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;

import java.util.Arrays;
import java.util.List;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.when;


// 모든 설정을 커스텀 어노테이션으로 한 개로 대체
@ServiceTest
class StudentServiceTest {

    // test에서만 사용되는 가짜 객체
    @Mock
    private StudentRepository studentRepository;

    // test에서만 사용되는 가짜 객체가 주입될 객체
    @InjectMocks
    private StudentService studentService;

    @Test
    void searchNamesTest() {
        // given
        List<String> givenNames = Arrays.asList("test1", "test2", "test3");
        // repository selectNames의 결과값을 test에서만 givenNames으로 변경
        when(studentRepository.selectNames()).thenReturn(givenNames);

        // when
        List<String> names = studentService.searchNames();

        // then
        assertEquals(3, names.size()); // 문자열 길이 검증
        for (String name : names) {
            assertFalse(name.contains("repository")); // 기존 로직에서 확인 가능한 repository 문자열 존재 검증
            assertTrue(name.contains("service")); // service 내부 로직인 formatting 수행 검증
        }
    }
}

 

 

 

[ 3. 테스트 결과 확인 ]

 

커스텀 어노테이션 테스트 결과 확인

 

 

 

 

 

 

 

 

이상으로 Layer별 테스트 코드 작성하기 두 번째인 Service 테스트하는 방법에 대해 간단하게 알아보는 시간이었습니다.

 

읽어주셔서 감사합니다.

 

 

 

728x90
반응형

댓글