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 > calculator 에 CalculatorTest로 만들어주자. (프로덕션 코드와 패키지 구조를 맞춰주는게 컨벤션이다)
given-when-then 패턴을 예로 들겠다.
크게 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()
}
}
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
}
}
_xxx로 private으로 선언하고, xxx 커스텀 프로퍼티로 _xxx 값을 클래스 내부에서 반환해준다._ 를 쓰는 것이 컨벤션이다.fun addTest() {
val calculator = Calculator(5)
calculator.add(3)
if (calculator.number != 8) {
throw IllegalStateException()
}
}
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()
}
try catch를 사용해야 하는 등 직접 구현해야 할 부분이 많아 불편하다.Calculator(5) 생성)자바에서 사용되는 어노테이션과 사용법/동작이 동일하다. 다만, @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")
}
}
주요 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로 메시지 필드를 확인할 수 있는 것이다. // when & then
assertThrows<IllegalArgumentException> {
calculator.divide(0)
}.apply {
assertThat(message).isEqualTo("0으로 나눌 수 없습니다")
}@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 nullUser 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")
}
@Nullable로 처리해주고, save하면 null이 아니므로 !!(단언)을 사용했다.@SpringBootTest
class BookServiceTest @Autowired constructor(
private val bookService: BookService,
private val bookRepository: BookRepository,
private val userRepository: UserRepository,
private val userLoanHistoryRepository: UserLoanHistoryRepository,
) {
UserServiceTest 클래스가 com.group.libraryapp.service.user 에 있어야 어노테이션 만으로 동작한다.@Autowired를 각각 모두 붙여줄 수 있지만, constructor(...) 앞에 @Autowired를 붙여줌으로써 의존성 주입을 한 번에 편하게 해줄 수 있다.추가)
Jackson이 코틀린 객체를 정상적으로 생성하도록 해준다.
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.13.3'
data class CreateUserRequest(
val name: String, // 필수
val age: Int? = null, // 선택
)
Controller 테스트를 작성할 때 nullability를 놓치는 경우 실패할 수 있다.
Java에서는 그냥 “필드 없으면 null” 느낌으로 넘어가던 걸,
Kotlin에서는 non-null / nullable로 강제하니까, 테스트 JSON을 쓸 때도 이걸 계속 의식해야 해야 한다.
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() }
}
@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")
}
}
서비스 테스트 때처럼
@DataJpaTest는 기본적으로 각 테스트마다 트랜잭션 + 롤백이 걸려서 격리가 된다.
@DataJpaTest
class UserRepositoryTest @Autowired constructor(/* ... */) {
// 별도 clean() 없어도 된다.
}