[Java JUnit5] (5) Spring 환경에서 테스트 하기
이제 실 MVC 환경에서 테스트를 어떻게 할지 알아보도록 하자
목차
- 폴더 구조
- 어노테이션
- Controller
- Service
- Repository
1. 폴더 구조
일단 가장 먼저 할것은 Test파일을 만드는것이다.
파일 폴더 구조는 위와 같다.
BookController 에 대해 각각
- BookControllerUnitTest
- BookControllerIntegrateTest 를 만들어 주었다.
이름에서 알수 있듯이 단위 테스트와 통합 테스트를 나누었다.
단위테스트는 Controller 만 테스트 하겠다는 의미이고 통합테스트는 스프링내 모든 기능을 테스트 하겠다는 의미이다.
통합 테스트는 모든 Bean들을 똑같이 IoC에 올리고 테스트를 하는것이고, 단위 테스트는 Controller 관련 로직만 띄워 Controller만 테스트 하겠다는것이다. (예를들면 Filter, ControllerAdvice 등)
그다음 BookReposioryUnitTest를 생성한다.
여기서도 Unit테스트이므로 여기선 이전에 만들었던 Contorller 관련 bean은 필요가 없다. 그렇기 때문에 여기선 DB 관련된 bean만 IoC에 등록이 되면 된다.
하나 더 만들어보자 BookServiceUnitTest 여긴 Service에 관련된 bean들 (여기선 BookRepository)만 IoC (memory)에 띄우기만 하면 된다 .
이렇게 단위 테스트를 하는 이유는 뭘까? UnitTest를 함으로써 띄워야 하는 빈의 수가 확 줄어들어 가벼워 진다는 장점이 있다. 하지만 테스트의 한계가 분명한건 사실이다. 이럴때 통합 테스를 진행한다.
2. 어노테이션
이번 포스팅에서 사용할 어노테이션들을 미리 익혀두고 진행하도록 하자
2-1 @SpringBootTest
통합 테스트 용도로 사용되며, @SpringBootApplication을 찾아가 하위의 모든 Bean을 스캔하여 로드한다. 그 후 Test용 Application Context를 만들어 Bean을 추가하고, MockBean을 찾아 교체한다.
@SpringBootTest(webEnviroment=WebEnviroment.RANDOM_PORT) // 실제 서블릿 톰캣으로 테스트 @SpringBootTest(webEnviroment=WebEnviroment.Mock) // 가상 톰켓 사용 @SpringBootTest(webEnviroment=WebEnviroment.DEFINED_PORT) : application.properties 또는 default 8080 사용
2-2 @ExtendWith
JUnit 4에서 @RunWith로 사용되던 어노테이션이 ExtendWith로 변경되었다. 메인으로 실행될 Class를 지정할 때 쓰는 어노테이션으로, @SpringBootTest를 쓸 떄 기본적으로 추가 되어있다.
2-3 @WebMvcTest(Class명)
괄호안에 작성된 매개 변수 클래스만 실제로 로드하여 테스트를 진행한다. 이름에서 보다시피 MVC Test를 하기 때문에, 매개변수를 지정해주지 않으면 @Controller @RestController, @RestControllerAdvice 등 주로 컨트롤러와 연관된 Bean이 모두 로드가 된다. 스프링의 모든 Bean을 로드하는 @SpringBootTest대신 컨트롤러 관련 코드만 테스트 할때 사용된다.
JUnit5 이전 버전에서는 @ExtendWith(SpringExtension.class) 가 없었다. 그러므로 JUnit4 이하는 반드시 추가하여 사용하도록 한다. (JUnit5에는 @WebMvcTest에 포함이 되어있음)
2-4 @Autowired about MockBean
Controller의 API를 테스트하는 용도인 MockMvc 객체를 주입받고, perform() 메소드를 활용하여 컨트롤러의 동작을 확인할 수 있다. andExpect(), andDo(), andReturn() 등의 메소드를 같이 활용한다.
2-5 @MockBean
테스트를 할 클래스에서 주입 받고 있는 객체에 대하여 가짜 객체를 생성해주는 어노테이션으로, 해당 객체는 실제 행위를 하지않고 given()메소드를 활용하여 가짜 객체의 동작에 대해 정의하여 사용이 가능하다.
2-6 @AutoConfigureMockMvc
spring.test.mockmvc의 설정을 로드하면서 MockMvc의 의존성을 자동으로 주입한다. MockMvc클래스는 Rest API 테스트를 할 수 있는 클래스이다.
2-7 @Import
필요한 class들을 Configuration으로 만들어 사용할 수 있으며, Configuration Component 클래스도 의존성 설정할 수 있다
2-8 @Transactional
import org.springframework.transaction.annotation.Transactional;
@Transactional은 각각의 테스트 함수가 종료될 떄마다 트랜잭션을 rollback 해주는 어노테이션이다.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK) // 실제 톰캣이 아닌, 다른 톰캣으로 테스트
@AutoConfigureMockMvc // MockMvc를 IoC에 등록해서 DI 가 가능하게 한다.
@Transactional //각각의 테스트 함수가 종료될 떄마다 트렌잭션을 롤백해주는 어노테이션
public class BookControllerIntegrateTest {
@Autowired
private MockMvc mvc;
@Test
public void test1() {
// DB insert
}
@Test
public void test2() {
// DB insert
}
}
위와 같이 test1, test2에서 db insert가 있었을떄, test2는 test1의 영향을 받지 않는다. 테스트가 모두 종료 되었을때는 당연하게 자동 롤백이 된다.
3. Controller
@Slf4j
@WebMvcTest
class BookControllerUnitTest {
@Autowired
private MockMvc mockMvc; // 주소 호출 해서 테스트를 도와주는 도구 정도로 생각하자.
@Test
public void save_test() {
log.info("save_test 시작");
}
}
이대로 실행한다면 Application Context 에러가 발생한다. 이유는 BookService 가 등록이 안되이 안되있기 때문이다. 이걸 해결하기 위해선 아래와 같이 @Mock 과 함께 BookService를 추가해주면 된다.
@Slf4j
@WebMvcTest
class BookControllerUnitTest {
@Autowired
private MockMvc mockMvc; // 주소 호출 해서 테스트를 도와주는 도구 정도로 생각하자.
@MockBean
private BookService bookService;
@Test
public void add_test() {
log.info("add_test 시작");
Book book = bookService.addBook(new Book(1, "새책"));
System.out.println(book); // null
}
}
그런데 여기서 addbook 을 했을때 이 값이 null이 나오게 된다. 이러한 이유는 무엇일까? 지금 bookService는 가짜이기 때문이다. 왜 가짜를 사용하였을까? 진짜 bookService를 사용하기 위해선 repository까지 모두 필요하기 때문이다. 이러한 이유에서 Mock 가짜 bookService를 사용한 것이다.
본격적인 Contorller를 작성해보자 여기선 BDD (given when then) 패턴을 사용한다.
@Autowired
private MockMvc mockMvc; // 주소 호출 해서 테스트를 도와주는 도구 정도로 생각하자.
@MockBean
private BookService bookService;
@Test
public void addTest() throws Exception {
// given (테스트를 하기 위한 준비)
Book book = new Book(null, "새책");
String content = new ObjectMapper().writeValueAsString(book);
// 스텁
when(bookService.addBook(book)).thenReturn(new Book(1, "새책"));
// when (테스트 실행)
ResultActions resultActions = mockMvc.perform(post("/book")
.contentType(MediaType.APPLICATION_JSON)
.content(content)
.accept(MediaType.APPLICATION_JSON));
// then (검증)
resultActions
.andExpect(status().isCreated())
.andExpect(jsonPath("$.title").value("새책"))
.andDo(MockMvcResultHandlers.print());
}
@Test
public void findAllTest() throws Exception {
/**
* given
* 통합테스트를 하면 아래 given은 없어도 된다. mock 이니 실제는 안님,
*/
List<Book> books = new ArrayList<>();
books.add(new Book(1, "new book"));
books.add(new Book(2, "new book2"));
when(bookService.getAllBooks()).thenReturn(books);
// when
ResultActions resultActions = mockMvc.perform(get("/book")
.accept(MediaType.APPLICATION_JSON));
// then
resultActions
.andExpect(status().isOk())
.andExpect(jsonPath("$", Matchers.hasSize(2))) // JUnit5 가 들고있는 라이브러리 5개중 하나인 hamcrest
.andExpect(jsonPath("$.[0].title").value("new book"))
.andDo(MockMvcResultHandlers.print());
}
@Test
public void findById() throws Exception {
// given
Integer id = 1;
when(bookService.findById(id)).thenReturn(new Book(1, "new Book")); // 이러한 방식이 포스트맨보다 좋은 이유는 한번만 작성하고 실행만 하면 된다는 점이다. 그냥 초록색이 뜨는지만 확인하면 된다.
// when
ResultActions resultActions = mockMvc.perform((get("/book/{id}", id))
.accept(MediaType.APPLICATION_JSON));
// then
resultActions
.andExpect(status().isOk())
.andExpect(jsonPath("$.title").value("new Book"))
.andDo(MockMvcResultHandlers.print());
}
@Test
public void updateTest() throws Exception {
//given1. when에서 던질 데이터
Integer id = 1;
Book book = new Book(null, "save book");
String content = new ObjectMapper().writeValueAsString(book);
// given2 스턱을 만들어 이값이 들어오면 이값이 리턴이 될것이다 정의를 미리 해둔다.
when(bookService.saveBook(id, book)).thenReturn(new Book(1, "saveBook"));
// when given1을 던져서 given2에서 기대한 값이 나오는지 확인한다.
ResultActions resultActions = mockMvc.perform(put("/book/{id}", id)
.content(content)
.accept(MediaType.APPLICATION_JSON));
// then
resultActions
.andExpect(status().isOk())
.andDo(MockMvcResultHandlers.print());
}
4. Service
여긴 Service에 관련된 bean들 (여기선 BookRepository)만 IoC (memory)에 띄우기만 하면 된다 .
서비스에서 중요한건 repository에 의존적이라는 점이다. 그렇기 위해 repository가 IoC에 올라오게 된다면 Service는 더이상 단위테스트라 할수 없게 된다.
그렇기 때문에 우리는 BookRepository를 IoC에 올리는것이 아닌 Mockito 메모리 공간에 BookRepository를 올려서 사용한다.
그럼 Mock에서 BookService가 뜬것은 IoC컨터에너에서 뜬것은 아니게 되고 Memory에는 뜨지만 BookRepository는 null이 된다. 이럴때 만약 BookRepository를 주입 받고 싶다면 @InjectMocks 를 사용하면 된다.
그렇게 되면 BookService객체가 만들어질때, BookServiceUnitTest파일에 @Mock로 등록된 모든 객체들을 주입받는다. 그럼 BookService내에 BookRepository 더이상 null이 아닌 Mock으로 생성된 가짜 객체를 주입받는것이 가능해진다.
BookService
@RequiredArgsConstructor
@Service
public class BookService {
private BookRepository repository;
public void addBook(BookDto dto) {
repository.addBook(dto);
}
}
위예제에서의 서비스의 Unit 테스트는 크게 의미가 없다 그렇기 때문에 간단히 update쪽만 남긴다. 형태만 기억해두자 나중에 서비스 로직이 들어간다면 그떄는 그것을 확인하는 테스트를 남기도록 하자
BookServiceUnitTest
/**
* 단위 테스트 (Service와 관련된 애들만 메모리에 띄운다.
* BookRepository => 가짜 객체로 만듬
*/
@ExtendWith(MockitoExtension.class)
class BookServiceUnitTest {
@InjectMocks // BookService 객체가 만들어 질 때 BookServiceUnitTest 파일에 @Mock로 등록된 모든 애들을 주입받는다.
private BookService bookService;
@Mock
private BookRepository bookRepository;
@Test
public void saveTest() {
// BODMockito 방식
// given
Book book = new Book();
book.setTitle("책제목");
// stub 동작 지정
when(bookRepository.save(book)).thenReturn(book);
// test execute
Book bookEntity = bookService.saveBook(book);
//then
assertEquals(bookEntity, book);
}
}
5. Repository
여기선 DB에 관련된 Bean만 IoC에 등록이 되면 된다. Service Contorller 어느것도 필요가 없다 이럴떈 @DataJpaTest라는 어노테이션을 사용한다.
5-1. 예제
repository도 service와 동일하게 어떻게 사용하는지 폼만 기억하도록 하자.
/**
* 단위 테스트 (DB 관련된 Bean이 IoC에 등록되면 된다.
*/
@Transactional
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.ANY) // 실제 DB아님
//@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) // 실제 DB (통테에서 쓰자)
@DataJpaTest // Repository들을 모두 IoC에 등록해준다.
class BookRepositoryUnitTest {
// @Mock // 이걸로 띄울필요없음 @DataJpaTest 가 이미 등록을 마침
@Autowired
private BookRepository bookRepository;
@Test
public void saveTest() {
// given
Book book = new Book(null, "save book");
// when
Book bookEntity = bookRepository.save(book);
//then
assertEquals("save book", bookEntity.getTitle());
}
}
5-2. Autowired
테스트를 하다 id를 만들때 자동 생성 증감값이 생각했던과는 다르게 1이 아닌 다른 값으로 id 가 증감할때가 있다. 이럴때 테스트를 정확하게 하기위해 테스트에 아래 옵셜을 사용한다.
@Autowired
private EntityManager entityManager
@BeforeEach
public void init() {
entityManager.createNativeQuery("ALTER TALBE test AUTO_INCREMENT = 1").executeUpdate();
}
참고
https://www.youtube.com/watch?v=0XVrPkXJLk0