Spring Boot에서의 Test 어노테이션

라모스·2022년 12월 11일
0
post-thumbnail

Spring Boot Test

Spring Boot는 기본적인 테스트 스타터를 제공한다. 스타터에 웬만한 테스트 라이브러리들을 한데 뭉쳐놓았기 때문에 편리하게 사용할 수 있다. 스타터는 크게 두 가지 모듈로 구성된다.

  • spring-boot-test
  • spring-boot-test-autoconfigure

spring-boot-test-autoconfigure는 테스트 관련 자동 설정 기능을 제공한다.

보통은 spring-boot-starter-test로 위 두 모듈을 함께 사용한다.

Spring Boot 1.4 버전부터는 각종 테스트를 위한 어노테이션 기반 기능을 제공하여 특정 주제에 맞게 테스트를 구현하고 관리할 수 있다. 자주 사용되는 어노테이션들은 다음과 같다.

  • @SpringBootTest
  • @WebMvcTest
  • @DataJpaTest
  • @RestClientTest
  • @JsonTest

@SpringBootTest

@SpringBootTest통합 테스트를 제공하는 기본적인 스프링 부트 테스트 어노테이션이다.

애플리케이션이 실행될 때의 설정을 임의로 바꾸어 테스트를 진행할 수 있으며 여러 단위 테스트를 하나의 통합된 테스트로 수행할 때 적합하다. Spring Boot 프로젝트를 만들면 메인 클레스와 함께 기본으로 제공된다.

@SpringBootTest는 만능이라 할 수 있는데, 실제 구동되는 애플리케이션과 똑같이 ApplicationContext를 로드하여 테스트하기 때문에 하고 싶은 테스트를 모두 수행할 수 있다.

단, 애플리케이션에 설정된 빈을 모두 로드하기 때문에 애플리케이션의 규모가 클수록 느려진다.

기본으로 제공되는 테스트 코드는 다음과 같다. 프로젝트명에 'Tests'를 붙인 형태로 자동 생성된다.

import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;

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

	@Test
    public void contextLoads() {
    }
}

이 클래스를 실행하면 애플리케이션 컨텍스트를 로드하여 스프링 부트 테스트를 진행한다.

@RunWith 어노테이션을 사용하면 JUnit에 내장된 러너를 사용하는 대신 어노테이션에 정의된 러너 클래스를 사용한다. @SpringBootTest 어노테이션을 사용하려면 JUnit 실행에 필요한 SpringJUnit4ClassRunner 클래스를 상속받은 @RunWith(SpringRunner.class)를 꼭 붙여서 사용해야 한다. 그렇지 않으면 정상적으로 동작하지 않는다.

import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;

@RunWith(SpringRunner.class)
@SpringBootTest(value = "value=test", properties = {"property.value=propertyTest"},
				classes = {SpringBootTestApplication.class},
                webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class SpringBootTestApplicationTests {

	@Value("${value}")
    private String value;
    
    @Value("${property.value}")
    private String propertyValue;

	@Test
    public void contextLoads() {
    	assertThat(value, is("test"));
        assertThat(propertyValue, is("propertyTest");
    }
}

위 예제 코드는 실행 시 에러가 발생한다. @SpringBootTest의 프로퍼티 중 value와 properties를 함께 사용하면 안 되기 때문이다. 실제 테스트에선 하나만 사용하도록 하자.

  • value: 테스트가 실행되기 전에 적용할 프로퍼티를 주입시킬 수 있다. 기존의 프로퍼티를 오버라이드 한다.
  • properties: 테스트가 실행되기 전에 {key=value} 형식으로 프로퍼티를 추가할 수 있다.
  • classes: 애플리케이션 컨텍스트에 로드할 클래스를 지정할 수 있다. 따로 지정하지 않으면 @SpringBootConfiguration을 찾아 로드한다.
  • webEnvironment: 애플리케이션이 실행될 때의 웹 환경을 설정할 수 있다. 기본값은 Mock 서블릿을 로드하여 구동되며 위 예시에선 랜덤 포트값을 주어 구동시켰다.

추가적인 사용 팁은 다음과 같다.

  • 프로파일 환경(개발, QA, 운영 환경)마다 다른 데이터소스(DataSource)를 갖는다면, @ActiveProfiles("local")과 같은 방식으로 원하는 프로파일 환경값을 부여하면 된다.
  • 테스트에서 @Transactional을 사용하면 테스트를 마치고 나서 수정된 데이터가 롤백된다. 다만, 테스트가 서버의 다른 스레드에서 실행 중이면 WebEnvironment의 RANDOM_PORT나 DEFINED_PORT를 사용하여 테스트를 수행해도 트랜잭션이 롤백되지 않는다.
  • @SpringBootTest는 기본적으로 검색 알고리즘을 사용하여 @SpringBootApplication이나 @SpringBootConfiguration 어노테이션을 찾는다. 스프링 부트 테스트이기 때문에 해당 어노테이션 중 하나는 필수이다.

@WebMvcTest

MVC를 위한 테스트이다. 웹에서 테스트하기 힘든 컨트롤러를 테스트하는 데 적합하다. 웹 상에서 요청과 응답에 대해 테스트할 수 있다. 뿐만 아니라 시큐리티 혹은 필터까지 자동으로 테스트하며 수동으로 추가 및 삭제까지 가능하다.

@WebMvcTest 어노테이션을 사용하면 MVC 관련 설정인 @Controller, @ControllerAdvice, @JsonComponentFilter, WebMvcConfigurer, HandlerMethodArgumentResolver만 로드되기 때문에 @SpringBootTest 어노테이션보다 가볍게 테스트할 수 있다.

// 테스트에서 다른 컴포넌트를 스캔하고 싶다면 테스트코드에 @Import 로 직접 설정해 주어야 한다.

예제 코드는 다음과 같다. BookService 인터페이스를 구현하는 구현체를 따로 만들지 않고 Mock 데이터를 이용해 테스트를 진행해보도록 하자.

@RunWith(SpringRunner.class)
@WebMvcTest(BookController.class)
public class BookControllerTest {
    
    @Autowired
    private MockMvc mvc;
    
    @MockBean
    private BookService bookService;
    
    @Test
    void Book_MVC_test() throws Exception {
        Book book = new Book("Spring Boot Book", LocalDateTime.now());
        
        given(bookService.getBookList()).willReturn(Collections.singletonList(book));
        
        mvc.perform(get("/books"))
        		.andExpect(status().isOk())
                .andExpect(view().name("book"))
                .andExpect(model().attributeExists("bookList"))
                .andExpect(model().attribute("bookList", contains(book)));
    }
}

@WebMvcTest를 사용하기 위해 테스트할 특정 컨트롤러명을 명시해주어야 한다. 주입된 MockMvc는 컨트롤러 테스트 시 모든 의존성을 로드하는 것이 아닌 해당 컨트롤러 관련 빈만 로드하여 가벼운 MVC 테스트를 수행한다. 위 예제 코드는 MockMvc를 주입시켰기 때문에 전체 HTTP 서버를 실행하지 않고 테스트할 수 있다.

@Service 어노테이션은 @WebMvcTest의 적용 대상이 아니다. BookService 인터페이스를 구현한 구현체는 없지만 @MockBean을 적극적으로 활용하여 컨트롤러 내부의 의존성 요소인 BookService를 가짜 객체로 대체하였다. 이를 흔히 목 객체라고 한다. 목 객체는 실제 객체는 아니지만 특정 행위를 지정하여 실제 객체처럼 동작하게 만들 수 있다.

  • andExpect(status().isOk()): HTTP 상태값이 200인지 테스트
  • andExpect(view().name("book")): 반환되는 뷰의 이름이 'book'인지 테스트
  • andExpect(model().attributeExists("bookList")): 모델의 프로퍼티 중 'bookList'라는 프로퍼티가 존재하는지 테스트
  • andExpect(model().attribute("bookList", contains(book))): 모델의 프로퍼티 중 'bookList'라는 프로퍼티에 'book' 객체가 담겨져 있는지 테스트

Spring에서의 Controller Test

Service 레이어를 생략하고 바로 Repository를 접근한 Controller이지만, 이를 신경 쓰지 말고 @BeforeEach와 생성자 주입 방식으로 사전 세팅을 한 점에 주목하도록 하자.

아래와 같이 MockMvcBuilders.standaloneSetup()을 사용하여 MVC에 대한 단위테스트를 생성할 수 있다.
// 참고로 MockMvcBuilders.webAppContextSetup()은 통합테스트를 수행하는 방식이라는 점을 알아두자. 이에 대해선 https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/test/web/servlet/setup/MockMvcBuilders.html 를 참고해보자.

class AdminControllerTest {

    private MockMvc mockMvc;

    private InquiryRepository inquiryRepository;

    @BeforeEach
    void setUp() {
        inquiryRepository = mock(InquiryRepositoryImpl.class);
        mockMvc = MockMvcBuilders.standaloneSetup(new AdminController(inquiryRepository))
                .setControllerAdvice(WebControllerAdvice.class)
                .build();
    }

    @Test
    @DisplayName("category query parameter 없이 adminMain 페이지를 반환한다.")
    void adminMain_withOutCategory() throws Exception {
        mockMvc.perform(get("/cs/admin"))
                .andExpect(status().isOk())
                .andExpect(view().name("adminMain"));
    }
    
    // ...
}

@DataJpaTest

@DataJpaTest 어노테이션은 JPA 관련 테스트 설정만 로드한다.
데이터소스의 설정이 정상적인지, JPA를 사용하여 데이터를 제대로 생성, 수정, 삭제하는지 등의 테스트가 가능하다. 또한 내장형 DB를 사용하여 실제 데이터베이스를 사용하지 않고 테스트 데이터베이스로 테스트할 수 있다.

@DataJpaTest는 기본적으로 인메모리 임베디드 데이터베이스를 사용하며, @Entity 클래스를 스캔하여 스프링 데이터 JPA 저장소를 구성한다. 만약, 최적화한 별도의 데이터소스를 사용하여 테스트하고 싶다면 기본 설정된 데이터소스를 사용하지 않도록 다음과 같이 설정한다.

@RunWith(SpringRunner.class)
@DataJpaTest
@ActiveProfiles("...") // ← 여기에 주목
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
public class JpaTest {
    // ...
}

@AutoConfigureTestDatabase 어노테이션의 기본 설정값인 Replace.Any를 사용하면 기본적으로 내장된 데이터소스를 사용한다. 위와 같이 Replace.NONE으로 설정하면 @ActiveProfiles에 설정한 프로파일 환경값에 따라 데이터소스가 적용된다.

이 외에도 자동 설정 방식을 이용해 설정할 수도 있다. application.yml에서 프로퍼티 설정을 spring.test.database.replace: NONE으로 변경하면 된다.

@DataJpaTestJPA 테스트가 끝날 때마다 자동으로 테스트에 사용한 데이터를 롤백한다. 또한 어떤 테스트 데이터베이스를 사용할 것인지도 선택할 수 있다. spring.test.database.connection: H2와 같이 프로퍼티를 설정하는 방법과, @AutoConfigureTestDatabase(connection = H2) 어노테이션으로 설정하는 방법이 있다. connection의 옵션으로 H2, Derby, HSQL 등의 테스트 데이터베이스 종류를 선택할 수 있다.

또한 @DataJpaTest에서 EntityManager의 대체제로 만들어진 테스트용 TestEntityManager를 사용하면 persist, flush, find 등과 같은 기본적인 JPA 테스트를 할 수 있다.

@RunWith(SpringRunner.class)
@DataJpaTest
public class BookJpaTest {
    private static final String BOOT_TEST_TITLE = "Spring Boot Test Book";
    
    @Autowired
    private TestEntityManager testEntityManager;
    
    @Autowired
    private BookRepository bookRepository;
    
    @Test
    void Book_save_test() {
        Book book = Book.builder()
        				.title(BOOT_TEST_TITLE)
                        .publishedAt(LocalDateTime.now()).build();
        testEntityManager.persist(book);
        assertThat(bookRepository.getOne(book.getIdx()), is(book));
    }
    
    @Test
   	void BookList_save_and_search_test() {
        Book book1 = Book.builder()
        				.title(BOOT_TEST_TITLE + "1")
                        .publishedAt(LocalDateTime.now()).build();
        testEntityManager.persist(book1);
        
        Book book2 = Book.builder()
        				.title(BOOT_TEST_TITLE + "2")
                        .publishedAt(LocalDateTime.now()).build();
        testEntityManager.persist(book2);
        
        Book book3 = Book.builder()
        				.title(BOOT_TEST_TITLE + "3")
                        .publishedAt(LocalDateTime.now()).build();
        testEntityManager.persist(book3);
        
        List<Book> bookList = bookRepository.findAll();
        assertThat(bookList, hasSize(3));
        assertThat(bookList, contains(book1, book2, book3));
    }
    
    @Test
    void BookList_save_and_delete_test() {
        Book book1 = Book.builder()
        				.title(BOOT_TEST_TITLE + "1")
                        .publishedAt(LocalDateTime.now()).build();
        testEntityManager.persist(book1);
        
        Book book2 = Book.builder()
        				.title(BOOT_TEST_TITLE + "2")
                        .publishedAt(LocalDateTime.now()).build();
        testEntityManager.persist(book2);
        
        bookRepository.deleteAll();
        assertThat(bookRepository.findAll(), IsEmptyCollection.empty());
    }
}

위 테스트코드 예제를 요약하면 다음과 같다.

  • testEntityManagerpersist() 기능이 정상 동작하는지 테스트한다.
  • Book 3개를 저장한 뒤 저장된 Book의 개수가 3개가 맞는지, 저장된 Book에 각 Book 객체가 모두 포함되어 있는지 테스트한다.
  • 저장된 Book 중에서 2개가 제대로 삭제되었는지 테스트한다.

이 외에도 올바르게 도메인 관계가 매핑되는지 여부도 테스트할 수 있다. 더 나아가 이 외에도 JDBC를 테스트하는 @JdbcTest, MongoDB를 테스트하는 @DataMongoTest 어노테이션 등 여러 테스트 어노테이션이 있다.

Spring에서의 Repository Test

Spring에서 JPA 기반 Repository를 테스트하는 경우의 예시는 다음과 같다.

@ExtendWith(SpringExtension.class)
@WebAppConfiguration
@Transactional
@ContextHierarchy({
        @ContextConfiguration(classes = RootConfig.class),
        @ContextConfiguration(classes = WebConfig.class)
})
class BirthDeathReportResidentRepositoryTest {

    @Autowired
    BirthDeathReportResidentRepository repository;

    @Test
    @DisplayName("출생신고서가 없는 주민의 경우 null을 반환한다.")
    void getBirthCertification_fail() throws Exception {
        //given
        long targetSerialNumber = 1L;

        //when
        BirthCertificateResponse birthCertification = repository.getBirthCertification(targetSerialNumber);

        //then
        assertThat(birthCertification).isNull();
    }
    
	// ...
}

@RestClientTest

@RestClientTest는 REST 관련 테스트를 도와주는 어노테이션이다. REST 통신의 데이터형으로 사용되는 JSON 형식이 예상대로 응답을 반환하는지 등을 테스트할 수 있다.

테스트 예제를 위한 Controller와 Service 예제는 다음과 같다.

@RestController
public class BookRestController {

	@Autowired
    private BookRestService bookRestService;
    
    @GetMapping("/rest/test", produces = MediaType.APPLICATION_JSON_VALUE)
    public Book getRestBooks() {
        return bookRestService.getRestBook();
    }
}

@Service
public class BookRestService {
    
    private final RestTemplate restTemplate;
    
    public BookRestService(RestTemplateBuilder restTemplateBuilder) {
        this.restTemplate = restTemplateBuilder.rootUri("/rest/test").build();
    }
    
    public Book getRestBook() {
        return this.restTemplate.getForObject("/rest/test", Book.class);
    }
}

RestTemplate을 생성하여 특정 URL로 비동기 요청을 처리하는 예제이다.

  • RestTemplateBuilder를 사용하여 RestTemplate을 생성했다. 이는 RestTemplate을 핸들링하는 빌더 객체로, connectionTimeout, ReadTimeOut 설정뿐만 아니라 여러 다른 설정을 간편하게 제공한다.
  • RestTemplate의 Get 방식으로 통신하는 getForObject() 메서드를 사용하여 '/rest/test' URI에 요청을 보내고 요청에 대한 응답을 Book 객체 형식으로 받아온다.

테스트 코드는 다음과 같다.

@RunWith(SpringRunner.class)
@RestClientTest(BookRestService.class)
public class BookRestTest {
    
    @Rule
    public ExpectedException thrown = ExpectedException.none();
    
    @Autowired
    private BookRestService bookRestService;
    
    @Autowired
    private MockRestServiceServer server;
    
    @Test
    void rest_test() {
        this.servier.expect(requestTo("/rest/test"))
        	.andRespond(withSuccess(new ClasspathResource("/test.json",
            					getClass()), MediaType.APPLICATION_JSON));
        Book book = this.bookRestService.getRestBook();
        assertThat(book.getTitle()).isEqualTo("테스트");
    }
    
    @Test
    void rest_error_test() {
        this.servier.expect(requestTo("/rest/test"))
        	.andRespond(withServerError());
        this.thrown.expect(HttpServerErrorException.class);
        this.bookRestService.getRestBook();
    }
}
  • @RestClientTest는 테스트 대상이 되는 빈을 주입받는다. 이 어노테이션이 BookRestService.class를 파라미터로 주입받지 못하면 '빈이 없다'는 에러가 뜰 것이다.
  • @Rule로 지정한 필드값은 @Before@After 어노테이션에 상관없이 하나의 테스트 메서드가 끝날 때마다 정의한 값으로 초기화시켜준다. 테스트 자체적으로 규칙을 정의하여 재사용할 때 유용하다.
  • MockRestServiceServer는 클라이언트와 서버 사이의 REST 테스트를 위한 객체이다. 내부에서 RestTemplate을 바인딩하여 실제로 통신이 이루어지게끔 구성할 수도 있다. 위 예제에선 목 객체와 같이 실제로 통신이 이루어지진 않지만 지정한 경로에 예상되는 반환값 혹은 에러를 반환하도록 명시하여 간단하게 테스트를 진행하도록 작성하였다.

위 테스트 메소드에 필요한 리소스 파일은 다음과 같다.

// /test/resources/test.json
{"idx": null, "title":"테스트", "publishedAt":null}

@JsonTest

@JsonTest는 JSON 테스트를 지원하는 어노테이션이다. JSON의 직렬화와 역직렬화를 수행하는 라이브러리인 Gson과 Jackson API의 테스트를 제공한다. 각각 GsonTesterJacksonTester를 사용하여 테스트를 수행한다.

JSON 테스트는 크게 두 가지로 나뉜다. 문자열로 나열된 JSON 데이터를 객체로 변환하여 변환된 객체값을 테스트하거나 그 반대의 경우이다.

@RunWith(SpringRunner.class)
@JsonTest
public class BookJsonTest {
    
    @Autowired
    private JacksonTester<Book> json;
    
    @Test
    void json_test() throws Exception {
        Book book = Book.builder()
        				.title("테스트")
                        .build();
                        
        String content = "{\"title\":\"테스트\"}";
        
        assertThat(this.json.parseObject(content).getTitle()).isEqualTo(book.getTitle());
        assertThat(this.json.parseObject(content).getPublishedAt()).isNull();
        assertThat(this.json.write(book)).isEqualToJson("/test.json"); // test.json 파일에 정의한 내용과 일치하는지
        assertThat(this.json.write(book)).hasJsonPathStringValue("title");
        assertThat(this.json.write(book)).extractingJsonPathStringValue("title").isEqualTo("테스트");
    }
}

Spring Boot의 테스트 어노테이션은 JUnit 자체에 내장된 테스트 메서드를 스프링에서 사용하기 편하도록 가공한 것이다.

스프링의 모든 빈을 올리는 대신, 각 테스트에 필요한 Mock 객체를 만들어 테스트하는 방법을 사용해 상황에 맞는 테스트를 하도록 하자.

추가

Q. Service 테스트에서의 @SpringBootTest

Manty 수석님께 받은 답변은 다음과 같다.

Service 계층에서의 테스트는 Mock을 활용한 테스트나 느리더라도 @SpringBootTest를 통해 필요한 빈들을 주입받아 하는 테스트하는 방식 밖에 없을 것이라고 하신다. 애플리케이션이 복잡해질수록 의존성 관리 또한 복잡해지기 때문에 @SpringBootTest를 통한 테스트를 좀 더 선호하신다고 하셨다.

Q. @RunWith vs @ExtendWith

해당 어노테이션들은 JUnit Test를 Spring TestContext Framework와 결합하는 것이다. JUnit 버전에 따라 다음과 같이 사용해야 한다.

// JUnit4
@RunWith(SpringRunner.class)

// JUnit5
@ExtendWIth(SpringExtension.class)

References

profile
Step by step goes a long way.

0개의 댓글