학습 자료

테스트 — 가짜를 끼워 넣는다


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강이 돌아온다. 그 강의는 OrderServiceNotifier 를 생성자로 받는 것을 보여주고 이렇게 적었다.

가짜 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());
    }
}
세 토막이다
  1. 01

    준비한다

    가짜 리포지토리를 만들고, 99번은 빈 상자를 주도록 시킨다. 그것을 생성자로 넣는다.

  2. 02

    부른다

    service.findOne(99L) 을 부른다. 데이터베이스는 없다. BookRepository 도 진짜가 아니다.

  3. 03

    확인한다

    BookNotFoundException 이 났는지, 메시지가 맞는지 본다. assertEquals 가 자바 편 21강 러너의 if 자리다.

메서드 이름을 한글로 적었다. JUnit 은 이름을 그대로 리포트에 찍는다.

돌린다.

./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강까지 쌓아온 구조가 주는 값이다.

다시 짚어보기
  1. 01

    JUnit 은 자바 편 21강 러너의 완성품이다

    표식이 붙은 메서드를 찾아 실행한다. 우리가 열 줄로 짰던 그것이다.

  2. 02

    가짜를 손으로 만들 수 없다

    BookRepository 는 메서드가 37개다 (16강). 하나를 쓰려고 37개를 구현할 수는 없다.

    static class FakeBookRepository implements BookRepository { }
    // error: is not abstract and does not override abstract method
  3. 03

    Mockito 가 가짜를 만들어준다

    16강에서 Spring 이 프록시를 만들어준 것과 같은 종류다. 필요한 답만 시켜둔다.

    BookRepository fake = mock(BookRepository.class);
    when(fake.findById(99L)).thenReturn(Optional.empty());
    
    BookService service = new BookService(fake);
  4. 04

    Spring 없이 도는 테스트는 빠르다

    두 개에 0.5초다. @SpringBootTest 는 세 개에 6.3초다. 규칙은 빠른 쪽에서 확인한다.

  5. 05

    MockMvc 가 우리의 curl 이다

    상태코드와 JSON 본문까지 검사한다. 21강의 핸들러도 함께 검사된다.

    mvc.perform(get("/books/99"))
            .andExpect(status().isNotFound())
            .andExpect(jsonPath("$.message").value("99번 책을 찾을 수 없다"));
  6. 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메가짜리는 무엇이 다른가.

마지막 강의에서 그것을 연다.

테스트 — 가짜를 끼워 넣는다 — 디코드랩(DCODELAB)