본문 바로가기

Back-End/Spring

Spring Boot Test 및 심화

커머스 회사에서 개발을 하다보면 테스트 코드의 중요성은 스스로 깨닫게 된다. 그래서 Spring Boot 테스트 기능에 대해 간단히 정리해 보려한다.

 

테스트 코드는 멋으로 하는거 아니냐?

이렇게 생각하신 분은 아직 테스트 코드를 짤 줄 모르거나 짜지 않아서라 생각한다. 내가 생각하는 테스트 코드의 장점은

  1. 코드가 자동으로 깔끔해진다. 테스트 코드를 짜려면 결국 한가지 기능을 가진 클래스를 짤 수 밖에 없다.
  2. 리팩토링에 대한 자신감이 생긴다.
  3. 다른 개발자들에게는 설명서가 된다.
  4. (가장크다고 생각하는 부분) 테스트 코드가 없다면 주문/결제 테스트를 위해서 웹사이트에 들어가 상품검색부터 정보 입력까지 다 한 후에 코드를 테스트해야 한다. 한번만 테스트하면 끝이 나는가?? 수정->테스트->수정->테스트의 무한 반복이다. 즉, 이 과정에서 엄청난 리소스가 낭비된다.
  5. (등등이 있겠지?)

Spring Boot에서 테스트를 해보자

Spring boot에서 테스트 모듈은 spring-boot-test, spring-boot-test-autoconfigure가 존재한다.

그리고 포함되어 있는 라이브러리는

  • JUnit
  • Spring Boot Test
  • AssedrtJ
  • Hamcrest
  • Mockito
  • JSONassert
  • JsonPath

가 있다.

 

@SpringBootTest

spring boot test를 할 때 가장 기본적으로 사용하는 어노테이션이다. 이 부분은 모든 개발자가 잘 알고 있다.

@RunWith(SpringRunner.class)
@SpringBootTest
public class StayGolfOrderServiceImplTest {

SpringBootTest 너무 느려요??

개발하고 있는 프로젝트가 커져 빈이 많아지면, 당연히 IoC컨테이너에 빈을 등록하는데 오랜 시간이 걸린다. ( 뜨는데 2~5초인데 그것도 못참아? 라고 하시는 분들은 테스트 코드를 많이 돌리지 않거나 테스트 코드가 프로젝트에 많지 않아서 일것이다. 이 2~5초차이가 상당히 크다고 생각한다. ) 

 

그래서 내가 원하는 빈만을 등록하려면 어떻게 해야할까?

  • SpringBootTest 어노테이션에 원하는 class 등록하기
  • EnableConfigurationProperties 어노테이션에 원하는 Configuration 빈 적어주기
@RunWith(SpringRunner.class)
@ActiveProfiles("dev")
@SpringBootTest(classes = {StayGolfOrderServiceImpl.class, StayGolfApiServiceConfiguration.class})
@EnableConfigurationProperties(StayGolfConfiguration.class)
public class StayGolfOrderServiceImplTest {

@EnableConfigurationProperties 왜 사용하는걸까?

내가 원하는 빈만 등록해서 사용하지 해놓고, Configuration 컴포넌트가 application.yml에서 값을 못가져오는 문제를 발견하게 될 것이다. ( SpringBootTest(classes=...) 만 선언한 경우 )

 

무슨 차이 때문에 그렇게 된걸까? 바로 @SpringBootTest 에서 빈을 등록할 때 MainApplication 클래스에 선언된 @SpringBootApplication 어노테이션 때문이다.

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@SpringBootConfiguration
@EnableAutoConfiguration
@ComponentScan(excludeFilters = { @Filter(type = FilterType.CUSTOM, classes = TypeExcludeFilter.class),
		@Filter(type = FilterType.CUSTOM, classes = AutoConfigurationExcludeFilter.class) })
public @interface SpringBootApplication {

 여기에 @EnableAutoConfiguration을 들어가보면 AutoConfigurationImportSelector라는 어노테이션을 발견할 수 있다. 해당 어노테이션이 딱 봐도 리소스로더의 구현체라는 것을 알 수 있다.

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@AutoConfigurationPackage
@Import(AutoConfigurationImportSelector.class)
public @interface EnableAutoConfiguration {
public class AutoConfigurationImportSelector implements DeferredImportSelector, BeanClassLoaderAware,
		ResourceLoaderAware, BeanFactoryAware, EnvironmentAware, Ordered 

즉, 해당 어노테이션을 가지고 있던 MainApplication.class가 빈으로 등록되지 않아 application.yml의 리소스를 가지고 오지 못한것이다.

이를 해결하는 방법이 아래의 어노테이션이다.

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Import(EnableConfigurationPropertiesImportSelector.class)
public @interface EnableConfigurationProperties {

Properties

application.properties 에서 테스트 설정을 따로 만들고 싶은 경우 아래처럼 사용하면 된다.

@RunWith(SpringBoot.class)
@SpringBootTest(properties = "classpath:application-test.yml")
public class ApplicationTest {
    ...
}

@JsonTest

외부 API 호출과 관련하여 테스트를 할 때, JSON serialization과 deserialization 테스트를 많이 한다. 이때 @JsonTest를 사용하면 편하게 테스트를 할 수 있다. 테스트를 위한 빈으로는 JacksonTester, GsonTest, BasicJsonTester 등이 있다.

@RunWith(SpringRunner.class)
@JsonTest
public class ArticleJsonTest {
    @Autowired
    private JacksonTester<Article> json;

    @Test
    public void testSerialize() throws IOException {
        Article article = new Article(
                1,
                "kwseo",
                "good",
                "good article",
                Timestamp.valueOf((LocalDateTime.now())));

        // assertThat(json.write(article)).isEqualToJson("expected.json");  직접 파일과 비교
        assertThat(json.write(article)).hasJsonPathStringValue("@.author");
        assertThat(json.write(article))
                .extractingJsonPathStringValue("@.title")
                .isEqualTo("good");
    }

    @Test
    public void testDeserialize() throws IOException {
        Article article = new Article(
                1,
                "kwseo",
                "good",
                "good article",
                new Timestamp(1499655600000L));
        String jsonString = "{\"id\": 1, \"author\": \"kwseo\", \"title\": \"good\", \"content\": \"good article\", \"createdDate\": 1499655600000}";

        assertThat(json.parse(jsonString)).isEqualTo(article);
        assertThat(json.parseObject(jsonString).getAuthor()).isEqualTo("kwseo");
    }
}

@DataJpaTest

Spring data JPA를 테스트하려는 경우 @DataJpaTest 기능을 사용하면 된다. 

  • in-memory embedded database를 기본으로 사용
  • @Entity 클래스를 스캔 ( 다른 컴포넌트를 스캔하지 않음 )
  • @Transactional 어노테이션을 포함하고 있기 때문에 따로 선언하지 않아도 됨
// 1. Transactional 기능이 필요하지 않은 경우

@RunWith(SpringRunner.class)
@DataJpaTest
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public class SomejpaTest {
    ...
}

// 2. TestEntityManager 빈 사용하기
@RunWith(SpringRunner.class)
@DataJpaTest
public class ArticleDaoTest {
    @Autowired
    private TestEntityManager entityManager;
    @Autowired
    private ArticleDao articleDao;

    @Test
    public void test() {
        Article articleByKwseo = new Article(1, "kwseo", "good", "hello", Timestamp.valueOf(LocalDateTime.now()));
        Article articleByKim = new Article(2, "kim", "good", "hello", Timestamp.valueOf(LocalDateTime.now()));
        entityManager.persist(articleByKwseo);
        entityManager.persist(articleByKim);


        List<Article> articles = articleDao.findByAuthor("kwseo");
        assertThat(articles)
                .isNotEmpty()
                .hasSize(1)
                .contains(articleByKwseo)
                .doesNotContain(articleByKim);
    }
}

// 3. real database 사용하기
@RunWith(SpringRunner.class)
@DataJpaTest
@AutoConfigureTestDatabase(replace = Replace.NONE)
public class SomeJpaTest {
    ...
}

MockBean

테스트 코드를 짤 때 중요한 부분 중 하나가 Mock 생성이라 생각한다. @MockBean 어노테이션을 사용해서 이름 그대로 Mock 객체를 빈으로써 등록할 수 있다.

@RunWith(SpringRunner.class)
@SpringBootTest(classes = ArticleServiceImpl.class)
public class ArticleServiceImplTest {
    @MockBean
    private RestTemplate restTemplate;
    @MockBean
    private ArticleDao articleDao;
    @Autowired
    private ArticleServiceImpl articleServiceImpl;

    @Test
    public void testFindFromDB() {
        List<Article> expected = Arrays.asList(
                new Article(0, "author1", "title1", "content1", Timestamp.valueOf(LocalDateTime.now())),
                new Article(1, "author2", "title2", "content2", Timestamp.valueOf(LocalDateTime.now())));

        given(articleDao.findAll()).willReturn(expected);

        List<Article> articles = articleServiceImpl.findFromDB();
        assertThat(articles).isEqualTo(expected);
    }
}