[java-springboot] 테스트 코드 종류
환경: java11, junit5, springboot2.6
내가 생각하는 테스트 코드의 레벨은 아래와 같다.
1. 컨트롤러 단 검증: input변수 확인
1-1. 서비스로 나가는 변수 capture해서 확인
2. 서비스 검증: 서비스에 있는 로직 위주로 나머지 통신은 mocking
2-1. 유틸 클래스 및 dto 변환 로직 테스트
2-2. 외부 api는 보통 mocking 해서 진행하지만, timeout이랑 http code != 200과 같이 mocking이 힘든 경우
3. 레파지토리 검증: 쿼리 검증 위주
4. 통합검증: 2, 3번 함께 검증, 필요시 1번도 연결
1. 컨트롤러 단 검증
mockMvc를 통해 post와 같이 request body가 있는 경우를 검증
-> body로 들어오는 Json이 object mapper를 통해 파싱되는 원리 이용
-> controller 로 변수 세팅할 때 validation annotaion 검증
@ExtendWith(MockitoExtension.class)
@DisplayName("@Valid, @Validated 등 컨트롤러 단으로 들어오는 파라미터 검증")
class ControllerTest {
private static ObjectMapper mapper;
protected MockMvc mockMvc;
protected MvcResult result;
@BeforeAll
protected static void setup() {
mapper = new ObjectMapper();
mapper.registerModule(new JavaTimeModule());
}
protected void thenExpectResponseHasBadRequestStatus() {
assertEquals(HttpStatus.BAD_REQUEST.value(), result.getResponse().getStatus());
}
protected void thenExpectResponseHasOkRequestStatus() {
assertEquals(HttpStatus.OK.value(), result.getResponse().getStatus());
}
protected void thenExceptionIncludeBadParameter(String... requestMembers) {
var allIncluded = Arrays.asList(requestMembers).stream().allMatch(val -> result.getResolvedException().getMessage().contains(val));
assertTrue(allIncluded);
}
protected void thenNoValidationException() {
assertNull(result.getResolvedException());
}
@SneakyThrows
protected String objectToJsonString(Object object) {
return mapper.writeValueAsString(object);
}
}
class MailControllerTest extends ControllerTest {
@InjectMocks
private MailController mailController;
@Mock
MailService mailService
@BeforeEach
void init() {
mockMvc = MockMvcBuilders.standaloneSetup(mailController).setControllerAdvice(ExceptionAdvice.class).build();
}
@Test
@DisplayName("updateReserveMail : 불량한 파라미터")
void updateReserveMail__validation_test_fail() throws Exception {
//given
SendRequest request = new SendRequest();
var url = "/ss/1234";
//when
result = mockMvc.perform(put(url).contentType(MediaType.APPLICATION_JSON).content(objectToJsonString(request)))
.andDo(MockMvcResultHandlers.print())
.andReturn();
//then
thenExceptionIncludeBadParameter("mailIdx", "expireDate", "mailAttachment", "mailMessages");
thenExpectResponseHasBadRequestStatus();
}
...
//// in SendRequest.class
@Valid
@NotNull(groups = {SendValidation.class, UpdateValidation.class})
private MailAttachment mailAttachment;
1-1. controller로 변수 받아 세팅 -> service에 넘어간 변수가 잘 세팅되었는지 확인하기 위해 captor사용
@ExtendWith(MockitoExtension.class) //필요
@Captor
ArgumentCaptor<ExternalAddResidentRequest> addRequestCaptor;
@Test
void addResident__validation() throws Exception {
AddResidentRequest request = AddResidentRequest.builder().searchType(UserSearchType.USER_ID).searchValue("1050").newCharacterId("123").build();
//mockito 사용 captor 주입
when(residentService.addResident(addRequestCaptor.capture())).thenReturn(Mono.empty());
result = mockMvc.perform(
post("/ss/residents/" + CHARACTER_INDEX).contentType(MediaType.APPLICATION_JSON).content(objectToJsonString(request)))
.andDo(MockMvcResultHandlers.print())
.andReturn();
ExternalAddResidentRequest expected = addRequestCaptor.getValue(); //꺼내서
thenNoValidationException();
thenExpectResponseHasOkRequestStatus();
assertThat(request.getNewCharacterId()).isEqualTo(expected.getNewCharacterId()); //비교
}
2. 서비스 단 검증
-> mockitoExtension을 이용하여 외부 api/db로 받을 데이터를 다 가정(given-willReturn)
@ExtendWith(MockitoExtension.class)
class TableServiceTest {
@InjectMocks
@Spy
private TableService tableService;
@Mock
private TableExternalService tableExternalService;
@Mock
private CharacterExternalService characterExternalService;
@BeforeEach
void init() {
ReflectionTestUtils.setField(tableService, "FOLDER_PATH", path);
}
@Test
@DisplayName("Files 클래스의 함수 확인: valid path")
void testFilesWith__ValidPath() {
...
given(tableExternalService.getTableData(FILE_NAME)).willReturn(Mono.just(CONTENT));
...
assertTrue(Files.exists(itemPath));
assertFalse(Files.isDirectory(itemPath));
assertFalse(!Files.exists(itemPath) || Files.isDirectory(itemPath));
//파일이 중복 존재하면 에러 뱉음
assertThrows(FileAlreadyExistsException.class, () -> Files.createDirectories(itemPath));
//폴더가 이미 존재하면 에러 안 뱉음
assertDoesNotThrow(() -> Files.createDirectories(Paths.get(path)));
}
2-1. 유틸 테스트
딱히 가정하거나 따로 라이브러리 쓰는 것 없이 간단히 한다.
@Test
@DisplayName("String[] empty 확인")
void checkUtilEmpty() {
var arrayEmpty = new String[]{};
assertThat(arrayEmpty).isEmpty(); //length == 0
assertThat(ArrayUtils.isEmpty(arrayEmpty)).isTrue();
var arrayHavingEmptyString = new String[]{""};
assertThat(arrayHavingEmptyString).hasSize(1); //length == 1
assertThat(ArrayUtils.isEmpty(arrayHavingEmptyString)).isFalse();
}
2-2. 외부 api mocking
-> 외부 api에서 타임아웃이 나면 어떻게 테스트하냐는 PR을 받아서.. 계속 고민하다가 짜게 된 코드
-> webClient를 통해 외부 api호출을 하고 있던 터라 MockWebServer라는 라이브러리를 dependency에 추가해야 한다.
testImplementation 'com.squareup.okhttp3:mockwebserver:4.10.0'
testImplementation 'com.squareup.okhttp3:okhttp:4.10.0'
testImplementation 'io.projectreactor:reactor-test'
공통으로 쓸 클래스를 만들어서 이걸 extend 하여 사용하게 했다.
핵심은 주입받을 서비스에서 생성자로 받는 것.. 그래서 별도 mocking이 필요 없다.
class ExternalMockTest {
protected MockWebServer mockWebServer;
protected String url;
protected void setupMockWebServer() {
mockWebServer = new MockWebServer();
url = mockWebServer.url("/").url().toString();
}
protected WebClient.Builder getWebClient() {
ConnectionProvider provider = ConnectionProvider.builder("webclient-pool")
.maxConnections(500)
.maxIdleTime(Duration.ofSeconds(20))
.maxLifeTime(Duration.ofSeconds(60))
.pendingAcquireTimeout(Duration.ofSeconds(60))
.evictInBackground(Duration.ofSeconds(120))
.build();
HttpClient httpClient = HttpClient.create(provider)
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000)
.doOnConnected(
conn -> conn.addHandlerLast(new ReadTimeoutHandler(5, TimeUnit.SECONDS)).addHandlerLast(new WriteTimeoutHandler(5, TimeUnit.SECONDS)));
ClientHttpConnector connector = new ReactorClientHttpConnector(httpClient);
return WebClient.builder().clientConnector(connector);
}
}
class TableExternalMockTest extends ExternalMockTest {
private TableService tableService;
private CharacterExternalService characterExternalService;
@BeforeEach
void init() {
super.setupMockWebServer();
characterExternalService = new CharacterExternalService(getWebClient(), url);
tableService = new TableService(null, characterExternalService);
}
@Test
@DisplayName("지역 탐사탑 해금 드랍다운: api timeout")
void getDivisionInfo__timeout() {
mockWebServer.enqueue(new MockResponse().setResponseCode(200).setBodyDelay(5100, TimeUnit.MILLISECONDS) //5초 설정이라 5.1초로 확인
.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE).setBody("{\"resultCode\":0}"));
assertThatThrownBy(() -> tableService.getDivisionInfo()).isInstanceOf(WebClientResponseException.class)
.hasMessageContaining(ExternalApiResultCode.UNKNOWN_ERROR.getStringCode());
}
...
3. 레파지토리 테스트
사실 이게 왜 필요한가 싶은데,, jpa의 작동을 확인하거나 복잡한 조회 쿼리를 확인하는 정도로 사용하고 있다.
-> 우선 테스트용 DB를 사용하기로 했다. 여기서는 h2로 했다.
-> 그래서 별도의 디비설정이 필요하다. h2 관련 dependency를 추가하고 application.property도 다시 써야 한다.
-> dataSource와 transactionManager도 바뀌기에 재 설정이 필요했다.
testRuntimeOnly 'com.h2database:h2'
spring.test.database.replace=none
spring.main.allow-bean-definition-overriding=true
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.url=jdbc:h2:mem://localhost/~/TESTDB;MODE=MYSQL;INIT=CREATE SCHEMA IF NOT EXISTS TESTDB
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.show_sql=true
spring.jpa.generate-ddl=true
spring.jpa.hibernate.ddl-auto=none
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL57Dialect
spring.jpa.properties.hibernate.dialect.storage_engine=innodb
spring.jpa.properties.hibernate.globally_quoted_identifiers=true
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.properties.hibernate.jdbc.lob.non_contextual_creation=true
spring.jpa.properties.hibernate.metadata_builder_contributor=com.local.SqlMetaBuilderContributor
spring.jpa.defer-datasource-initialization=true
spring.h2.console.enabled=true
spring.h2.console.path=/h2-console
external.admin-support.use.server-list=dev
## log
logging.level.org.hibernate.type.descriptor.sql=trace
@TestConfiguration
public class TestDataSourceConfig {
@PersistenceContext
private EntityManager entityManager;
@Bean
public JPAQueryFactory jpaQueryFactory() {
return new JPAQueryFactory(entityManager);
}
}
@Sql({"classpath:maalog/item-log.sql", "classpath:maalog/item-withdraw-log.sql"})
@Import(TestDataSourceConfig.class)
@DataJpaTest
class ItemLogRepositoryTest {
@Autowired
private ItemLogRepository itemLogRepository;
@Test
void givenValidateData_whenFindBySeq_thenSuccess() {
var result = itemLogRepository.findBySeq(BigInteger.valueOf(22L)).get();
assertThat(result, notNullValue());
assertThat(result.getInvenSeq(), is(BigInteger.valueOf(0)));
}
4. 통합검증
외부 api는 나에게 제어권이 없기 때문에 mocking으로 테스트가 충분하다고 생각했고
디비는 내가 제어할 수 있기 때문에 통합테스트가 필요할 수 있다고 느꼈다.
근데 운영은 다중 디비지만 테스트는 로컬 하나의 디비라서 설정이 까다로웠고.. 아래와 같이 다중 디비 연결 관련 설정을 오버라이드하기 위해 해당 위치에 빈 설정을 넣어줘야 했다.
아 쿼리 dsl 관련 빈도.. 빈 설정으로 오버라이드 했다.
@TestConfiguration
class LogDbConfig {
}
공통으로 사용하려고 설정으로 빼두었는데 3번 설정과 겹쳐서 extend한다.
그리고 추가적으로 3개의 transactionManager도 h2용으로 연결했다.
여기서 계속 헤맸던 게 소스 상에서는 3개의 transactionManager만 사용하는데 자꾸 default transactionManager가 없다고 나오는 게 아닌가.. 이게 왜 필요한가 봤더니.. @Sql 때문인 것 같았다..
@TestConfiguration
public class IntegrationTestConfigurations extends TestDataSourceConfig {
//NOTE: 통합테스트의 목적은 서비스의 비즈니스 로직 + 디비 로직 검증이므로 아래와 같은 외부 api 연동은 Mockito를 이용하도록 한다.
//외부 api 쏘는 서비스
@MockBean
TanServerConfigService tanServerConfigService;
...
//TODO: 아래 서비스들은 서비스 내부에 비즈니스 로직 + 외부 api 연동이 섞여 있으므로 통합테스트를 위해서는 external은 분리하는 작업이 필요하다.
@MockBean
GoodsService goodsService;
...
@Bean({"aTransactionManager", "bTransactionManager", "cTransactionManager", "transactionManager"})
public PlatformTransactionManager transactionManager(EntityManagerFactory entityManagerFactory) {
return new JpaTransactionManager(entityManagerFactory);
}
@Import(IntegrationTestConfigurations.class)
@SpringBootTest
class EventLoginRewardIntegrationTest {
//필요한 서비스, 레파지토리 주입
@Autowired
private EventLoginRewardService eventLoginRewardService;
...
@AfterEach
void tearDown() {
//관련 디비 내용물 삭제
//테스트용 @Transactional은 사용하지 않았음(의도적)
staticMessageRepository.deleteAll();
...
}
@Test
@DisplayName("전체 리스트 조최; 데이터 들어있다는 전제")
@Sql({"classpath:dynamic-login-reward.sql", "classpath:service-login-reward.sql",
"classpath:message-login-reward.sql",})
void getRewardEvents__success() {
//when
Page<LoginRewardResponse> responsePage = eventLoginRewardService.getRewardEvents(PageRequest.of(0, 10, Sort.by("seq").descending()));
//then
assertEquals(4, responsePage.getContent().size());
assertEquals("일정 기간 동안 접속 시 보상 획득 (한번)", responsePage.getContent().get(3).getDescription());
}
...
테스트용 데이터를 삽입하려고 @Sql을 달았는데, 이것 때문에 별도 트랜젝션이 필요한 듯하다. 그래서 기본 transactionManager를 달아주었다..
물론 이 설정이 다 맞고 옳은 방법인지 확신은 없는데, 어쨌건 우선 잘 되니까 지켜보려고 한다.ㅠㅠ
참고
1. 나와 비슷하게 생각하는 사람, 테스트의 종류에 대해
https://howtodoinjava.com/spring-boot2/testing/spring-boot-2-junit-5/
Testing Controller, Service and DAO in Spring Boot - HowToDoInJava
Learn to test a Spring boot 2.4 application which has JUnit 5 dependencies. Learn to write unit tests and integration tests for all layers.
howtodoinjava.com
2. 왜 통합테스트에서 @Transactional로 자동 롤백을 사용하지 않았는가, 이 역시 나와 동일한 의견
https://javabom.tistory.com/103
JPA 사용시 테스트 코드에서 @Transactional 주의하기
서비스 레이어(@Service)에 대해 테스트를 한다면 보통 DB와 관련된 테스트 코드를 작성하게 된다. 이러면 테스트 메서드 내부에서 사용했던 데이터들이 그대로 남아있게 되어서 실제 서비스에 영
javabom.tistory.com
3. mockWebServer 사용법
https://www.arhohuttunen.com/spring-boot-webclient-mockwebserver/
Testing Spring Boot WebClient With MockWebServer | Code With Arho
Mocking the Spring Boot WebClient can be difficult. Learn how to replace the remote service with a mock service using MockWebServer.
www.arhohuttunen.com