SpringBoot 1.4 기반의 Integration Test 작성하기

기본적인 내용은 Spring 블로그에 포스팅된 내용을 바탕으로 한다.   한글로 읽기 귀찮다면 링크된 본문을 참고하자!!

 

스프링 부트는 복잡한 설정없이 손쉽게 웹 어플리케이션 서버를 실행할 수 있다는 점때문에 자바 언어 세계에서 널리 사용되어오고 있다.  부트 역시 스프링 프레임워크에서 지원했던 방식과 유사한 형식의 테스트 방식을 지원하고 있었다.  하지만 부트의 테스트는 부트 자체가 웹 어플리케이션 개발을 쉽게 했던 만큼은 더 나아가지 못했다.

가장 간단한 테스트를 작성하는 방법은 물론 스프링을 배제한 형태다.  스프링의 각종 @(어노테이션 – Annotation)을 벗어난 코드라면 이 방식의 테스트가 가장 적절하다.  사람들이 오해하는 것 가운데 하나는 @Component 혹은 @Service라고 어노테이션이 붙은 클래스를 테스트할 때 꼭 스프링을 끼고 해야한다고 생각한다는 점이다.

굳이 그럴 필요가 없다.

  • 객체는 그냥 new 를 이용해서 만들면 된다.
  • @Value 어노테이트된 값은 그냥 값을 셋팅하면 된다.
  • 테스트 대상 메소드는 대부분 public 키워드를 갖는다. 테스트 메소드에서 접근을 걱정할 일이 거의 없다.
  • 외부에 공개되지 않은 메소드를 테스트해야하거나 아니면 @Value 값을 강제로 설정해야하는 경우라면 아예 테스트 대상 클래스를 상속받는 클래스를 클래스를 테스트 패키지에 하나 만든다.  그럼 원형을 해칠 필요도 없고, 딱 테스트에 적합한 형태로 맘대로 가지고 놀 수 있다.

 

하지만 스프링과 엮인 부분들이 많다면 테스트에 스프링을 끼지 않을 수 없다. 난감하다. 스프링과 함께 테스트를 돌릴 때 가장 난감한 점은 테스트 실행 시간이 꽤 든다는 점이다.  특히 매 테스트마다 어플리케이션 전체가 올라갔다가 내려갔다를 반복된다.  단위 테스트라는 말을 쓸 수 있을까?  그래서 그런지 통합 테스트(Integration Test)라고 이야기하는 경우가 많다.  이런 것이 싫었던 것도 있어서 정말 Integration Test가 아니면 웬만하니 테스트를 잘 작성하지 않았다.

이러던 것이 스프링부트 1.4 버전을 기점으로 좀 더 쉬운 형태로 테스트 작성 방법이 바뀐 사실을 알게 됐다.  예전게 못먹을 거라고 생각이 들었던 반면에 이제는 좀 씹을만한 음식이 된 것 같다.  사실 부트 버전을 1.4로 올린 다음에 테스트를 돌려볼려고 하니 Deprecated warning이 이전 테스트에서 뜨길래 알았다.  그냥 쌩까고 해도 별 문제가 없을 것을 왜 이걸 굳이 봤을까…

이미 봐버렸으니 역시나 제대로 쓸 수 있도록 해야하지 않을까?  이제부터 언급하는 스프링 테스트는 부트 1.4 바탕의 테스트 이야기다.

@SpringBootApplication
public class Application {
   public static void main(String[] args) throws Exception {
      SpringApplication.run(IPDetectionApplication.class, args);
   }
}

테스트를 작성할려면 가장 먼저 어플리케이션 자체의 선언이 @SpringBootApplication 어노테이션으로 정의되야만 한다.  부트 어플리케이션이 되는 방법은 이 방법말고 여러 방법이 있지만 1.4의 테스트는 꼭 이걸 요구한다. 안달아주면 RuntimeException을 낸다.  강압적이지만 어쩔 수 없다.

환경을 갖췄으니 이제 테스트를 이야기하자.  가장 간단히 테스트를 돌리는 방법은 테스트 클래스에 다음 두개의 어노테이션을 붙혀주면 된다.

@RunWith(SpringRunner.class)

@SpringBootTest

이 두 줄이면 스프링 관련 속성이 있는 어떤 클래스라도 아래 코드처럼 테스트할 수 있다.

@RunWith(SpringRunner.class)
@SpringBootTest
@Log4j
public class SummonerCoreApiTest {
    @Autowired
    private SummonerCoreApi summonerApi;

    @Test
    public void shouldSummonerApiLookUpAnAccountWithGivenAccountId() {

다 좋은데 이 방식의 문제점은 실제 스프링 어플리케이션이 실행된다는 점이다.  물론 테스트 메소드(should…)가 끝나면 어플리케이션도 종료된다.  @SpringBootTest 라는 어노테이션이 주는 마력(!!)이다.  만약 Stage 빌드를 따르고 있다면 테스트를 위한 환경을 별도로 가질 수 있다.  메이븐을 이용해서 환경을 구분하는 경우에는 별 문제가 없지만 만약 스프링 프로파일(Spring profile)을 이용하고 있다면 별도로 지정을 해줘야 한다.  @ActiveProfiles(“local“) 어노테이션을 활용하면 쉽게 해당 환경을 지정할 수 있으니 쫄지 말자.

OMG

어떤 클래스라도 다 테스트를 할 수 있다!!  몇 번에 걸쳐 이야기하지만 어플리케이션 실행에 관련된 모든게 다 올라와야하기 때문에 시간이 오래 걸린다.  좀 더 현실적인 타협안이 있을까?

일반적으로 Integration Test는 RESTful 기반 어플리케이션의 특정 URL을 테스트한다.  따라서 테스트 대상이 아닌 다른 요소들이 메모리에 올라와서 실행 시간을 굳이 느리게 할 필요는 없다.  타협안으로 제공되는 기능이 @WebMvcTest 어노테이션이다.  어노테이션의 인자로 테스트 대상 Controller/Service/Component 클래스를 명시한다.  그럼 해당 클래스로부터 참조되는 클래스들 및 스프링의 의존성 부분들이 반영되어 아래 테스트처럼 실행된다.

@RunWith(SpringRunner.class)
@WebMvcTest(AccountInfoController.class)
@Log4j
public class AccountInfoControllerTest {
    @Autowired
    private MockMvc mvc;

    @Test
    public void shouldControllerRespondsJsonForTheGivenUsername() throws Exception {
        final String givenUsername = "chidoo";

        GIVEN: {
        }

        final ResultActions actualResult;
        WHEN: {
            actualResult = mvc.perform(get("/api/v1/account/name/" + givenUsername)
                    .contentType(MediaType.APPLICATION_JSON_UTF8)
                    .accept(MediaType.APPLICATION_JSON_UTF8));
        }

        THEN: {
            assertThat(actualResult.getPlayerId(), is(...));
        }
    }
    ...

이 테스트는 Integration Test이다.  역시나 Integration Test는 비용이 많이 들고, 테스트 자체가 제대로 돌도록 만드는 것 자체가 힘들다.  DB를 연동하면 DB에 대한 값도 설정을 맞춰야하고, 외부 시스템이랑 연동을 한다면 것도 또 챙겨야한다.

포기할 수 없다. 이걸 단위 테스트할려면 어떻게 해야하는거지?  Mocking을 이용하면 된다!!!  테스트 대상 코드에서 “스프링 관련성이 있다.“는 것의 대표는 바로 @Autowire에 의해 클래스 내부에 Injection되는 요소들이다.   이런 요소들이 DB가 되고, 위부 시스템이 된다.  해당 부분을 아래 코드처럼 Mocking 방법을 알아보자.

  • 테스트 대상 코드의 내부 Injecting 요소를 @MockBean이라는 요소로 선언한다.  이러면 대상에서 Autowired 될 객체들이 Mocking 객체로 만들어져 테스트 대상 클래스에 반영된다.
  • 이 요소들에 대한 호출 부위를 BDDMockito 클래스에서 제공하는 정적 메소드를 활용해서 Mocking을 해준다.
  • given/willReturn/willThrow 등 과 같이 일반적인 Mockito 수준에서 활용했던 코드들을 모두 활용할 수 있다.

그럼 명확하게 클래스의 동작 상황을 원하는 수준까지 시뮬레이션할 수 있다.

@RunWith(SpringRunner.class)
@WebMvcTest(AccountInfoController.class)
@Log4j
public class AccountInfoControllerTest {
    private JacksonTester<AccountInfo> responseJson;
    private JacksonTester<AccountInfo[]> responseJsonByPlayerId;

    @Autowired
    private MockMvc mvc;

    @MockBean
    private AccountInfoService accountInfoService;

    @Before
    public void setup() {
        JacksonTester.initFields(this, new ObjectMapper());
    }

    @Test
    public void shouldControllerRespondsJsonForTheGivenUsername() throws Exception {
        final String givenUsername = "chidoo";
        final AccountInfo expectedAccountInfo;

        GIVEN: {
            expectedAccountInfo = new AccountInfo("dontCareAccountId", givenUsername, "dontCarePlayerId");
            given(accountInfoService.queryByUsername(givenUsername)).willReturn(expectedAccountInfo);
        }

        final ResultActions actualResult;
        WHEN: {
            actualResult = mvc.perform(get("/api/v1/account/name/" + givenUsername)
                    .contentType(MediaType.APPLICATION_JSON_UTF8)
                    .accept(MediaType.APPLICATION_JSON_UTF8));
        }

        THEN: {
            actualResult.andExpect(status().isOk())
                    .andExpect(content().string(responseJson.write(expectedAccountInfo).getJson()));
        }
    }
    ...

특정 서비스를 테스트하는데 전체를 다 로딩하지 않고, 관련된 부분 모듈들만 테스트 하고 싶은 경우에는 아래 코드를 참고한다.

@RunWith(SpringRunner.class)
@SpringBootTest(classes = { SomeService.class, InterfacingApi.class, CoreApi.class})
@Log4j
public class SomeServiceTest {
    @Autowired
    private SomeService service;

   @Test
    public void shouldServiceQueryAccount() {
        final long accountIdAsRiotPlatform = 1000l;
        GIVEN: {}

        final Account account;
        WHEN: {
            account = service.lookup(accountIdAsRiotPlatform);
        }

        THEN: {
            assertThat(account.getAccountId(), is(accountIdAsRiotPlatform));
        }
    }
}

만약 Controller를 메소드를 직접 호출하는게 아니라 MockMvc를 사용해 호출하는 경우라면 다음 2개의 어노테이션을 추가하면 된다. 이러면 테스트할 대상 하위 클래스를 지정할 수 있을뿐만 아니라 MockMvc를 활용해서 Controller를 통해 값을 제대로 받아올 수 있는지 여부도 명시적으로 확인할 수 있다.

@AutoConfigureMockMvc
@AutoConfigureWebMvc

Swagger 관련 오류에 대응하는 방법

JPA 관련된 테스트 혹은 WebMvcTest를 작성하는 과정에서 아래와 같은 Exception을 만나는 경우가 있다.

Caused by: org.springframework.beans.factory.NoSuchBeanDefinitionException: No qualifying bean of type 'java.util.List<org.springframework.web.servlet.mvc.method.RequestMappingInfoHandlerMapping>' available: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: {}
	at org.springframework.beans.factory.support.DefaultListableBeanFactory.raiseNoMatchingBeanFound(DefaultListableBeanFactory.java:1466)
	at org.springframework.beans.factory.support.DefaultListableBeanFactory.doResolveDependency(DefaultListableBeanFactory.java:1097)
	at org.springframework.beans.factory.support.DefaultListableBeanFactory.resolveDependency(DefaultListableBeanFactory.java:1059)
	at org.springframework.beans.factory.support.ConstructorResolver.resolveAutowiredArgument(ConstructorResolver.java:835)
	at org.springframework.beans.factory.support.ConstructorResolver.createArgumentArray(ConstructorResolver.java:741)
	... 58 more

별거 한거 없이 정석대로 진행을 했는데 이런 뜬금없는 오류가 발생하는 상황에서는 프로젝트에 Swagger 관련된 설정이 있는지 확인해봐야한다. Swagger에 대한 Configuration에서 다른 설정을 해주지 않았다면 스프링부트 테스트는 Swagger와 관련된 Bean 객체들도 실행시킬려고 한다. 이걸 회피하기 위해서는 다음의 두가지 설정을 Swagger쪽과 문제가 되는 테스트쪽에 설정해줘야 한다.

Swagger 설정

@Configuration
@EnableSwagger2
@Profile({"default", "local", "dev", "qa", "prod"})
public class SwaggerConfiguration {
...
}

스프링부트가 기본으로 스프링 프로파일을 사용한다. 본인이 스프링 프로파일을 사용하든 안하든… 위의 케이스처럼 스프링이 적용될 환경을 명시적으로 정의해둔다. 대부분의 환경이 위의 5가지 환경으로 분류되고, 별도로 명시하지 않으면 default가 기본 프로파일로 잡힌다. 물론 테스트 케이스를 실행하는 경우에도 명시적으로 해주지 않으면 default 프로파일이 적용된다. 때문에 이제 테스트가 이를 회피하기 위한 프로파일을 명시해준다.

테스트 클래스 설정

@RunWith(SpringRunner.class)
@DataJpaTest
@ActiveProfiles("test")
@Log4j
public class AccountInfoServiceTest {
...
}

이렇게 실행되면 테스트가 실행될 때 test라는 프로파일을 가지기 때문에 Swagger 관련 모듈들을 로딩하지 않고, 테스트가 실행된다.

일단 여기까지 정리해봤다.