반응형

환경: 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

 

728x90
반응형

+ Recent posts