테스트 코드는 어플리케이션의 동작을 보장해 준다.
또한, 잘 작성한 테스트 코드는 그 자체로 문서화가 된다.
TDD 방법론으로 개발을 진행하면, 실제 동작하는 클래스를 구현할 수 없으니, Mock 또는 Fake 객체 같은 모의 객체를 사용한다.
굳이 TDD가 아니더라도, 통합 테스트가 아닌 단위 테스트, 슬라이스 테스트같이 외부 의존성에 대해 강하게 결합된 의존을 끊고 빠르게 핵심을 테스트하기 위해 모의 객체를 사용한다.
테스트 코드에는 한 파일 내부에 여러 테스트를 작성하는데, 이때 모의 객체에 정의한 Stub이 다른 테스트에 영향을 줄 수 있다.
즉, 테스트의 격리성이 보장되지 않는다.
스프링을 사용한 통합 테스트에서는 @Transcational
어노테이션을 사용하면 롤백이 되기 때문에 테스트의 격리성이 보장되지만, 스프링을 사용하지 않는 일반적인 단위 테스트의 경우 이러한 격리성을 보장받지 못한다.
그렇다면 어떻게 테스트의 격리성을 지켜야 할까?
다음과 같은 코틀린 테스트 코드가 있다.
class KotestTest: StringSpec({
val memberRepository = MemoryMemberRepository()
"회원의 수를 조회한다. 1" {
// given
val member = Member("1", SocialType.LOCAL, "seokjin8678")
// when
memberRepository.save(member)
// then
memberRepository.countBy() shouldBe 1
}
"회원의 수를 조회한다. 2" {
// given
val member = Member("1", SocialType.LOCAL, "seokjin8678")
// when
memberRepository.save(member)
// then
memberRepository.countBy() shouldBe 1
}
})
해당 테스트를 따로 실행시킨다면 문제가 발생하지 않고 통과할 것이다.
하지만 동시에 모든 테스트를 실행하면 둘 중 하나의 테스트는 실패한다.
이유는 테스트에서 사용되는 memberRepository
가 공유되기 때문이다.
하지만 Kotest
가 아닌 JUnit5
환경에서 실행하면 테스트는 실패하지 않는다.
class JUnitTest {
private val memberRepository = MemoryMemberRepository()
@Test
fun `회원의 수를 조회한다 1`() {
// given
val member = Member("1", SocialType.LOCAL, "seokjin8678")
// when
memberRepository.save(member)
// then
memberRepository.countBy() shouldBe 1
}
@Test
fun `회원의 수를 조회한다 2`() {
// given
val member = Member("1", SocialType.LOCAL, "seokjin8678")
// when
memberRepository.save(member)
// then
memberRepository.countBy() shouldBe 1
}
}
이유는 기본 JUnit5
환경에서는 기본으로 테스트 메서드마다 새로운 테스트 인스턴스를 생성하기 때문이다.
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
어노테이션을 사용하여 하나의 테스트 인스턴스를 사용하게 할 수 있다.
하지만 Kotest
환경에서는 기본으로 단 하나의 테스트 인스턴스를 생성하기 때문에 JUnit5
와 다른 테스트 결과가 발생했고 격리성이 보장되지 않는다.
따라서 이 문제를 해결하기 가장 단순하고 쉬운 방법은 memberRepository
변수를 테스트 블럭안에 선언하는 것이다.
"회원을 저장한다." {
// given
val memberRepository = MemoryMemberRepository()
val member = Member("1", SocialType.LOCAL, "seokjin8678")
// when
memberRepository.save(member)
// then
memberRepository.findById(member.id) shouldBe member
}
"회원의 수를 조회한다." {
// given
val memberRepository = MemoryMemberRepository()
val member = Member("1", SocialType.LOCAL, "seokjin8678")
// when
memberRepository.save(member)
// then
memberRepository.countBy() shouldBe 1
}
하지만 이 방법을 사용하게 되면, 테스트 코드마다 memberRepository
를 중복적으로 선언하게 되는 문제가 발생한다.
이 문제는 JUnit5
를 사용해 본 경험이 있다면 간단하게 해결할 수 있다.
바로 @AfterEach
, @BeforeAll
어노테이션을 사용한 테스트 프레임워크의 생명 주기 훅을 사용하면 된다.
@BeforeEach
fun setUp() {
memberRepository.deleteAll()
}
Kotest
는 JUnit5
에서 제공하는 훅과 다르게 어노테이션을 사용하지 않고 beforeTest()
, beforeAny()
, prepareSpec()
과 같은 다양한 훅을 제공한다.
AnnotationSpec
을 사용하면JUnit5
의 어노테이션을 사용할 수 있다.
Kotest
의 다양한 훅을 이해하기 이전에 TestType
에 대해 알아야 할 필요가 있다.
Kotest
에서는 JUnit5
의 @Nested
와 마찬가지로 테스트 클래스 내부에 중첩으로 테스트를 만들 수 있는 기능을 제공한다.
DescribeSpec
기준 describe()
, context()
로 정의한다.
해당 테스트를 Container
라고 부르며 타입은 TestType.Container
이다.
그리고 일반 테스트의 타입은 TestType.Test
이다.
테스트 스타일마다, Container
와 Test
타입의 테스트를 선언하는 방법이 다르니 사용하는 테스트 스타일의 문서를 확인하자.
Container
와 Test
모두 검증 로직을 수행할 수 있다.
하지만 Test
타입은 내부에 Test
를 선언할 수 없다.
class KotestTest : DescribeSpec({
describe("Container - Test Case 포함 O") {
println(this.testCase.type) // Container
it("Test") {
println(this.testCase.type) // Test
1 shouldBe 1
}
}
describe("Container - Test Case 포함 X") {
println(this.testCase.type) // Container
1 shouldBe 1
}
it("Test Case") {
println(this.testCase.type) // Test
1 shouldBe 1
// it("InvalidDslException 예외가 발생한다.") {
// 1 shouldBe 1
// }
}
})
참고로 TestType
에는 Container
, Test
말고도 Dynamic
도 존재한다.
Property-based Testing 또는 Data-driven Testing을 사용하면 Dynamic 타입이 된다.
// Data-driven Testing
// "kotest-framework-datatest" 모듈을 추가해야 withDate() 메서드를 사용할 수 있다.
describe("Dynamic") {
println(this.testCase.type) // Container
withData(
nameFn = { "Dynamic $it" },
1 to "1",
2 to "2"
) {
println(this.testCase.type) // Dynamic
it.first.toString() shouldBe it.second
}
}
여기서 중요한 것은 TestType
에 상관 없이, 테스트 메서드는 테스트 라는 점이다!
Kotest
의 Lifecycle hook은 다음과 같은 종류가 있다.
Callback | 설명 |
---|---|
beforeContainer | Type이 Container 인 테스트가 실행되기 전에 실행된다. |
afterContainer | Type이 Container 인 테스트가 실행된 후에 실행된다.테스트가 실패하더라도 실행된다. |
beforeEach | Type이 Test 인 테스트가 실행되기 전에 실행된다. |
afterEach | Type이 Test 인 테스트가 실행된 후에 실행된다.테스트가 실패하더라도 실행된다. |
beforeAny | Type에 상관 없이 테스트가 실행되기 전에 실행된다. |
afterAny | Type에 상관 없이 테스트가 실행되기 전에 실행된다. 테스트가 실패하더라도 실행된다. |
beforeTest | beforeAny 와 같은 동작을 한다. |
afterTest | afterAny 와 같은 동작을 한다. |
beforeSpec | 테스트가 실행되는 인스턴스 이전에 실행된다. IsolationMode가 SingleInstance 인 경우, prepareSpec 과 같은 동작을 한다. |
afterSpec | 테스트가 실행되는 인스턴스 이후에 실행된다. IsolationMode가 SingleInstance 인 경우, finalizeSpec 과 같은 동작을 한다.해당 Callback에서 예외가 발생하면 이후 beforeSpec , AfterSpec 은 스킵된다. |
prepareSpec | 테스트 실행 전 단 한 번 실행된다. 인스턴스 횟수와 상관 없이 해당 콜백은 한 번만 호출된다. 테스트 케이스가 없어도 해당 콜백은 실행된다. IsolationMode가 SingleInstance 인 경우, beforeSpec 과 같은 동작을 한다. |
finalizeSpec | 모든 테스트가 실행되고 단 한 번 실행된다. 인스턴스 횟수와 상관 없이 해당 콜백은 한 번만 호출된다. IsolationMode가 SingleInstance 인 경우, afterSpec 과 같은 동작을 한다. |
beforeInvocation | 테스트가 invocation 설정을 통해 실행되기 전 실행된다.invocation 설정이 없으면 beforeTest 와 같은 동작을 한다. |
afterInvocation | 테스트가 invocation 설정을 통해 실행된 후 실행된다.invocation 설정이 없으면 afterTest 와 같은 동작을 한다. |
Kotest 5.7.2 기준
prepareSpec
훅 메서드가 존재하지 않아서 사용할 수 없었다. 😂
다음과 같이 Hook을 정의하고 테스트 코드를 실행하면 결과는 다음과 같다.
class KotestTest: FunSpec({
beforeAny {
println("beforeAny: ${it.name.testName}")
}
afterAny {
println("afterAny: ${it.a.name.testName}")
}
beforeTest {
println("beforeTest: ${it.name.testName}")
}
afterTest {
println("afterTest: ${it.a.name.testName}")
}
beforeEach {
println("beforeEach: ${it.name.testName}")
}
afterEach {
println("afterEach: ${it.a.name.testName}")
}
beforeContainer {
println("beforeContainer: ${it.name.testName}")
}
afterContainer {
println("afterContainer: ${it.a.name.testName}")
}
beforeSpec {
println("beforeSpec: $it")
}
afterSpec {
println("afterSpec: $it")
}
finalizeSpec {
println("finalizeSpec: ${it.a.simpleName}")
}
test("Test") {
println(this.testCase.name.testName)
1 shouldBe 1
}
})
beforeSpec: kr.galaxyhub.sc.KotestTest@7ae211f5
beforeEach: Test
beforeAny: Test
beforeTest: Test
Test
afterTest: Test
afterAny: Test
afterEach: Test
afterSpec: kr.galaxyhub.sc.KotestTest@7ae211f5
finalizeSpec: KotestTest
그리고 Container
에 여러 테스트를 정의하고 실행했을 때 결과는 다음과 같다.
class KotestTest: FunSpec({
// 위에서 정의한 Hooks 포함
context("Container") {
println(this.testCase.name.testName)
test("Inner Test 1") {
println(this.testCase.name.testName)
1 shouldBe 1
}
test("Inner Test 2") {
println(this.testCase.name.testName)
1 shouldBe 1
}
}
})
beforeSpec: kr.galaxyhub.sc.KotestTest@245583c7
beforeContainer: Container
beforeAny: Container
beforeTest: Container
Container
beforeEach: Inner Test 1
beforeAny: Inner Test 1
beforeTest: Inner Test 1
Inner Test 1
afterTest: Inner Test 1
afterAny: Inner Test 1
afterEach: Inner Test 1
beforeEach: Inner Test 2
beforeAny: Inner Test 2
beforeTest: Inner Test 2
Inner Test 2
afterTest: Inner Test 2
afterAny: Inner Test 2
afterEach: Inner Test 2
afterTest: Container
afterAny: Container
afterContainer: Container
afterSpec: kr.galaxyhub.sc.KotestTest@245583c7
finalizeSpec: KotestTest
다양한 Lifecycle Hook을 정의하여 테스트 간의 격리성을 보장할 수 있다.
하지만 JUnit5
환경에서는 굳이 Hook을 정의하지 않아도 됐었고, Hook 중에서 IsolationMode
라는 용어가 자주 나왔다.
위에서 언급했지만, Kotest
는 테스트 클래스를 기본으로 단 하나의 인스턴스만 생성하는데, JUnit5
환경처럼 테스트마다 새로운 인스턴스를 생성할 수 있다면 굳이 Hook을 정의하지 않아도 테스트의 격리성을 보장할 수 있다.
Kotest
는 IsolationMode
를 제공하며, IsolationMode
를 커스텀하여 JUnit5
환경과 같이 테스트 인스턴스를 테스트마다 생성하여 격리성을 보장시킬 수 있다.
IsolationMode
는 Enum이며, 다음과 같은 값이 존재한다.
Kotest
의 기본 IsolationMode
은 SingleInstance
이다.
따라서 위의 결과를 보면 단 하나의 인스턴스만 생성된 것을 볼 수 있다.
그렇다면 IsolationMode
를 JUnit5
와 같이 설정하려면 InstancePerTest
를 사용하면 될까..?
IsolationMode
는 프로퍼티를 재설정하거나, 메서드를 재정의하여 커스텀 할 수 있다.
class KotestTest : FunSpec({
isolationMode = IsolationMode.InstancePerTest
...
})
또는
class KotestTest : FunSpec({
...
} {
override fun isolationMode(): IsolationMode = IsolationMode.InstancePerTest
}
그리고 다음과 같이 두 개의 테스트가 있을 때 실행 결과는 다음과 같다.
class KotestTest: FunSpec({
// 위에서 정의한 Hooks 포함
test("Test 1") {
println(this.testCase.name.testName)
1 shouldBe 1
}
test("Test 2") {
println(this.testCase.name.testName)
1 shouldBe 1
}
})
beforeSpec: kr.galaxyhub.sc.KotestTest@17c634f5
beforeEach: Test 1
beforeAny: Test 1
beforeTest: Test 1
Test 1
afterTest: Test 1
afterAny: Test 1
afterEach: Test 1
afterSpec: kr.galaxyhub.sc.KotestTest@17c634f5
beforeSpec: kr.galaxyhub.sc.KotestTest@28cd7ad2
beforeEach: Test 2
beforeAny: Test 2
beforeTest: Test 2
Test 2
afterTest: Test 2
afterAny: Test 2
afterEach: Test 2
afterSpec: kr.galaxyhub.sc.KotestTest@28cd7ad2
finalizeSpec: KotestTest
finalizeSpec: KotestTest
결과를 보면 테스트 마다 다른 인스턴스를 사용하는 것을 볼 수 있다.
만약, Container
에 포함된 여러 테스트라면 어떤 결과가 나올까?
class KotestTest: FunSpec({
// 위에서 정의한 Hooks
context("Container") {
println(this.testCase.name.testName)
test("Inner Test 1") {
println(this.testCase.name.testName)
1 shouldBe 1
}
test("Inner Test 2") {
println(this.testCase.name.testName)
1 shouldBe 1
}
}
})
beforeSpec: kr.galaxyhub.sc.KotestTest@4e02846c
beforeContainer: Container
beforeAny: Container
beforeTest: Container
Container
beforeSpec: kr.galaxyhub.sc.KotestTest@6b0562b2
beforeContainer: Container
beforeAny: Container
beforeTest: Container
Container
beforeEach: Inner Test 1
beforeAny: Inner Test 1
beforeTest: Inner Test 1
Inner Test 1
afterTest: Inner Test 1
afterAny: Inner Test 1
afterEach: Inner Test 1
afterTest: Container
afterAny: Container
afterContainer: Container
afterSpec: kr.galaxyhub.sc.KotestTest@6b0562b2
beforeSpec: kr.galaxyhub.sc.KotestTest@6757205a
beforeContainer: Container
beforeAny: Container
beforeTest: Container
Container
beforeEach: Inner Test 2
beforeAny: Inner Test 2
beforeTest: Inner Test 2
Inner Test 2
afterTest: Inner Test 2
afterAny: Inner Test 2
afterEach: Inner Test 2
afterTest: Container
afterAny: Container
afterContainer: Container
afterSpec: kr.galaxyhub.sc.KotestTest@6757205a
afterTest: Container
afterAny: Container
afterContainer: Container
afterSpec: kr.galaxyhub.sc.KotestTest@4e02846c
finalizeSpec: KotestTest
finalizeSpec: KotestTest
finalizeSpec: KotestTest
결과를 보면 약간 의아한 결과가 나왔다.
테스트는 2개인데, 총 3개의 인스턴스가 생성되었다.
왜냐하면 TestType이 Container
인 테스트도 테스트이기 때문이다.
따라서 의도하든, 의도치 않든 Container
도 새로운 인스턴스로 테스트가 생성되었다.
테스트마다 인스턴스를 새로 생성하여 격리성을 보장받고 있는데, Container
를 새롭게 인스턴스로 생성할 필요가 있을까?
그렇다면 Container
를 제외하고 테스트 케이스에만 인스턴스를 생성하려면 어떻게 해야 할까?
InstancePerLeaf
를 사용하면 각 테스트 케이스 마다 인스턴스를 생성하게 할 수 있다.
따라서 위 테스트의 IsolationMode
을 InstancePerLeaf
로 설정하고 실행하면 다음과 같은 결과가 나온다.
beforeSpec: kr.galaxyhub.sc.KotestTest@7a0c750a
beforeContainer: Container
beforeAny: Container
beforeTest: Container
Container
beforeEach: Inner Test 1
beforeAny: Inner Test 1
beforeTest: Inner Test 1
Inner Test 1
afterTest: Inner Test 1
afterAny: Inner Test 1
afterEach: Inner Test 1
afterTest: Container
afterAny: Container
afterContainer: Container
afterSpec: kr.galaxyhub.sc.KotestTest@7a0c750a
beforeSpec: kr.galaxyhub.sc.KotestTest@2fc729bd
beforeContainer: Container
beforeAny: Container
beforeTest: Container
Container
beforeEach: Inner Test 2
beforeAny: Inner Test 2
beforeTest: Inner Test 2
Inner Test 2
afterTest: Inner Test 2
afterAny: Inner Test 2
afterEach: Inner Test 2
afterTest: Container
afterAny: Container
afterContainer: Container
afterSpec: kr.galaxyhub.sc.KotestTest@2fc729bd
finalizeSpec: KotestTest
finalizeSpec: KotestTest
결과를 보면 우리가 원하던 2개의 테스트 인스턴스가 생성된 것을 볼 수 있다.
여기서 추가로, Container
에 Container
가 있는 경우 인스턴스는 몇 개 생성될까?
context("Container") {
println(this.testCase.name.testName)
context("Inner Container") {
println(this.testCase.name.testName)
1 shouldBe 1
}
}
beforeSpec: kr.galaxyhub.sc.KotestTest@36aeb1eb
beforeContainer: Container
beforeAny: Container
beforeTest: Container
Container
beforeContainer: Inner Container
beforeAny: Inner Container
beforeTest: Inner Container
Inner Container
afterTest: Inner Container
afterAny: Inner Container
afterContainer: Inner Container
afterTest: Container
afterAny: Container
afterContainer: Container
afterSpec: kr.galaxyhub.sc.KotestTest@36aeb1eb
finalizeSpec: KotestTest
단 하나의 테스트 인스턴스가 생성된 것을 볼 수 있다.
즉, InstancePerLeaf
는 TestType
에 상관없이 말 그대로 Leaf(최하위) 테스트마다 인스턴스를 생성한다.
따라서 IsolationMode
를 사용하여 테스트 격리성을 지키려면 InstancePerTest
보다는 InstancePerLeaf
를 사용하는 것이 더 효율적이다.
JUnit5
와 다르게, Kotest
에서는 테스트 클래스 당 하나의 인스턴스를 생성하기에 격리성을 보장하려면 추가적인 설정이 필요하다.
Kotest
환경에서 테스트 간 격리성을 지키기 위해 Lifecycle Hook
그리고 IsolationMode
을 제공한다.
따라서 테스트의 격리성을 보장해 주려면 사용 중인 테스트 프레임워크의 격리 모드, 생명 주기를 잘 이해하고 적용할 수 있어야 한다.
또한, 테스트마다 격리 수준을 효과적으로 적용하기 위해 두 설정을 적절히 조합하여 적용해야 한다.
JUnit5
과 다르게 Kotest
에서 기본 격리 수준을 singleInstance
로 설정한 것은 테스트 마다 인스턴스를 생성하지 않고 Lifecycle Hook
을 사용하여 효율적으로 테스트 코드를 작성하라고 한 것이 아닌가 합리적 의심을 해본다.