ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 테스트 코드 리팩토링 (Feat. 단위테스트, 최적화, 인수테스트 구현하기)
    학습로그 2022. 6. 10. 10:26

     

     

     

     

    이전의 테스트 코드는 단위테스트 구성이 전혀 되어있지 않았고, 테스트 비용을 생각하는 최적화부분도 없고 심지어 인수테스트 조차 없었다...!🤦‍♀️

     

    코드리뷰를 받으면서 테스트 코드 리팩토링도 함께 진행하였는데, 

    가장 먼저 1) 테스트 환경을 분리하고 2) 단위테스트 와 3) 인수테스트 도 구현하도록 하였다. 

     

    또한, @SpringBootTest, @DataJpaTest 등의 어노테이션을 사용하여 테스트를 진행할 때, 

    해당 어노테이션의 옵션이 다르거나, mockBean의 생성 부분들에 영향을 받게 되면 추가로 어플리케이션 컨텍스트를 생성하여 테스트를 진행하여 속도를 늦추는 원인이 된다는 것을 배우게 되었다. 

     

    즉, 같은 @DataJpaTest와 @SpringBootTest 어노테이션을 적용하여 각각 2개의 테스트를 적용하였을때에도 테스트에서 사용하는 @MockBean을 새로 생성하거나 옵션을 다르게 중복 적용하기만 해도 총 2개의 어플리케이션 컨텍스트가 아닌 추가로 어플리케이션 컨텍스트를 2개 더 새로 만들어 총 4개의 어플리케이션 컨텍스트를 생성하게 되는 것이다.

     

    나의 경우 이전 코드에서는 @MockBean의 중복 생성되는 부분을 코드에서 그대로 사용하도록 하는 부분이 있어 이 부분을 최적화 하도록 하고 기존에 개발 데이터베이스를 그대로 사용하던 Repository Test를 테스트 코드를 분리하여 실행하도록 설정하였다. 

     

     

     

     

    ① RepositoryTest에서 개발환경 데이터 베이스 분리하기 

    먼저 테스트 properties를 따로 h2 데이터 베이스를 사용하도록 변경해주었다. 

    의존관계를 먼저 추가해주고 properties를 따로 지정해주었다. 

    		<dependency>
    			<groupId>com.h2database</groupId>
    			<artifactId>h2</artifactId>
    			<scope>test</scope>
    		</dependency>
    spring.h2.console.enabled=true  
    spring.h2.console.path=/h2-console  
    
    spring.datasource.url=jdbc:h2:mem:testdb;MODE=MySQL;DB_CLOSE_DELAY=-1
    spring.datasource.driverClassName=org.h2.Driver
    spring.datasource.username=sa
    spring.datasource.password=
    
    spring.jpa.hibernate.ddl-auto=create
    
    logging.level.org.hibernate.type.descriptor.sql=trace

     

     

    먼저 이전의 테스트 코드는 다음과 같았다. 롤백도 하지 않고 실제 데이터 베이스를 사용하고 있었으니 기본적인 분리된 테스트 환경구성을 하나도 하지 않았다. 🤦‍♀️

    @DataJpaTest
    @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
    @Rollback(false)
    public class CompanyRepositoryTest {
    }

     

     

    이 부분을 다음과 같이 수정해주었다. 롤백도 적용하고 테스트데이터 베이스 설정을 연결해주었다. 

    @TestPropertySource(locations = "/config/application-test.properties")
    @DataJpaTest
    @RunWith(SpringRunner.class)
    @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
    public class CompanyRepositoryTest {
    }

     

     

     

    ② MockBean을 이용하여 테스트 코드 분리하기 

    먼저 원래의 서비스 테스트 코드를 보게 되면 필요한 빈을 @Autowired를 이용해서 실제 객체를 가져와서 테스트를 진행하기 때문에 Repository 계층이나 다른 서비스 계층과 분리가 하나도 되지 않은 채 코드가 실행되는 모습을 보였다. 즉, 단위 테스트 진행이 하나도 되지 않은 것! 🤦‍♀️

    @RunWith(SpringRunner.class)
    @SpringBootTest
    @Transactional
    public class UserServiceTests {
    
        @Autowired
        UserService userService;
    
        @Autowired
        UserRepository userRepository;
    
        @Autowired
        PasswordEncoder passwordEncoder;
        
        @Test
        public void register() throws IOException {
    
        }
     }

     

    이 부분을 MockBean을 이용하여 테스트를 분리하고 단위테스트가 진행되도록 변경하였다. 

    @RunWith(SpringRunner.class)
    @ExtendWith(MockitoExtension.class)
    @SpringBootTest
    @Transactional
    class UserServiceTest extends MockBeans {
    
        @MockBean
        protected UserRepository userRepository;
    
        @MockBean
        protected PasswordEncoder passwordEncoder;
    
        @MockBean
        protected FileUploadService fileUploadService;
        
        @InjectMocks
        private UserService userService = new UserService(TEST_UPLOAD_FOLDER, userRepository, fileUploadService, teamRepository, passwordEncoder);
    
        private UserRequest request;
        private User user;
        
        @Test
        void register_user() {
            // when
            when(userRepository.existsByEmail(any())).thenReturn(false);
            when(userRepository.save(any())).thenReturn(user);
            when(fileUploadService.saveProfileImage(any(), any())).thenReturn(IMAGE_URL);
            when(passwordEncoder.encode(PASSWORD)).thenReturn(PASSWORD);
            UserResponse response = userService.register(request, MULTIPART_FILE);
    
            //then
            checkEquals(response, user);
            assertThat(response.getRole()).isEqualTo(Role.MEMBER.name());
        }  
    }

     

    여기까지 실행되었던 테스트 코드의 속도는 다음과 같았다. 

     

     

     

     

    ③ 중복되는 부분을 abstract 클래스로 빼내기

    이전에 테스트 코드부분은 어노테이션을 중복으로 적용하고 테스트 클래스마다 필요한 mockBean을 개별로 사용하고 있어 mockBean을 중복하여 새로 생성하고 있었다. 또한 mockBean은 비용이 비싸기 때문에 중복해서 생성할 경우 속도가 느려질 수 있는 원인이 될 수 있었다. 

    기존의 코드는 다음과 같았다. 

    @RunWith(SpringRunner.class)
    @ExtendWith(MockitoExtension.class)
    @SpringBootTest
    @Transactional
    class CompanyServiceTest extends MockBeans {
    
        @MockBean
        protected UserService userService;
    
        @MockBean
        protected CompanyRepository companyRepository;
    
        @InjectMocks
        CompanyService companyService = new CompanyService(companyRepository, userService);
    
        private Company company;
        private CompanyRequest request;
    
        @Test
        void create_company() {
        }
    }
    
    @RunWith(SpringRunner.class)
    @ExtendWith(MockitoExtension.class)
    @SpringBootTest
    @Transactional
    class TeamServiceTest extends MockBeans {
    
        @MockBean
        protected CompanyService companyService;
    
        @MockBean
        protected TeamRepository teamRepository;
    
        @MockBean
        protected UserService userService;
        
        @InjectMocks
        private TeamService teamService = new TeamService(teamRepository, companyService, userService);
    
        private Team team;
        private TeamRequest request;
    
        @Test
        void create_team() {
        }

     

     

    이 부분은 중복되는 부분을 MockBeans라는 클래스로 꺼내어 중복적용을 해결하였다. 

    @Transactional
    @RunWith(SpringRunner.class)
    @ExtendWith(MockitoExtension.class)
    @SpringBootTest
    public abstract class MockBeans {
    
        @MockBean
        protected UserRepository userRepository;
    
        @MockBean
        protected TeamRepository teamRepository;
    
        @MockBean
        protected PasswordEncoder passwordEncoder;
    
        @MockBean
        protected FileUploadService fileUploadService;
    
        @MockBean
        protected UserService userService;
    
        @MockBean
        protected CompanyRepository companyRepository;
    
        @MockBean
        protected JwtUserDetailsService jwtUserDetailsService;
    
        @MockBean
        protected CompanyService companyService;
    
        @MockBean
        protected ConversationRepository conversationRepository;
    
        @MockBean
        protected MessageRepository messageRepository;
    
        @MockBean
        protected ConversationService conversationService;
    
    
    }
    class CompanyServiceTest extends MockBeans {
    
        @InjectMocks
        CompanyService companyService = new CompanyService(companyRepository, userService);
    
        private Company company;
        private CompanyRequest request;
        
        @Test
        void create_company() {
        }
    }
    
    
    class TeamServiceTest extends MockBeans {
    
        @InjectMocks
        private TeamService teamService = new TeamService(teamRepository, companyService, userService);
    
        private Team team;
        private TeamRequest request;
    
        @Test
        void create_team() {
        }

     

     

    Repository계층의 테스트 클래스의 중복부분도 RepositoryTest라는 abstract 클래스로 빼내어 중복부분을 제거해주었다. 

    @TestPropertySource(locations = "/config/application-test.properties")
    @DataJpaTest
    @RunWith(SpringRunner.class)
    @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
    public abstract class RepositoryTest {
    
        @Autowired
        protected ConversationRepository conversationRepository;
    
        @Autowired
        protected UserRepository userRepository;
    
        @Autowired
        protected CompanyRepository companyRepository;
    
        @Autowired
        protected MessageRepository messageRepository;
    
        @Autowired
        protected TeamRepository teamRepository;
    
    }

     

     

     

    ④ 인수테스트 작성 

    기존의 코드에서는 사용자 시나리로를 검증하는 필수적인 인수테스트 조차 작성되지 않았었기 때문에(!!) 다음과 같이 인수테스트도 작성해 주었다. 

    먼저 이전 인수테스트 블로그에서와 같이 인수테스트에 필요한 설정을 작성해주었다. 

    https://dodop-blog.tistory.com/289

     

    인수테스트 (Acceptance Test) ① 환경 구축 및 작성

    지난 주, 미션 진행으로 인수테스트 코드를 작성하는 방법을 배웠다. 인수테스트에 대해서 알아보고 미션 수행 후 작성한 코드를 통해서 작성 방법을 정리한다. 인수테스트란 시스템이 예상대

    dodop-blog.tistory.com

    @Service
    @ActiveProfiles("test")
    public class DatabaseCleanup implements InitializingBean {
    
        @PersistenceContext
        private EntityManager entityManager;
    
        private List<String> tableNames;
    
        @Override
        public void afterPropertiesSet() {
            tableNames = entityManager.getMetamodel().getEntities().stream()
                .filter(e -> e.getJavaType().getAnnotation(Entity.class) != null)
                .map(e -> CaseFormat.UPPER_CAMEL.to(CaseFormat.LOWER_UNDERSCORE, e.getName()))
                .collect(Collectors.toList());
        }
    
        @Transactional
        public void execute() {
            entityManager.flush();
            entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY FALSE").executeUpdate();
    
            for (final String tableName : tableNames) {
                entityManager.createNativeQuery("TRUNCATE TABLE " + tableName).executeUpdate();
            }
    
            entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY TRUE").executeUpdate();
        }
    }

     

     

    이제 인수테스트 설정을 사용하는 abstract AcceptanceTest 클래스를 만들고 중복 부분을 작성해주도록 한다. 이때 코드를 작성하면서, 중복되는 요청이 발생하는 부분, 상태코드를 확인하는 부분은 추상클래스에 작성해두고 구현 클래스들이 이 부분을 활용하도록 작성해주었다. 

    @TestPropertySource(locations = "/config/application-test.properties")
    @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
    public class AcceptanceTest {
    
        @LocalServerPort
        int port;
    
        @Autowired
        private DatabaseCleanup databaseCleanup;
    
        @BeforeEach
        public void setUp() {
            if (RestAssured.port == RestAssured.UNDEFINED_PORT) {
                RestAssured.port = port;
                databaseCleanup.afterPropertiesSet();
            }
            databaseCleanup.execute();
        }
    
        public static ExtractableResponse<Response> create_request(Object request, String uri, String token) {
            return RestAssured
                .given().log().all()
                .header("Authorization", "Bearer " + token)
                .contentType(MediaType.APPLICATION_JSON_VALUE)
                .body(request)
                .when().post("/api" + uri)
                .then().log().all()
                .extract();
        }
    
        public static ExtractableResponse<Response> update_Request(Object request, String uri, String token) {
            return RestAssured
                .given().log().all()
                .header("Authorization", "Bearer " + token)
                .contentType(MediaType.APPLICATION_JSON_VALUE)
                .body(request)
                .when().post("/api" + uri)
                .then().log().all()
                .extract();
        }
    
        public static ExtractableResponse<Response> update_Request(String uri, String token) {
            return RestAssured
                .given().log().all()
                .header("Authorization", "Bearer " + token)
                .contentType(MediaType.APPLICATION_JSON_VALUE)
                .when().post("/api" + uri)
                .then().log().all()
                .extract();
        }
    
        public static ExtractableResponse<Response> find_request(String uri, String token) {
            return RestAssured
                .given().log().all()
                .header("Authorization", "Bearer " + token)
                .contentType(MediaType.APPLICATION_JSON_VALUE)
                .when().get("/api" + uri)
                .then().log().all()
                .extract();
        }
    
        public static ExtractableResponse<Response> delete_request(String uri, String token) {
            return RestAssured
                .given().log().all()
                .header("Authorization", "Bearer " + token)
                .contentType(MediaType.APPLICATION_JSON_VALUE)
                .when().delete("/api" + uri)
                .then().log().all()
                .extract();
        }
    
        public static void check_create_response(ExtractableResponse<Response> response) {
            assertThat(response.statusCode()).isEqualTo(HttpStatus.CREATED.value());
        }
    
        public static void check_ok_response(ExtractableResponse<Response> response) {
            assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value());
        }
    
        public static void check_delete_response(ExtractableResponse<Response> response) {
            assertThat(response.statusCode()).isEqualTo(HttpStatus.NO_CONTENT.value());
        }
    
        public static void check_unauthorized_response(ExtractableResponse<Response> response) {
            assertThat(response.statusCode()).isEqualTo(HttpStatus.UNAUTHORIZED.value());
        }
    
    
    }

     

     

    추상클래스를 상속하는 실제 인수테스트 구현 코드를 확인하면 다음과 같다. 코드를 보면 중복부분은 AcceptanceTest의 코드를 활용하여 요청 및 확인을 진행하도록 하고 multipartFile등을 활용하는 개별의 코드는 각 클래스에서 메소드를 만들어 사용하도록 해주었다. 

    @DisplayName("사용자 관련 기능 인수테스트")
    public class UserAcceptanceTest extends AcceptanceTest {
    
        @Test
        void manage_user() {
            // when
            ExtractableResponse<Response> createResponse = create_user_request(imageFile, requestFile);
            // then
            check_user_created(createResponse);
    
            // when
            ExtractableResponse<Response> findResponse = find_user_request(createResponse);
            // then
            check_user_found(findResponse);
    
            // when
            ExtractableResponse<Response> findAllResponse = find_users_request(1);
            // then
            check_users_found(findAllResponse);
    
            // when
            ExtractableResponse<Response> updateResponse = update_user_request(createResponse, updateImageFile, updateRequestFile);
            // then
            check_user_updated(updateResponse);
        }
    
        public static ExtractableResponse<Response> create_user_request(File imageFile, File requestFile) {
            return RestAssured
                .given().log().all()
                .contentType("multipart/form-data")
                .multiPart("multipartFile", imageFile, "image/jpeg")
                .multiPart("userRequest", requestFile, "application/json")
                .when().post("/api/users")
                .then().log().all()
                .extract();
        }
    
        public static void check_user_created(ExtractableResponse<Response> response) {
            check_create_response(response);
        }
    
        public static ExtractableResponse<Response> find_user_request(ExtractableResponse<Response> response) {
            String uri = response.header("Location");
            return find_request(uri, "");
        }
    
        public static void check_user_found(ExtractableResponse<Response> response) {
            check_ok_response(response);
        }
        //...
    
    }

     

     

    여기까지 코드를 작성하고 진행한 테스트 시간 결과는 다음과 같았다. 

     

     

     

    (참고한 사이트)

    https://bperhaps.tistory.com/entry/%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%BD%94%EB%93%9C-%EC%B5%9C%EC%A0%81%ED%99%94-%EC%97%AC%ED%96%89%EA%B8%B0-4

     

    테스트 코드 최적화 여행기 (4)

    안녕하세요 깃들다의 손너잘 입니다. 이 글을 시작하기 전에 저희 프로젝트의 도메인에 대한 설명이 필요할 것 같은데요... 그냥 인스타그램이라고 생각하시면 편합니다! 그러면 글 시작하겠습

    bperhaps.tistory.com

     

     

     

Designed by Tistory.