SpringBoot 프로젝트 테스트 도입기

성종호·2023년 12월 9일
3
post-custom-banner

테스트 환경 구축 과정

이번에 처음으로 SpringBoot와 Java를 이용하여 테스트 코드를 작성하는 과정을 적었습니다. 어떠한 고민과 어떤 생각을 가지고 이런 환경을 구축했는지에 대해서 글을 작성하려고 합니다.

테스트 환경 관심사의 분리

일단 테스트 환경을 구축하는데에 있어서 최소단위로 유닛테스트를 하기로 마음을 먹었습니다.

유닛테스트의 단위로는 메소드 단위로 생각하고 계층별로 패키지화 해서 테스트를 진행하였습니다.

  • Controller (엔드포인트 및 유효성 검사)
  • Service (유스케이스 시나리오)
  • Mybatis (SQL)

테스팅 라이브러리

  • Junit5
  • Spring Test
  • Mockito
  • H2 DataBase
  • Mybatis Test

테스트 기법

BDD Given - When - Then 패턴 을 사용하여 테스트 케이스 내에서
1. 준비
2. 실행
3. 검증
순으로 시나리오를 작성하였습니다.

DCI Describe - Context - It 패턴으로 테스트 구조를 계층화 해서 어떠한 테스트를 하는지 보기 쉽게 작성하였습니다.


Controller Test

사실 이 부분을 만들때 제일 고민을 많이 하였습니다. 통합테스트로 갈지 유닛테스트로 갈지 고민을 하다가 Controller Test에 엔드포인트의 존재, Dto 유효성검사, Service 객체 메소드 호출 여부에 대한 유닛테스트로 진행하기로 결정

간단한 Controller 예시 코드를 보자면


@WebMvcTest(UserController.class)
@DisplayName("UserController 클래스")
public class UserControllerTest {
    @MockBean
    private UserFacade userFacade;
    @Autowired
    private MockMvc mockMvc;

    @Nested
    @DisplayName("signUp 메소드는")
    class Describe_signUp {
        private UserSignUpDto userSignUpDto;

        @BeforeEach
        public void setUp() {
            userSignUpDto = new UserSignUpDto("jonghao", "a123b123!", "whdgh9595", "01012341234", "abc.jpg");
        }

        @Test
        @DisplayName("올바른 데이터를 받으면 UserFacade.userSignUpAndNotificationSettingCreate 을 호출하고 status 201과 user create라는 메세지를 반환한다.")
        void 올바른_데이터를_받으면_userFacade_userSignUpAndNotificationSettingCreate를_호출하고_status_201_및_user_create_메세지를_반환한다() throws Exception {
            // given
            doNothing().when(userFacade).signUpUserAndCreateNotificationSetting(userSignUpDto);
            Gson gson = new Gson();
            String userSignUpDtoJson = gson.toJson(userSignUpDto);

            // when
            mockMvc.perform(post("/api/v1/users/sign-up")
                    .contentType(MediaType.APPLICATION_JSON)
                    .content(userSignUpDtoJson))
                    .andExpect(status().isCreated())
                    .andExpect(jsonPath("$.message").value("user create"))
                    .andDo(print());

            // then
            verify(userFacade, times(1)).signUpUserAndCreateNotificationSetting(userSignUpDto);
        }
    }
}

Annotation

@WebMvcTest
웹계층 빈(Controller, Controller Advice, Fileter, Intercepter)을 로드하기 위한 어노테이션 입니다.
@SpringBootTest를 사용하지 않은 이유로는 Controller만을 위한 유닛테스트로 작성할 것이고 모든 빈을 불러올 필요가 없기때문에 @WebMvcTest 어노테이션을 이용하여 작성하였습니다.

@DisplayName
테스트를 설명하기 위한 이름을 명시하는 어노테이션

@MockBean
특정 빈을 모의객체로 주입받기위한 어노테이션 입니다. Controller Test 이고 Service -> Repository로 향하기 때문에 DAO에서 네트트워크를 타지 않기 위해서 Service 계층의 객체를 Mock의 형태로 주입 받았습니다.

@BeforeEach
테스트 케이스가 돌아갈때마다 시작전에 한번은 실행되는 함수에 대한 정의를 하는 어노테이션 입니다. 테스트 슈트 시작 전 한번만 실행되는 어노테이션으로는 @BeforeAll이 있습니다.

@Nested
특정 테스트슈트를 이너 클래스로 묶어주고 중첩된 형태를 만들어주고싶을때 사용되는 어노테이션 입니다.

@Test
실제 테스트가 진행되는 테스트 케이스 함수에 사용되는 어노테이션 입니다.

테스트 케이스 Given - When - Then

given 준비단계
// Mock으로 주입받은 UserFacade 객체의 특정 void 함수를 아무 일도 
// 일어나지 않는 상태의 Mock 함수로 만듭니다.
doNothing().when(userFacade).signUpUserAndCreateNotificationSetting(userSignUpDto);
// Json형태의 RequestDto를 만듭니다.
Gson gson = new Gson();
String userSignUpDtoJson = gson.toJson(userSignUpDto);
when 실행단계
mockMvc.perform(post("/api/v1/users/sign-up") // 엔드포인트
    .contentType(MediaType.APPLICATION_JSON) // 컨텐츠타입
    .content(userSignUpDtoJson)) //Request Body
    .andExpect(status().isCreated()) // 전달받은 status 검증
    .andExpect(jsonPath("$.message").value("user create")) // 전달받은 message 값 검증
    .andDo(print());
then 검증단계
// 특정 메소드의 호출 여부 검증
verify(userFacade, times(1)).signUpUserAndCreateNotificationSetting(userSignUpDto);
  • 추가적으로 when절에서 검증을 했지만 perform결과값을 result로 검증을 할 수 있을것 같습니다.
  • Spy의 형태로 검증 메소드의 인자값을 추적하여 해당 값도 검증 할 수 있을것 같습니다.

Service Test

Service 테스트의 경우에는 유스케이스 즉 시나리오에 대한 테스트로 제가 제일 중요하게 생각하는 부분은 분기에 대한 테스트로 분기에 따라 테스트 케이스가 늘어나게 됩니다.

Controller나 Mapper 테스트처럼 특정 Application Context를 불러들이는 일이 없고 네트워크를 타지 않는 계층으로 격리되어 있어 제일 빠르게 테스트를 할수있는 계층입니다.

@ExtendWith(MockitoExtension.class)
@DisplayName("UserServiceImpl 클래스")
public class UserServiceImplTest {
    @Mock
    private UserRepository userRepository;
    @InjectMocks
    private UserServiceImpl userService;
    private UserSignUpDto user;
    
    @Nested
    @DisplayName("signUp 메소드는")
    class Describe_signUp {

        @BeforeEach
        void setUp() {
            user = new UserSignUpDto("jonghao", "a123b123", "whdgh9595", "01012341234", null);
        }
        @Test()
        @DisplayName("UserRepository.findByUsername()을 호출하고 결과가 있으면 UserDuplicatedException을 발생시킨다.")
        void 유저_아이디가_중복된_경우_UserDuplicatedException을_발생시킨다() {
            // given
            when(userRepository.findOneByUsername(user.getUsername())).thenReturn(Optional.of(user.toUser()));

            // when // then
            UserDuplicatedException e = assertThrows(UserDuplicatedException.class, () -> userService.signUp(user));
            assertEquals("이미 존재하는 아이디입니다.", e.getMessage());
        }

        @Test()
        @DisplayName("UserRepository.findByPhoneNumber()을 호출하고 결과가 있으면 UserDuplicatedException을 발생시킨다.")
        void 유저_핸드폰번호가_중복된_경우_UserDuplicatedException을_발생시킨다() {
            // given
            when(userRepository.findOneByPhoneNumber(user.getPhoneNumber())).thenReturn(Optional.of(user.toUser()));

            // when // then
            UserDuplicatedException e = assertThrows(UserDuplicatedException.class, () -> userService.signUp(user));
            assertEquals("이미 가입된 전화번호입니다.", e.getMessage());
        }

        @Test()
        @DisplayName("핸드폰번호와 아이디가 중복되지 않으면 유저를 생성하고 회원가입이 완료된다.")
        void 핸드폰번호와_아이디가_중복되지_않으면_유저를_생성하고_회원가입이_완료된다() {
            // given
            when(userRepository.findOneByUsername(user.getUsername())).thenReturn(Optional.empty());
            when(userRepository.findOneByPhoneNumber(user.getPhoneNumber())).thenReturn(Optional.empty());

            // when
            userService.signUp(user);

            // then
            verify(userRepository, times(1)).createUser(any());
        }
    }
}

Annotation

@ExtendWith(MockitoExtension.class)
Junit5에서 Mockito를 확장해서 사용하기 위한 어노테이션 입니다.

@Mock
Mockito에서 제공하는 어노테이션으로 특정 클래스를 Mock으로 테스트 대상 객체에 주입되는 객체에 붙는 어노테이션 입니다.

@InjectMocks
테스트 대상 객체로 실제 테스트를 적용할 객체에 붙는 어노테이션 입니다.

테스트 케이스 Given - When - Then

given 준비단계
// 주입받은 Mock 객체의 특정 메소드 값의 리턴값을 정의
when(userRepository.findOneByUsername(user.getUsername())).thenReturn(Optional.of(user.toUser()));
when 실행단계 + then
//테스트 대상의 메소드 호출과 동시에 Exception 타입 검증
UserDuplicatedException e = assertThrows(UserDuplicatedException.class, () -> userService.signUp(user));
then 검증단계
// Exception 결과 검증
assertEquals("이미 존재하는 아이디입니다.", e.getMessage());

Mybatis Test

제가 실무를 하면서 어플리케이션 코드보다도 제일 실수가 많이 일어났던 부분이고 Node.js에서 Spring으로 넘어가면서 제일 테스트를 해보고 싶었던 영역이였는데요

실제로 테스트 환경을 구축하면서 제일 애를 먹었던 부분이였습니다.

Profile은 아래와 같이 3개로 나누어져 있고
1. Local
2. Test
3. Production

데이터베이스의 경우 Local, Production에서는 MySQL을 Test환경에서는
H2 DataBase를 이용하여 메모리에서 동작하게 만들 예정입니다.

그리고 DataSource는 Replication을 사용하기 때문에 데이터베이스 설정을 Properties에서 정의를 하고 Java Config로 코드에서 설정을 커스터마이징 하였습니다.

의존성 설치

testImplementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter-test:3.0.1'
testImplementation 'com.h2database:h2'

MapperTestConfig

@Import({MyBatisConfig.class, DataSourceConfig.class})
@TestConfiguration
public class RepositoryTestConfiguration {
}
  1. 실제로 사용하는 Config Class를 TestConfiguration에 import를 해줌으로써 해당 설정을 Test Conetext에서 사용할수 있게끔 하였습니다.

BaseMapperTest

@ActiveProfiles("test")
@MybatisTest(properties = "spring.profiles.active=test")
@MapperScan(basePackages = "com.jongho.**.dao.mapper")
@ContextConfiguration(classes = RepositoryTestConfiguration.class)
public class BaseMapperTest {
    @Autowired
    private DataSource dataSource;

    private void setUpTable(String dummyDataSql){
        try(Connection connection = dataSource.getConnection();
            PreparedStatement preparedStatement = connection.prepareStatement(dummyDataSql);
        ){
            preparedStatement.execute();
        }
        catch (SQLException e){
            e.printStackTrace();
        }
    }

    private void excuteTruncateTable(String tableName){
        String sql = "TRUNCATE TABLE " + tableName;
        try(Connection connection = dataSource.getConnection();
            PreparedStatement preparedStatement = connection.prepareStatement(sql)){
            preparedStatement.execute();
        }
        catch (SQLException e){
            e.printStackTrace();
        }
    }

    private void initializeAutoIncrement(String tableName){
        String sql = "ALTER TABLE " + tableName + " ALTER COLUMN id RESTART WITH 1;";
        try(Connection connection = dataSource.getConnection();
            PreparedStatement preparedStatement = connection.prepareStatement(sql)){
            preparedStatement.execute();
        }
        catch (SQLException e){
            e.printStackTrace();
        }
    }

    protected void cleanUpCategoryTable(){
        excuteTruncateTable("hobby_categories");
        initializeAutoIncrement("hobby_categories");
    }

    protected void setUpCategoryTable(){
        try {
            String sql = new String(Files.readAllBytes(Paths.get("src/test/resources/setupDummyData/categoryDummyData.sql")));
            setUpTable(sql);
        } catch (IOException e) {
            e.printStackTrace();
            return;
        }

    }
}

Annotation

@MapperScan
Mapper를 스캔할 패키지 위치를 지정합니다.

@ContextConfiguration(classes = RepositoryTestConfiguration.class)
테스트 컨텍스트에서 아까 만들어둔 TestConfiguration 설정을 주입합니다.

@ActiveProfiles("test")
Profile을 test로 지정하는 어노테이션 입니다.

@MybatisTest(properties = "spring.profiles.active=test")
MybatisTest는 어플리케이션 컨텍스트를 로드하며 Mapper를 찾아서 빈으로 등록을 해주는역할을 합니다.

의아한점
@ActiveProfiles에서 test환경으로 지정을 했는데
@MybatisTest(properties = "spring.profiles.active=test")에서 한번더 환경을 test로 지정을 한 이유가 궁금하실수도 있을것 같습니다.

@ActiveProfiles("test")로 인해서 테스트 런타임이 실행될때 환경은 test로 지정이 되는게 맞습니다.

하지만 MyBatisTest를 사용하면서 어플리케이션 컨텍스트를 로드하게 되는데요
@ActiveProfiles만 환경을 지정을 해줬을경우 아래와 같습니다.

  1. 테스트 실행시 환경이 test로 지정
  2. mapper를 빈으로 등록하기위해 어플리케이션 컨텍스트를 구성
  3. main의 resources의 application.properties의 springboot.profiles.active값에 환경이 주입되지 않아 default값이 주입
  4. 빈으로 등록된 빈들의 설정값이 local에 지정된 값으로 설정
  5. mapper test시 H2 DataBase가 아닌 MySQL로 지정

이렇게 activce는 test가 맞지만 springboot.profiles.active을 참조하는 Java Config 클래스들은 local로 지정되기 때문에 @MybatisTest에서 springboot.profiles.active을 오버라이딩 하여서 test값으로 지정하며 해결하였습니다.

setUp && tearDown

setUpTable 메소드
H2데이터베이스에 테이블을 생성하는 메소드 입니다.

excuteTruncateTable 메소드
특정 테이블을 청소해주는 메소드 입니다.

initializeAutoIncrement 메소드
테이블 청소 후 AutoIncrement값을 초기화 해주는 메소드입니다.

CleanUpTable 메소드
특정 테이블을 청소하고 PK값을 초기화 합니다.

SetUpTable 메소드
더미데이터를 넣어 특정 테이블을 세팅해주는 메소드 입니다.

Mapper Test

위의 설정과 미리 만들어둔 BaseClass를 이용하여 테스트의 결과물 입니다.

package com.jongho.category.dao.mapper;

import com.jongho.category.domain.model.Category;
import com.jongho.common.dao.BaseMapperTest;
import org.junit.jupiter.api.*;
import org.springframework.beans.factory.annotation.Autowired;

import java.util.List;

import static org.junit.jupiter.api.Assertions.assertEquals;

@DisplayName("CategoryMapper 인터페이스")
public class CategoryMapperTest extends BaseMapperTest {
    @Autowired
    private CategoryMapper categoryMapper;

    @Nested
    @DisplayName("selectSubCategory 메소드는")
    class Describe_selectSubCategory {
        @BeforeEach
        void setUp() {
            setUpCategoryTable(); // main category 5개, sub category 5개 총 10개의 카테고리를 생성한다.
        }
        @AfterEach
        void tearDown() {
            cleanUpCategoryTable();
        }
        @Test
        @DisplayName("서브 카테고리를 조회한다.")
        void 서브_카테고리를_조회한다() {
            // given
            Long categoryId = 1L;

            // when
            List<Category> categories = categoryMapper.selectSubCategory(categoryId);

            // then
            for (Category category : categories) {
                assertEquals(categoryId, category.getParentId());
            }
            assertEquals(1, categories.size());
        }
    }
}

아쉬운점

이렇게 Node에서 Spring으로 넘어가면서 처음으로 구축?해서 도입해본 테스트코드 도입 과정에 대해서 작성을 해봤는데요

자주 사용되는 문자열 리터럴 상수값을 Enum으로 만들지 않고 개발을 한 점과 아직 Spring과 친하지 않아서 생긴 여러가지 문제들이 있는데 일단은 개발을 하고 추후에 최적화 과정과 리팩토링을 통해 가독성을 챙길것 같습니다.

profile
아자
post-custom-banner

2개의 댓글

comment-user-thumbnail
2023년 12월 10일

다양한 환경에서의 단위 테스트를 진행 하셨었네요! 잘 보고 갑니다 :)
그런데 이러한 꼼꼼한 테스트로 인한 문제점은 없을까요?

답글 달기
comment-user-thumbnail
2023년 12월 12일

안녕하세요 종호님!
이번 1번째 주제가 저와 비슷한 주제인 테스트 코드라서 관심있게 봤습니다. 😊👍
해당 글을 읽으면서, 저도 실전에 적용해봐야겠다는 생각이 드네요. 🔥
그런데 혹시 Repository Test에 대한 내용만 없는데, 아직 적용을 안했는지 궁금합니다~

답글 달기