[코프링] Java 테스트 코드를 Kotlin으로 전환해보기

최준호·2022년 8월 21일
0

코프링

목록 보기
1/3
post-thumbnail

출처 실전! 코틀린과 스프링 부트로 도서관리 애플리케이션 개발하기 (Java 프로젝트 리팩토링)

앞으로 해당 시리즈에 관련된 소스와 글은 위 링크가 출처임을 밝힙니다.

👏 Java에서 Kotlin?

이전부터 Kotlin에 관심이 있었는데 막상 시작하기엔 디자인 패턴도 공부해야하고 알고리즘도 공부해야하고 스프링도 공부해야하고 개발자가 된 이후에 왜이리 공부할게 많은지 모르겠다... 또 최근에 이직을 해서 그런가 정신이 없어서 공부도 손에 잘 안잡히던 와중 인프런에 실전! 코틀린과 스프링 부트로 도서관리 애플리케이션 개발하기 (Java 프로젝트 리팩토링) 강의가 개설되어 할인중인것을 보고 아무 생각없이 일단 결제해놨었다.

공부를 시작하자니 일단 내가 재밌을거 같은거 부터 시작하자는 마음이 커서 앞서 공부하던 디자인 패턴은 잠시 멈춰두고 코프링을 먼저 공부해보려고 한다.

📗 Java 테스트 코드를 Kotlin으로 전환

📄 gradle 설정 변경

...

plugins {
    ...
    
    id 'org.jetbrains.kotlin.jvm' version '1.6.21'
}


dependencies {
	...
    implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8'
}

compileKotlin{
    kotlinOptions {
        jvmTarget = "11"
    }
}

compileTestKotlin{
    kotlinOptions {
        jvmTarget = "11"
    }
}

기존 java project에서 다음과 같이 kotlin 설정을 추가해주었다.

해당 설정이 kotlin이면 무조건 설정되는 것인지 아니면 해당 프로젝트를 위해 설정된 것인지는 아직 모르겠다!

📄 디렉토리 추가

기존 java 프로젝트에서는 /src/main/java 디렉토리 내에 소스들을 정리해두었을 것이다.

하지만 이제는 우리는 kotlin 프로젝트를 관리할 것이기 때문에 src/main/kotlin 디렉토리를 추가하여 소스를 관리해보자.

main 디렉토리에 새로운 디렉터리 추가를 눌렀을 때 gradle 설정이 정상적으로 처리되었다면 다음과 같이 폴더를 선택할 수 있는 옵션이 나온다. 없다면 kotlin 이름으로 만들면 된다.

그리고 다음과 같이 main과 test에 각각 추가해주면 된다. 만약 여기서 intellij가 파란색 폴더로 표시해주지 않는다면 디렉토리를 우클릭 > Mark Directory as > Source Set Root 옵션을 클릭해주면 된다.

그 후에는 내부 패키지 형태를 동일하게 가져가기 위해 다음과 같이 추가해주자

테스트 쪽도 똑같이 추가해주면 된다.

📄 계산기 프로그램을 만들어 테스트해보기

data class Calculator (private var number:Int) {
    fun add(operand: Int){
        this.number += operand
    }

    fun minus(operand: Int){
       this.number -= operand
    }

    fun multiply(operand: Int){
        this.number *= operand
    }

    fun divide(operand: Int){
        if(operand == 0){
            throw IllegalArgumentException("0으로 나눌 수 없습니다.");
        }
        this.number /= operand
    }
}

다음과 같이 코틀린으로 계산기 프로그램을 간단하게 작성했다. 여기서 java와 다른점을 몇개 찝어보고 넘어가자. 왜냐면 나는 코틀린 초보니까^^

  1. data class가 존재한다.
  2. 먼저 생성자를 java와 같이 만들 수 있지만 하나의 생성자만 선언할 경우 생성자 자체에 매개변수를 설정해서 위 코드와 같이 선언할 수 있다.
  3. 각 메서드를 대신하여 fun이라는 예약어를 통해 function을 정의한다.
  4. java와는 반대로 객체에 대한 타입을 typescript처럼 뒤에 붙여서 선언한다.
  5. exception을 정의할 때 java는 throw new Exception()과 같은 형식으로 선언하는 것에 비해 코틀린은 new를 제외하고 사용한다.
  6. 마지막으로 코드에 끝에 해당 코드가 종료되었다는 ;을 사용하지 않는다.

data class는 기존 class에서 boilerplate code인 toString(), equals(), hashCode(), copy() 등 반복되는 코드를 줄여주기 위한 class로 class 앞에 data만 붙여주면 자동으로 해당 fun를 가진 class로 정의된다.

📄 main()을 활용하여 코드 실행해보기

fun main() {
    val calculatorTest = CalculatorTest()
    calculatorTest.addTest()
}

class CalculatorTest{

    fun addTest(){
        val calculator = Calculator(5)
        calculator.add(2)	// 파라미터 값을 3으로 고치면 정상실행!

        val expectedCalculator = Calculator(8)
        if(calculator != expectedCalculator) throw IllegalArgumentException("예상 값이 다릅니다!")
    }
}

main function을 가지는 다음 Test 코드를 작성해보았다. Junit은 아직 사용하지 않는 코드로 실행해보자!

다음과 같이 java와 동일한 에러코드의 로그를 확인할 수 있다. 물론 저기에서 add(3)으로 고쳐주면 정상 실행된다.

📄 또 다른 방식의 테스트 코드 작성

해당 부분은 kotlin을 이해하기 위한 부분이므로 이미 코틀린 문법에 익숙한 분이면 굳이 따라하지 않으셔도 됩니다!

class Calculator (var number:Int) {
    fun add(operand: Int){
        this.number += operand
    }

    fun minus(operand: Int){
       this.number -= operand
    }

    fun multiply(operand: Int){
        this.number *= operand
    }

    fun divide(operand: Int){
        if(operand == 0){
            throw IllegalArgumentException("0으로 나눌 수 없습니다.");
        }
        this.number /= operand
    }
}

앞선 코드와 조금 다르게 data 대신 생성자 매개변수에 private을 지워서 사용했다. 참고로 kotlin은 default 값이 public이다.

fun main() {
    val calculatorTest = CalculatorTest()
    calculatorTest.addTest()
}

class CalculatorTest{

    fun addTest(){
        val calculator = Calculator(5)
        calculator.add(2)

        val expectedCalculator = Calculator(8)
        if(calculator.number != expectedCalculator.number) throw IllegalArgumentException("예상 값이 다릅니다!")
    }
}

그럼 다음과 같이 테스트 코드의 확인도 다음과 같이 다시 작성할 수 있다.

하지만 우리는 여기서 불편함을 느껴야한다. 왜냐? 필드값 public으로 set 행동을 쉽게할 수 있게 되기 때문에 값의 변경이 너무 쉽게 되기 때문이다. 그럼 get만 열어주는 방식으로 다시 짜보자.

class Calculator (private var _number:Int) {
    val number: Int
        get() = this._number

    fun add(operand: Int){
        this._number += operand
    }

    fun minus(operand: Int){
       this._number -= operand
    }

    fun multiply(operand: Int){
        this._number *= operand
    }

    fun divide(operand: Int){
        if(operand == 0){
            throw IllegalArgumentException("0으로 나눌 수 없습니다.");
        }
        this._number /= operand
    }
}

기존 class 코드의 생성자 부분을 다시 private으로 막아주고 public으로 열린 number 필드를 선언해준다. 그리고 get() 부분을 다음과 같이 선언해주면 외부에서도 접근이 가능한 number가 새롭게 정의되고 set은 막혀있게 된다.

fun main() {
    val calculatorTest = CalculatorTest()
    calculatorTest.addTest()
}

class CalculatorTest{

    fun addTest(){
        val calculator = Calculator(5)
        calculator.add(2)

        val expectedCalculator = Calculator(8)
        if(calculator.number != expectedCalculator.number) throw IllegalArgumentException("예상 값이 다릅니다!")
    }
}

테스트 코드 쪽은 다음과 같이 number에 그대로 접근할 수 있게 된다.

강의의 강사님인 최태현 강사님께서는 다음과 같이 setter를 막아두는 코드가 좋지만 kotlin이 java보다 더 깔끔하고 가독성 좋은 코드를 지향하고 있는 만큼 위 코드와 같이 어려운 코드가 추가되어지는 것 보단 개발자들끼리 setter를 쓰지 않기로 약속을 하고 public으로 열어둔 코드를 작성하는 방법도 있다고 알려주셨다.
자신 있으면 그렇게 하자.

나머지 테스트도 작성해보자.

fun main() {
    val calculatorTest = CalculatorTest()
    calculatorTest.addTest()
    calculatorTest.minusTest()
    calculatorTest.multiplyTest()
    calculatorTest.divideTest()
    calculatorTest.divideExceptionTest()
}

class CalculatorTest{

    fun addTest(){
        val calculator = Calculator(5)
        calculator.add(3)

        val expectedCalculator = Calculator(8)
        if(calculator.number != expectedCalculator.number) throw IllegalArgumentException("예상 값이 다릅니다!")
    }

    fun minusTest(){
        val calculator = Calculator(5)
        calculator.minus(3)

        val expectedCalculator = Calculator(2)
        if(calculator.number != expectedCalculator.number) throw IllegalArgumentException("예상 값이 다릅니다!")
    }

    fun multiplyTest(){
        val calculator = Calculator(5)
        calculator.multiply(3)

        val expectedCalculator = Calculator(15)
        if(calculator.number != expectedCalculator.number) throw IllegalArgumentException("예상 값이 다릅니다!")
    }

    fun divideTest(){
        val calculator = Calculator(5)
        calculator.divide(2)

        val expectedCalculator = Calculator(2)
        if(calculator.number != expectedCalculator.number) throw IllegalArgumentException("예상 값이 다릅니다!")
    }
    
    fun divideExceptionTest(){
        val calculator = Calculator(5)
        try{
            calculator.divide(0)
        }catch (e: IllegalArgumentException){
            if(e.message != "0으로 나눌 수 없습니다.") throw IllegalStateException("예상치 못한 메세지 발생")
            return
        }catch (e: Exception){
            throw IllegalStateException("예상치 못한 예외 발생")
        }
        
        throw IllegalStateException("예외가 발생하지 않았습니다.")
    }
}

main에서 모두 코드가 정상 실행된다면 정상적으로 진행된 것이다.

이미 테스트 코드를 경험해본 분이라면 왜 이렇게 작성할까? 라는 생각이 들었을거다 왜냐면 우리에겐 Java쪽에서부터 사용하던 Junit이 있으니까 ㅎㅎ... 이제 테스트 코드를 Junit5를 사용하여 다시 작성해보자!

📄 Junit5 사용하여 테스트 코드 작성 1

class JunitTest {
    companion object{
        @BeforeAll
        @JvmStatic
        fun beforeAll(){
            println("***** 모두 시작 *****")
        }
        @AfterAll
        @JvmStatic
        fun afterAll(){
            println("***** 모두 종료 *****")
        }
    }

    @BeforeEach
    fun beforeEach(){
        println("----- 시작 -----")
    }

    @AfterEach
    fun afterEach(){
        println("----- 종료 -----")
    }
    
    @Test
    fun test1(){
        println("테스트1")
    }
    @Test
    fun test2(){
        println("테스트2")
    }
}

기존에 테스트 코드를 작성하신 분들은 금방 이해하겠지만 kotlin이 조금 다른 부분이 있어서 적어놨다. 대부분 똑같이 작성하면 되지만 @BeforAll @AfterAll 부분이 기존 java 코드와는 조금 달라서 작성해두었다. 두 어노테이션이 붙은 테스트는 java에서는 static으로 관리되어야 했었는데 코틀린에서는 static 대신 companion object를 사용한다고 한다. static과 비슷한 개념이지만 조금 다른 개념도 있으니 모르면 찾아보자! 그리고 @JvmStatic을 사용하여 더 java의 static과 동일하게 사용할 수 있도록 옵션을 주어야한다.

참고 @JvmStatic가 무엇일까?

테스트 결과는 다음과 같이 나오면 정상이다!

프로젝트를 진행하던 도중 println()이 intellij에서 읽지 못하는 오류가 있었다. 이때 해결방법으로 File > Invalidate Caches / Restart를 눌러서 프로젝트를 다시 시작하면 잘 된다.

📄 Junit5 사용하여 테스트 코드 작성 2

이제 이전에 작성했던 테스트를 Junit5를 통하여 다시 작성해보자!

그전에 Assertions에서 사용할 단언문들을 살짝 정리해보자.

assertThat(expect).isEqualsTo(actual)
assertThat(expect).isTrue
assertThat(expect).isFalse

위 단언문을 이용하여 예상값이 실제 값과 맞는지 확인해볼 수 있다.

val people = listOf(Person("A"), Person("B"))
assertThat(people).hasSize(2)
assertThat(people).extracting("name").containsExactlyInAnyOrder("A","B")	//순서 상관없이 체크
assertThat(people).extracting("name").containsExactly("A","B")	// 순서대로 체크

위 단언문을 이용하여 list 값들을 체크할 수 있다.

assertThrows<IllegalArgumentException> {
	function1()
}

val message = assertThrows<IllegalArgumentException> {
	function1()
}.message
assertThat(message).isEqualsTo("잘못된 값이 들어왔습니다.")

function1() 함수를 실행했을 때 발생하는 Exception은 다음과 같이 체크할 수 있다.
message를 사용하여 예외의 메세지값도 가져올 수 있다.

import org.assertj.core.api.Assertions
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import java.lang.IllegalArgumentException

class CalculatorJunitTest {

    @Test
    fun addTest(){
        val calculator = Calculator(5)

        calculator.add(3)

        Assertions.assertThat(calculator.number).isEqualTo(8)
    }

    @Test
    fun minusTest(){
        val calculator = Calculator(5)

        calculator.minus(3)

        Assertions.assertThat(calculator.number).isEqualTo(2)
    }

    @Test
    fun multiplyTest(){
        val calculator = Calculator(5)

        calculator.multiply(3)

        Assertions.assertThat(calculator.number).isEqualTo(15)
    }

    @Test
    fun divideTest(){
        val calculator = Calculator(5)

        calculator.divide(2)

        Assertions.assertThat(calculator.number).isEqualTo(2)
    }

    @Test
    fun divideExceptionTest(){
        //given
        val calculator = Calculator(5)

        //when & then
        val message = assertThrows<IllegalArgumentException> {
            calculator.divide(0)
        }.message

        Assertions.assertThat(message).isEqualTo("0으로 나눌 수 없습니다.")
    }
}

다음과 같이 모두 작성하면 된다.

📄 Service 계층 테스트하기

@SpringBootTest
class UserServiceTest(
    @Autowired private val userRepository: UserRepository,
    @Autowired private val userService: UserService
) {

}

Service 계층을 테스트하기 위해 다음과 같이 의존성을 주입받는 class를 만들었다. 여기서 @Autowired가 중복되어지는 해당 부분은

@SpringBootTest
class UserServiceTest @Autowired constructor(
    private val userRepository: UserRepository,
    private val userService: UserService
) {

}

다음과 같이 수정해서 사용할수 있다.

@SpringBootTest
class UserServiceTest @Autowired constructor(
    private val userRepository: UserRepository,
    private val userService: UserService
) {
    @Test
    fun saveUserTest(){
        //given
        val request = UserCreateRequest("juno", null)
        //when
        userService.saveUser(request)
        //then
        val results = userRepository.findAll()
        Assertions.assertThat(results).extracting("name").containsExactlyInAnyOrder("juno")
    }
}

테스트 코드를 마저 작성한다면 다음과 같이 작성해볼 수 있다.

@SpringBootTest
class UserServiceTest @Autowired constructor(
    private val userRepository: UserRepository,
    private val userService: UserService
) {
    @AfterEach
    fun clean(){
        userRepository.deleteAll()
    }

    @Test
    fun saveUserTest(){
        //given
        val request = UserCreateRequest("juno", 10)
        //when
        userService.saveUser(request)
        //then
        val results = userRepository.findAll()
        Assertions.assertThat(results).extracting("name").containsExactlyInAnyOrder("juno")
    }

    @Test
    fun getUsersTest(){
        //given
        userRepository.saveAll(listOf(
            User("A",20),
            User("B",21)
        ))
        //when
        val results = userService.getUsers()

        //then
        Assertions.assertThat(results).hasSize(2)
        Assertions.assertThat(results).extracting("name").containsExactlyInAnyOrder("A","B")
        Assertions.assertThat(results).extracting("age").containsExactlyInAnyOrder(20,21)
    }

    @Test
    fun updateUserNameTest(){
        //given
        val savedUser = userRepository.save(User("A", 20))
        val request = UserUpdateRequest(savedUser.id, "B")
        //when
        userService.updateUserName(request)
        //then
        val result = userRepository.findAll()[0]
        Assertions.assertThat(result.name).isEqualTo("B")
    }

    @Test
    fun deleteUserTest(){
        //given
        userRepository.save(User("A",20))
        //when
        userService.deleteUser("A")
        //then
        Assertions.assertThat(userRepository.findAll()).isEmpty()
    }
}

모든 테스트를 다 작성하면 다음과 같이 작성해볼 수 있다.

📄 Service 계층 테스트하기 2

import com.group.libraryapp.domain.book.Book
import com.group.libraryapp.domain.book.BookRepository
import com.group.libraryapp.domain.user.User
import com.group.libraryapp.domain.user.UserRepository
import com.group.libraryapp.domain.user.loanhistory.UserLoanHistory
import com.group.libraryapp.domain.user.loanhistory.UserLoanHistoryRepository
import com.group.libraryapp.dto.book.request.BookLoanRequest
import com.group.libraryapp.dto.book.request.BookRequest
import com.group.libraryapp.dto.book.request.BookReturnRequest
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import java.lang.IllegalArgumentException

@SpringBootTest
class BookServiceTest @Autowired constructor(
    private val bookService: BookService,
    private val bookRepository: BookRepository,
    private val userRepository: UserRepository,
    private val userLoanHistoryRepository: UserLoanHistoryRepository,
){

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

    @Test
    @DisplayName("책 등록 정상 동작")
    fun saveBook(){
        //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
        val book = bookRepository.save(Book("이상한 나라의 엘리스"))
        val user = userRepository.save(User("juno", 20))
        val request = BookLoanRequest("juno", "이상한 나라의 엘리스")
        //when
        bookService.loanBook(request)
        //then
        val results = userLoanHistoryRepository.findAll()
        assertThat(results).hasSize(1)
        assertThat(results[0].bookName).isEqualTo("이상한 나라의 엘리스")
        assertThat((results[0].user.id)).isEqualTo(user.id)
        assertThat((results[0].isReturn)).isFalse
    }

    @Test
    @DisplayName("책이 이미 대출되어 있다면, 신규 대출이 실패한다.")
    fun loanBookFailTest(){
        //given
        val book = bookRepository.save(Book("이상한 나라의 엘리스"))
        val user = userRepository.save(User("juno", 20))
        //이미 대출한 데이터 입력
        userLoanHistoryRepository.save(UserLoanHistory(user, "이상한 나라의 엘리스", false))
        val request = BookLoanRequest("juno", "이상한 나라의 엘리스")
        //when & then
        assertThrows<IllegalArgumentException> {
            bookService.loanBook(request)
        }.apply {
            assertThat(message).isEqualTo("진작 대출되어 있는 책입니다")
        }
    }

    @Test
    @DisplayName("책 반납이 정상 동작한다")
    fun returnBookTest(){
        //given
        val book = bookRepository.save(Book("이상한 나라의 엘리스"))
        val user = userRepository.save(User("juno", 20))
        //이미 대출한 데이터 입력
        userLoanHistoryRepository.save(UserLoanHistory(user, "이상한 나라의 엘리스", false))
        val request = BookReturnRequest(user.name, book.name)

        //when
        bookService.returnBook(request)

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

이미 테스트 코드를 잘 작성하는 부들이면 참고할 필요가 없지만 코틀린을 사용하여 코드를 어떻게 짜는지 더 확인하고 싶은 사람들을 위해 작성해두었다.

또한 예외를 테스트하는 코드를 보면 .apply 부분이 있는데 해당 부분은 코틀린의 스코프 함수이다.

참고 [Kotlin] 코틀린 표준 스코프 함수 정리

profile
코딩을 깔끔하게 하고 싶어하는 초보 개발자 (편하게 글을 쓰기위해 반말체를 사용하고 있습니다! 양해 부탁드려요!) 현재 KakaoVX 근무중입니다!

0개의 댓글