실전! 코틀린과 스프링 부트로 도서관리 애플리케이션 개발하기

Ch01. 도서관리 애플리케이션 리팩토링 준비하기 - JUnit5으로 Spring Boot 테스트 하기

webmaster 2022. 10. 29. 19:22
728x90

Controller - Service - Repository - Domain 계층의 테스트하는 방식이 각각 다르다.

Domain(POJO), 그외(Bean)
Domain/ service,repository 테스트 방법
controller 테스트 방법

  • Controller, Service, Repository는 스프링이 관리하는 bean으로 @SpringBootTest 어노테이션을 붙여서, Domain 은 스프링이 관리하는 bean이 아니므로 단위 테스트(클래스 테스트)를 하면 된다.
  • Controller 같은 경우는 응답받는 JSON을 비롯한 HTTP 위주의 테스트를 하면 된다(응답 코드, 반환 JSON...)
  • Service, Repository는 데이터 위주의 테스트를 진행하면 된다.

UserService.createRequest() Test

@SpringBootTest //스프링 부트 테스트(빈으로 등록된 객체를 테스트해야되므로)
class UserServiceTest @Autowired constructor(
    /*
    @Autowired private val userRepository: UserRepository,
    @Autowired private val userService: UserService,
     */
    private val userRepository: UserRepository,
    private val userService: UserService,
) {

    @Test
    fun saveUserTest() {
        //given
        val request = UserCreateRequest("최태현", null)

        //when
        userService.saveUser(request)

        //then
        //실제 DB에 값이 있는지 확인
        val results = userRepository.findAll()
        assertThat(results).hasSize(1)
        assertThat(results[0].name).isEqualTo("최태현")
        assertThat(results[0].age).isNull() //코틀린은 age가 null일지 아닐지를 모르기 때문에 null이 아니라고 단언한다(코틀린 플렛폼)
        //null을 허용하기 위해서는 @nullable 어노테이션을 붙여야 한다.
    }

}

User.class


@Entity
public class User {
  
  //... 
  
  @NotNull
  public String getName() {
    return name;
  }

  @Nullable
  public Integer getAge() {
    return age;
  }

  //....

}
  • @SpringBootTest : 스프링 컨텍스트를 띄우는 테스트임을 표시하며, 이 테스트가 실행될 때는 컨텍스트가 자동으로 뜬다.
  • 생성자에서 @Autowired 어노테이션을 통해 bean을 주입받는다.
    • 이때, 매번 @Autowired 가 중복이 되므로, @Autowired construtor를 통해 중복을 제거할 수 있다
  • saveUserTest에서는 UserCreateRequest를 생성하여 name = "최태현", age = null을 넣었다.
    • 자바에선 age는 Integer 타입으로 null을 넣을 수 있다
  • saveUserTest를 실제 실행하면 오류가 발생한다 
    • kotlin에서 Integer타입을 age가 null일지 아닐지를 판단할 수 없기 때문에 기본적으로는 null이 아니라고 판단하게 되는데 이를 코틀린 플랫폼 타입이라고 한다.
    • 현재는 null이 아닌 변수에 age를 담으려고 하니 오류가 발생하는 것이다(results [0]. age에서 getter를 호출하는 데 이 값이 null이기 때문)
    • 이를 해결하기 위해서는 null이 불가능한 타입에는 @NotNull 어노테이션을 null이 가능한 타입에는 @Nullable 어노테이션을 붙이면 된다.

UserService.saveUser, getUsers, updateUser, deleteUser Test

@AfterEach
fun clear() {
    userRepository.deleteAll()
}


@Test
@DisplayName("유저 저장이 정상 동작한다.")
fun saveUserTest() {
    //given
    val request = UserCreateRequest("최태현", null)

    //when
    userService.saveUser(request)

    //then
    //실제 DB에 값이 있는지 확인
    val results = userRepository.findAll()
    assertThat(results).hasSize(1)
    assertThat(results[0].name).isEqualTo("최태현")
    assertThat(results[0].age).isNull() //코틀린은 age가 null일지 아닐지를 모르기 때문에 null이 아니라고 단언한다(코틀린 플렛폼)
    //null을 허용하기 위해서는 @nullable 어노테이션을 붙여야 한다.
}

@Test
@DisplayName("유저 조회가 정상 동작한다.")
fun getUsersTest() {
    //given
    userRepository.saveAll(
        listOf(
            User("A", 20),
            User("B", null)
        )
    )

    //when
    val results = userService.getUsers()

    //then
    //단위 실행하면 오류가 발생하지 않지만, 모두 실행하면 SpringContext를 공유하기 떄문에 문제가 된다.
    //이를 해결하기 위해서는 @AfterEach에서 database를 초기화 해주자
    assertThat(results).hasSize(2)
    assertThat(results).extracting("name").containsExactlyInAnyOrder("A", "B") //["A", "B"]
    assertThat(results).extracting("age").containsExactlyInAnyOrder(20, null) //[20, null]
}

@Test
@DisplayName("유저 업데이트가 정상 동작한다.")
fun updateUserNameTest() {
    //given
    val saveUser = userRepository.save(User("A", null))
    val request = UserUpdateRequest(saveUser.id, "B")

    //when
    userService.updateUserName(request)

    //then
    val result = userRepository.findAll()[0]
    assertThat(result.name).isEqualTo("B")
}

@Test
@DisplayName("유저 삭제가 정상 동작한다.")
fun deleteUserTest() {
    //given
    userRepository.save(User("A", null))

    //when
    userService.deleteUser("A")

    //then
    assertThat(userRepository.findAll()).isEmpty()
}
  • getUsers() 테스트에서는 extracting("프로퍼티").containsExactlyInAnyOrder()를 사용해서 포함 여부를 체크한다
  • 전체 실행하면, 동작하지 않는다 -> SpringContext를 공유하기 때문
    • @SpringBootTest를 실행하면 스프링 컨텍스트에서 공유하고 있는 DB가 같기 때문에, 저장 테스트에서 사용되었던 데이터가 조회 테스트에서 조회가 되어 문제가 발생한다.
    • 이를 해결하기 위해서는 테스트를 진행한 뒤에는 반드시 DB를 초기화해야 한다.
    • 메서드마다 deleteAll() 메서드를 호출해 줄 수 도 있지만, afterEach에서 자동화시키는 것이 좋다
  • 테스트 메서드의 이름을 직관적인 한글 이름을 붙이고 싶다면, @DisplayName 어노테이션을 붙이면 된다.

Book 관련 기능 TEST

@SpringBootTest
class BookServiceTest @Autowired constructor(
    private val bookService: BookService,
    private val bookRepository: BookRepository, //마지막에 콤마를 찍을 수 있다 -> git diff에 변하는 부분만 보기 위해 해당 기능 허용
    private val userRepository: UserRepository,
    private val userLoanHistoryRepository: UserLoanHistoryRepository,
) {

    @AfterEach
    fun clean() {
        bookRepository.deleteAll()
        userRepository.deleteAll()
    }

    @Test
    @DisplayName("책 등록이 정상 동작한다")
    fun saveBookTest() {
        //given
        val request = BookRequest("이상한 나라의 엘리스")

        //when
        bookService.saveBook(request)

        //then
        val books = bookRepository.findAll()
        assertThat(books).hasSize(1)
        assertThat(books[0].name).isEqualTo("이상한 나라의 엘리스")
    }


    @Test
    @DisplayName("책 대출이 정상 동작한다")
    fun loanBookTest() {
        //given
        bookRepository.save(Book("이상한 나라의 엘리스"))
        val savedUser = userRepository.save(User("최태현", null))
        val request = BookLoanRequest("최태현", "이상한 나라의 엘리스")

        //when
        bookService.loanBook(request)

        //then
        val results = userLoanHistoryRepository.findAll()
        assertThat(results).hasSize(1)
        assertThat(results[0].bookName).isEqualTo("이상한 나라의 엘리스")
        assertThat(results[0].user.id).isEqualTo(savedUser.id)
        assertThat(results[0].isReturn).isFalse
    }


    @Test
    @DisplayName("책이 진작 대출되어 있다면, 신규 대출이 실패한다")
    fun loanBookFailTest() {
        //given
        bookRepository.save(Book("이상한 나라의 엘리스"))
        val savedUser = userRepository.save(User("최태현", null))
        userLoanHistoryRepository.save(UserLoanHistory(savedUser, "이상한 나라의 엘리스", false)) //대출 중인 상태
        val request = BookLoanRequest("최태현", "이상한 나라의 엘리스")

        //when & then
        assertThrows<IllegalArgumentException> {
            bookService.loanBook(request)
        }.apply {
            assertThat(message).isEqualTo("진작 대출되어 있는 책입니다")
        }
    }

    @Test
    @DisplayName("책 반납이 정상 동작한다")
    fun returnBookTest() {
        //given
        val savedUser = userRepository.save(User("최태현", null))
        userLoanHistoryRepository.save(UserLoanHistory(savedUser, "이상한 나라의 엘리스", false)) //대출 중인 상태
        val request = BookReturnRequest("최태현", "이상한 나라의 엘리스")

        //when
        bookService.returnBook(request)

        //then
        val results = userLoanHistoryRepository.findAll()
        assertThat(results).hasSize(1)
        assertThat(results[0].isReturn).isTrue
    }
}

UserLoanHistory.class


@Entity
public class UserLoanHistory {

  //...

  @NotNull
  public String getBookName() {
    return this.bookName;
  }

  @NotNull
  public User getUser() {
    return user;
  }

  @NotNull
  public boolean isReturn() {
    return isReturn;
  }
}
  • 책을 이름과 유저 이름, 반환 여부는 null이 될 수 없으므로 @NotNull 어노테이션을 붙인다.
  • 책 대출 테스트 같은 경우 정상적으로 대출이 되었을 경우와, 이미 책을 빌려 정상 동작하지 않는 경우를 테스트해야 한다.
    • assertThrows를 활용해 IllegalargumentException 이 잘 발생하는지 확인한다.
728x90