Kotest
Kotest: Kotlin テストフレームワーク完全ガイド
目次
はじめに
Kotestは、Kotlin専用に設計されたモダンで表現力豊かなテストフレームワークです。JUnitやTestNGなどの従来のフレームワークとは異なり、Kotlinの言語機能を最大限に活用し、テストコードの可読性と記述性を向上させます。
Kotestは以下の特徴を持ちます:
- 複数のテストスタイル:FunSpec、StringSpec、DescribeSpec、BehaviorSpec、FreeSpec、WordSpec、ShouldSpecなど7種類以上のテストスタイルをサポート
- 豊富なアサーション:数百個の組み込みマッチャーとカスタムマッチャーの作成機能
- プロパティベーステスト:自動生成されたテストデータを使用したプロパティベーステストのサポート
- スケーラビリティ:大規模テストスイートの並列実行と効率的なリソース管理
- DI対応:Spring、Guice、Quarkusなどの依存性注入フレームワークとの統合
- 拡張可能性:テストライフサイクルのカスタマイズと拡張ポイントの豊富さ
Kotestの基本概念
テストコンテナとテストケース
Kotestの基本構造は、テストコンテナ(テストスイート)とテストケースから成り立ちます。
テストコンテナ(TestCase)
├── テストケース1
├── ネストされたテストコンテナ
│ ├── テストケース2
│ └── テストケース3
└── テストケース4
テストライフサイクル
Kotestのテストライフサイクルは以下の段階で構成されます:
-
初期化フェーズ(Initialization Phase)
- テストクラスのインスタンス化
- テストコンテナの構築
-
準備フェーズ(Setup Phase)
beforeSpec()/afterSpec(): スペック全体の前後処理beforeEach()/afterEach(): 各テストの前後処理beforeContainer()/afterContainer(): ネストされたコンテナの前後処理
-
実行フェーズ(Execution Phase)
- テストケースの並列または順序実行
-
破棄フェーズ(Teardown Phase)
- リソースのクリーンアップ
隔離戦略
Kotestは複数の隔離戦略をサポートします:
- InstancePerLeaf(デフォルト):各テストケースに対してテストクラスの新しいインスタンスを作成
- InstancePerTest:各テストケースに対してテストクラスの新しいインスタンスを作成
- SingleInstance:すべてのテストに対して同じインスタンスを使用
- InstancePerRun:テスト実行全体で同じインスタンスを使用
アーキテクチャ概要
コアコンポーネント
┌─────────────────────────────────────────────────────┐
│ Kotest Core │
├─────────────────────────────────────────────────────┤
│ ┌────────────────────────────────────────────┐ │
│ │ Test Discovery & Execution Engine │ │
│ │ - ClassScanner │ │
│ │ - SpecInstantiator │ │
│ │ - TestCaseExecutor │ │
│ │ - ParallelExecutor │ │
│ └────────────────────────────────────────────┘ │
│ │
│ ┌────────────────────────────────────────────┐ │
│ │ Test Style System │ │
│ │ - AbstractSpec │ │
│ │ - RootTestListener │ │
│ │ - TestCaseContext │ │
│ └────────────────────────────────────────────┘ │
│ │
│ ┌────────────────────────────────────────────┐ │
│ │ Assertion & Matcher Framework │ │
│ │ - Matcher<T> │ │
│ │ - Result │ │
│ │ - Internal Matchers Library │ │
│ └────────────────────────────────────────────┘ │
│ │
│ ┌────────────────────────────────────────────┐ │
│ │ Configuration System │ │
│ │ - ProjectConfig │ │
│ │ - ConfigLoader │ │
│ └────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
│ │ │
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌──────────┐
│ Arrow │ │ Property│ │ Mocking │
│ Testing │ │ Testing │ │ Library │
└─────────┘ └─────────┘ └──────────┘
主要なクラス階層
AbstractSpec
├── FunSpec
├── StringSpec
├── DescribeSpec
├── BehaviorSpec
├── FreeSpec
├── WordSpec
├── ShouldSpec
└── AnnotationSpec
実行フロー
1. テスト発見
↓
2. スペック読み込み
↓
3. テストコンテナ構築
↓
4. 並列実行スケジューリング
↓
5. テスト実行(ライフサイクルコールバック付き)
↓
6. 結果集約
↓
7. リポーター通知
主要な機能
1. テストスタイル
FunSpec:関数型スタイル
class UserServiceFunSpecTest : FunSpec({
test("getUserById returns user") {
val service = UserService()
val user = service.getUserById(1)
user.name shouldBe "John"
}
test("getUserById throws when invalid") {
val service = UserService()
shouldThrow<NotFoundException> {
service.getUserById(-1)
}
}
})
DescribeSpec:RSpec風スタイル
class UserServiceDescribeSpecTest : DescribeSpec({
describe("UserService") {
describe("getUserById") {
it("returns user when exists") {
val service = UserService()
val user = service.getUserById(1)
user.name shouldBe "John"
}
it("throws when invalid") {
val service = UserService()
shouldThrow<NotFoundException> {
service.getUserById(-1)
}
}
}
describe("createUser") {
it("creates and returns user") {
val service = UserService()
val user = service.createUser("Jane", "jane@example.com")
user.name shouldBe "Jane"
}
}
}
})
BehaviorSpec:BDD風スタイル
class UserServiceBehaviorSpecTest : BehaviorSpec({
given("a UserService") {
val service = UserService()
when("getting an existing user") {
val user = service.getUserById(1)
then("it returns the correct user") {
user.name shouldBe "John"
}
then("it has an email") {
user.email shouldNotBe null
}
}
when("getting a non-existent user") {
then("it throws NotFoundException") {
shouldThrow<NotFoundException> {
service.getUserById(-1)
}
}
}
}
})
StringSpec:シンプルなスタイル
class UserServiceStringSpecTest : StringSpec({
"getUserById returns user" {
val service = UserService()
val user = service.getUserById(1)
user.name shouldBe "John"
}
"getUserById throws when invalid" {
val service = UserService()
shouldThrow<NotFoundException> {
service.getUserById(-1)
}
}
})
WordSpec:単語連鎖スタイル
class UserServiceWordSpecTest : WordSpec({
"UserService" should {
"getUserById returns user" {
val service = UserService()
val user = service.getUserById(1)
user.name shouldBe "John"
}
"createUser creates user" {
val service = UserService()
val user = service.createUser("Jane", "jane@example.com")
user.name shouldBe "Jane"
}
}
})
2. アサーションと マッチャー
Kotestは数百個の組み込みマッチャーを提供します:
// 基本的なマッチャー
value shouldBe expected
value shouldNotBe notExpected
value shouldEqual expected
// 型チェック
value shouldBeInstanceOf<String>()
value.shouldBeInstanceOf<String>()
// Null チェック
value.shouldBeNull()
value.shouldNotBeNull()
// 例外テスト
shouldThrow<IllegalArgumentException> {
throwingFunction()
}
shouldThrowAny {
throwingFunction()
}
shouldThrowMessage("error message") {
throwingFunction()
}
// コレクションマッチャー
list.shouldBeEmpty()
list.shouldHaveSize(3)
list.shouldContain("item")
list.shouldContainAll("a", "b", "c")
list.shouldNotContainDuplicates()
// 文字列マッチャー
"hello".shouldBeEmpty()
"hello".shouldStartWith("he")
"hello".shouldEndWith("lo")
"hello".shouldContain("ll")
"hello".shouldMatch(Regex("h.*o"))
// 数値マッチャー
5 shouldBe between(3, 7)
5 shouldBeGreaterThan 3
5 shouldBeLessThan 10
5.shouldBeEven()
5.shouldBeOdd()
// ブール値マッチャー
true.shouldBeTrue()
false.shouldBeFalse()
// 日付マッチャー
date.shouldHaveDayOfMonth(15)
date.shouldHaveMonth(Month.DECEMBER)
date1.shouldBeBefore(date2)
date1.shouldBeAfter(date2)
カスタムマッチャーの作成
fun String.shouldBeValidEmail() {
this should match("^[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,}$".toRegex(RegexOption.IGNORE_CASE))
}
fun <T> T.shouldBeOfType(expected: Class<T>) {
this.shouldBeInstanceOf(expected)
}
// 複合マッチャー
fun String.shouldBeValidPhoneNumber() {
this should {
satisfy {
it.length in 10..15 && it.all { c -> c.isDigit() || c == '-' }
}
}
}
3. プロパティベーステスト
Kotestはプロパティベーステストのための包括的なサポートを提供します:
class PropertyBasedTestExample : FunSpec({
test("addition is commutative") {
forAll(Arb.int(), Arb.int()) { a, b ->
a + b shouldBe b + a
}
}
test("list concatenation is associative") {
forAll(
Arb.list(Arb.string()),
Arb.list(Arb.string()),
Arb.list(Arb.string())
) { a, b, c ->
(a + b) + c shouldBe a + (b + c)
}
}
test("string length") {
forAll(Arb.string(10..100)) { s ->
s.length shouldBeInRange 10..100
}
}
})
Arb(任意値生成)
// 基本型
Arb.int()
Arb.long()
Arb.float()
Arb.double()
Arb.boolean()
Arb.string()
Arb.char()
// 範囲指定
Arb.int(1..100)
Arb.long(1000L..5000L)
Arb.string(5..20)
// コレクション
Arb.list(Arb.int())
Arb.set(Arb.string())
Arb.map(Arb.string(), Arb.int())
// カスタムジェネレーター
val userArb = Arb.bind(
Arb.string(),
Arb.email()
) { name, email ->
User(name, email)
}
4. データ駆動テスト
class DataDrivenTestExample : FunSpec({
test("addition") {
forAll(
row(1, 2, 3),
row(5, 5, 10),
row(-1, 1, 0),
row(0, 0, 0)
) { a, b, expected ->
a + b shouldBe expected
}
}
})
5. タイムアウトと終了
class TimeoutExample : FunSpec({
test("should complete within timeout").config(timeout = 100.milliseconds) {
delay(50)
true.shouldBeTrue()
}
test("should fail on timeout").config(timeout = 50.milliseconds) {
delay(100) // Will timeout
}
test("test will be invoked 3 times").config(invocations = 3) {
// This test runs 3 times
}
})
設定と構成
kotest.properties ファイル
プロジェクトルートに kotest.properties ファイルを作成:
# 並列実行の設定
kotest.framework.execution.parallelism=8
# テスト実行モード
kotest.framework.execution.mode=parallel
# タイムアウトの設定
kotest.framework.timeouts.default=300s
kotest.framework.timeouts.execution=600s
# 隔離戦略
kotest.framework.isolation.instance=InstancePerLeaf
# テスト順序
kotest.framework.testcase.ordering=Random
# スタックトレース表示
kotest.framework.stacktrace.format=short
# ディスプレイ・フォーマッター
kotest.framework.display.filter=all
kotest.framework.display.order=property
# 失敗時の動作
kotest.framework.fail.on.empty.test.suite=true
# レポーター設定
kotest.framework.reporters=io.kotest.engine.reporters.TeamCityTestEventListener
ProjectConfig クラス
ProjectConfig を拡張してプログラマティックな設定を行う:
object TestProjectConfig : ProjectConfig() {
override val parallelism: Int = 8
override val timeout: Duration = 300.seconds
override val invocations: Int? = null
override val failOnEmptyTestSuite: Boolean = true
override val logLevel: LogLevel = LogLevel.Info
override val testCaseOrder: TestCaseOrder = TestCaseOrder.Random
override val isolationMode: IsolationMode = IsolationMode.InstancePerLeaf
override val concurrentSpecs: Int? = null
override val concurrentTests: Int? = null
override val testNameTruncateLength: Int? = null
override val displayFullTestPath: Boolean = false
// ライフサイクルコールバック
override suspend fun beforeAll() {
println("Running tests...")
}
override suspend fun afterAll() {
println("Tests completed")
}
// リスナー登録
override fun listeners(): List<TestListener> = listOf(
MyCustomTestListener()
)
// 拡張機能登録
override fun extensions(): List<Extension> = listOf(
MyCustomExtension()
)
}
gradle.properties での設定
# Kotest設定
kotest.parallelism=8
kotest.timeout=300000
kotest.isolation=InstancePerLeaf
kotest.test.order=Random
kotest.fail.empty=true
build.gradle.kts での Kotest 設定
dependencies {
testImplementation("io.kotest:kotest-runner-junit5:5.6.2")
testImplementation("io.kotest:kotest-assertions-core:5.6.2")
testImplementation("io.kotest:kotest-property:5.6.2")
testImplementation("io.kotest:kotest-extensions-spring:5.6.2")
testImplementation("io.kotest:kotest-extensions-mockserver:5.6.2")
}
tasks.test {
useJUnitPlatform()
systemProperties = mapOf(
"kotest.framework.execution.parallelism" to "8",
"kotest.framework.execution.timeout" to "300000",
"kotest.framework.isolation.instance" to "InstancePerLeaf"
)
}
高度な機能
1. テストリスナー
class CustomTestListener : TestListener {
override suspend fun beforeTest(testCase: TestCase) {
println("Starting test: ${testCase.name}")
}
override suspend fun afterTest(testCase: TestCase, result: TestResult) {
println("Completed test: ${testCase.name} - ${result.status}")
when (result.status) {
TestStatus.Success -> println("✓ Passed")
TestStatus.Failure -> println("✗ Failed: ${result.errorOrNull}")
TestStatus.Ignored -> println("⊘ Ignored")
TestStatus.Skipped -> println("⊘ Skipped")
TestStatus.Error -> println("✗ Error: ${result.errorOrNull}")
}
}
override suspend fun specFinished(klass: KClass<out Spec>, results: Map<TestCase, TestResult>) {
val passed = results.values.count { it.isSuccess }
val failed = results.values.count { it.isFailure }
println("Spec ${klass.simpleName}: $passed passed, $failed failed")
}
}
2. テスト拡張(Extension)
class DatabaseExtension : TestExtension {
private lateinit var database: TestDatabase
override suspend fun beforeSpec(spec: Spec) {
database = TestDatabase()
database.start()
}
override suspend fun afterSpec(spec: Spec) {
database.stop()
}
override suspend fun beforeEach(testCase: TestCase) {
database.clearData()
}
}
class CoroutineExtension : TestExtension {
override suspend fun beforeTest(testCase: TestCase) {
// Coroutineセットアップ
}
}
3. Spring 統合
@SpringBootTest
class UserServiceSpringTest : FunSpec() {
override fun extensions() = listOf(SpringExtension)
@Autowired
private lateinit var userService: UserService
@Autowired
private lateinit var userRepository: UserRepository
init {
test("should create user") {
val user = userService.createUser("John", "john@example.com")
user.id shouldNotBe null
}
test("should find user") {
val saved = userRepository.save(User(name = "Jane", email = "jane@example.com"))
val found = userService.getUserById(saved.id!!)
found.name shouldBe "Jane"
}
}
}
4. Mockk 統合
class UserServiceMockTest : FunSpec({
test("should call repository") {
val userRepository = mockk<UserRepository>()
val userService = UserService(userRepository)
every { userRepository.findById(1) } returns User(1, "John", "john@example.com")
val user = userService.getUserById(1)
user.name shouldBe "John"
verify { userRepository.findById(1) }
}
})
5. ネストされたテスト構造
class NestedTestStructureExample : DescribeSpec({
describe("User API") {
describe("GET /users/{id}") {
describe("when user exists") {
describe("and user has profile") {
it("returns user with profile") {
// Test implementation
}
it("includes email") {
// Test implementation
}
}
}
describe("when user does not exist") {
it("returns 404") {
// Test implementation
}
}
}
describe("POST /users") {
describe("with valid input") {
it("creates user") {
// Test implementation
}
}
describe("with invalid input") {
it("returns validation error") {
// Test implementation
}
}
}
}
})
6. テストタグとフィルタリング
class TaggingExample : FunSpec({
test("fast test").config(tags = setOf(Tag("fast"), Tag("unit"))) {
true.shouldBeTrue()
}
test("slow test").config(tags = setOf(Tag("slow"), Tag("integration"))) {
Thread.sleep(1000)
true.shouldBeTrue()
}
test("database test").config(tags = setOf(Tag("database"), Tag("integration"))) {
// Requires database
}
})
object TestTags {
val FAST = Tag("fast")
val SLOW = Tag("slow")
val UNIT = Tag("unit")
val INTEGRATION = Tag("integration")
val DATABASE = Tag("database")
}
// 使用例
class FilteredTestExample : FunSpec({
test("fast test").config(tags = setOf(TestTags.FAST, TestTags.UNIT)) {
true.shouldBeTrue()
}
})
7. 条件付き実行
class ConditionalExecutionExample : FunSpec({
test("should run on CI").config(
enabled = System.getenv("CI") != null
) {
// Only runs on CI
}
test("should skip on Windows").config(
enabled = !System.getProperty("os.name").contains("Windows")
) {
// Skipped on Windows
}
test("should run if condition met").config(
enabledIf = {
val shouldRun = testConfig.shouldRun()
shouldRun
}
) {
true.shouldBeTrue()
}
})
実装例
例1: 計算機サービスのテスト
data class CalculationResult(
val operation: String,
val operand1: Double,
val operand2: Double,
val result: Double
)
class Calculator {
fun add(a: Double, b: Double): Double = a + b
fun subtract(a: Double, b: Double): Double = a - b
fun multiply(a: Double, b: Double): Double = a * b
fun divide(a: Double, b: Double): Double {
if (b == 0.0) throw IllegalArgumentException("Division by zero")
return a / b
}
}
class CalculatorTest : DescribeSpec({
val calculator = Calculator()
describe("Calculator") {
describe("add operation") {
it("should add two positive numbers") {
val result = calculator.add(5.0, 3.0)
result shouldBe 8.0
}
it("should handle negative numbers") {
val result = calculator.add(-5.0, 3.0)
result shouldBe -2.0
}
it("should handle decimals") {
val result = calculator.add(1.5, 2.5)
result shouldBe 4.0
}
}
describe("divide operation") {
it("should divide two numbers") {
val result = calculator.divide(10.0, 2.0)
result shouldBe 5.0
}
it("should throw on division by zero") {
shouldThrow<IllegalArgumentException> {
calculator.divide(10.0, 0.0)
}
}
}
}
})
// プロパティベーステスト
class CalculatorPropertyTest : FunSpec({
val calculator = Calculator()
test("addition is commutative") {
forAll(Arb.double(), Arb.double()) { a, b ->
calculator.add(a, b) shouldBe calculator.add(b, a)
}
}
test("multiplication distributes over addition") {
forAll(Arb.double(), Arb.double(), Arb.double()) { a, b, c ->
calculator.multiply(a, calculator.add(b, c)) shouldBe
calculator.add(
calculator.multiply(a, b),
calculator.multiply(a, c)
)
}
}
})
例2: ユーザー管理サービスのテスト
data class User(
val id: Long? = null,
val name: String,
val email: String,
val age: Int? = null,
val active: Boolean = true
)
interface UserRepository {
suspend fun save(user: User): User
suspend fun findById(id: Long): User?
suspend fun findAll(): List<User>
suspend fun delete(id: Long): Boolean
}
class UserService(private val repository: UserRepository) {
suspend fun createUser(name: String, email: String, age: Int? = null): User {
if (name.isBlank()) throw IllegalArgumentException("Name cannot be blank")
if (!email.contains("@")) throw IllegalArgumentException("Invalid email")
val user = User(name = name, email = email, age = age)
return repository.save(user)
}
suspend fun getUserById(id: Long): User {
return repository.findById(id) ?: throw NotFoundException("User not found")
}
suspend fun updateUser(id: Long, name: String?, email: String?): User {
val existing = getUserById(id)
val updated = existing.copy(
name = name ?: existing.name,
email = email ?: existing.email
)
return repository.save(updated)
}
}
class UserServiceTest : BehaviorSpec({
val userRepository = mockk<UserRepository>()
val userService = UserService(userRepository)
given("a UserService with mocked repository") {
when("creating a valid user") {
val newUser = User(name = "John Doe", email = "john@example.com", age = 30)
val savedUser = newUser.copy(id = 1)
coEvery { userRepository.save(any()) } returns savedUser
val result = userService.createUser("John Doe", "john@example.com", 30)
then("it should return the saved user") {
result.id shouldBe 1
result.name shouldBe "John Doe"
result.email shouldBe "john@example.com"
}
then("it should save to repository") {
coVerify { userRepository.save(any()) }
}
}
when("creating user with blank name") {
then("it should throw IllegalArgumentException") {
shouldThrow<IllegalArgumentException> {
userService.createUser("", "john@example.com")
}
}
}
when("creating user with invalid email") {
then("it should throw IllegalArgumentException") {
shouldThrow<IllegalArgumentException> {
userService.createUser("John", "invalid-email")
}
}
}
when("getting non-existent user") {
coEvery { userRepository.findById(999) } returns null
then("it should throw NotFoundException") {
shouldThrow<NotFoundException> {
userService.getUserById(999)
}
}
}
}
})
// データ駆動テスト
class UserServiceDataDrivenTest : FunSpec({
val userRepository = mockk<UserRepository>()
val userService = UserService(userRepository)
test("email validation") {
forAll(
row("valid@example.com", true),
row("user.name@example.co.uk", true),
row("invalid.email", false),
row("@example.com", false),
row("user@", false)
) { email, shouldBeValid ->
val isValid = email.contains("@")
isValid shouldBe shouldBeValid
}
}
})
例3: API統合テスト
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class UserApiIntegrationTest : FunSpec() {
override fun extensions() = listOf(SpringExtension)
@Autowired
private lateinit var testRestTemplate: TestRestTemplate
@Autowired
private lateinit var userRepository: UserRepository
init {
describe("User API") {
describe("GET /api/users/{id}") {
it("should return user") {
val user = userRepository.save(User(name = "John", email = "john@example.com"))
val response = testRestTemplate.getForEntity(
"/api/users/${user.id}",
User::class.java
)
response.statusCode shouldBe HttpStatus.OK
response.body?.name shouldBe "John"
}
it("should return 404 for non-existent user") {
val response = testRestTemplate.getForEntity(
"/api/users/99999",
String::class.java
)
response.statusCode shouldBe HttpStatus.NOT_FOUND
}
}
describe("POST /api/users") {
it("should create user") {
val newUser = User(name = "Jane", email = "jane@example.com")
val response = testRestTemplate.postForEntity(
"/api/users",
newUser,
User::class.java
)
response.statusCode shouldBe HttpStatus.CREATED
response.body?.id shouldNotBe null
response.body?.name shouldBe "Jane"
}
}
describe("PUT /api/users/{id}") {
it("should update user") {
val existing = userRepository.save(User(name = "John", email = "john@example.com"))
val updated = existing.copy(name = "Updated John")
testRestTemplate.put("/api/users/${existing.id}", updated)
val retrieved = userRepository.findById(existing.id!!).orElseThrow()
retrieved.name shouldBe "Updated John"
}
}
}
}
}
ベストプラクティス
1. 適切なテストスタイルの選択
// ✓ Good: テスト構造が明確
class UserServiceTest : DescribeSpec({
describe("UserService") {
describe("getUserById") {
it("returns user when exists") { }
it("throws when invalid") { }
}
}
})
// ✗ Bad: テスト構造が不明確
class UserServiceTest : StringSpec({
"user service get user by id returns user when exists" { }
"user service get user by id throws when invalid" { }
})
2. 一貫性のあるテスト命名
// ✓ Good: 明確でテスト可能な内容を表現
class CalculatorTest : FunSpec({
test("add returns sum of two numbers") { }
test("add with negative numbers") { }
test("divide throws when divisor is zero") { }
})
// ✗ Bad: 曖昧な名前
class CalculatorTest : FunSpec({
test("test1") { }
test("test2") { }
})
3. 適切なアサーション構文
// ✓ Good: 読みやすいアサーション
user.name shouldBe "John"
user.age shouldBeGreaterThan 18
list.shouldBeEmpty()
list.shouldContain("item")
// ✗ Bad: 読みにくいアサーション
assert(user.name == "John")
assertTrue(user.age > 18)
assertTrue(list.isEmpty())
assertTrue(list.contains("item"))
4. セットアップの共有
// ✓ Good: セットアップを初期化ブロック内で
class UserServiceTest : FunSpec({
val userRepository = mockk<UserRepository>()
val userService = UserService(userRepository)
test("should create user") {
coEvery { userRepository.save(any()) } returns User(id = 1, name = "John")
val result = userService.createUser("John", "john@example.com")
result.id shouldBe 1
}
})
// ✗ Bad: 各テストで独立してセットアップ
class UserServiceTest : FunSpec({
test("should create user") {
val userRepository = mockk<UserRepository>()
val userService = UserService(userRepository)
// ...
}
test("should update user") {
val userRepository = mockk<UserRepository>()
val userService = UserService(userRepository)
// ...
}
})
5. プロパティベーステストの活用
// ✓ Good: プロパティベーステストで多くのケースをカバー
test("addition is commutative") {
forAll(Arb.int(), Arb.int()) { a, b ->
a + b shouldBe b + a
}
}
// ✗ Bad: 手動でいくつかのケースのみテスト
test("addition") {
(5 + 3) shouldBe (3 + 5)
(10 + 20) shouldBe (20 + 10)
(-5 + 3) shouldBe (3 + (-5))
}
6. 例外テストの適切な使用
// ✓ Good: 例外の型とメッセージを検証
shouldThrow<IllegalArgumentException> {
calculator.divide(10, 0)
}.message shouldContain "Division by zero"
// ✗ Bad: 例外だけをチェック
shouldThrow<Exception> {
calculator.divide(10, 0)
}
7. モッキングの適切な使用
// ✓ Good: 依存関係をモック、テスト対象は実装
class UserServiceTest : FunSpec({
val userRepository = mockk<UserRepository>()
val userService = UserService(userRepository) // 本実装
test("creates user") {
coEvery { userRepository.save(any()) } returns User(id = 1, name = "John")
val result = userService.createUser("John", "john@example.com")
result.id shouldBe 1
}
})
// ✗ Bad: テスト対象自体をモック
class UserServiceTest : FunSpec({
val userService = mockk<UserService>()
test("creates user") {
coEvery { userService.createUser("John", "john@example.com") }
returns User(id = 1, name = "John")
val result = userService.createUser("John", "john@example.com")
result.id shouldBe 1
}
})
8. パフォーマンステスト
class PerformanceTest : FunSpec({
test("large list processing").config(
timeout = 5.seconds,
tags = setOf(Tag("performance"))
) {
val largeList = (1..1000000).toList()
val start = System.currentTimeMillis()
val filtered = largeList.filter { it % 2 == 0 }
val duration = System.currentTimeMillis() - start
duration shouldBeLessThan 1000 // 1秒以内
filtered.shouldNotBeEmpty()
}
})
9. テスト分離と独立性
// ✓ Good: 各テストが独立している
class UserServiceTest : FunSpec({
test("test1 should work") {
val result = someOperation()
result shouldBe expected1
}
test("test2 should work") {
val result = anotherOperation()
result shouldBe expected2
}
})
// ✗ Bad: テスト間に依存関係がある
class UserServiceTest : FunSpec({
var globalUser: User? = null
test("test1 creates user") {
globalUser = userService.createUser("John", "john@example.com")
}
test("test2 uses user from test1") {
globalUser?.name shouldBe "John" // test1が実行されなければ失敗
}
})
10. テスト可能性の設計
// ✓ Good: 依存関係をコンストラクタで注入
class UserService(private val repository: UserRepository) {
suspend fun createUser(name: String, email: String): User {
return repository.save(User(name = name, email = email))
}
}
// ✗ Bad: 依存関係が硬くコードされている
class UserService {
private val repository = UserRepository() // テスト困難
suspend fun createUser(name: String, email: String): User {
return repository.save(User(name = name, email = email))
}
}
パフォーマンス最適化
並列実行の設定
# kotest.properties
kotest.framework.execution.parallelism=8
kotest.framework.execution.mode=concurrent
kotest.framework.concurrentSpecs=4
kotest.framework.concurrentTests=2
テスト実行時間の短縮
class OptimizedTest : FunSpec({
// 並列実行可能なテストをグループ化
test("independent test 1").config(
tags = setOf(Tag("parallel"))
) { }
test("independent test 2").config(
tags = setOf(Tag("parallel"))
) { }
// 依存性のあるテスト
test("dependent test").config(
tags = setOf(Tag("sequential")),
enabled = !isRunningInParallel()
) { }
})
トラブルシューティング
よくある問題と解決策
問題1: テストがハングする
// ✗ 問題:タイムアウトが設定されていない
test("may hang") {
while(true) { } // 無限ループ
}
// ✓ 解決策:タイムアウトを設定
test("may hang").config(timeout = 10.seconds) {
while(true) { }
}
問題2: 並列実行でテストが失敗する
// ✗ 問題:テスト間に依存関係がある
var sharedState = mutableListOf<String>()
test("test 1") {
sharedState.add("item1")
}
test("test 2") {
sharedState.shouldContain("item1") // test1に依存
}
// ✓ 解決策:各テストで独立したデータを使用
test("test 1") {
val state = mutableListOf<String>()
state.add("item1")
state.shouldContain("item1")
}
test("test 2") {
val state = mutableListOf<String>()
state.add("item2")
state.shouldContain("item2")
}
問題3: Spring統合でBeanが見つからない
// ✓ 解決策:SpringExtensionを使用
@SpringBootTest
class ServiceTest : FunSpec() {
override fun extensions() = listOf(SpringExtension)
@Autowired
private lateinit var service: MyService
}
まとめ
Kotestは、Kotlinの言語機能を最大限に活用した強力で柔軟なテストフレームワークです。以下の点が重要です:
- 複数のテストスタイル:プロジェクトの文化や要件に合わせて選択可能
- 豊富なアサーション:読みやすく、保守しやすいテストコード
- プロパティベーステスト:バグ発見率の向上
- 高度な設定機能:テスト実行の完全な制御
- 拡張可能性:カスタム拡張とリスナーによる柔軟な統合
これらの機能を適切に活用することで、品質の高いテストスイートを構築できます。