[Java to Kotlin] 코틀린에서 테스트 코드(Junit, AssertJ, Spring Boot-Layered Architecture)

bebeis·2025년 11월 15일

자바 프로젝트에 kotlin 코드 작성

Kotlin 코드를 작성하기 위해 build.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"
    }
}

검증 대상 기능: 계산기

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
  }
}

테스트 클래스는 src > test > kotlin > com > group > libraryapp > calculatorCalculatorTest로 만들어주자. (프로덕션 코드와 패키지 구조를 맞춰주는게 컨벤션이다)

Junit 없이 순수 코틀린으로 테스트하기

테스트 케이스

given-when-then 패턴을 예로 들겠다.

  • 5 + 3 = 8

결과를 검증하는 방법

크게 2가지가 있다.
1. data class의 equals 를 사용하는 방법 (자동 생성)

data class Calculator(
  private var number: Int,
) {
  // 생략...
}

fun addTest() {
  val calculator = Calculator(5)
  calculator.add(3)
  
  val expectedResult = Calculator(8)
  if (calculator != expectedResult) {
    throw IllegalStateException()
  }
}
  1. Calculator로부터 number를 가져오는 방법

    package com.group.libraryapp.calculator
    
    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
        }
    }
    • 원래는 Calculator가 private number를 가지고 있었다.
    • 따라서 number의 get 프로퍼티를 열어줘야 한다.
    • 프로퍼티 자체를 public으로 열어둘 수도 있지만, setter를 사용할 수 있다는 단점이 있다.
      • 이를 위해, 원본 자체는 _xxx로 private으로 선언하고, xxx 커스텀 프로퍼티로 _xxx 값을 클래스 내부에서 반환해준다.
      • 이를 Kotlin에서는 backing property 라고 하며, backing property에는 _ 를 쓰는 것이 컨벤션이다.
    • public 프로퍼티를 사용하는 방법은 setter가 노출된다는 단점이 있고, backing property를 사용하는 방법은 코드가 장황해진다는 단점이 있다.
    fun addTest() {
      val calculator = Calculator(5)
      calculator.add(3)
      
      if (calculator.number != 8) {
        throw IllegalStateException()
      }
    }
    • data class를 사용하는 것 보다 코드가 간결해진다.

예외 검증

  1. 0이 아닌 다른 숫자를 넣었을때 나눗셈이 정상적으로 동작하는지
  2. 0을 넣었을때 우리가 의도한 예외가 발생하는지
    를 검증해야 한다.
fun divideTest() {
  // given
  val calculator = Calculator(5)
  
  // when
  calculator.divide(2)
  
  // then
  if (calculator.number != 2) {
    throw IllegalStateException()
  }
}
fun divideExceptionTest() {
  // given
  val calculator = Calculator(5)
  
  // when & then
  try {
    calculator.divide(0)
  } catch(e: java.lang.IllegalArgumentException) {
    // 테스트 통과
    return
  } catch(e: Exception) {
    throw IllegalStateException()
  }
  throw IllegalStateException("기대하는 예외가 발생하지 않았습니다.")
}

추가적으로 메시지를 확인해줄 수도 있다. 첫 번째 catch에 한 번 if문을 중첩해 처리하는 방식이다.

try {
  calculator.divide(0)
} catch(e: IllegalArgumentException) {
  if (e.message != "0으로 나눌 수 없습니다") {
    throw IllegalStateException("메시지가 다릅니다.")
  }
  return
}

이제 만든 테스트를 모두 메인 메서드에서 실행하여 예외가 발생하는지의 유무로 테스트 통과/실패를 검증할 수 있다.

fun main() {
	addTest()
	minusTest()
	multiplyTest()
	divideTest()
	divideExceptionTest()
}

수동으로 만든 테스트 코드의 단점

  1. 테스트 클래스와 메소드가 생길 때마다 메인 메소드에 수동으로 코드를 작성해주어야 하고, 메인 메소드가 아주 커진다. 테스트 메소드를 개별적으로 실행하기도 어렵다.
  2. 테스트가 실패한 경우 무엇을 기대하였고, 어떤 잘못된 값이 들어와 실패했는지 알려주지 않는다. 예외를 던지거나, try catch를 사용해야 하는 등 직접 구현해야 할 부분이 많아 불편하다.
  3. 테스트 메소드별로 공통적으로 처리해야 하는 기능이 있다면, 메소드마다 중복이 생긴다. (예: 매번 Calculator(5) 생성)

Junit5 && assertJ로 테스트 코드 리팩토링

Junit5

자바에서 사용되는 어노테이션과 사용법/동작이 동일하다. 다만, @BeforeAll@AfterAll의 경우 static 메서드 대상이므로 코틀린에서 사용하기 위해서는 companion object, @JvmStatic을 사용해야 한다.

class JunitTest {

  companion object {
    @JvmStatic
    @BeforeAll
    fun beforeAll() {
      println("모든 테스트 시작 전")
    }
    
    @JvmStatic
    @AfterAll
    fun afterAll() {
      println("모든 테스트 실행 후")
    }
  }

  @BeforeEach
  fun beforeEach() {
    println("각 테스트 시작 전")
  }

  @AfterEach
  fun afterEach() {
    println("각 테스트 실행 후")
  }

  @Test
  fun test1() {
    println("테스트 1")
  }

  @Test
  fun test2() {
    println("테스트 2")
  }
}

assertJ - 단언문(assert문)

주요 assert문은 다음과 같다.

  • 주어진 값이 true 인지 / false인지 검증한다.

    val isNew = true
    assertThat(isNew).isTrue
    assertThat(isNew).isFalse
  • 주어진 컬렉션의 size가 원하는 값인지 검증한다.

    val people = listOf(Person("A"), Person("B"))
    assertThat(people).hasSize(2)
  • 주어진 컬렉션 안의 item 들에서 name 이라는 프로퍼티를 추출한 후 (extracting), 그 값을 검증한다. (이때 순서는 중요하지 않다)

    val people = listOf(Person("A"), Person("B"))
    assertThat(people).extracting("name").containsExactlyInAnyOrder("A", "B")
  • 이때 순서도 중요하다.

    val people = listOf(Person("A"), Person("B"))
    assertThat(people).extracting("name").containsExactly("A", "B")
  • 함수(function1())를 실행했을 때 원하는 예외가 나오는지 검증한다.

    assertThrows<IllegalArgumentException> {
      function1()
    }
  • 예외 메시지까지 검증할 수 있다.

    val message = assertThrows<IllegalArgumentException> {
      function1()
    }.message
    assertThat(message).isEqualTo("잘못된 값이 들어왔습니다")
    • assertThrows는 함수를 인자로 받아서[function1()], 예외 객체를 반환해준다.
    • 따라서 .message로 메시지 필드를 확인할 수 있는 것이다.
  • scope function을 활용하면 예외에 대한 메시지 검증을 아래와 같이 리팩토링 할 수도 있다.
        // when & then
        assertThrows<IllegalArgumentException> {
          calculator.divide(0)
        }.apply {
          assertThat(message).isEqualTo("0으로 나눌 수 없습니다")
        }

서브 미션

  • 자바로 작성된 테스트 코드를 코틀린으로 변환한다.
  • 자바로 작성된 클래스를 코틀린에서 사용한다.

Service Layer

UserSerivceTest

@SpringBootTest
class UserServiceTest @Autowired constructor(
  private val userService: UserService,
  private val userRepository: UserRepository,
) {
  
  @Test
  fun saveUserTest() {
    // given
    val request = UserCreateRequest("bebe", null)
    
    // when
    userService.saveUser(request)
    
    // then
    val users = userRepository.findAll()
    assertThat(users).hasSize(1)
    assertThat(users[0].name).isEqualTo("bebe")
    assertThat(users[0].age).isNull()
  }
}
  • 자바와 크게 다른 부분이 없지만 신경써야 하는 부분은 다음과 같다.
    • UserServiceTest 클래스가 com.group.libraryapp.service.user 에 있어야 어노테이션 만으로 동작한다.
    • 프로퍼티에 @Autowired를 각각 모두 붙여줄 수 있지만, constructor(...) 앞에 @Autowired를 붙여줌으로써 의존성 주입을 한 번에 편하게 해줄 수 있다.

위 테스트 코드를 실행하게 되면 에러가 발생하는 부분이 존재한다.

  • users[0].age must not be null

User getAge()가 Java 타입으로 Integer라고 되어 있는데 (즉, 플랫폼 타입이다.) Kotlin 입장에서는 이 Int가 nullable인지 non-nullable인지 몰라 users[0].age 라고만 타이핑한 경우 null이 아닌 변수에 age를 담으려고 한다. 하지만 실제 값은 null이었기 때문에 null을 non-null 변수에 넣으려가 에러가 난 것이다!
(1일차에 공부했던 플랫폼 타입 개념이다.)

User 클래스의 getter 2개에 Annotation을 붙여주면된다.
name은 null이 불가능하니 @NotNull 을 붙여주고, age는 null이 가능하니 @Nullable 을 붙여주자.

  @NotNull
  public String getName() {
    return name;
  }

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

그후 테스트를 다시 돌리면, Kotlin이 어노테이션을 참고해 변수를 할당해준다. 즉, nullable한 변수인지 아닌지 판단하여 코틀린 타입으로 잘 변환해준다!

  @Test
  fun getUsersTest() {
    // given
    userRepository.saveAll(listOf(
      User("A", 20),
      User("B", null),
    ))
    
    // when
    val results = userService.getUsers()
    
    // then
    assertThat(results).hasSize(2)
    assertThat(results).extracting("name").containsExactlyInAnyOrder("A", "B")
    assertThat(results).extracting("age").containsExactlyInAnyOrder(20, null)
  }
  • userService.getUsers()는 코틀린 프로퍼티가 아닌, 서비스 계층의 메서드이므로 프로퍼티처럼 접근하지 않도록 주의하자.

테스트 격리

전체 테스트를 실행해보니 에러가 난다. 그 이유는 두 테스트가 Spring Context를 공유하기 때문이다.
같은 H2 DB를 공유하기 때문에 서로의 테스트에 영향을 미치게 된다. 테스트가 끝나면 공유 자원을 지워줘야 한다.

  @AfterEach
  fun clean() {
    userRepository.deleteAll()
  }
  • 또는 @Transactional을 사용할 수도 있다. 다만, 서비스 계층에서 트랜잭션을 시작하고 끝내는 경우가 많은데 테스트에서 @Transactional을 써버리면 트랜잭션 전파등을 완전히 검증하기 어렵다고 생각한다.
  @Test
  fun updateUserNameTest() {
    // given
    val savedUser = userRepository.save(User("A", null))
    val request = UserUpdateRequest(savedUser.id!!, "B")
    
    // when
    userService.updateUserName(request)
    
    // then
    val result = userRepository.findAll()[0]
    assertThat(result.name).isEqualTo("B")
  }
  • 엔티티의 최초 ID는 null일 수 있으니 @Nullable로 처리해주고, save하면 null이 아니므로 !!(단언)을 사용했다.

Book Service 테스트

코틀린 trailing comma

@SpringBootTest
class BookServiceTest @Autowired constructor(
  private val bookService: BookService,
  private val bookRepository: BookRepository,
  private val userRepository: UserRepository, 
  private val userLoanHistoryRepository: UserLoanHistoryRepository,
) {
  • 코틀린에선 trailing comma라고 해서, 누군가 뒤에 필드를 추가할 때 그 부분만 git diff를 보여주기 위해 마지막 필드에 ,를 허용한다.

배운점 정리

  • UserServiceTest 클래스가 com.group.libraryapp.service.user 에 있어야 어노테이션 만으로 동작한다.
  • 프로퍼티에 @Autowired를 각각 모두 붙여줄 수 있지만, constructor(...) 앞에 @Autowired를 붙여줌으로써 의존성 주입을 한 번에 편하게 해줄 수 있다.
  • 자바 클래스를 가져다 사용할 때, 플랫폼 타입에 유의하자.
  • 테스트간 격리성을 보장하자.
  • kotlin은 trailing comma를 통해 git diff가 깔끔하게 처리되도록 한다.

추가)

Controller 테스트

kotlin-Jackson 라이브러리

Jackson이 코틀린 객체를 정상적으로 생성하도록 해준다.

implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.13.3'

Request DTO의 nullability와 JSON 스펙이 1:1로 이어진다

data class CreateUserRequest(
    val name: String,       // 필수
    val age: Int? = null,   // 선택
)
  • name은 JSON에 없으면 안 됨
  • age는 아예 빼도 되고, null도 허용

Controller 테스트를 작성할 때 nullability를 놓치는 경우 실패할 수 있다.
Java에서는 그냥 “필드 없으면 null” 느낌으로 넘어가던 걸,
Kotlin에서는 non-null / nullable로 강제하니까, 테스트 JSON을 쓸 때도 이걸 계속 의식해야 해야 한다.

Kotlin DSL

Spring MockMvc의 Kotlin 확장 기능도 이 Kotlin DSL 형식으로 제공된다고 한다.

package org.springframework.test.web.servlet.result

public final class StatusResultMatchersDsl internal constructor(actions: org.springframework.test.web.servlet.ResultActions) {
    private final val actions: org.springframework.test.web.servlet.ResultActions /* compiled code */ /* hasBackingField: true */

    private final val matchers: org.springframework.test.web.servlet.result.StatusResultMatchers /* compiled code */ /* hasBackingField: true */

    public final fun is1xxInformational(): kotlin.Unit { /* compiled code */ }

    public final fun is2xxSuccessful(): kotlin.Unit { /* compiled code */ }

    public final fun is3xxRedirection(): kotlin.Unit { /* compiled code */ }

    public final fun is4xxClientError(): kotlin.Unit { /* compiled code */ }

    public final fun is5xxServerError(): kotlin.Unit { /* compiled code */ }

	// ....
mockMvc.perform(post("/users")
    .content("{\"name\":\"bebe\"}")
    .contentType(MediaType.APPLICATION_JSON))
    .andExpect(status().isOk());

자바로 이렇게 작성하던 코드를 아래와 같이 변경할 수 있다.

mockMvc.post("/users") {
    content = objectMapper.writeValueAsString(UserCreateRequest("bebe", null))
    contentType = MediaType.APPLICATION_JSON
}.andExpect {
    status { isOk() }
}

Repository 테스트

findByIdOrNull

@Entity
class User(
    @Column(nullable = false)
    var name: String,

    var age: Int? = null,

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long? = null,
)
@DataJpaTest
class UserRepositoryTest @Autowired constructor(
    private val userRepository: UserRepository,
) {

    @Test
    fun `사용자 저장 및 조회`() {
        val saved = userRepository.save(User("A", null))

        val found = userRepository.findByIdOrNull(saved.id!!)
        assertThat(found).isNotNull
        assertThat(found!!.name).isEqualTo("A")
    }
}
  • Optional을 사용하지 않아도 된다.

플러그인

서비스 테스트 때처럼

  • kotlin-jpa (no-arg)
  • kotlin-spring (open)
    을 쓰면, 레포지토리 테스트도 편해진다.

테스트 격리: @DataJpaTest 기본 롤백 vs 직접 deleteAll()

@DataJpaTest는 기본적으로 각 테스트마다 트랜잭션 + 롤백이 걸려서 격리가 된다.

@DataJpaTest
class UserRepositoryTest @Autowired constructor(/* ... */) {
    // 별도 clean() 없어도 된다.
}
profile
단순함은 복잡함을 이긴다.

0개의 댓글