테스트 — 가짜를 끼워 넣는다
Article
우리는 스물두 강 내내 같은 짓을 했다.
curl http://localhost:8080/books/99
띄우고, 치고, 눈으로 본다. 코드를 고치면 또 띄우고, 또 친다. 22강에서 컨트롤러를 통째로 갈아엎었을 때도 그랬다. curl 을 열 번 넘게 쳐서 계약이 안 깨졌는지 확인했다.
그 일을 코드에게 시킨다.
자바 편에서 이미 만들어봤다
자바 편 21강에서 표식을 만들고, 그 표식이 붙은 메서드를 찾아 실행하는 러너를 손으로 짰다.
for (Method m : MyTests.class.getDeclaredMethods()) {
if (m.isAnnotationPresent(Test.class)) {
System.out.println("찾았다: " + m.getName());
m.invoke(new MyTests());
}
}
JUnit 이 그 러너의 완성품이다. 우리가 짠 열 줄이 하는 일을, 훨씬 잘 한다.
우리 프로젝트에는 이미 들어와 있다. 4강에서 start.spring.io 가 만들어준 파일 중에 이런 것이 있었다.
@SpringBootTest
class BookstoreApplicationTests {
@Test
void contextLoads() {
}
}
스물두 강 동안 한 번도 안 열어봤다. 이제 연다.
서비스를 테스트한다
22강에서 BookService 를 만들었다. 규칙이 사는 층이다. 없는 책을 찾으면 예외를 던진다.
@Transactional(readOnly = true)
public Book findOne(Long id) {
return repository.findById(id)
.orElseThrow(() -> new BookNotFoundException(id));
}
정말 그런가. 확인하려면 BookService 를 만들어야 하고, 그러려면 생성자에 BookRepository 를 넣어줘야 한다.
여기서 자바 편 15강이 돌아온다. 그 강의는 OrderService 가 Notifier 를 생성자로 받는 것을 보여주고 이렇게 적었다.
가짜
Notifier를 넣으면 된다.
6강에서 그 "밖"이 컨테이너라고 했다. 그런데 컨테이너만 넣을 수 있는 게 아니다. 우리가 손으로 넣어도 된다.
BookRepository fake = /* 가짜 */;
BookService service = new BookService(fake);
new 다. Spring 을 안 띄운다.
가짜를 만들려는데 컴파일이 안 된다
가짜를 손으로 만들어본다.
static class FakeBookRepository implements BookRepository {
// 아무것도 구현하지 않는다
}
- error: FakeBookRepository is not abstract and does not override abstract method
- <S>findAll(Example<S>,Sort) in JpaRepository
- 1 error
16강이 돌아왔다. 그때 BookRepository 의 메서드를 세어봤다.
- 우리가 적은 메서드 = 0개
- 쓸 수 있는 메서드 = 37개
공짜로 얻은 37개가 이제 짐이다. 가짜를 만들려면 37개를 전부 구현해야 한다. 우리가 쓰는 것은 findById 하나인데.
자바 편 15강의 Notifier 는 메서드가 하나뿐이라 가짜를 손으로 만들 수 있었다. 여기서는 안 된다.
가짜도 만들어준다
16강에서 Spring 이 BookRepository 의 구현을 실행 중에 만들어준 것을 봤다. $Proxy116 이었다.
같은 일을 테스트에서도 할 수 있다. 도구 이름이 Mockito 다. 테스트 의존성에 이미 들어 있다.
BookRepository fake = mock(BookRepository.class);
이 한 줄이 37개를 전부 구현한 가짜를 만든다. 전부 아무것도 안 하고 null 이나 빈 값을 돌려준다. 우리에게 필요한 것만 시킨다.
when(fake.findById(99L)).thenReturn(Optional.empty());
"99번을 물어보면 빈 상자를 줘라." 17강에서 배운 Optional 이다.
테스트 전체는 이렇게 된다.
class BookServiceTest {
@Test
void 없는_책을_찾으면_예외가_난다() {
BookRepository fake = mock(BookRepository.class);
when(fake.findById(99L)).thenReturn(Optional.empty());
BookService service = new BookService(fake);
BookNotFoundException e = assertThrows(
BookNotFoundException.class,
() -> service.findOne(99L));
assertEquals("99번 책을 찾을 수 없다", e.getMessage());
}
}
- 01
준비한다
가짜 리포지토리를 만들고, 99번은 빈 상자를 주도록 시킨다. 그것을 생성자로 넣는다.
- 02
부른다
service.findOne(99L)을 부른다. 데이터베이스는 없다.BookRepository도 진짜가 아니다. - 03
확인한다
BookNotFoundException이 났는지, 메시지가 맞는지 본다.assertEquals가 자바 편 21강 러너의if자리다.
돌린다.
./gradlew test
- > Task :test
- BUILD SUCCESSFUL in 5s
Spring 이 안 떴다. 톰캣도, 컨테이너도, H2 도 없다. 두 개의 테스트가 0.5초에 끝났다.
일부러 기대값을 틀리게 적어보면 이렇게 알려준다.
- BookServiceTest > 없는_책을_찾으면_예외가_난다() FAILED
- org.opentest4j.AssertionFailedError at BookServiceTest.java:24
- # 리포트에는 이렇게 적혀 있다
- expected: <99번 책이 없습니다> but was: <99번 책을 찾을 수 없다>
curl 로는 이걸 못 한다. 눈으로 봐야 한다.
진짜로 띄워서 테스트한다
서비스는 봤다. 그런데 우리가 curl 로 확인하던 것은 그보다 바깥이다. 상태코드가 404 인가. JSON 에 사유가 실렸는가.
그건 진짜로 띄워야 안다. Spring 을 띄우는 테스트가 따로 있다.
@SpringBootTest
@AutoConfigureMockMvc
class BookApiTest {
@Autowired
MockMvc mvc;
@Test
void 없는_책은_404_다() throws Exception {
mvc.perform(get("/books/99"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.message").value("99번 책을 찾을 수 없다"));
}
@Test
void 빈_제목은_400_이다() throws Exception {
mvc.perform(post("/books")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"title\":\"\",\"price\":100}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.message").value("제목은 비어 있을 수 없다"));
}
}
MockMvc 가 우리의 curl 이다. perform(get("/books/99")) 이 curl http://localhost:8080/books/99 고, andExpect 가 우리 눈이다.
@Autowired 는 5강·6강의 그 주입이다. 필드에 붙는 형태가 처음이지만, 하는 일은 같다. 컨테이너가 MockMvc 를 넣어준다.
BookServiceTest
Spring 없이
new BookService(가짜)로 만든다- 규칙이 맞는지만 본다
- 두 개에 0.5초
- 데이터베이스도 톰캣도 없다
BookApiTest
@SpringBootTest
- 진짜 컨테이너를 띄운다
- 상태코드와 JSON 까지 본다
- 세 개에 6.3초
- 21강의 핸들러도 함께 검사된다
테스트가 진짜 데이터베이스를 건드렸다
책을 만드는 테스트도 하나 넣었다.
@Test
void 책을_만들면_201_이다() throws Exception {
mvc.perform(post("/books")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"title\":\"테스트로 만든 책\",\"price\":10000}"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.title").value("테스트로 만든 책"));
}
통과한다. 그런데 테스트를 돌린 뒤 앱을 띄워보면.
- $ ./gradlew test
- BUILD SUCCESSFUL
- $ ./gradlew bootRun # 앱을 띄우고
- $ curl http://localhost:8080/books
- [{"id":1,"title":"테스트로 만든 책","price":10000}]
테스트가 만든 책이 진짜 데이터베이스에 남았다.
당연하다. @SpringBootTest 는 진짜 컨테이너를 띄우고, 진짜 application.properties 를 읽고, 진짜 jdbc:h2:file:./data/bookdb 에 붙는다. 14강에서 만든 그 파일이다.
테스트를 돌릴 때마다 쓰레기가 쌓인다. 어제 돌린 테스트가 오늘 테스트를 깨뜨릴 수도 있다.
해법을 우리는 이미 배웠다. 22강에서 예외가 나면 되돌린다고 했다. 그 되돌리기를 테스트가 끝날 때마다 시킨다.
@SpringBootTest
@AutoConfigureMockMvc
@Transactional
class BookApiTest {
표식 하나다. 테스트 클래스에 @Transactional 을 붙이면 각 테스트가 끝날 때 롤백한다. 성공해도 롤백한다.
- $ ./gradlew test
- BUILD SUCCESSFUL
- $ curl http://localhost:8080/books
- []
아무것도 안 남았다. 테스트는 통과했고, 데이터베이스는 깨끗하다.
정리하면
생성자로 받았기 때문에 가짜를 넣을 수 있다. 그것이 22강까지 쌓아온 구조가 주는 값이다.
- 01
JUnit 은 자바 편 21강 러너의 완성품이다
표식이 붙은 메서드를 찾아 실행한다. 우리가 열 줄로 짰던 그것이다.
- 02
가짜를 손으로 만들 수 없다
BookRepository는 메서드가 37개다 (16강). 하나를 쓰려고 37개를 구현할 수는 없다.static class FakeBookRepository implements BookRepository { } // error: is not abstract and does not override abstract method - 03
Mockito 가 가짜를 만들어준다
16강에서 Spring 이 프록시를 만들어준 것과 같은 종류다. 필요한 답만 시켜둔다.
BookRepository fake = mock(BookRepository.class); when(fake.findById(99L)).thenReturn(Optional.empty()); BookService service = new BookService(fake); - 04
Spring 없이 도는 테스트는 빠르다
두 개에 0.5초다.
@SpringBootTest는 세 개에 6.3초다. 규칙은 빠른 쪽에서 확인한다. - 05
MockMvc가 우리의curl이다상태코드와 JSON 본문까지 검사한다. 21강의 핸들러도 함께 검사된다.
mvc.perform(get("/books/99")) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.message").value("99번 책을 찾을 수 없다")); - 06
테스트 클래스에
@Transactional안 붙이면 테스트가 만든 데이터가 진짜 데이터베이스에 남는다. 통과해도 롤백한다.
이제 코드를 고치고 ./gradlew test 한 줄이면 계약이 안 깨졌는지 알 수 있다. 3강에서 정한 약속을 코드가 지키고 있는지, 기계가 대신 봐준다.
남은 것은 하나다. 이 프로그램을 내 컴퓨터 밖으로 내보내는 일.
4강에서 ./gradlew build 를 한 번 돌려봤다. 그때 산출물이 둘 나왔고, 표로 이렇게 적었다.
| 파일 | 크기 | |
|---|---|---|
bookstore-0.0.1-SNAPSHOT-plain.jar |
1,509 바이트 | 우리가 쓴 코드만 |
bookstore-0.0.1-SNAPSHOT.jar |
약 19 MB | 톰캣까지 전부 |
그때는 이름만 보고 지나갔다. 그 강의는 "24강에서 다시 본다"고 적어뒀다.
자바 편 21강은 jar cf myapp.jar . 로 끝났다. 그 명령이 만든 것과 저 19메가짜리는 무엇이 다른가.
마지막 강의에서 그것을 연다.