-
테스트 코드 리팩토링 (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
@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); } //... }
여기까지 코드를 작성하고 진행한 테스트 시간 결과는 다음과 같았다.
(참고한 사이트)
'학습로그' 카테고리의 다른 글
월간 개발로그 - 2022년 9월 (1) 2022.10.03 월간 멘토링 - 마무리 ( + 새로운 시작 📍) (3) 2022.09.23 프로젝트 코드 리팩토링 (0) 2022.06.10 withEmployee(Springboot + React) 프로젝트 코드개선 및 성능 개선 (0) 2022.06.05 walkerholic(Springboot + React) 프로젝트 ④ 성능 개선 결과 확인 (0) 2022.06.03