mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-03-30 13:43:26 +08:00
feat: add kotlin commands and skill pack
This commit is contained in:
36
.cursor/rules/kotlin-coding-style.md
Normal file
36
.cursor/rules/kotlin-coding-style.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
---
|
||||||
|
description: "Kotlin coding style extending common rules"
|
||||||
|
globs: ["**/*.kt", "**/*.kts", "**/build.gradle.kts"]
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
# Kotlin Coding Style
|
||||||
|
|
||||||
|
> This file extends the common coding style rule with Kotlin-specific content.
|
||||||
|
|
||||||
|
## Formatting
|
||||||
|
|
||||||
|
- **ktfmt** or **ktlint** are mandatory for consistent formatting
|
||||||
|
- Use trailing commas in multiline declarations
|
||||||
|
|
||||||
|
## Immutability
|
||||||
|
|
||||||
|
- `val` over `var` always
|
||||||
|
- Immutable collections by default (`List`, `Map`, `Set`)
|
||||||
|
- Use `data class` with `copy()` for immutable updates
|
||||||
|
|
||||||
|
## Null Safety
|
||||||
|
|
||||||
|
- Avoid `!!` -- use `?.`, `?:`, `require`, or `checkNotNull`
|
||||||
|
- Handle platform types explicitly at Java interop boundaries
|
||||||
|
|
||||||
|
## Expression Bodies
|
||||||
|
|
||||||
|
Prefer expression bodies for single-expression functions:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
fun isAdult(age: Int): Boolean = age >= 18
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reference
|
||||||
|
|
||||||
|
See skill: `kotlin-patterns` for comprehensive Kotlin idioms and patterns.
|
||||||
16
.cursor/rules/kotlin-hooks.md
Normal file
16
.cursor/rules/kotlin-hooks.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
description: "Kotlin hooks extending common rules"
|
||||||
|
globs: ["**/*.kt", "**/*.kts", "**/build.gradle.kts"]
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
# Kotlin Hooks
|
||||||
|
|
||||||
|
> This file extends the common hooks rule with Kotlin-specific content.
|
||||||
|
|
||||||
|
## PostToolUse Hooks
|
||||||
|
|
||||||
|
Configure in `~/.claude/settings.json`:
|
||||||
|
|
||||||
|
- **ktfmt/ktlint**: Auto-format `.kt` and `.kts` files after edit
|
||||||
|
- **detekt**: Run static analysis after editing Kotlin files
|
||||||
|
- **./gradlew build**: Verify compilation after changes
|
||||||
50
.cursor/rules/kotlin-patterns.md
Normal file
50
.cursor/rules/kotlin-patterns.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
---
|
||||||
|
description: "Kotlin patterns extending common rules"
|
||||||
|
globs: ["**/*.kt", "**/*.kts", "**/build.gradle.kts"]
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
# Kotlin Patterns
|
||||||
|
|
||||||
|
> This file extends the common patterns rule with Kotlin-specific content.
|
||||||
|
|
||||||
|
## Sealed Classes
|
||||||
|
|
||||||
|
Use sealed classes/interfaces for exhaustive type hierarchies:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
sealed class Result<out T> {
|
||||||
|
data class Success<T>(val data: T) : Result<T>()
|
||||||
|
data class Failure(val error: AppError) : Result<Nothing>()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Extension Functions
|
||||||
|
|
||||||
|
Add behavior without inheritance, scoped to where they're used:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
fun String.toSlug(): String =
|
||||||
|
lowercase().replace(Regex("[^a-z0-9\\s-]"), "").replace(Regex("\\s+"), "-")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Scope Functions
|
||||||
|
|
||||||
|
- `let`: Transform nullable or scoped result
|
||||||
|
- `apply`: Configure an object
|
||||||
|
- `also`: Side effects
|
||||||
|
- Avoid nesting scope functions
|
||||||
|
|
||||||
|
## Dependency Injection
|
||||||
|
|
||||||
|
Use Koin for DI in Ktor projects:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
val appModule = module {
|
||||||
|
single<UserRepository> { ExposedUserRepository(get()) }
|
||||||
|
single { UserService(get()) }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reference
|
||||||
|
|
||||||
|
See skill: `kotlin-patterns` for comprehensive Kotlin patterns including coroutines, DSL builders, and delegation.
|
||||||
58
.cursor/rules/kotlin-security.md
Normal file
58
.cursor/rules/kotlin-security.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
---
|
||||||
|
description: "Kotlin security extending common rules"
|
||||||
|
globs: ["**/*.kt", "**/*.kts", "**/build.gradle.kts"]
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
# Kotlin Security
|
||||||
|
|
||||||
|
> This file extends the common security rule with Kotlin-specific content.
|
||||||
|
|
||||||
|
## Secret Management
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
val apiKey = System.getenv("API_KEY")
|
||||||
|
?: throw IllegalStateException("API_KEY not configured")
|
||||||
|
```
|
||||||
|
|
||||||
|
## SQL Injection Prevention
|
||||||
|
|
||||||
|
Always use Exposed's parameterized queries:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// Good: Parameterized via Exposed DSL
|
||||||
|
UsersTable.selectAll().where { UsersTable.email eq email }
|
||||||
|
|
||||||
|
// Bad: String interpolation in raw SQL
|
||||||
|
exec("SELECT * FROM users WHERE email = '$email'")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
Use Ktor's Auth plugin with JWT:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
install(Authentication) {
|
||||||
|
jwt("jwt") {
|
||||||
|
verifier(
|
||||||
|
JWT.require(Algorithm.HMAC256(secret))
|
||||||
|
.withAudience(audience)
|
||||||
|
.withIssuer(issuer)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
validate { credential ->
|
||||||
|
val payload = credential.payload
|
||||||
|
if (payload.audience.contains(audience) &&
|
||||||
|
payload.issuer == issuer &&
|
||||||
|
payload.subject != null) {
|
||||||
|
JWTPrincipal(payload)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Null Safety as Security
|
||||||
|
|
||||||
|
Kotlin's type system prevents null-related vulnerabilities -- avoid `!!` to maintain this guarantee.
|
||||||
38
.cursor/rules/kotlin-testing.md
Normal file
38
.cursor/rules/kotlin-testing.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
---
|
||||||
|
description: "Kotlin testing extending common rules"
|
||||||
|
globs: ["**/*.kt", "**/*.kts", "**/build.gradle.kts"]
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
# Kotlin Testing
|
||||||
|
|
||||||
|
> This file extends the common testing rule with Kotlin-specific content.
|
||||||
|
|
||||||
|
## Framework
|
||||||
|
|
||||||
|
Use **Kotest** with spec styles (StringSpec, FunSpec, BehaviorSpec) and **MockK** for mocking.
|
||||||
|
|
||||||
|
## Coroutine Testing
|
||||||
|
|
||||||
|
Use `runTest` from `kotlinx-coroutines-test`:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
test("async operation completes") {
|
||||||
|
runTest {
|
||||||
|
val result = service.fetchData()
|
||||||
|
result.shouldNotBeEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Coverage
|
||||||
|
|
||||||
|
Use **Kover** for coverage reporting:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew koverHtmlReport
|
||||||
|
./gradlew koverVerify
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reference
|
||||||
|
|
||||||
|
See skill: `kotlin-testing` for detailed Kotest patterns, MockK usage, and property-based testing.
|
||||||
118
agents/kotlin-build-resolver.md
Normal file
118
agents/kotlin-build-resolver.md
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
---
|
||||||
|
name: kotlin-build-resolver
|
||||||
|
description: Kotlin/Gradle build, compilation, and dependency error resolution specialist. Fixes build errors, Kotlin compiler errors, and Gradle issues with minimal changes. Use when Kotlin builds fail.
|
||||||
|
tools: ["Read", "Write", "Edit", "Bash", "Grep", "Glob"]
|
||||||
|
model: sonnet
|
||||||
|
---
|
||||||
|
|
||||||
|
# Kotlin Build Error Resolver
|
||||||
|
|
||||||
|
You are an expert Kotlin/Gradle build error resolution specialist. Your mission is to fix Kotlin build errors, Gradle configuration issues, and dependency resolution failures with **minimal, surgical changes**.
|
||||||
|
|
||||||
|
## Core Responsibilities
|
||||||
|
|
||||||
|
1. Diagnose Kotlin compilation errors
|
||||||
|
2. Fix Gradle build configuration issues
|
||||||
|
3. Resolve dependency conflicts and version mismatches
|
||||||
|
4. Handle Kotlin compiler errors and warnings
|
||||||
|
5. Fix detekt and ktlint violations
|
||||||
|
|
||||||
|
## Diagnostic Commands
|
||||||
|
|
||||||
|
Run these in order:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew build 2>&1
|
||||||
|
./gradlew detekt 2>&1 || echo "detekt not configured"
|
||||||
|
./gradlew ktlintCheck 2>&1 || echo "ktlint not configured"
|
||||||
|
./gradlew dependencies --configuration runtimeClasspath 2>/dev/null | head -100
|
||||||
|
```
|
||||||
|
|
||||||
|
## Resolution Workflow
|
||||||
|
|
||||||
|
```text
|
||||||
|
1. ./gradlew build -> Parse error message
|
||||||
|
2. Read affected file -> Understand context
|
||||||
|
3. Apply minimal fix -> Only what's needed
|
||||||
|
4. ./gradlew build -> Verify fix
|
||||||
|
5. ./gradlew test -> Ensure nothing broke
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Fix Patterns
|
||||||
|
|
||||||
|
| Error | Cause | Fix |
|
||||||
|
|-------|-------|-----|
|
||||||
|
| `Unresolved reference: X` | Missing import, typo, missing dependency | Add import or dependency |
|
||||||
|
| `Type mismatch: Required X, Found Y` | Wrong type, missing conversion | Add conversion or fix type |
|
||||||
|
| `None of the following candidates is applicable` | Wrong overload, wrong argument types | Fix argument types or add explicit cast |
|
||||||
|
| `Smart cast impossible` | Mutable property or concurrent access | Use local `val` copy or `let` |
|
||||||
|
| `'when' expression must be exhaustive` | Missing branch in sealed class `when` | Add missing branches or `else` |
|
||||||
|
| `Suspend function can only be called from coroutine` | Missing `suspend` or coroutine scope | Add `suspend` modifier or launch coroutine |
|
||||||
|
| `Cannot access 'X': it is internal in 'Y'` | Visibility issue | Change visibility or use public API |
|
||||||
|
| `Conflicting declarations` | Duplicate definitions | Remove duplicate or rename |
|
||||||
|
| `Could not resolve: group:artifact:version` | Missing repository or wrong version | Add repository or fix version |
|
||||||
|
| `Execution failed for task ':detekt'` | Code style violations | Fix detekt findings |
|
||||||
|
|
||||||
|
## Gradle Troubleshooting
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check dependency tree for conflicts
|
||||||
|
./gradlew dependencies --configuration runtimeClasspath
|
||||||
|
|
||||||
|
# Force refresh dependencies
|
||||||
|
./gradlew build --refresh-dependencies
|
||||||
|
|
||||||
|
# Clear project-local Gradle build cache
|
||||||
|
./gradlew clean && rm -rf .gradle/build-cache/
|
||||||
|
|
||||||
|
# Check Gradle version compatibility
|
||||||
|
./gradlew --version
|
||||||
|
|
||||||
|
# Run with debug output
|
||||||
|
./gradlew build --debug 2>&1 | tail -50
|
||||||
|
|
||||||
|
# Check for dependency conflicts
|
||||||
|
./gradlew dependencyInsight --dependency <name> --configuration runtimeClasspath
|
||||||
|
```
|
||||||
|
|
||||||
|
## Kotlin Compiler Flags
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// build.gradle.kts - Common compiler options
|
||||||
|
kotlin {
|
||||||
|
compilerOptions {
|
||||||
|
freeCompilerArgs.add("-Xjsr305=strict") // Strict Java null safety
|
||||||
|
allWarningsAsErrors = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Principles
|
||||||
|
|
||||||
|
- **Surgical fixes only** -- don't refactor, just fix the error
|
||||||
|
- **Never** suppress warnings without explicit approval
|
||||||
|
- **Never** change function signatures unless necessary
|
||||||
|
- **Always** run `./gradlew build` after each fix to verify
|
||||||
|
- Fix root cause over suppressing symptoms
|
||||||
|
- Prefer adding missing imports over wildcard imports
|
||||||
|
|
||||||
|
## Stop Conditions
|
||||||
|
|
||||||
|
Stop and report if:
|
||||||
|
- Same error persists after 3 fix attempts
|
||||||
|
- Fix introduces more errors than it resolves
|
||||||
|
- Error requires architectural changes beyond scope
|
||||||
|
- Missing external dependencies that need user decision
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
|
||||||
|
```text
|
||||||
|
[FIXED] src/main/kotlin/com/example/service/UserService.kt:42
|
||||||
|
Error: Unresolved reference: UserRepository
|
||||||
|
Fix: Added import com.example.repository.UserRepository
|
||||||
|
Remaining errors: 2
|
||||||
|
```
|
||||||
|
|
||||||
|
Final: `Build Status: SUCCESS/FAILED | Errors Fixed: N | Files Modified: list`
|
||||||
|
|
||||||
|
For detailed Kotlin patterns and code examples, see `skill: kotlin-patterns`.
|
||||||
172
commands/kotlin-build.md
Normal file
172
commands/kotlin-build.md
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
---
|
||||||
|
description: Fix Kotlin/Gradle build errors, compiler warnings, and dependency issues incrementally. Invokes the kotlin-build-resolver agent for minimal, surgical fixes.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Kotlin Build and Fix
|
||||||
|
|
||||||
|
This command invokes the **kotlin-build-resolver** agent to incrementally fix Kotlin build errors with minimal changes.
|
||||||
|
|
||||||
|
## What This Command Does
|
||||||
|
|
||||||
|
1. **Run Diagnostics**: Execute `./gradlew build`, `detekt`, `ktlintCheck`
|
||||||
|
2. **Parse Errors**: Group by file and sort by severity
|
||||||
|
3. **Fix Incrementally**: One error at a time
|
||||||
|
4. **Verify Each Fix**: Re-run build after each change
|
||||||
|
5. **Report Summary**: Show what was fixed and what remains
|
||||||
|
|
||||||
|
## When to Use
|
||||||
|
|
||||||
|
Use `/kotlin-build` when:
|
||||||
|
- `./gradlew build` fails with errors
|
||||||
|
- Kotlin compiler reports errors
|
||||||
|
- `./gradlew detekt` reports violations
|
||||||
|
- Gradle dependency resolution fails
|
||||||
|
- After pulling changes that break the build
|
||||||
|
|
||||||
|
## Diagnostic Commands Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Primary build check
|
||||||
|
./gradlew build
|
||||||
|
|
||||||
|
# Static analysis
|
||||||
|
./gradlew detekt
|
||||||
|
./gradlew ktlintCheck
|
||||||
|
|
||||||
|
# Dependency issues
|
||||||
|
./gradlew dependencies --configuration runtimeClasspath
|
||||||
|
./gradlew build --refresh-dependencies
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example Session
|
||||||
|
|
||||||
|
````text
|
||||||
|
User: /kotlin-build
|
||||||
|
|
||||||
|
Agent:
|
||||||
|
# Kotlin Build Resolution
|
||||||
|
|
||||||
|
## Initial Diagnostics
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ ./gradlew build
|
||||||
|
e: src/main/kotlin/com/example/service/UserService.kt:25:15 Unresolved reference: UserRepository
|
||||||
|
e: src/main/kotlin/com/example/routes/UserRoutes.kt:42:9 Type mismatch: inferred type is String but Int was expected
|
||||||
|
e: src/main/kotlin/com/example/routes/UserRoutes.kt:58:5 'when' expression must be exhaustive
|
||||||
|
```
|
||||||
|
|
||||||
|
Errors found: 3
|
||||||
|
|
||||||
|
## Fix 1: Unresolved Reference
|
||||||
|
|
||||||
|
File: src/main/kotlin/com/example/service/UserService.kt:25
|
||||||
|
Error: Unresolved reference: UserRepository
|
||||||
|
Cause: Missing import
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// Added import
|
||||||
|
import com.example.repository.UserRepository
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ ./gradlew build
|
||||||
|
# 2 errors remaining
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fix 2: Type Mismatch
|
||||||
|
|
||||||
|
File: src/main/kotlin/com/example/routes/UserRoutes.kt:42
|
||||||
|
Error: Type mismatch: inferred type is String but Int was expected
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// Changed
|
||||||
|
val count = call.parameters["count"]
|
||||||
|
// To
|
||||||
|
val count = call.parameters["count"]?.toIntOrNull()
|
||||||
|
?: return@get call.respond(HttpStatusCode.BadRequest, "Invalid count")
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ ./gradlew build
|
||||||
|
# 1 error remaining
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fix 3: Non-Exhaustive When
|
||||||
|
|
||||||
|
File: src/main/kotlin/com/example/routes/UserRoutes.kt:58
|
||||||
|
Error: 'when' expression must be exhaustive
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// Added missing branch
|
||||||
|
when (user.role) {
|
||||||
|
Role.ADMIN -> handleAdmin(user)
|
||||||
|
Role.USER -> handleUser(user)
|
||||||
|
Role.MODERATOR -> handleModerator(user) // Added
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ ./gradlew build
|
||||||
|
# Build successful!
|
||||||
|
```
|
||||||
|
|
||||||
|
## Final Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ ./gradlew detekt
|
||||||
|
# No issues
|
||||||
|
|
||||||
|
$ ./gradlew test
|
||||||
|
# All tests passed
|
||||||
|
```
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
| Metric | Count |
|
||||||
|
|--------|-------|
|
||||||
|
| Build errors fixed | 3 |
|
||||||
|
| Detekt issues fixed | 0 |
|
||||||
|
| Files modified | 2 |
|
||||||
|
| Remaining issues | 0 |
|
||||||
|
|
||||||
|
Build Status: ✅ SUCCESS
|
||||||
|
````
|
||||||
|
|
||||||
|
## Common Errors Fixed
|
||||||
|
|
||||||
|
| Error | Typical Fix |
|
||||||
|
|-------|-------------|
|
||||||
|
| `Unresolved reference: X` | Add import or dependency |
|
||||||
|
| `Type mismatch` | Fix type conversion or assignment |
|
||||||
|
| `'when' must be exhaustive` | Add missing sealed class branches |
|
||||||
|
| `Suspend function can only be called from coroutine` | Add `suspend` modifier |
|
||||||
|
| `Smart cast impossible` | Use local `val` or `let` |
|
||||||
|
| `None of the following candidates is applicable` | Fix argument types |
|
||||||
|
| `Could not resolve dependency` | Fix version or add repository |
|
||||||
|
|
||||||
|
## Fix Strategy
|
||||||
|
|
||||||
|
1. **Build errors first** - Code must compile
|
||||||
|
2. **Detekt violations second** - Fix code quality issues
|
||||||
|
3. **ktlint warnings third** - Fix formatting
|
||||||
|
4. **One fix at a time** - Verify each change
|
||||||
|
5. **Minimal changes** - Don't refactor, just fix
|
||||||
|
|
||||||
|
## Stop Conditions
|
||||||
|
|
||||||
|
The agent will stop and report if:
|
||||||
|
- Same error persists after 3 attempts
|
||||||
|
- Fix introduces more errors
|
||||||
|
- Requires architectural changes
|
||||||
|
- Missing external dependencies
|
||||||
|
|
||||||
|
## Related Commands
|
||||||
|
|
||||||
|
- `/kotlin-test` - Run tests after build succeeds
|
||||||
|
- `/kotlin-review` - Review code quality
|
||||||
|
- `/verify` - Full verification loop
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- Agent: `agents/kotlin-build-resolver.md`
|
||||||
|
- Skill: `skills/kotlin-patterns/`
|
||||||
140
commands/kotlin-review.md
Normal file
140
commands/kotlin-review.md
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
---
|
||||||
|
description: Comprehensive Kotlin code review for idiomatic patterns, null safety, coroutine safety, and security. Invokes the kotlin-reviewer agent.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Kotlin Code Review
|
||||||
|
|
||||||
|
This command invokes the **kotlin-reviewer** agent for comprehensive Kotlin-specific code review.
|
||||||
|
|
||||||
|
## What This Command Does
|
||||||
|
|
||||||
|
1. **Identify Kotlin Changes**: Find modified `.kt` and `.kts` files via `git diff`
|
||||||
|
2. **Run Build & Static Analysis**: Execute `./gradlew build`, `detekt`, `ktlintCheck`
|
||||||
|
3. **Security Scan**: Check for SQL injection, command injection, hardcoded secrets
|
||||||
|
4. **Null Safety Review**: Analyze `!!` usage, platform type handling, unsafe casts
|
||||||
|
5. **Coroutine Review**: Check structured concurrency, dispatcher usage, cancellation
|
||||||
|
6. **Generate Report**: Categorize issues by severity
|
||||||
|
|
||||||
|
## When to Use
|
||||||
|
|
||||||
|
Use `/kotlin-review` when:
|
||||||
|
- After writing or modifying Kotlin code
|
||||||
|
- Before committing Kotlin changes
|
||||||
|
- Reviewing pull requests with Kotlin code
|
||||||
|
- Onboarding to a new Kotlin codebase
|
||||||
|
- Learning idiomatic Kotlin patterns
|
||||||
|
|
||||||
|
## Review Categories
|
||||||
|
|
||||||
|
### CRITICAL (Must Fix)
|
||||||
|
- SQL/Command injection vulnerabilities
|
||||||
|
- Force-unwrap `!!` without justification
|
||||||
|
- Platform type null safety violations
|
||||||
|
- GlobalScope usage (structured concurrency violation)
|
||||||
|
- Hardcoded credentials
|
||||||
|
- Unsafe deserialization
|
||||||
|
|
||||||
|
### HIGH (Should Fix)
|
||||||
|
- Mutable state where immutable suffices
|
||||||
|
- Blocking calls inside coroutine context
|
||||||
|
- Missing cancellation checks in long loops
|
||||||
|
- Non-exhaustive `when` on sealed types
|
||||||
|
- Large functions (>50 lines)
|
||||||
|
- Deep nesting (>4 levels)
|
||||||
|
|
||||||
|
### MEDIUM (Consider)
|
||||||
|
- Non-idiomatic Kotlin (Java-style patterns)
|
||||||
|
- Missing trailing commas
|
||||||
|
- Scope function misuse or nesting
|
||||||
|
- Missing sequence for large collection chains
|
||||||
|
- Redundant explicit types
|
||||||
|
|
||||||
|
## Automated Checks Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build check
|
||||||
|
./gradlew build
|
||||||
|
|
||||||
|
# Static analysis
|
||||||
|
./gradlew detekt
|
||||||
|
|
||||||
|
# Formatting check
|
||||||
|
./gradlew ktlintCheck
|
||||||
|
|
||||||
|
# Tests
|
||||||
|
./gradlew test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example Usage
|
||||||
|
|
||||||
|
````text
|
||||||
|
User: /kotlin-review
|
||||||
|
|
||||||
|
Agent:
|
||||||
|
# Kotlin Code Review Report
|
||||||
|
|
||||||
|
## Files Reviewed
|
||||||
|
- src/main/kotlin/com/example/service/UserService.kt (modified)
|
||||||
|
- src/main/kotlin/com/example/routes/UserRoutes.kt (modified)
|
||||||
|
|
||||||
|
## Static Analysis Results
|
||||||
|
✓ Build: Successful
|
||||||
|
✓ detekt: No issues
|
||||||
|
⚠ ktlint: 2 formatting warnings
|
||||||
|
|
||||||
|
## Issues Found
|
||||||
|
|
||||||
|
[CRITICAL] Force-Unwrap Null Safety
|
||||||
|
File: src/main/kotlin/com/example/service/UserService.kt:28
|
||||||
|
Issue: Using !! on nullable repository result
|
||||||
|
```kotlin
|
||||||
|
val user = repository.findById(id)!! // NPE risk
|
||||||
|
```
|
||||||
|
Fix: Use safe call with error handling
|
||||||
|
```kotlin
|
||||||
|
val user = repository.findById(id)
|
||||||
|
?: throw UserNotFoundException("User $id not found")
|
||||||
|
```
|
||||||
|
|
||||||
|
[HIGH] GlobalScope Usage
|
||||||
|
File: src/main/kotlin/com/example/routes/UserRoutes.kt:45
|
||||||
|
Issue: Using GlobalScope breaks structured concurrency
|
||||||
|
```kotlin
|
||||||
|
GlobalScope.launch {
|
||||||
|
notificationService.sendWelcome(user)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Fix: Use the call's coroutine scope
|
||||||
|
```kotlin
|
||||||
|
launch {
|
||||||
|
notificationService.sendWelcome(user)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
- CRITICAL: 1
|
||||||
|
- HIGH: 1
|
||||||
|
- MEDIUM: 0
|
||||||
|
|
||||||
|
Recommendation: ❌ Block merge until CRITICAL issue is fixed
|
||||||
|
````
|
||||||
|
|
||||||
|
## Approval Criteria
|
||||||
|
|
||||||
|
| Status | Condition |
|
||||||
|
|--------|-----------|
|
||||||
|
| ✅ Approve | No CRITICAL or HIGH issues |
|
||||||
|
| ⚠️ Warning | Only MEDIUM issues (merge with caution) |
|
||||||
|
| ❌ Block | CRITICAL or HIGH issues found |
|
||||||
|
|
||||||
|
## Integration with Other Commands
|
||||||
|
|
||||||
|
- Use `/kotlin-test` first to ensure tests pass
|
||||||
|
- Use `/kotlin-build` if build errors occur
|
||||||
|
- Use `/kotlin-review` before committing
|
||||||
|
- Use `/code-review` for non-Kotlin-specific concerns
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- Agent: `agents/kotlin-reviewer.md`
|
||||||
|
- Skills: `skills/kotlin-patterns/`, `skills/kotlin-testing/`
|
||||||
312
commands/kotlin-test.md
Normal file
312
commands/kotlin-test.md
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
---
|
||||||
|
description: Enforce TDD workflow for Kotlin. Write Kotest tests first, then implement. Verify 80%+ coverage with Kover.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Kotlin TDD Command
|
||||||
|
|
||||||
|
This command enforces test-driven development methodology for Kotlin code using Kotest, MockK, and Kover.
|
||||||
|
|
||||||
|
## What This Command Does
|
||||||
|
|
||||||
|
1. **Define Types/Interfaces**: Scaffold function signatures first
|
||||||
|
2. **Write Kotest Tests**: Create comprehensive test specs (RED)
|
||||||
|
3. **Run Tests**: Verify tests fail for the right reason
|
||||||
|
4. **Implement Code**: Write minimal code to pass (GREEN)
|
||||||
|
5. **Refactor**: Improve while keeping tests green
|
||||||
|
6. **Check Coverage**: Ensure 80%+ coverage with Kover
|
||||||
|
|
||||||
|
## When to Use
|
||||||
|
|
||||||
|
Use `/kotlin-test` when:
|
||||||
|
- Implementing new Kotlin functions or classes
|
||||||
|
- Adding test coverage to existing Kotlin code
|
||||||
|
- Fixing bugs (write failing test first)
|
||||||
|
- Building critical business logic
|
||||||
|
- Learning TDD workflow in Kotlin
|
||||||
|
|
||||||
|
## TDD Cycle
|
||||||
|
|
||||||
|
```
|
||||||
|
RED -> Write failing Kotest test
|
||||||
|
GREEN -> Implement minimal code to pass
|
||||||
|
REFACTOR -> Improve code, tests stay green
|
||||||
|
REPEAT -> Next test case
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example Session
|
||||||
|
|
||||||
|
````text
|
||||||
|
User: /kotlin-test I need a function to validate user registration requests
|
||||||
|
|
||||||
|
Agent:
|
||||||
|
# TDD Session: Registration Validator
|
||||||
|
|
||||||
|
## Step 1: Define Interface
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// validator/RegistrationValidator.kt
|
||||||
|
package com.example.validator
|
||||||
|
|
||||||
|
data class RegistrationRequest(
|
||||||
|
val name: String,
|
||||||
|
val email: String,
|
||||||
|
val password: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed class ValidationResult {
|
||||||
|
data object Valid : ValidationResult()
|
||||||
|
data class Invalid(val errors: List<String>) : ValidationResult()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun validateRegistration(request: RegistrationRequest): ValidationResult {
|
||||||
|
TODO("not implemented")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 2: Write Kotest Tests (RED)
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// validator/RegistrationValidatorTest.kt
|
||||||
|
package com.example.validator
|
||||||
|
|
||||||
|
import io.kotest.core.spec.style.FunSpec
|
||||||
|
import io.kotest.matchers.shouldBe
|
||||||
|
import io.kotest.matchers.types.shouldBeInstanceOf
|
||||||
|
|
||||||
|
class RegistrationValidatorTest : FunSpec({
|
||||||
|
test("valid registration returns Valid") {
|
||||||
|
val request = RegistrationRequest(
|
||||||
|
name = "Alice",
|
||||||
|
email = "alice@example.com",
|
||||||
|
password = "SecureP@ss1",
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = validateRegistration(request)
|
||||||
|
|
||||||
|
result.shouldBeInstanceOf<ValidationResult.Valid>()
|
||||||
|
}
|
||||||
|
|
||||||
|
test("blank name returns Invalid") {
|
||||||
|
val request = RegistrationRequest(
|
||||||
|
name = "",
|
||||||
|
email = "alice@example.com",
|
||||||
|
password = "SecureP@ss1",
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = validateRegistration(request)
|
||||||
|
|
||||||
|
val invalid = result.shouldBeInstanceOf<ValidationResult.Invalid>()
|
||||||
|
invalid.errors shouldBe listOf("Name is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
test("invalid email returns Invalid") {
|
||||||
|
val request = RegistrationRequest(
|
||||||
|
name = "Alice",
|
||||||
|
email = "not-an-email",
|
||||||
|
password = "SecureP@ss1",
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = validateRegistration(request)
|
||||||
|
|
||||||
|
val invalid = result.shouldBeInstanceOf<ValidationResult.Invalid>()
|
||||||
|
invalid.errors shouldBe listOf("Invalid email format")
|
||||||
|
}
|
||||||
|
|
||||||
|
test("short password returns Invalid") {
|
||||||
|
val request = RegistrationRequest(
|
||||||
|
name = "Alice",
|
||||||
|
email = "alice@example.com",
|
||||||
|
password = "short",
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = validateRegistration(request)
|
||||||
|
|
||||||
|
val invalid = result.shouldBeInstanceOf<ValidationResult.Invalid>()
|
||||||
|
invalid.errors shouldBe listOf("Password must be at least 8 characters")
|
||||||
|
}
|
||||||
|
|
||||||
|
test("multiple errors returns all errors") {
|
||||||
|
val request = RegistrationRequest(
|
||||||
|
name = "",
|
||||||
|
email = "bad",
|
||||||
|
password = "short",
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = validateRegistration(request)
|
||||||
|
|
||||||
|
val invalid = result.shouldBeInstanceOf<ValidationResult.Invalid>()
|
||||||
|
invalid.errors.size shouldBe 3
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 3: Run Tests - Verify FAIL
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ ./gradlew test
|
||||||
|
|
||||||
|
RegistrationValidatorTest > valid registration returns Valid FAILED
|
||||||
|
kotlin.NotImplementedError: An operation is not implemented
|
||||||
|
|
||||||
|
FAILED (5 tests, 0 passed, 5 failed)
|
||||||
|
```
|
||||||
|
|
||||||
|
✓ Tests fail as expected (NotImplementedError).
|
||||||
|
|
||||||
|
## Step 4: Implement Minimal Code (GREEN)
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// validator/RegistrationValidator.kt
|
||||||
|
package com.example.validator
|
||||||
|
|
||||||
|
private val EMAIL_REGEX = Regex("^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$")
|
||||||
|
private const val MIN_PASSWORD_LENGTH = 8
|
||||||
|
|
||||||
|
fun validateRegistration(request: RegistrationRequest): ValidationResult {
|
||||||
|
val errors = buildList {
|
||||||
|
if (request.name.isBlank()) add("Name is required")
|
||||||
|
if (!EMAIL_REGEX.matches(request.email)) add("Invalid email format")
|
||||||
|
if (request.password.length < MIN_PASSWORD_LENGTH) add("Password must be at least $MIN_PASSWORD_LENGTH characters")
|
||||||
|
}
|
||||||
|
|
||||||
|
return if (errors.isEmpty()) ValidationResult.Valid
|
||||||
|
else ValidationResult.Invalid(errors)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 5: Run Tests - Verify PASS
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ ./gradlew test
|
||||||
|
|
||||||
|
RegistrationValidatorTest > valid registration returns Valid PASSED
|
||||||
|
RegistrationValidatorTest > blank name returns Invalid PASSED
|
||||||
|
RegistrationValidatorTest > invalid email returns Invalid PASSED
|
||||||
|
RegistrationValidatorTest > short password returns Invalid PASSED
|
||||||
|
RegistrationValidatorTest > multiple errors returns all errors PASSED
|
||||||
|
|
||||||
|
PASSED (5 tests, 5 passed, 0 failed)
|
||||||
|
```
|
||||||
|
|
||||||
|
✓ All tests passing!
|
||||||
|
|
||||||
|
## Step 6: Check Coverage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ ./gradlew koverHtmlReport
|
||||||
|
|
||||||
|
Coverage: 100.0% of statements
|
||||||
|
```
|
||||||
|
|
||||||
|
✓ Coverage: 100%
|
||||||
|
|
||||||
|
## TDD Complete!
|
||||||
|
````
|
||||||
|
|
||||||
|
## Test Patterns
|
||||||
|
|
||||||
|
### StringSpec (Simplest)
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
class CalculatorTest : StringSpec({
|
||||||
|
"add two positive numbers" {
|
||||||
|
Calculator.add(2, 3) shouldBe 5
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### BehaviorSpec (BDD)
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
class OrderServiceTest : BehaviorSpec({
|
||||||
|
Given("a valid order") {
|
||||||
|
When("placed") {
|
||||||
|
Then("should be confirmed") { /* ... */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data-Driven Tests
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
class ParserTest : FunSpec({
|
||||||
|
context("valid inputs") {
|
||||||
|
withData("2026-01-15", "2026-12-31", "2000-01-01") { input ->
|
||||||
|
parseDate(input).shouldNotBeNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Coroutine Testing
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
class AsyncServiceTest : FunSpec({
|
||||||
|
test("concurrent fetch completes") {
|
||||||
|
runTest {
|
||||||
|
val result = service.fetchAll()
|
||||||
|
result.shouldNotBeEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Coverage Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run tests with coverage
|
||||||
|
./gradlew koverHtmlReport
|
||||||
|
|
||||||
|
# Verify coverage thresholds
|
||||||
|
./gradlew koverVerify
|
||||||
|
|
||||||
|
# XML report for CI
|
||||||
|
./gradlew koverXmlReport
|
||||||
|
|
||||||
|
# Open HTML report
|
||||||
|
open build/reports/kover/html/index.html
|
||||||
|
|
||||||
|
# Run specific test class
|
||||||
|
./gradlew test --tests "com.example.UserServiceTest"
|
||||||
|
|
||||||
|
# Run with verbose output
|
||||||
|
./gradlew test --info
|
||||||
|
```
|
||||||
|
|
||||||
|
## Coverage Targets
|
||||||
|
|
||||||
|
| Code Type | Target |
|
||||||
|
|-----------|--------|
|
||||||
|
| Critical business logic | 100% |
|
||||||
|
| Public APIs | 90%+ |
|
||||||
|
| General code | 80%+ |
|
||||||
|
| Generated code | Exclude |
|
||||||
|
|
||||||
|
## TDD Best Practices
|
||||||
|
|
||||||
|
**DO:**
|
||||||
|
- Write test FIRST, before any implementation
|
||||||
|
- Run tests after each change
|
||||||
|
- Use Kotest matchers for expressive assertions
|
||||||
|
- Use MockK's `coEvery`/`coVerify` for suspend functions
|
||||||
|
- Test behavior, not implementation details
|
||||||
|
- Include edge cases (empty, null, max values)
|
||||||
|
|
||||||
|
**DON'T:**
|
||||||
|
- Write implementation before tests
|
||||||
|
- Skip the RED phase
|
||||||
|
- Test private functions directly
|
||||||
|
- Use `Thread.sleep()` in coroutine tests
|
||||||
|
- Ignore flaky tests
|
||||||
|
|
||||||
|
## Related Commands
|
||||||
|
|
||||||
|
- `/kotlin-build` - Fix build errors
|
||||||
|
- `/kotlin-review` - Review code after implementation
|
||||||
|
- `/verify` - Run full verification loop
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- Skill: `skills/kotlin-testing/`
|
||||||
|
- Skill: `skills/tdd-workflow/`
|
||||||
17
rules/kotlin/hooks.md
Normal file
17
rules/kotlin/hooks.md
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
---
|
||||||
|
paths:
|
||||||
|
- "**/*.kt"
|
||||||
|
- "**/*.kts"
|
||||||
|
- "**/build.gradle.kts"
|
||||||
|
---
|
||||||
|
# Kotlin Hooks
|
||||||
|
|
||||||
|
> This file extends [common/hooks.md](../common/hooks.md) with Kotlin-specific content.
|
||||||
|
|
||||||
|
## PostToolUse Hooks
|
||||||
|
|
||||||
|
Configure in `~/.claude/settings.json`:
|
||||||
|
|
||||||
|
- **ktfmt/ktlint**: Auto-format `.kt` and `.kts` files after edit
|
||||||
|
- **detekt**: Run static analysis after editing Kotlin files
|
||||||
|
- **./gradlew build**: Verify compilation after changes
|
||||||
719
skills/kotlin-exposed-patterns/SKILL.md
Normal file
719
skills/kotlin-exposed-patterns/SKILL.md
Normal file
@@ -0,0 +1,719 @@
|
|||||||
|
---
|
||||||
|
name: kotlin-exposed-patterns
|
||||||
|
description: JetBrains Exposed ORM patterns including DSL queries, DAO pattern, transactions, HikariCP connection pooling, Flyway migrations, and repository pattern.
|
||||||
|
origin: ECC
|
||||||
|
---
|
||||||
|
|
||||||
|
# Kotlin Exposed Patterns
|
||||||
|
|
||||||
|
Comprehensive patterns for database access with JetBrains Exposed ORM, including DSL queries, DAO, transactions, and production-ready configuration.
|
||||||
|
|
||||||
|
## When to Use
|
||||||
|
|
||||||
|
- Setting up database access with Exposed
|
||||||
|
- Writing SQL queries using Exposed DSL or DAO
|
||||||
|
- Configuring connection pooling with HikariCP
|
||||||
|
- Creating database migrations with Flyway
|
||||||
|
- Implementing the repository pattern with Exposed
|
||||||
|
- Handling JSON columns and complex queries
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
Exposed provides two query styles: DSL for direct SQL-like expressions and DAO for entity lifecycle management. HikariCP manages a pool of reusable database connections configured via `HikariConfig`. Flyway runs versioned SQL migration scripts at startup to keep the schema in sync. All database operations run inside `newSuspendedTransaction` blocks for coroutine safety and atomicity. The repository pattern wraps Exposed queries behind an interface so business logic stays decoupled from the data layer and tests can use an in-memory H2 database.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### DSL Query
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
suspend fun findUserById(id: UUID): UserRow? =
|
||||||
|
newSuspendedTransaction {
|
||||||
|
UsersTable.selectAll()
|
||||||
|
.where { UsersTable.id eq id }
|
||||||
|
.map { it.toUser() }
|
||||||
|
.singleOrNull()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### DAO Entity Usage
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
suspend fun createUser(request: CreateUserRequest): User =
|
||||||
|
newSuspendedTransaction {
|
||||||
|
UserEntity.new {
|
||||||
|
name = request.name
|
||||||
|
email = request.email
|
||||||
|
role = request.role
|
||||||
|
}.toModel()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### HikariCP Configuration
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
val hikariConfig = HikariConfig().apply {
|
||||||
|
driverClassName = config.driver
|
||||||
|
jdbcUrl = config.url
|
||||||
|
username = config.username
|
||||||
|
password = config.password
|
||||||
|
maximumPoolSize = config.maxPoolSize
|
||||||
|
isAutoCommit = false
|
||||||
|
transactionIsolation = "TRANSACTION_READ_COMMITTED"
|
||||||
|
validate()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database Setup
|
||||||
|
|
||||||
|
### HikariCP Connection Pooling
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// DatabaseFactory.kt
|
||||||
|
object DatabaseFactory {
|
||||||
|
fun create(config: DatabaseConfig): Database {
|
||||||
|
val hikariConfig = HikariConfig().apply {
|
||||||
|
driverClassName = config.driver
|
||||||
|
jdbcUrl = config.url
|
||||||
|
username = config.username
|
||||||
|
password = config.password
|
||||||
|
maximumPoolSize = config.maxPoolSize
|
||||||
|
isAutoCommit = false
|
||||||
|
transactionIsolation = "TRANSACTION_READ_COMMITTED"
|
||||||
|
validate()
|
||||||
|
}
|
||||||
|
|
||||||
|
return Database.connect(HikariDataSource(hikariConfig))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class DatabaseConfig(
|
||||||
|
val url: String,
|
||||||
|
val driver: String = "org.postgresql.Driver",
|
||||||
|
val username: String = "",
|
||||||
|
val password: String = "",
|
||||||
|
val maxPoolSize: Int = 10,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Flyway Migrations
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// FlywayMigration.kt
|
||||||
|
fun runMigrations(config: DatabaseConfig) {
|
||||||
|
Flyway.configure()
|
||||||
|
.dataSource(config.url, config.username, config.password)
|
||||||
|
.locations("classpath:db/migration")
|
||||||
|
.baselineOnMigrate(true)
|
||||||
|
.load()
|
||||||
|
.migrate()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Application startup
|
||||||
|
fun Application.module() {
|
||||||
|
val config = DatabaseConfig(
|
||||||
|
url = environment.config.property("database.url").getString(),
|
||||||
|
username = environment.config.property("database.username").getString(),
|
||||||
|
password = environment.config.property("database.password").getString(),
|
||||||
|
)
|
||||||
|
runMigrations(config)
|
||||||
|
val database = DatabaseFactory.create(config)
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Migration Files
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- src/main/resources/db/migration/V1__create_users.sql
|
||||||
|
CREATE TABLE users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
email VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
role VARCHAR(20) NOT NULL DEFAULT 'USER',
|
||||||
|
metadata JSONB,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_users_email ON users(email);
|
||||||
|
CREATE INDEX idx_users_role ON users(role);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Table Definitions
|
||||||
|
|
||||||
|
### DSL Style Tables
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// tables/UsersTable.kt
|
||||||
|
object UsersTable : UUIDTable("users") {
|
||||||
|
val name = varchar("name", 100)
|
||||||
|
val email = varchar("email", 255).uniqueIndex()
|
||||||
|
val role = enumerationByName<Role>("role", 20)
|
||||||
|
val metadata = jsonb<UserMetadata>("metadata", Json.Default).nullable()
|
||||||
|
val createdAt = timestampWithTimeZone("created_at").defaultExpression(CurrentTimestampWithTimeZone)
|
||||||
|
val updatedAt = timestampWithTimeZone("updated_at").defaultExpression(CurrentTimestampWithTimeZone)
|
||||||
|
}
|
||||||
|
|
||||||
|
object OrdersTable : UUIDTable("orders") {
|
||||||
|
val userId = uuid("user_id").references(UsersTable.id)
|
||||||
|
val status = enumerationByName<OrderStatus>("status", 20)
|
||||||
|
val totalAmount = long("total_amount")
|
||||||
|
val currency = varchar("currency", 3)
|
||||||
|
val createdAt = timestampWithTimeZone("created_at").defaultExpression(CurrentTimestampWithTimeZone)
|
||||||
|
}
|
||||||
|
|
||||||
|
object OrderItemsTable : UUIDTable("order_items") {
|
||||||
|
val orderId = uuid("order_id").references(OrdersTable.id, onDelete = ReferenceOption.CASCADE)
|
||||||
|
val productId = uuid("product_id")
|
||||||
|
val quantity = integer("quantity")
|
||||||
|
val unitPrice = long("unit_price")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Composite Tables
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
object UserRolesTable : Table("user_roles") {
|
||||||
|
val userId = uuid("user_id").references(UsersTable.id, onDelete = ReferenceOption.CASCADE)
|
||||||
|
val roleId = uuid("role_id").references(RolesTable.id, onDelete = ReferenceOption.CASCADE)
|
||||||
|
override val primaryKey = PrimaryKey(userId, roleId)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## DSL Queries
|
||||||
|
|
||||||
|
### Basic CRUD
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// Insert
|
||||||
|
suspend fun insertUser(name: String, email: String, role: Role): UUID =
|
||||||
|
newSuspendedTransaction {
|
||||||
|
UsersTable.insertAndGetId {
|
||||||
|
it[UsersTable.name] = name
|
||||||
|
it[UsersTable.email] = email
|
||||||
|
it[UsersTable.role] = role
|
||||||
|
}.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select by ID
|
||||||
|
suspend fun findUserById(id: UUID): UserRow? =
|
||||||
|
newSuspendedTransaction {
|
||||||
|
UsersTable.selectAll()
|
||||||
|
.where { UsersTable.id eq id }
|
||||||
|
.map { it.toUser() }
|
||||||
|
.singleOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select with conditions
|
||||||
|
suspend fun findActiveAdmins(): List<UserRow> =
|
||||||
|
newSuspendedTransaction {
|
||||||
|
UsersTable.selectAll()
|
||||||
|
.where { (UsersTable.role eq Role.ADMIN) }
|
||||||
|
.orderBy(UsersTable.name)
|
||||||
|
.map { it.toUser() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update
|
||||||
|
suspend fun updateUserEmail(id: UUID, newEmail: String): Boolean =
|
||||||
|
newSuspendedTransaction {
|
||||||
|
UsersTable.update({ UsersTable.id eq id }) {
|
||||||
|
it[email] = newEmail
|
||||||
|
it[updatedAt] = CurrentTimestampWithTimeZone
|
||||||
|
} > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete
|
||||||
|
suspend fun deleteUser(id: UUID): Boolean =
|
||||||
|
newSuspendedTransaction {
|
||||||
|
UsersTable.deleteWhere { UsersTable.id eq id } > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Row mapping
|
||||||
|
private fun ResultRow.toUser() = UserRow(
|
||||||
|
id = this[UsersTable.id].value,
|
||||||
|
name = this[UsersTable.name],
|
||||||
|
email = this[UsersTable.email],
|
||||||
|
role = this[UsersTable.role],
|
||||||
|
metadata = this[UsersTable.metadata],
|
||||||
|
createdAt = this[UsersTable.createdAt],
|
||||||
|
updatedAt = this[UsersTable.updatedAt],
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Advanced Queries
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// Join queries
|
||||||
|
suspend fun findOrdersWithUser(userId: UUID): List<OrderWithUser> =
|
||||||
|
newSuspendedTransaction {
|
||||||
|
(OrdersTable innerJoin UsersTable)
|
||||||
|
.selectAll()
|
||||||
|
.where { OrdersTable.userId eq userId }
|
||||||
|
.orderBy(OrdersTable.createdAt, SortOrder.DESC)
|
||||||
|
.map { row ->
|
||||||
|
OrderWithUser(
|
||||||
|
orderId = row[OrdersTable.id].value,
|
||||||
|
status = row[OrdersTable.status],
|
||||||
|
totalAmount = row[OrdersTable.totalAmount],
|
||||||
|
userName = row[UsersTable.name],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aggregation
|
||||||
|
suspend fun countUsersByRole(): Map<Role, Long> =
|
||||||
|
newSuspendedTransaction {
|
||||||
|
UsersTable
|
||||||
|
.select(UsersTable.role, UsersTable.id.count())
|
||||||
|
.groupBy(UsersTable.role)
|
||||||
|
.associate { row ->
|
||||||
|
row[UsersTable.role] to row[UsersTable.id.count()]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subqueries
|
||||||
|
suspend fun findUsersWithOrders(): List<UserRow> =
|
||||||
|
newSuspendedTransaction {
|
||||||
|
UsersTable.selectAll()
|
||||||
|
.where {
|
||||||
|
UsersTable.id inSubQuery
|
||||||
|
OrdersTable.select(OrdersTable.userId).withDistinct()
|
||||||
|
}
|
||||||
|
.map { it.toUser() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// LIKE and pattern matching — always escape user input to prevent wildcard injection
|
||||||
|
private fun escapeLikePattern(input: String): String =
|
||||||
|
input.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||||
|
|
||||||
|
suspend fun searchUsers(query: String): List<UserRow> =
|
||||||
|
newSuspendedTransaction {
|
||||||
|
val sanitized = escapeLikePattern(query.lowercase())
|
||||||
|
UsersTable.selectAll()
|
||||||
|
.where {
|
||||||
|
(UsersTable.name.lowerCase() like "%${sanitized}%") or
|
||||||
|
(UsersTable.email.lowerCase() like "%${sanitized}%")
|
||||||
|
}
|
||||||
|
.map { it.toUser() }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pagination
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
data class Page<T>(
|
||||||
|
val data: List<T>,
|
||||||
|
val total: Long,
|
||||||
|
val page: Int,
|
||||||
|
val limit: Int,
|
||||||
|
) {
|
||||||
|
val totalPages: Int get() = ((total + limit - 1) / limit).toInt()
|
||||||
|
val hasNext: Boolean get() = page < totalPages
|
||||||
|
val hasPrevious: Boolean get() = page > 1
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun findUsersPaginated(page: Int, limit: Int): Page<UserRow> =
|
||||||
|
newSuspendedTransaction {
|
||||||
|
val total = UsersTable.selectAll().count()
|
||||||
|
val data = UsersTable.selectAll()
|
||||||
|
.orderBy(UsersTable.createdAt, SortOrder.DESC)
|
||||||
|
.limit(limit)
|
||||||
|
.offset(((page - 1) * limit).toLong())
|
||||||
|
.map { it.toUser() }
|
||||||
|
|
||||||
|
Page(data = data, total = total, page = page, limit = limit)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Batch Operations
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// Batch insert
|
||||||
|
suspend fun insertUsers(users: List<CreateUserRequest>): List<UUID> =
|
||||||
|
newSuspendedTransaction {
|
||||||
|
UsersTable.batchInsert(users) { user ->
|
||||||
|
this[UsersTable.name] = user.name
|
||||||
|
this[UsersTable.email] = user.email
|
||||||
|
this[UsersTable.role] = user.role
|
||||||
|
}.map { it[UsersTable.id].value }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert (insert or update on conflict)
|
||||||
|
suspend fun upsertUser(id: UUID, name: String, email: String) {
|
||||||
|
newSuspendedTransaction {
|
||||||
|
UsersTable.upsert(UsersTable.email) {
|
||||||
|
it[UsersTable.id] = EntityID(id, UsersTable)
|
||||||
|
it[UsersTable.name] = name
|
||||||
|
it[UsersTable.email] = email
|
||||||
|
it[updatedAt] = CurrentTimestampWithTimeZone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## DAO Pattern
|
||||||
|
|
||||||
|
### Entity Definitions
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// entities/UserEntity.kt
|
||||||
|
class UserEntity(id: EntityID<UUID>) : UUIDEntity(id) {
|
||||||
|
companion object : UUIDEntityClass<UserEntity>(UsersTable)
|
||||||
|
|
||||||
|
var name by UsersTable.name
|
||||||
|
var email by UsersTable.email
|
||||||
|
var role by UsersTable.role
|
||||||
|
var metadata by UsersTable.metadata
|
||||||
|
var createdAt by UsersTable.createdAt
|
||||||
|
var updatedAt by UsersTable.updatedAt
|
||||||
|
|
||||||
|
val orders by OrderEntity referrersOn OrdersTable.userId
|
||||||
|
|
||||||
|
fun toModel(): User = User(
|
||||||
|
id = id.value,
|
||||||
|
name = name,
|
||||||
|
email = email,
|
||||||
|
role = role,
|
||||||
|
metadata = metadata,
|
||||||
|
createdAt = createdAt,
|
||||||
|
updatedAt = updatedAt,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
class OrderEntity(id: EntityID<UUID>) : UUIDEntity(id) {
|
||||||
|
companion object : UUIDEntityClass<OrderEntity>(OrdersTable)
|
||||||
|
|
||||||
|
var user by UserEntity referencedOn OrdersTable.userId
|
||||||
|
var status by OrdersTable.status
|
||||||
|
var totalAmount by OrdersTable.totalAmount
|
||||||
|
var currency by OrdersTable.currency
|
||||||
|
var createdAt by OrdersTable.createdAt
|
||||||
|
|
||||||
|
val items by OrderItemEntity referrersOn OrderItemsTable.orderId
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### DAO Operations
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
suspend fun findUserByEmail(email: String): User? =
|
||||||
|
newSuspendedTransaction {
|
||||||
|
UserEntity.find { UsersTable.email eq email }
|
||||||
|
.firstOrNull()
|
||||||
|
?.toModel()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun createUser(request: CreateUserRequest): User =
|
||||||
|
newSuspendedTransaction {
|
||||||
|
UserEntity.new {
|
||||||
|
name = request.name
|
||||||
|
email = request.email
|
||||||
|
role = request.role
|
||||||
|
}.toModel()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updateUser(id: UUID, request: UpdateUserRequest): User? =
|
||||||
|
newSuspendedTransaction {
|
||||||
|
UserEntity.findById(id)?.apply {
|
||||||
|
request.name?.let { name = it }
|
||||||
|
request.email?.let { email = it }
|
||||||
|
updatedAt = OffsetDateTime.now(ZoneOffset.UTC)
|
||||||
|
}?.toModel()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Transactions
|
||||||
|
|
||||||
|
### Suspend Transaction Support
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// Good: Use newSuspendedTransaction for coroutine support
|
||||||
|
suspend fun performDatabaseOperation(): Result<User> =
|
||||||
|
runCatching {
|
||||||
|
newSuspendedTransaction {
|
||||||
|
val user = UserEntity.new {
|
||||||
|
name = "Alice"
|
||||||
|
email = "alice@example.com"
|
||||||
|
}
|
||||||
|
// All operations in this block are atomic
|
||||||
|
user.toModel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Good: Nested transactions with savepoints
|
||||||
|
suspend fun transferFunds(fromId: UUID, toId: UUID, amount: Long) {
|
||||||
|
newSuspendedTransaction {
|
||||||
|
val from = UserEntity.findById(fromId) ?: throw NotFoundException("User $fromId not found")
|
||||||
|
val to = UserEntity.findById(toId) ?: throw NotFoundException("User $toId not found")
|
||||||
|
|
||||||
|
// Debit
|
||||||
|
from.balance -= amount
|
||||||
|
// Credit
|
||||||
|
to.balance += amount
|
||||||
|
|
||||||
|
// Both succeed or both fail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Transaction Isolation
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
suspend fun readCommittedQuery(): List<User> =
|
||||||
|
newSuspendedTransaction(transactionIsolation = Connection.TRANSACTION_READ_COMMITTED) {
|
||||||
|
UserEntity.all().map { it.toModel() }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun serializableOperation() {
|
||||||
|
newSuspendedTransaction(transactionIsolation = Connection.TRANSACTION_SERIALIZABLE) {
|
||||||
|
// Strictest isolation level for critical operations
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Repository Pattern
|
||||||
|
|
||||||
|
### Interface Definition
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
interface UserRepository {
|
||||||
|
suspend fun findById(id: UUID): User?
|
||||||
|
suspend fun findByEmail(email: String): User?
|
||||||
|
suspend fun findAll(page: Int, limit: Int): Page<User>
|
||||||
|
suspend fun search(query: String): List<User>
|
||||||
|
suspend fun create(request: CreateUserRequest): User
|
||||||
|
suspend fun update(id: UUID, request: UpdateUserRequest): User?
|
||||||
|
suspend fun delete(id: UUID): Boolean
|
||||||
|
suspend fun count(): Long
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Exposed Implementation
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
class ExposedUserRepository(
|
||||||
|
private val database: Database,
|
||||||
|
) : UserRepository {
|
||||||
|
|
||||||
|
override suspend fun findById(id: UUID): User? =
|
||||||
|
newSuspendedTransaction(db = database) {
|
||||||
|
UsersTable.selectAll()
|
||||||
|
.where { UsersTable.id eq id }
|
||||||
|
.map { it.toUser() }
|
||||||
|
.singleOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun findByEmail(email: String): User? =
|
||||||
|
newSuspendedTransaction(db = database) {
|
||||||
|
UsersTable.selectAll()
|
||||||
|
.where { UsersTable.email eq email }
|
||||||
|
.map { it.toUser() }
|
||||||
|
.singleOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun findAll(page: Int, limit: Int): Page<User> =
|
||||||
|
newSuspendedTransaction(db = database) {
|
||||||
|
val total = UsersTable.selectAll().count()
|
||||||
|
val data = UsersTable.selectAll()
|
||||||
|
.orderBy(UsersTable.createdAt, SortOrder.DESC)
|
||||||
|
.limit(limit)
|
||||||
|
.offset(((page - 1) * limit).toLong())
|
||||||
|
.map { it.toUser() }
|
||||||
|
Page(data = data, total = total, page = page, limit = limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun search(query: String): List<User> =
|
||||||
|
newSuspendedTransaction(db = database) {
|
||||||
|
val sanitized = escapeLikePattern(query.lowercase())
|
||||||
|
UsersTable.selectAll()
|
||||||
|
.where {
|
||||||
|
(UsersTable.name.lowerCase() like "%${sanitized}%") or
|
||||||
|
(UsersTable.email.lowerCase() like "%${sanitized}%")
|
||||||
|
}
|
||||||
|
.orderBy(UsersTable.name)
|
||||||
|
.map { it.toUser() }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun create(request: CreateUserRequest): User =
|
||||||
|
newSuspendedTransaction(db = database) {
|
||||||
|
UsersTable.insert {
|
||||||
|
it[name] = request.name
|
||||||
|
it[email] = request.email
|
||||||
|
it[role] = request.role
|
||||||
|
}.resultedValues!!.first().toUser()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun update(id: UUID, request: UpdateUserRequest): User? =
|
||||||
|
newSuspendedTransaction(db = database) {
|
||||||
|
val updated = UsersTable.update({ UsersTable.id eq id }) {
|
||||||
|
request.name?.let { name -> it[UsersTable.name] = name }
|
||||||
|
request.email?.let { email -> it[UsersTable.email] = email }
|
||||||
|
it[updatedAt] = CurrentTimestampWithTimeZone
|
||||||
|
}
|
||||||
|
if (updated > 0) findById(id) else null
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun delete(id: UUID): Boolean =
|
||||||
|
newSuspendedTransaction(db = database) {
|
||||||
|
UsersTable.deleteWhere { UsersTable.id eq id } > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun count(): Long =
|
||||||
|
newSuspendedTransaction(db = database) {
|
||||||
|
UsersTable.selectAll().count()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ResultRow.toUser() = User(
|
||||||
|
id = this[UsersTable.id].value,
|
||||||
|
name = this[UsersTable.name],
|
||||||
|
email = this[UsersTable.email],
|
||||||
|
role = this[UsersTable.role],
|
||||||
|
metadata = this[UsersTable.metadata],
|
||||||
|
createdAt = this[UsersTable.createdAt],
|
||||||
|
updatedAt = this[UsersTable.updatedAt],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## JSON Columns
|
||||||
|
|
||||||
|
### JSONB with kotlinx.serialization
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// Custom column type for JSONB
|
||||||
|
inline fun <reified T : Any> Table.jsonb(
|
||||||
|
name: String,
|
||||||
|
json: Json,
|
||||||
|
): Column<T> = registerColumn(name, object : ColumnType<T>() {
|
||||||
|
override fun sqlType() = "JSONB"
|
||||||
|
|
||||||
|
override fun valueFromDB(value: Any): T = when (value) {
|
||||||
|
is String -> json.decodeFromString(value)
|
||||||
|
is PGobject -> {
|
||||||
|
val jsonString = value.value
|
||||||
|
?: throw IllegalArgumentException("PGobject value is null for column '$name'")
|
||||||
|
json.decodeFromString(jsonString)
|
||||||
|
}
|
||||||
|
else -> throw IllegalArgumentException("Unexpected value: $value")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun notNullValueToDB(value: T): Any =
|
||||||
|
PGobject().apply {
|
||||||
|
type = "jsonb"
|
||||||
|
this.value = json.encodeToString(value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Usage in table
|
||||||
|
@Serializable
|
||||||
|
data class UserMetadata(
|
||||||
|
val preferences: Map<String, String> = emptyMap(),
|
||||||
|
val tags: List<String> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
object UsersTable : UUIDTable("users") {
|
||||||
|
val metadata = jsonb<UserMetadata>("metadata", Json.Default).nullable()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing with Exposed
|
||||||
|
|
||||||
|
### In-Memory Database for Tests
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
class UserRepositoryTest : FunSpec({
|
||||||
|
lateinit var database: Database
|
||||||
|
lateinit var repository: UserRepository
|
||||||
|
|
||||||
|
beforeSpec {
|
||||||
|
database = Database.connect(
|
||||||
|
url = "jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;MODE=PostgreSQL",
|
||||||
|
driver = "org.h2.Driver",
|
||||||
|
)
|
||||||
|
transaction(database) {
|
||||||
|
SchemaUtils.create(UsersTable)
|
||||||
|
}
|
||||||
|
repository = ExposedUserRepository(database)
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeTest {
|
||||||
|
transaction(database) {
|
||||||
|
UsersTable.deleteAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("create and find user") {
|
||||||
|
val user = repository.create(CreateUserRequest("Alice", "alice@example.com"))
|
||||||
|
|
||||||
|
user.name shouldBe "Alice"
|
||||||
|
user.email shouldBe "alice@example.com"
|
||||||
|
|
||||||
|
val found = repository.findById(user.id)
|
||||||
|
found shouldBe user
|
||||||
|
}
|
||||||
|
|
||||||
|
test("findByEmail returns null for unknown email") {
|
||||||
|
val result = repository.findByEmail("unknown@example.com")
|
||||||
|
result.shouldBeNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
test("pagination works correctly") {
|
||||||
|
repeat(25) { i ->
|
||||||
|
repository.create(CreateUserRequest("User $i", "user$i@example.com"))
|
||||||
|
}
|
||||||
|
|
||||||
|
val page1 = repository.findAll(page = 1, limit = 10)
|
||||||
|
page1.data shouldHaveSize 10
|
||||||
|
page1.total shouldBe 25
|
||||||
|
page1.hasNext shouldBe true
|
||||||
|
|
||||||
|
val page3 = repository.findAll(page = 3, limit = 10)
|
||||||
|
page3.data shouldHaveSize 5
|
||||||
|
page3.hasNext shouldBe false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Gradle Dependencies
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// build.gradle.kts
|
||||||
|
dependencies {
|
||||||
|
// Exposed
|
||||||
|
implementation("org.jetbrains.exposed:exposed-core:1.0.0")
|
||||||
|
implementation("org.jetbrains.exposed:exposed-dao:1.0.0")
|
||||||
|
implementation("org.jetbrains.exposed:exposed-jdbc:1.0.0")
|
||||||
|
implementation("org.jetbrains.exposed:exposed-kotlin-datetime:1.0.0")
|
||||||
|
implementation("org.jetbrains.exposed:exposed-json:1.0.0")
|
||||||
|
|
||||||
|
// Database driver
|
||||||
|
implementation("org.postgresql:postgresql:42.7.5")
|
||||||
|
|
||||||
|
// Connection pooling
|
||||||
|
implementation("com.zaxxer:HikariCP:6.2.1")
|
||||||
|
|
||||||
|
// Migrations
|
||||||
|
implementation("org.flywaydb:flyway-core:10.22.0")
|
||||||
|
implementation("org.flywaydb:flyway-database-postgresql:10.22.0")
|
||||||
|
|
||||||
|
// Testing
|
||||||
|
testImplementation("com.h2database:h2:2.3.232")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Reference: Exposed Patterns
|
||||||
|
|
||||||
|
| Pattern | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `object Table : UUIDTable("name")` | Define table with UUID primary key |
|
||||||
|
| `newSuspendedTransaction { }` | Coroutine-safe transaction block |
|
||||||
|
| `Table.selectAll().where { }` | Query with conditions |
|
||||||
|
| `Table.insertAndGetId { }` | Insert and return generated ID |
|
||||||
|
| `Table.update({ condition }) { }` | Update matching rows |
|
||||||
|
| `Table.deleteWhere { }` | Delete matching rows |
|
||||||
|
| `Table.batchInsert(items) { }` | Efficient bulk insert |
|
||||||
|
| `innerJoin` / `leftJoin` | Join tables |
|
||||||
|
| `orderBy` / `limit` / `offset` | Sort and paginate |
|
||||||
|
| `count()` / `sum()` / `avg()` | Aggregation functions |
|
||||||
|
|
||||||
|
**Remember**: Use the DSL style for simple queries and the DAO style when you need entity lifecycle management. Always use `newSuspendedTransaction` for coroutine support, and wrap database operations behind a repository interface for testability.
|
||||||
689
skills/kotlin-ktor-patterns/SKILL.md
Normal file
689
skills/kotlin-ktor-patterns/SKILL.md
Normal file
@@ -0,0 +1,689 @@
|
|||||||
|
---
|
||||||
|
name: kotlin-ktor-patterns
|
||||||
|
description: Ktor server patterns including routing DSL, plugins, authentication, Koin DI, kotlinx.serialization, WebSockets, and testApplication testing.
|
||||||
|
origin: ECC
|
||||||
|
---
|
||||||
|
|
||||||
|
# Ktor Server Patterns
|
||||||
|
|
||||||
|
Comprehensive Ktor patterns for building robust, maintainable HTTP servers with Kotlin coroutines.
|
||||||
|
|
||||||
|
## When to Activate
|
||||||
|
|
||||||
|
- Building Ktor HTTP servers
|
||||||
|
- Configuring Ktor plugins (Auth, CORS, ContentNegotiation, StatusPages)
|
||||||
|
- Implementing REST APIs with Ktor
|
||||||
|
- Setting up dependency injection with Koin
|
||||||
|
- Writing Ktor integration tests with testApplication
|
||||||
|
- Working with WebSockets in Ktor
|
||||||
|
|
||||||
|
## Application Structure
|
||||||
|
|
||||||
|
### Standard Ktor Project Layout
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/main/kotlin/
|
||||||
|
├── com/example/
|
||||||
|
│ ├── Application.kt # Entry point, module configuration
|
||||||
|
│ ├── plugins/
|
||||||
|
│ │ ├── Routing.kt # Route definitions
|
||||||
|
│ │ ├── Serialization.kt # Content negotiation setup
|
||||||
|
│ │ ├── Authentication.kt # Auth configuration
|
||||||
|
│ │ ├── StatusPages.kt # Error handling
|
||||||
|
│ │ └── CORS.kt # CORS configuration
|
||||||
|
│ ├── routes/
|
||||||
|
│ │ ├── UserRoutes.kt # /users endpoints
|
||||||
|
│ │ ├── AuthRoutes.kt # /auth endpoints
|
||||||
|
│ │ └── HealthRoutes.kt # /health endpoints
|
||||||
|
│ ├── models/
|
||||||
|
│ │ ├── User.kt # Domain models
|
||||||
|
│ │ └── ApiResponse.kt # Response envelopes
|
||||||
|
│ ├── services/
|
||||||
|
│ │ ├── UserService.kt # Business logic
|
||||||
|
│ │ └── AuthService.kt # Auth logic
|
||||||
|
│ ├── repositories/
|
||||||
|
│ │ ├── UserRepository.kt # Data access interface
|
||||||
|
│ │ └── ExposedUserRepository.kt
|
||||||
|
│ └── di/
|
||||||
|
│ └── AppModule.kt # Koin modules
|
||||||
|
src/test/kotlin/
|
||||||
|
├── com/example/
|
||||||
|
│ ├── routes/
|
||||||
|
│ │ └── UserRoutesTest.kt
|
||||||
|
│ └── services/
|
||||||
|
│ └── UserServiceTest.kt
|
||||||
|
```
|
||||||
|
|
||||||
|
### Application Entry Point
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// Application.kt
|
||||||
|
fun main() {
|
||||||
|
embeddedServer(Netty, port = 8080, module = Application::module).start(wait = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Application.module() {
|
||||||
|
configureSerialization()
|
||||||
|
configureAuthentication()
|
||||||
|
configureStatusPages()
|
||||||
|
configureCORS()
|
||||||
|
configureDI()
|
||||||
|
configureRouting()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Routing DSL
|
||||||
|
|
||||||
|
### Basic Routes
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// plugins/Routing.kt
|
||||||
|
fun Application.configureRouting() {
|
||||||
|
routing {
|
||||||
|
userRoutes()
|
||||||
|
authRoutes()
|
||||||
|
healthRoutes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// routes/UserRoutes.kt
|
||||||
|
fun Route.userRoutes() {
|
||||||
|
val userService by inject<UserService>()
|
||||||
|
|
||||||
|
route("/users") {
|
||||||
|
get {
|
||||||
|
val users = userService.getAll()
|
||||||
|
call.respond(users)
|
||||||
|
}
|
||||||
|
|
||||||
|
get("/{id}") {
|
||||||
|
val id = call.parameters["id"]
|
||||||
|
?: return@get call.respond(HttpStatusCode.BadRequest, "Missing id")
|
||||||
|
val user = userService.getById(id)
|
||||||
|
?: return@get call.respond(HttpStatusCode.NotFound)
|
||||||
|
call.respond(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
val request = call.receive<CreateUserRequest>()
|
||||||
|
val user = userService.create(request)
|
||||||
|
call.respond(HttpStatusCode.Created, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
put("/{id}") {
|
||||||
|
val id = call.parameters["id"]
|
||||||
|
?: return@put call.respond(HttpStatusCode.BadRequest, "Missing id")
|
||||||
|
val request = call.receive<UpdateUserRequest>()
|
||||||
|
val user = userService.update(id, request)
|
||||||
|
?: return@put call.respond(HttpStatusCode.NotFound)
|
||||||
|
call.respond(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
delete("/{id}") {
|
||||||
|
val id = call.parameters["id"]
|
||||||
|
?: return@delete call.respond(HttpStatusCode.BadRequest, "Missing id")
|
||||||
|
val deleted = userService.delete(id)
|
||||||
|
if (deleted) call.respond(HttpStatusCode.NoContent)
|
||||||
|
else call.respond(HttpStatusCode.NotFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Route Organization with Authenticated Routes
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
fun Route.userRoutes() {
|
||||||
|
route("/users") {
|
||||||
|
// Public routes
|
||||||
|
get { /* list users */ }
|
||||||
|
get("/{id}") { /* get user */ }
|
||||||
|
|
||||||
|
// Protected routes
|
||||||
|
authenticate("jwt") {
|
||||||
|
post { /* create user - requires auth */ }
|
||||||
|
put("/{id}") { /* update user - requires auth */ }
|
||||||
|
delete("/{id}") { /* delete user - requires auth */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Content Negotiation & Serialization
|
||||||
|
|
||||||
|
### kotlinx.serialization Setup
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// plugins/Serialization.kt
|
||||||
|
fun Application.configureSerialization() {
|
||||||
|
install(ContentNegotiation) {
|
||||||
|
json(Json {
|
||||||
|
prettyPrint = true
|
||||||
|
isLenient = false
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
encodeDefaults = true
|
||||||
|
explicitNulls = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Serializable Models
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Serializable
|
||||||
|
data class UserResponse(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
val email: String,
|
||||||
|
val role: Role,
|
||||||
|
@Serializable(with = InstantSerializer::class)
|
||||||
|
val createdAt: Instant,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class CreateUserRequest(
|
||||||
|
val name: String,
|
||||||
|
val email: String,
|
||||||
|
val role: Role = Role.USER,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ApiResponse<T>(
|
||||||
|
val success: Boolean,
|
||||||
|
val data: T? = null,
|
||||||
|
val error: String? = null,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun <T> ok(data: T): ApiResponse<T> = ApiResponse(success = true, data = data)
|
||||||
|
fun <T> error(message: String): ApiResponse<T> = ApiResponse(success = false, error = message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class PaginatedResponse<T>(
|
||||||
|
val data: List<T>,
|
||||||
|
val total: Long,
|
||||||
|
val page: Int,
|
||||||
|
val limit: Int,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom Serializers
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
object InstantSerializer : KSerializer<Instant> {
|
||||||
|
override val descriptor = PrimitiveSerialDescriptor("Instant", PrimitiveKind.STRING)
|
||||||
|
override fun serialize(encoder: Encoder, value: Instant) =
|
||||||
|
encoder.encodeString(value.toString())
|
||||||
|
override fun deserialize(decoder: Decoder): Instant =
|
||||||
|
Instant.parse(decoder.decodeString())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
### JWT Authentication
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// plugins/Authentication.kt
|
||||||
|
fun Application.configureAuthentication() {
|
||||||
|
val jwtSecret = environment.config.property("jwt.secret").getString()
|
||||||
|
val jwtIssuer = environment.config.property("jwt.issuer").getString()
|
||||||
|
val jwtAudience = environment.config.property("jwt.audience").getString()
|
||||||
|
val jwtRealm = environment.config.property("jwt.realm").getString()
|
||||||
|
|
||||||
|
install(Authentication) {
|
||||||
|
jwt("jwt") {
|
||||||
|
realm = jwtRealm
|
||||||
|
verifier(
|
||||||
|
JWT.require(Algorithm.HMAC256(jwtSecret))
|
||||||
|
.withAudience(jwtAudience)
|
||||||
|
.withIssuer(jwtIssuer)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
validate { credential ->
|
||||||
|
if (credential.payload.audience.contains(jwtAudience)) {
|
||||||
|
JWTPrincipal(credential.payload)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
challenge { _, _ ->
|
||||||
|
call.respond(HttpStatusCode.Unauthorized, ApiResponse.error<Unit>("Invalid or expired token"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extracting user from JWT
|
||||||
|
fun ApplicationCall.userId(): String =
|
||||||
|
principal<JWTPrincipal>()
|
||||||
|
?.payload
|
||||||
|
?.getClaim("userId")
|
||||||
|
?.asString()
|
||||||
|
?: throw AuthenticationException("No userId in token")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Auth Routes
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
fun Route.authRoutes() {
|
||||||
|
val authService by inject<AuthService>()
|
||||||
|
|
||||||
|
route("/auth") {
|
||||||
|
post("/login") {
|
||||||
|
val request = call.receive<LoginRequest>()
|
||||||
|
val token = authService.login(request.email, request.password)
|
||||||
|
?: return@post call.respond(
|
||||||
|
HttpStatusCode.Unauthorized,
|
||||||
|
ApiResponse.error<Unit>("Invalid credentials"),
|
||||||
|
)
|
||||||
|
call.respond(ApiResponse.ok(TokenResponse(token)))
|
||||||
|
}
|
||||||
|
|
||||||
|
post("/register") {
|
||||||
|
val request = call.receive<RegisterRequest>()
|
||||||
|
val user = authService.register(request)
|
||||||
|
call.respond(HttpStatusCode.Created, ApiResponse.ok(user))
|
||||||
|
}
|
||||||
|
|
||||||
|
authenticate("jwt") {
|
||||||
|
get("/me") {
|
||||||
|
val userId = call.userId()
|
||||||
|
val user = authService.getProfile(userId)
|
||||||
|
call.respond(ApiResponse.ok(user))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Status Pages (Error Handling)
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// plugins/StatusPages.kt
|
||||||
|
fun Application.configureStatusPages() {
|
||||||
|
install(StatusPages) {
|
||||||
|
exception<ContentTransformationException> { call, cause ->
|
||||||
|
call.respond(
|
||||||
|
HttpStatusCode.BadRequest,
|
||||||
|
ApiResponse.error<Unit>("Invalid request body: ${cause.message}"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
exception<IllegalArgumentException> { call, cause ->
|
||||||
|
call.respond(
|
||||||
|
HttpStatusCode.BadRequest,
|
||||||
|
ApiResponse.error<Unit>(cause.message ?: "Bad request"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
exception<AuthenticationException> { call, _ ->
|
||||||
|
call.respond(
|
||||||
|
HttpStatusCode.Unauthorized,
|
||||||
|
ApiResponse.error<Unit>("Authentication required"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
exception<AuthorizationException> { call, _ ->
|
||||||
|
call.respond(
|
||||||
|
HttpStatusCode.Forbidden,
|
||||||
|
ApiResponse.error<Unit>("Access denied"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
exception<NotFoundException> { call, cause ->
|
||||||
|
call.respond(
|
||||||
|
HttpStatusCode.NotFound,
|
||||||
|
ApiResponse.error<Unit>(cause.message ?: "Resource not found"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
exception<Throwable> { call, cause ->
|
||||||
|
call.application.log.error("Unhandled exception", cause)
|
||||||
|
call.respond(
|
||||||
|
HttpStatusCode.InternalServerError,
|
||||||
|
ApiResponse.error<Unit>("Internal server error"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
status(HttpStatusCode.NotFound) { call, status ->
|
||||||
|
call.respond(status, ApiResponse.error<Unit>("Route not found"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## CORS Configuration
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// plugins/CORS.kt
|
||||||
|
fun Application.configureCORS() {
|
||||||
|
install(CORS) {
|
||||||
|
allowHost("localhost:3000")
|
||||||
|
allowHost("example.com", schemes = listOf("https"))
|
||||||
|
allowHeader(HttpHeaders.ContentType)
|
||||||
|
allowHeader(HttpHeaders.Authorization)
|
||||||
|
allowMethod(HttpMethod.Put)
|
||||||
|
allowMethod(HttpMethod.Delete)
|
||||||
|
allowMethod(HttpMethod.Patch)
|
||||||
|
allowCredentials = true
|
||||||
|
maxAgeInSeconds = 3600
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Koin Dependency Injection
|
||||||
|
|
||||||
|
### Module Definition
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// di/AppModule.kt
|
||||||
|
val appModule = module {
|
||||||
|
// Database
|
||||||
|
single<Database> { DatabaseFactory.create(get()) }
|
||||||
|
|
||||||
|
// Repositories
|
||||||
|
single<UserRepository> { ExposedUserRepository(get()) }
|
||||||
|
single<OrderRepository> { ExposedOrderRepository(get()) }
|
||||||
|
|
||||||
|
// Services
|
||||||
|
single { UserService(get()) }
|
||||||
|
single { OrderService(get(), get()) }
|
||||||
|
single { AuthService(get(), get()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Application setup
|
||||||
|
fun Application.configureDI() {
|
||||||
|
install(Koin) {
|
||||||
|
modules(appModule)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using Koin in Routes
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
fun Route.userRoutes() {
|
||||||
|
val userService by inject<UserService>()
|
||||||
|
|
||||||
|
route("/users") {
|
||||||
|
get {
|
||||||
|
val users = userService.getAll()
|
||||||
|
call.respond(ApiResponse.ok(users))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Koin for Testing
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
class UserServiceTest : FunSpec(), KoinTest {
|
||||||
|
override fun extensions() = listOf(KoinExtension(testModule))
|
||||||
|
|
||||||
|
private val testModule = module {
|
||||||
|
single<UserRepository> { mockk() }
|
||||||
|
single { UserService(get()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private val repository by inject<UserRepository>()
|
||||||
|
private val service by inject<UserService>()
|
||||||
|
|
||||||
|
init {
|
||||||
|
test("getUser returns user") {
|
||||||
|
coEvery { repository.findById("1") } returns testUser
|
||||||
|
service.getById("1") shouldBe testUser
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Request Validation
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// Validate request data in routes
|
||||||
|
fun Route.userRoutes() {
|
||||||
|
val userService by inject<UserService>()
|
||||||
|
|
||||||
|
post("/users") {
|
||||||
|
val request = call.receive<CreateUserRequest>()
|
||||||
|
|
||||||
|
// Validate
|
||||||
|
require(request.name.isNotBlank()) { "Name is required" }
|
||||||
|
require(request.name.length <= 100) { "Name must be 100 characters or less" }
|
||||||
|
require(request.email.matches(Regex(".+@.+\\..+"))) { "Invalid email format" }
|
||||||
|
|
||||||
|
val user = userService.create(request)
|
||||||
|
call.respond(HttpStatusCode.Created, ApiResponse.ok(user))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Or use a validation extension
|
||||||
|
fun CreateUserRequest.validate() {
|
||||||
|
require(name.isNotBlank()) { "Name is required" }
|
||||||
|
require(name.length <= 100) { "Name must be 100 characters or less" }
|
||||||
|
require(email.matches(Regex(".+@.+\\..+"))) { "Invalid email format" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## WebSockets
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
fun Application.configureWebSockets() {
|
||||||
|
install(WebSockets) {
|
||||||
|
pingPeriod = 15.seconds
|
||||||
|
timeout = 15.seconds
|
||||||
|
maxFrameSize = 64 * 1024 // 64 KiB — increase only if your protocol requires larger frames
|
||||||
|
masking = false // Server-to-client frames are unmasked per RFC 6455; client-to-server are always masked by Ktor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Route.chatRoutes() {
|
||||||
|
val connections = Collections.synchronizedSet<Connection>(LinkedHashSet())
|
||||||
|
|
||||||
|
webSocket("/chat") {
|
||||||
|
val thisConnection = Connection(this)
|
||||||
|
connections += thisConnection
|
||||||
|
|
||||||
|
try {
|
||||||
|
send("Connected! Users online: ${connections.size}")
|
||||||
|
|
||||||
|
for (frame in incoming) {
|
||||||
|
frame as? Frame.Text ?: continue
|
||||||
|
val text = frame.readText()
|
||||||
|
val message = ChatMessage(thisConnection.name, text)
|
||||||
|
|
||||||
|
// Snapshot under lock to avoid ConcurrentModificationException
|
||||||
|
val snapshot = synchronized(connections) { connections.toList() }
|
||||||
|
snapshot.forEach { conn ->
|
||||||
|
conn.session.send(Json.encodeToString(message))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("WebSocket error", e)
|
||||||
|
} finally {
|
||||||
|
connections -= thisConnection
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class Connection(val session: DefaultWebSocketSession) {
|
||||||
|
val name: String = "User-${counter.getAndIncrement()}"
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val counter = AtomicInteger(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## testApplication Testing
|
||||||
|
|
||||||
|
### Basic Route Testing
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
class UserRoutesTest : FunSpec({
|
||||||
|
test("GET /users returns list of users") {
|
||||||
|
testApplication {
|
||||||
|
application {
|
||||||
|
install(Koin) { modules(testModule) }
|
||||||
|
configureSerialization()
|
||||||
|
configureRouting()
|
||||||
|
}
|
||||||
|
|
||||||
|
val response = client.get("/users")
|
||||||
|
|
||||||
|
response.status shouldBe HttpStatusCode.OK
|
||||||
|
val body = response.body<ApiResponse<List<UserResponse>>>()
|
||||||
|
body.success shouldBe true
|
||||||
|
body.data.shouldNotBeNull().shouldNotBeEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("POST /users creates a user") {
|
||||||
|
testApplication {
|
||||||
|
application {
|
||||||
|
install(Koin) { modules(testModule) }
|
||||||
|
configureSerialization()
|
||||||
|
configureStatusPages()
|
||||||
|
configureRouting()
|
||||||
|
}
|
||||||
|
|
||||||
|
val client = createClient {
|
||||||
|
install(io.ktor.client.plugins.contentnegotiation.ContentNegotiation) {
|
||||||
|
json()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val response = client.post("/users") {
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody(CreateUserRequest("Alice", "alice@example.com"))
|
||||||
|
}
|
||||||
|
|
||||||
|
response.status shouldBe HttpStatusCode.Created
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("GET /users/{id} returns 404 for unknown id") {
|
||||||
|
testApplication {
|
||||||
|
application {
|
||||||
|
install(Koin) { modules(testModule) }
|
||||||
|
configureSerialization()
|
||||||
|
configureStatusPages()
|
||||||
|
configureRouting()
|
||||||
|
}
|
||||||
|
|
||||||
|
val response = client.get("/users/unknown-id")
|
||||||
|
|
||||||
|
response.status shouldBe HttpStatusCode.NotFound
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing Authenticated Routes
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
class AuthenticatedRoutesTest : FunSpec({
|
||||||
|
test("protected route requires JWT") {
|
||||||
|
testApplication {
|
||||||
|
application {
|
||||||
|
install(Koin) { modules(testModule) }
|
||||||
|
configureSerialization()
|
||||||
|
configureAuthentication()
|
||||||
|
configureRouting()
|
||||||
|
}
|
||||||
|
|
||||||
|
val response = client.post("/users") {
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody(CreateUserRequest("Alice", "alice@example.com"))
|
||||||
|
}
|
||||||
|
|
||||||
|
response.status shouldBe HttpStatusCode.Unauthorized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("protected route succeeds with valid JWT") {
|
||||||
|
testApplication {
|
||||||
|
application {
|
||||||
|
install(Koin) { modules(testModule) }
|
||||||
|
configureSerialization()
|
||||||
|
configureAuthentication()
|
||||||
|
configureRouting()
|
||||||
|
}
|
||||||
|
|
||||||
|
val token = generateTestJWT(userId = "test-user")
|
||||||
|
|
||||||
|
val client = createClient {
|
||||||
|
install(io.ktor.client.plugins.contentnegotiation.ContentNegotiation) { json() }
|
||||||
|
}
|
||||||
|
|
||||||
|
val response = client.post("/users") {
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
bearerAuth(token)
|
||||||
|
setBody(CreateUserRequest("Alice", "alice@example.com"))
|
||||||
|
}
|
||||||
|
|
||||||
|
response.status shouldBe HttpStatusCode.Created
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### application.yaml
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
ktor:
|
||||||
|
application:
|
||||||
|
modules:
|
||||||
|
- com.example.ApplicationKt.module
|
||||||
|
deployment:
|
||||||
|
port: 8080
|
||||||
|
|
||||||
|
jwt:
|
||||||
|
secret: ${JWT_SECRET}
|
||||||
|
issuer: "https://example.com"
|
||||||
|
audience: "https://example.com/api"
|
||||||
|
realm: "example"
|
||||||
|
|
||||||
|
database:
|
||||||
|
url: ${DATABASE_URL}
|
||||||
|
driver: "org.postgresql.Driver"
|
||||||
|
maxPoolSize: 10
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reading Config
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
fun Application.configureDI() {
|
||||||
|
val dbUrl = environment.config.property("database.url").getString()
|
||||||
|
val dbDriver = environment.config.property("database.driver").getString()
|
||||||
|
val maxPoolSize = environment.config.property("database.maxPoolSize").getString().toInt()
|
||||||
|
|
||||||
|
install(Koin) {
|
||||||
|
modules(module {
|
||||||
|
single { DatabaseConfig(dbUrl, dbDriver, maxPoolSize) }
|
||||||
|
single { DatabaseFactory.create(get()) }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Reference: Ktor Patterns
|
||||||
|
|
||||||
|
| Pattern | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `route("/path") { get { } }` | Route grouping with DSL |
|
||||||
|
| `call.receive<T>()` | Deserialize request body |
|
||||||
|
| `call.respond(status, body)` | Send response with status |
|
||||||
|
| `call.parameters["id"]` | Read path parameters |
|
||||||
|
| `call.request.queryParameters["q"]` | Read query parameters |
|
||||||
|
| `install(Plugin) { }` | Install and configure plugin |
|
||||||
|
| `authenticate("name") { }` | Protect routes with auth |
|
||||||
|
| `by inject<T>()` | Koin dependency injection |
|
||||||
|
| `testApplication { }` | Integration testing |
|
||||||
|
|
||||||
|
**Remember**: Ktor is designed around Kotlin coroutines and DSLs. Keep routes thin, push logic to services, and use Koin for dependency injection. Test with `testApplication` for full integration coverage.
|
||||||
711
skills/kotlin-patterns/SKILL.md
Normal file
711
skills/kotlin-patterns/SKILL.md
Normal file
@@ -0,0 +1,711 @@
|
|||||||
|
---
|
||||||
|
name: kotlin-patterns
|
||||||
|
description: Idiomatic Kotlin patterns, best practices, and conventions for building robust, efficient, and maintainable Kotlin applications with coroutines, null safety, and DSL builders.
|
||||||
|
origin: ECC
|
||||||
|
---
|
||||||
|
|
||||||
|
# Kotlin Development Patterns
|
||||||
|
|
||||||
|
Idiomatic Kotlin patterns and best practices for building robust, efficient, and maintainable applications.
|
||||||
|
|
||||||
|
## When to Use
|
||||||
|
|
||||||
|
- Writing new Kotlin code
|
||||||
|
- Reviewing Kotlin code
|
||||||
|
- Refactoring existing Kotlin code
|
||||||
|
- Designing Kotlin modules or libraries
|
||||||
|
- Configuring Gradle Kotlin DSL builds
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
This skill enforces idiomatic Kotlin conventions across seven key areas: null safety using the type system and safe-call operators, immutability via `val` and `copy()` on data classes, sealed classes and interfaces for exhaustive type hierarchies, structured concurrency with coroutines and `Flow`, extension functions for adding behaviour without inheritance, type-safe DSL builders using `@DslMarker` and lambda receivers, and Gradle Kotlin DSL for build configuration.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
**Null safety with Elvis operator:**
|
||||||
|
```kotlin
|
||||||
|
fun getUserEmail(userId: String): String {
|
||||||
|
val user = userRepository.findById(userId)
|
||||||
|
return user?.email ?: "unknown@example.com"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Sealed class for exhaustive results:**
|
||||||
|
```kotlin
|
||||||
|
sealed class Result<out T> {
|
||||||
|
data class Success<T>(val data: T) : Result<T>()
|
||||||
|
data class Failure(val error: AppError) : Result<Nothing>()
|
||||||
|
data object Loading : Result<Nothing>()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structured concurrency with async/await:**
|
||||||
|
```kotlin
|
||||||
|
suspend fun fetchUserWithPosts(userId: String): UserProfile =
|
||||||
|
coroutineScope {
|
||||||
|
val user = async { userService.getUser(userId) }
|
||||||
|
val posts = async { postService.getUserPosts(userId) }
|
||||||
|
UserProfile(user = user.await(), posts = posts.await())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Core Principles
|
||||||
|
|
||||||
|
### 1. Null Safety
|
||||||
|
|
||||||
|
Kotlin's type system distinguishes nullable and non-nullable types. Leverage it fully.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// Good: Use non-nullable types by default
|
||||||
|
fun getUser(id: String): User {
|
||||||
|
return userRepository.findById(id)
|
||||||
|
?: throw UserNotFoundException("User $id not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Good: Safe calls and Elvis operator
|
||||||
|
fun getUserEmail(userId: String): String {
|
||||||
|
val user = userRepository.findById(userId)
|
||||||
|
return user?.email ?: "unknown@example.com"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bad: Force-unwrapping nullable types
|
||||||
|
fun getUserEmail(userId: String): String {
|
||||||
|
val user = userRepository.findById(userId)
|
||||||
|
return user!!.email // Throws NPE if null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Immutability by Default
|
||||||
|
|
||||||
|
Prefer `val` over `var`, immutable collections over mutable ones.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// Good: Immutable data
|
||||||
|
data class User(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
val email: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Good: Transform with copy()
|
||||||
|
fun updateEmail(user: User, newEmail: String): User =
|
||||||
|
user.copy(email = newEmail)
|
||||||
|
|
||||||
|
// Good: Immutable collections
|
||||||
|
val users: List<User> = listOf(user1, user2)
|
||||||
|
val filtered = users.filter { it.email.isNotBlank() }
|
||||||
|
|
||||||
|
// Bad: Mutable state
|
||||||
|
var currentUser: User? = null // Avoid mutable global state
|
||||||
|
val mutableUsers = mutableListOf<User>() // Avoid unless truly needed
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Expression Bodies and Single-Expression Functions
|
||||||
|
|
||||||
|
Use expression bodies for concise, readable functions.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// Good: Expression body
|
||||||
|
fun isAdult(age: Int): Boolean = age >= 18
|
||||||
|
|
||||||
|
fun formatFullName(first: String, last: String): String =
|
||||||
|
"$first $last".trim()
|
||||||
|
|
||||||
|
fun User.displayName(): String =
|
||||||
|
name.ifBlank { email.substringBefore('@') }
|
||||||
|
|
||||||
|
// Good: When as expression
|
||||||
|
fun statusMessage(code: Int): String = when (code) {
|
||||||
|
200 -> "OK"
|
||||||
|
404 -> "Not Found"
|
||||||
|
500 -> "Internal Server Error"
|
||||||
|
else -> "Unknown status: $code"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bad: Unnecessary block body
|
||||||
|
fun isAdult(age: Int): Boolean {
|
||||||
|
return age >= 18
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Data Classes for Value Objects
|
||||||
|
|
||||||
|
Use data classes for types that primarily hold data.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// Good: Data class with copy, equals, hashCode, toString
|
||||||
|
data class CreateUserRequest(
|
||||||
|
val name: String,
|
||||||
|
val email: String,
|
||||||
|
val role: Role = Role.USER,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Good: Value class for type safety (zero overhead at runtime)
|
||||||
|
@JvmInline
|
||||||
|
value class UserId(val value: String) {
|
||||||
|
init {
|
||||||
|
require(value.isNotBlank()) { "UserId cannot be blank" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmInline
|
||||||
|
value class Email(val value: String) {
|
||||||
|
init {
|
||||||
|
require('@' in value) { "Invalid email: $value" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getUser(id: UserId): User = userRepository.findById(id)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sealed Classes and Interfaces
|
||||||
|
|
||||||
|
### Modeling Restricted Hierarchies
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// Good: Sealed class for exhaustive when
|
||||||
|
sealed class Result<out T> {
|
||||||
|
data class Success<T>(val data: T) : Result<T>()
|
||||||
|
data class Failure(val error: AppError) : Result<Nothing>()
|
||||||
|
data object Loading : Result<Nothing>()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <T> Result<T>.getOrNull(): T? = when (this) {
|
||||||
|
is Result.Success -> data
|
||||||
|
is Result.Failure -> null
|
||||||
|
is Result.Loading -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <T> Result<T>.getOrThrow(): T = when (this) {
|
||||||
|
is Result.Success -> data
|
||||||
|
is Result.Failure -> throw error.toException()
|
||||||
|
is Result.Loading -> throw IllegalStateException("Still loading")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sealed Interfaces for API Responses
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
sealed interface ApiError {
|
||||||
|
val message: String
|
||||||
|
|
||||||
|
data class NotFound(override val message: String) : ApiError
|
||||||
|
data class Unauthorized(override val message: String) : ApiError
|
||||||
|
data class Validation(
|
||||||
|
override val message: String,
|
||||||
|
val field: String,
|
||||||
|
) : ApiError
|
||||||
|
data class Internal(
|
||||||
|
override val message: String,
|
||||||
|
val cause: Throwable? = null,
|
||||||
|
) : ApiError
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ApiError.toStatusCode(): Int = when (this) {
|
||||||
|
is ApiError.NotFound -> 404
|
||||||
|
is ApiError.Unauthorized -> 401
|
||||||
|
is ApiError.Validation -> 422
|
||||||
|
is ApiError.Internal -> 500
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Scope Functions
|
||||||
|
|
||||||
|
### When to Use Each
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// let: Transform nullable or scoped result
|
||||||
|
val length: Int? = name?.let { it.trim().length }
|
||||||
|
|
||||||
|
// apply: Configure an object (returns the object)
|
||||||
|
val user = User().apply {
|
||||||
|
name = "Alice"
|
||||||
|
email = "alice@example.com"
|
||||||
|
}
|
||||||
|
|
||||||
|
// also: Side effects (returns the object)
|
||||||
|
val user = createUser(request).also { logger.info("Created user: ${it.id}") }
|
||||||
|
|
||||||
|
// run: Execute a block with receiver (returns result)
|
||||||
|
val result = connection.run {
|
||||||
|
prepareStatement(sql)
|
||||||
|
executeQuery()
|
||||||
|
}
|
||||||
|
|
||||||
|
// with: Non-extension form of run
|
||||||
|
val csv = with(StringBuilder()) {
|
||||||
|
appendLine("name,email")
|
||||||
|
users.forEach { appendLine("${it.name},${it.email}") }
|
||||||
|
toString()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Anti-Patterns
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// Bad: Nesting scope functions
|
||||||
|
user?.let { u ->
|
||||||
|
u.address?.let { addr ->
|
||||||
|
addr.city?.let { city ->
|
||||||
|
println(city) // Hard to read
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Good: Chain safe calls instead
|
||||||
|
val city = user?.address?.city
|
||||||
|
city?.let { println(it) }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Extension Functions
|
||||||
|
|
||||||
|
### Adding Functionality Without Inheritance
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// Good: Domain-specific extensions
|
||||||
|
fun String.toSlug(): String =
|
||||||
|
lowercase()
|
||||||
|
.replace(Regex("[^a-z0-9\\s-]"), "")
|
||||||
|
.replace(Regex("\\s+"), "-")
|
||||||
|
.trim('-')
|
||||||
|
|
||||||
|
fun Instant.toLocalDate(zone: ZoneId = ZoneId.systemDefault()): LocalDate =
|
||||||
|
atZone(zone).toLocalDate()
|
||||||
|
|
||||||
|
// Good: Collection extensions
|
||||||
|
fun <T> List<T>.second(): T = this[1]
|
||||||
|
|
||||||
|
fun <T> List<T>.secondOrNull(): T? = getOrNull(1)
|
||||||
|
|
||||||
|
// Good: Scoped extensions (not polluting global namespace)
|
||||||
|
class UserService {
|
||||||
|
private fun User.isActive(): Boolean =
|
||||||
|
status == Status.ACTIVE && lastLogin.isAfter(Instant.now().minus(30, ChronoUnit.DAYS))
|
||||||
|
|
||||||
|
fun getActiveUsers(): List<User> = userRepository.findAll().filter { it.isActive() }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Coroutines
|
||||||
|
|
||||||
|
### Structured Concurrency
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// Good: Structured concurrency with coroutineScope
|
||||||
|
suspend fun fetchUserWithPosts(userId: String): UserProfile =
|
||||||
|
coroutineScope {
|
||||||
|
val userDeferred = async { userService.getUser(userId) }
|
||||||
|
val postsDeferred = async { postService.getUserPosts(userId) }
|
||||||
|
|
||||||
|
UserProfile(
|
||||||
|
user = userDeferred.await(),
|
||||||
|
posts = postsDeferred.await(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Good: supervisorScope when children can fail independently
|
||||||
|
suspend fun fetchDashboard(userId: String): Dashboard =
|
||||||
|
supervisorScope {
|
||||||
|
val user = async { userService.getUser(userId) }
|
||||||
|
val notifications = async { notificationService.getRecent(userId) }
|
||||||
|
val recommendations = async { recommendationService.getFor(userId) }
|
||||||
|
|
||||||
|
Dashboard(
|
||||||
|
user = user.await(),
|
||||||
|
notifications = try {
|
||||||
|
notifications.await()
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
emptyList()
|
||||||
|
},
|
||||||
|
recommendations = try {
|
||||||
|
recommendations.await()
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
emptyList()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Flow for Reactive Streams
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// Good: Cold flow with proper error handling
|
||||||
|
fun observeUsers(): Flow<List<User>> = flow {
|
||||||
|
while (currentCoroutineContext().isActive) {
|
||||||
|
val users = userRepository.findAll()
|
||||||
|
emit(users)
|
||||||
|
delay(5.seconds)
|
||||||
|
}
|
||||||
|
}.catch { e ->
|
||||||
|
logger.error("Error observing users", e)
|
||||||
|
emit(emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Good: Flow operators
|
||||||
|
fun searchUsers(query: Flow<String>): Flow<List<User>> =
|
||||||
|
query
|
||||||
|
.debounce(300.milliseconds)
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.filter { it.length >= 2 }
|
||||||
|
.mapLatest { q -> userRepository.search(q) }
|
||||||
|
.catch { emit(emptyList()) }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cancellation and Cleanup
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// Good: Respect cancellation
|
||||||
|
suspend fun processItems(items: List<Item>) {
|
||||||
|
items.forEach { item ->
|
||||||
|
ensureActive() // Check cancellation before expensive work
|
||||||
|
processItem(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Good: Cleanup with try/finally
|
||||||
|
suspend fun acquireAndProcess() {
|
||||||
|
val resource = acquireResource()
|
||||||
|
try {
|
||||||
|
resource.process()
|
||||||
|
} finally {
|
||||||
|
withContext(NonCancellable) {
|
||||||
|
resource.release() // Always release, even on cancellation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Delegation
|
||||||
|
|
||||||
|
### Property Delegation
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// Lazy initialization
|
||||||
|
val expensiveData: List<User> by lazy {
|
||||||
|
userRepository.findAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Observable property
|
||||||
|
var name: String by Delegates.observable("initial") { _, old, new ->
|
||||||
|
logger.info("Name changed from '$old' to '$new'")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map-backed properties
|
||||||
|
class Config(private val map: Map<String, Any?>) {
|
||||||
|
val host: String by map
|
||||||
|
val port: Int by map
|
||||||
|
val debug: Boolean by map
|
||||||
|
}
|
||||||
|
|
||||||
|
val config = Config(mapOf("host" to "localhost", "port" to 8080, "debug" to true))
|
||||||
|
```
|
||||||
|
|
||||||
|
### Interface Delegation
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// Good: Delegate interface implementation
|
||||||
|
class LoggingUserRepository(
|
||||||
|
private val delegate: UserRepository,
|
||||||
|
private val logger: Logger,
|
||||||
|
) : UserRepository by delegate {
|
||||||
|
// Only override what you need to add logging to
|
||||||
|
override suspend fun findById(id: String): User? {
|
||||||
|
logger.info("Finding user by id: $id")
|
||||||
|
return delegate.findById(id).also {
|
||||||
|
logger.info("Found user: ${it?.name ?: "null"}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## DSL Builders
|
||||||
|
|
||||||
|
### Type-Safe Builders
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// Good: DSL with @DslMarker
|
||||||
|
@DslMarker
|
||||||
|
annotation class HtmlDsl
|
||||||
|
|
||||||
|
@HtmlDsl
|
||||||
|
class HTML {
|
||||||
|
private val children = mutableListOf<Element>()
|
||||||
|
|
||||||
|
fun head(init: Head.() -> Unit) {
|
||||||
|
children += Head().apply(init)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun body(init: Body.() -> Unit) {
|
||||||
|
children += Body().apply(init)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun toString(): String = children.joinToString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun html(init: HTML.() -> Unit): HTML = HTML().apply(init)
|
||||||
|
|
||||||
|
// Usage
|
||||||
|
val page = html {
|
||||||
|
head { title("My Page") }
|
||||||
|
body {
|
||||||
|
h1("Welcome")
|
||||||
|
p("Hello, World!")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration DSL
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
data class ServerConfig(
|
||||||
|
val host: String = "0.0.0.0",
|
||||||
|
val port: Int = 8080,
|
||||||
|
val ssl: SslConfig? = null,
|
||||||
|
val database: DatabaseConfig? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class SslConfig(val certPath: String, val keyPath: String)
|
||||||
|
data class DatabaseConfig(val url: String, val maxPoolSize: Int = 10)
|
||||||
|
|
||||||
|
class ServerConfigBuilder {
|
||||||
|
var host: String = "0.0.0.0"
|
||||||
|
var port: Int = 8080
|
||||||
|
private var ssl: SslConfig? = null
|
||||||
|
private var database: DatabaseConfig? = null
|
||||||
|
|
||||||
|
fun ssl(certPath: String, keyPath: String) {
|
||||||
|
ssl = SslConfig(certPath, keyPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun database(url: String, maxPoolSize: Int = 10) {
|
||||||
|
database = DatabaseConfig(url, maxPoolSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun build(): ServerConfig = ServerConfig(host, port, ssl, database)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun serverConfig(init: ServerConfigBuilder.() -> Unit): ServerConfig =
|
||||||
|
ServerConfigBuilder().apply(init).build()
|
||||||
|
|
||||||
|
// Usage
|
||||||
|
val config = serverConfig {
|
||||||
|
host = "0.0.0.0"
|
||||||
|
port = 443
|
||||||
|
ssl("/certs/cert.pem", "/certs/key.pem")
|
||||||
|
database("jdbc:postgresql://localhost:5432/mydb", maxPoolSize = 20)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sequences for Lazy Evaluation
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// Good: Use sequences for large collections with multiple operations
|
||||||
|
val result = users.asSequence()
|
||||||
|
.filter { it.isActive }
|
||||||
|
.map { it.email }
|
||||||
|
.filter { it.endsWith("@company.com") }
|
||||||
|
.take(10)
|
||||||
|
.toList()
|
||||||
|
|
||||||
|
// Good: Generate infinite sequences
|
||||||
|
val fibonacci: Sequence<Long> = sequence {
|
||||||
|
var a = 0L
|
||||||
|
var b = 1L
|
||||||
|
while (true) {
|
||||||
|
yield(a)
|
||||||
|
val next = a + b
|
||||||
|
a = b
|
||||||
|
b = next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val first20 = fibonacci.take(20).toList()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Gradle Kotlin DSL
|
||||||
|
|
||||||
|
### build.gradle.kts Configuration
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// Check for latest versions: https://kotlinlang.org/docs/releases.html
|
||||||
|
plugins {
|
||||||
|
kotlin("jvm") version "2.3.10"
|
||||||
|
kotlin("plugin.serialization") version "2.3.10"
|
||||||
|
id("io.ktor.plugin") version "3.4.0"
|
||||||
|
id("org.jetbrains.kotlinx.kover") version "0.9.7"
|
||||||
|
id("io.gitlab.arturbosch.detekt") version "1.23.8"
|
||||||
|
}
|
||||||
|
|
||||||
|
group = "com.example"
|
||||||
|
version = "1.0.0"
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
jvmToolchain(21)
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
// Ktor
|
||||||
|
implementation("io.ktor:ktor-server-core:3.4.0")
|
||||||
|
implementation("io.ktor:ktor-server-netty:3.4.0")
|
||||||
|
implementation("io.ktor:ktor-server-content-negotiation:3.4.0")
|
||||||
|
implementation("io.ktor:ktor-serialization-kotlinx-json:3.4.0")
|
||||||
|
|
||||||
|
// Exposed
|
||||||
|
implementation("org.jetbrains.exposed:exposed-core:1.0.0")
|
||||||
|
implementation("org.jetbrains.exposed:exposed-dao:1.0.0")
|
||||||
|
implementation("org.jetbrains.exposed:exposed-jdbc:1.0.0")
|
||||||
|
implementation("org.jetbrains.exposed:exposed-kotlin-datetime:1.0.0")
|
||||||
|
|
||||||
|
// Koin
|
||||||
|
implementation("io.insert-koin:koin-ktor:4.2.0")
|
||||||
|
|
||||||
|
// Coroutines
|
||||||
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
|
||||||
|
|
||||||
|
// Testing
|
||||||
|
testImplementation("io.kotest:kotest-runner-junit5:6.1.4")
|
||||||
|
testImplementation("io.kotest:kotest-assertions-core:6.1.4")
|
||||||
|
testImplementation("io.kotest:kotest-property:6.1.4")
|
||||||
|
testImplementation("io.mockk:mockk:1.14.9")
|
||||||
|
testImplementation("io.ktor:ktor-server-test-host:3.4.0")
|
||||||
|
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.withType<Test> {
|
||||||
|
useJUnitPlatform()
|
||||||
|
}
|
||||||
|
|
||||||
|
detekt {
|
||||||
|
config.setFrom(files("config/detekt/detekt.yml"))
|
||||||
|
buildUponDefaultConfig = true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling Patterns
|
||||||
|
|
||||||
|
### Result Type for Domain Operations
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// Good: Use Kotlin's Result or a custom sealed class
|
||||||
|
suspend fun createUser(request: CreateUserRequest): Result<User> = runCatching {
|
||||||
|
require(request.name.isNotBlank()) { "Name cannot be blank" }
|
||||||
|
require('@' in request.email) { "Invalid email format" }
|
||||||
|
|
||||||
|
val user = User(
|
||||||
|
id = UserId(UUID.randomUUID().toString()),
|
||||||
|
name = request.name,
|
||||||
|
email = Email(request.email),
|
||||||
|
)
|
||||||
|
userRepository.save(user)
|
||||||
|
user
|
||||||
|
}
|
||||||
|
|
||||||
|
// Good: Chain results
|
||||||
|
val displayName = createUser(request)
|
||||||
|
.map { it.name }
|
||||||
|
.getOrElse { "Unknown" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### require, check, error
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// Good: Preconditions with clear messages
|
||||||
|
fun withdraw(account: Account, amount: Money): Account {
|
||||||
|
require(amount.value > 0) { "Amount must be positive: $amount" }
|
||||||
|
check(account.balance >= amount) { "Insufficient balance: ${account.balance} < $amount" }
|
||||||
|
|
||||||
|
return account.copy(balance = account.balance - amount)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Collection Operations
|
||||||
|
|
||||||
|
### Idiomatic Collection Processing
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// Good: Chained operations
|
||||||
|
val activeAdminEmails: List<String> = users
|
||||||
|
.filter { it.role == Role.ADMIN && it.isActive }
|
||||||
|
.sortedBy { it.name }
|
||||||
|
.map { it.email }
|
||||||
|
|
||||||
|
// Good: Grouping and aggregation
|
||||||
|
val usersByRole: Map<Role, List<User>> = users.groupBy { it.role }
|
||||||
|
|
||||||
|
val oldestByRole: Map<Role, User?> = users.groupBy { it.role }
|
||||||
|
.mapValues { (_, users) -> users.minByOrNull { it.createdAt } }
|
||||||
|
|
||||||
|
// Good: Associate for map creation
|
||||||
|
val usersById: Map<UserId, User> = users.associateBy { it.id }
|
||||||
|
|
||||||
|
// Good: Partition for splitting
|
||||||
|
val (active, inactive) = users.partition { it.isActive }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Reference: Kotlin Idioms
|
||||||
|
|
||||||
|
| Idiom | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `val` over `var` | Prefer immutable variables |
|
||||||
|
| `data class` | For value objects with equals/hashCode/copy |
|
||||||
|
| `sealed class/interface` | For restricted type hierarchies |
|
||||||
|
| `value class` | For type-safe wrappers with zero overhead |
|
||||||
|
| Expression `when` | Exhaustive pattern matching |
|
||||||
|
| Safe call `?.` | Null-safe member access |
|
||||||
|
| Elvis `?:` | Default value for nullables |
|
||||||
|
| `let`/`apply`/`also`/`run`/`with` | Scope functions for clean code |
|
||||||
|
| Extension functions | Add behavior without inheritance |
|
||||||
|
| `copy()` | Immutable updates on data classes |
|
||||||
|
| `require`/`check` | Precondition assertions |
|
||||||
|
| Coroutine `async`/`await` | Structured concurrent execution |
|
||||||
|
| `Flow` | Cold reactive streams |
|
||||||
|
| `sequence` | Lazy evaluation |
|
||||||
|
| Delegation `by` | Reuse implementation without inheritance |
|
||||||
|
|
||||||
|
## Anti-Patterns to Avoid
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// Bad: Force-unwrapping nullable types
|
||||||
|
val name = user!!.name
|
||||||
|
|
||||||
|
// Bad: Platform type leakage from Java
|
||||||
|
fun getLength(s: String) = s.length // Safe
|
||||||
|
fun getLength(s: String?) = s?.length ?: 0 // Handle nulls from Java
|
||||||
|
|
||||||
|
// Bad: Mutable data classes
|
||||||
|
data class MutableUser(var name: String, var email: String)
|
||||||
|
|
||||||
|
// Bad: Using exceptions for control flow
|
||||||
|
try {
|
||||||
|
val user = findUser(id)
|
||||||
|
} catch (e: NotFoundException) {
|
||||||
|
// Don't use exceptions for expected cases
|
||||||
|
}
|
||||||
|
|
||||||
|
// Good: Use nullable return or Result
|
||||||
|
val user: User? = findUserOrNull(id)
|
||||||
|
|
||||||
|
// Bad: Ignoring coroutine scope
|
||||||
|
GlobalScope.launch { /* Avoid GlobalScope */ }
|
||||||
|
|
||||||
|
// Good: Use structured concurrency
|
||||||
|
coroutineScope {
|
||||||
|
launch { /* Properly scoped */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bad: Deeply nested scope functions
|
||||||
|
user?.let { u ->
|
||||||
|
u.address?.let { a ->
|
||||||
|
a.city?.let { c -> process(c) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Good: Direct null-safe chain
|
||||||
|
user?.address?.city?.let { process(it) }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Remember**: Kotlin code should be concise but readable. Leverage the type system for safety, prefer immutability, and use coroutines for concurrency. When in doubt, let the compiler help you.
|
||||||
824
skills/kotlin-testing/SKILL.md
Normal file
824
skills/kotlin-testing/SKILL.md
Normal file
@@ -0,0 +1,824 @@
|
|||||||
|
---
|
||||||
|
name: kotlin-testing
|
||||||
|
description: Kotlin testing patterns with Kotest, MockK, coroutine testing, property-based testing, and Kover coverage. Follows TDD methodology with idiomatic Kotlin practices.
|
||||||
|
origin: ECC
|
||||||
|
---
|
||||||
|
|
||||||
|
# Kotlin Testing Patterns
|
||||||
|
|
||||||
|
Comprehensive Kotlin testing patterns for writing reliable, maintainable tests following TDD methodology with Kotest and MockK.
|
||||||
|
|
||||||
|
## When to Use
|
||||||
|
|
||||||
|
- Writing new Kotlin functions or classes
|
||||||
|
- Adding test coverage to existing Kotlin code
|
||||||
|
- Implementing property-based tests
|
||||||
|
- Following TDD workflow in Kotlin projects
|
||||||
|
- Configuring Kover for code coverage
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
1. **Identify target code** — Find the function, class, or module to test
|
||||||
|
2. **Write a Kotest spec** — Choose a spec style (StringSpec, FunSpec, BehaviorSpec) matching the test scope
|
||||||
|
3. **Mock dependencies** — Use MockK to isolate the unit under test
|
||||||
|
4. **Run tests (RED)** — Verify the test fails with the expected error
|
||||||
|
5. **Implement code (GREEN)** — Write minimal code to pass the test
|
||||||
|
6. **Refactor** — Improve the implementation while keeping tests green
|
||||||
|
7. **Check coverage** — Run `./gradlew koverHtmlReport` and verify 80%+ coverage
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
The following sections contain detailed, runnable examples for each testing pattern:
|
||||||
|
|
||||||
|
### Quick Reference
|
||||||
|
|
||||||
|
- **Kotest specs** — StringSpec, FunSpec, BehaviorSpec, DescribeSpec examples in [Kotest Spec Styles](#kotest-spec-styles)
|
||||||
|
- **Mocking** — MockK setup, coroutine mocking, argument capture in [MockK](#mockk)
|
||||||
|
- **TDD walkthrough** — Full RED/GREEN/REFACTOR cycle with EmailValidator in [TDD Workflow for Kotlin](#tdd-workflow-for-kotlin)
|
||||||
|
- **Coverage** — Kover configuration and commands in [Kover Coverage](#kover-coverage)
|
||||||
|
- **Ktor testing** — testApplication setup in [Ktor testApplication Testing](#ktor-testapplication-testing)
|
||||||
|
|
||||||
|
## TDD Workflow for Kotlin
|
||||||
|
|
||||||
|
### The RED-GREEN-REFACTOR Cycle
|
||||||
|
|
||||||
|
```
|
||||||
|
RED -> Write a failing test first
|
||||||
|
GREEN -> Write minimal code to pass the test
|
||||||
|
REFACTOR -> Improve code while keeping tests green
|
||||||
|
REPEAT -> Continue with next requirement
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step-by-Step TDD in Kotlin
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// Step 1: Define the interface/signature
|
||||||
|
// EmailValidator.kt
|
||||||
|
package com.example.validator
|
||||||
|
|
||||||
|
fun validateEmail(email: String): Result<String> {
|
||||||
|
TODO("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Write failing test (RED)
|
||||||
|
// EmailValidatorTest.kt
|
||||||
|
package com.example.validator
|
||||||
|
|
||||||
|
import io.kotest.core.spec.style.StringSpec
|
||||||
|
import io.kotest.matchers.result.shouldBeFailure
|
||||||
|
import io.kotest.matchers.result.shouldBeSuccess
|
||||||
|
|
||||||
|
class EmailValidatorTest : StringSpec({
|
||||||
|
"valid email returns success" {
|
||||||
|
validateEmail("user@example.com").shouldBeSuccess("user@example.com")
|
||||||
|
}
|
||||||
|
|
||||||
|
"empty email returns failure" {
|
||||||
|
validateEmail("").shouldBeFailure()
|
||||||
|
}
|
||||||
|
|
||||||
|
"email without @ returns failure" {
|
||||||
|
validateEmail("userexample.com").shouldBeFailure()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Step 3: Run tests - verify FAIL
|
||||||
|
// $ ./gradlew test
|
||||||
|
// EmailValidatorTest > valid email returns success FAILED
|
||||||
|
// kotlin.NotImplementedError: An operation is not implemented
|
||||||
|
|
||||||
|
// Step 4: Implement minimal code (GREEN)
|
||||||
|
fun validateEmail(email: String): Result<String> {
|
||||||
|
if (email.isBlank()) return Result.failure(IllegalArgumentException("Email cannot be blank"))
|
||||||
|
if ('@' !in email) return Result.failure(IllegalArgumentException("Email must contain @"))
|
||||||
|
val regex = Regex("^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$")
|
||||||
|
if (!regex.matches(email)) return Result.failure(IllegalArgumentException("Invalid email format"))
|
||||||
|
return Result.success(email)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: Run tests - verify PASS
|
||||||
|
// $ ./gradlew test
|
||||||
|
// EmailValidatorTest > valid email returns success PASSED
|
||||||
|
// EmailValidatorTest > empty email returns failure PASSED
|
||||||
|
// EmailValidatorTest > email without @ returns failure PASSED
|
||||||
|
|
||||||
|
// Step 6: Refactor if needed, verify tests still pass
|
||||||
|
```
|
||||||
|
|
||||||
|
## Kotest Spec Styles
|
||||||
|
|
||||||
|
### StringSpec (Simplest)
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
class CalculatorTest : StringSpec({
|
||||||
|
"add two positive numbers" {
|
||||||
|
Calculator.add(2, 3) shouldBe 5
|
||||||
|
}
|
||||||
|
|
||||||
|
"add negative numbers" {
|
||||||
|
Calculator.add(-1, -2) shouldBe -3
|
||||||
|
}
|
||||||
|
|
||||||
|
"add zero" {
|
||||||
|
Calculator.add(0, 5) shouldBe 5
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### FunSpec (JUnit-like)
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
class UserServiceTest : FunSpec({
|
||||||
|
val repository = mockk<UserRepository>()
|
||||||
|
val service = UserService(repository)
|
||||||
|
|
||||||
|
test("getUser returns user when found") {
|
||||||
|
val expected = User(id = "1", name = "Alice")
|
||||||
|
coEvery { repository.findById("1") } returns expected
|
||||||
|
|
||||||
|
val result = service.getUser("1")
|
||||||
|
|
||||||
|
result shouldBe expected
|
||||||
|
}
|
||||||
|
|
||||||
|
test("getUser throws when not found") {
|
||||||
|
coEvery { repository.findById("999") } returns null
|
||||||
|
|
||||||
|
shouldThrow<UserNotFoundException> {
|
||||||
|
service.getUser("999")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### BehaviorSpec (BDD Style)
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
class OrderServiceTest : BehaviorSpec({
|
||||||
|
val repository = mockk<OrderRepository>()
|
||||||
|
val paymentService = mockk<PaymentService>()
|
||||||
|
val service = OrderService(repository, paymentService)
|
||||||
|
|
||||||
|
Given("a valid order request") {
|
||||||
|
val request = CreateOrderRequest(
|
||||||
|
userId = "user-1",
|
||||||
|
items = listOf(OrderItem("product-1", quantity = 2)),
|
||||||
|
)
|
||||||
|
|
||||||
|
When("the order is placed") {
|
||||||
|
coEvery { paymentService.charge(any()) } returns PaymentResult.Success
|
||||||
|
coEvery { repository.save(any()) } answers { firstArg() }
|
||||||
|
|
||||||
|
val result = service.placeOrder(request)
|
||||||
|
|
||||||
|
Then("it should return a confirmed order") {
|
||||||
|
result.status shouldBe OrderStatus.CONFIRMED
|
||||||
|
}
|
||||||
|
|
||||||
|
Then("it should charge payment") {
|
||||||
|
coVerify(exactly = 1) { paymentService.charge(any()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
When("payment fails") {
|
||||||
|
coEvery { paymentService.charge(any()) } returns PaymentResult.Declined
|
||||||
|
|
||||||
|
Then("it should throw PaymentException") {
|
||||||
|
shouldThrow<PaymentException> {
|
||||||
|
service.placeOrder(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### DescribeSpec (RSpec Style)
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
class UserValidatorTest : DescribeSpec({
|
||||||
|
describe("validateUser") {
|
||||||
|
val validator = UserValidator()
|
||||||
|
|
||||||
|
context("with valid input") {
|
||||||
|
it("accepts a normal user") {
|
||||||
|
val user = CreateUserRequest("Alice", "alice@example.com")
|
||||||
|
validator.validate(user).shouldBeValid()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
context("with invalid name") {
|
||||||
|
it("rejects blank name") {
|
||||||
|
val user = CreateUserRequest("", "alice@example.com")
|
||||||
|
validator.validate(user).shouldBeInvalid()
|
||||||
|
}
|
||||||
|
|
||||||
|
it("rejects name exceeding max length") {
|
||||||
|
val user = CreateUserRequest("A".repeat(256), "alice@example.com")
|
||||||
|
validator.validate(user).shouldBeInvalid()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Kotest Matchers
|
||||||
|
|
||||||
|
### Core Matchers
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
import io.kotest.matchers.shouldBe
|
||||||
|
import io.kotest.matchers.shouldNotBe
|
||||||
|
import io.kotest.matchers.string.*
|
||||||
|
import io.kotest.matchers.collections.*
|
||||||
|
import io.kotest.matchers.nulls.*
|
||||||
|
|
||||||
|
// Equality
|
||||||
|
result shouldBe expected
|
||||||
|
result shouldNotBe unexpected
|
||||||
|
|
||||||
|
// Strings
|
||||||
|
name shouldStartWith "Al"
|
||||||
|
name shouldEndWith "ice"
|
||||||
|
name shouldContain "lic"
|
||||||
|
name shouldMatch Regex("[A-Z][a-z]+")
|
||||||
|
name.shouldBeBlank()
|
||||||
|
|
||||||
|
// Collections
|
||||||
|
list shouldContain "item"
|
||||||
|
list shouldHaveSize 3
|
||||||
|
list.shouldBeSorted()
|
||||||
|
list.shouldContainAll("a", "b", "c")
|
||||||
|
list.shouldBeEmpty()
|
||||||
|
|
||||||
|
// Nulls
|
||||||
|
result.shouldNotBeNull()
|
||||||
|
result.shouldBeNull()
|
||||||
|
|
||||||
|
// Types
|
||||||
|
result.shouldBeInstanceOf<User>()
|
||||||
|
|
||||||
|
// Numbers
|
||||||
|
count shouldBeGreaterThan 0
|
||||||
|
price shouldBeInRange 1.0..100.0
|
||||||
|
|
||||||
|
// Exceptions
|
||||||
|
shouldThrow<IllegalArgumentException> {
|
||||||
|
validateAge(-1)
|
||||||
|
}.message shouldBe "Age must be positive"
|
||||||
|
|
||||||
|
shouldNotThrow<Exception> {
|
||||||
|
validateAge(25)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom Matchers
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
fun beActiveUser() = object : Matcher<User> {
|
||||||
|
override fun test(value: User) = MatcherResult(
|
||||||
|
value.isActive && value.lastLogin != null,
|
||||||
|
{ "User ${value.id} should be active with a last login" },
|
||||||
|
{ "User ${value.id} should not be active" },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage
|
||||||
|
user should beActiveUser()
|
||||||
|
```
|
||||||
|
|
||||||
|
## MockK
|
||||||
|
|
||||||
|
### Basic Mocking
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
class UserServiceTest : FunSpec({
|
||||||
|
val repository = mockk<UserRepository>()
|
||||||
|
val logger = mockk<Logger>(relaxed = true) // Relaxed: returns defaults
|
||||||
|
val service = UserService(repository, logger)
|
||||||
|
|
||||||
|
beforeTest {
|
||||||
|
clearMocks(repository, logger)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("findUser delegates to repository") {
|
||||||
|
val expected = User(id = "1", name = "Alice")
|
||||||
|
every { repository.findById("1") } returns expected
|
||||||
|
|
||||||
|
val result = service.findUser("1")
|
||||||
|
|
||||||
|
result shouldBe expected
|
||||||
|
verify(exactly = 1) { repository.findById("1") }
|
||||||
|
}
|
||||||
|
|
||||||
|
test("findUser returns null for unknown id") {
|
||||||
|
every { repository.findById(any()) } returns null
|
||||||
|
|
||||||
|
val result = service.findUser("unknown")
|
||||||
|
|
||||||
|
result.shouldBeNull()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Coroutine Mocking
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
class AsyncUserServiceTest : FunSpec({
|
||||||
|
val repository = mockk<UserRepository>()
|
||||||
|
val service = UserService(repository)
|
||||||
|
|
||||||
|
test("getUser suspending function") {
|
||||||
|
coEvery { repository.findById("1") } returns User(id = "1", name = "Alice")
|
||||||
|
|
||||||
|
val result = service.getUser("1")
|
||||||
|
|
||||||
|
result.name shouldBe "Alice"
|
||||||
|
coVerify { repository.findById("1") }
|
||||||
|
}
|
||||||
|
|
||||||
|
test("getUser with delay") {
|
||||||
|
coEvery { repository.findById("1") } coAnswers {
|
||||||
|
delay(100) // Simulate async work
|
||||||
|
User(id = "1", name = "Alice")
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = service.getUser("1")
|
||||||
|
result.name shouldBe "Alice"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Argument Capture
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
test("save captures the user argument") {
|
||||||
|
val slot = slot<User>()
|
||||||
|
coEvery { repository.save(capture(slot)) } returns Unit
|
||||||
|
|
||||||
|
service.createUser(CreateUserRequest("Alice", "alice@example.com"))
|
||||||
|
|
||||||
|
slot.captured.name shouldBe "Alice"
|
||||||
|
slot.captured.email shouldBe "alice@example.com"
|
||||||
|
slot.captured.id.shouldNotBeNull()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Spy and Partial Mocking
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
test("spy on real object") {
|
||||||
|
val realService = UserService(repository)
|
||||||
|
val spy = spyk(realService)
|
||||||
|
|
||||||
|
every { spy.generateId() } returns "fixed-id"
|
||||||
|
|
||||||
|
spy.createUser(request)
|
||||||
|
|
||||||
|
verify { spy.generateId() } // Overridden
|
||||||
|
// Other methods use real implementation
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Coroutine Testing
|
||||||
|
|
||||||
|
### runTest for Suspend Functions
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
|
||||||
|
class CoroutineServiceTest : FunSpec({
|
||||||
|
test("concurrent fetches complete together") {
|
||||||
|
runTest {
|
||||||
|
val service = DataService(testScope = this)
|
||||||
|
|
||||||
|
val result = service.fetchAllData()
|
||||||
|
|
||||||
|
result.users.shouldNotBeEmpty()
|
||||||
|
result.products.shouldNotBeEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("timeout after delay") {
|
||||||
|
runTest {
|
||||||
|
val service = SlowService()
|
||||||
|
|
||||||
|
shouldThrow<TimeoutCancellationException> {
|
||||||
|
withTimeout(100) {
|
||||||
|
service.slowOperation() // Takes > 100ms
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing Flows
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
import io.kotest.matchers.collections.shouldContainInOrder
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.toList
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.test.advanceTimeBy
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
|
||||||
|
class FlowServiceTest : FunSpec({
|
||||||
|
test("observeUsers emits updates") {
|
||||||
|
runTest {
|
||||||
|
val service = UserFlowService()
|
||||||
|
|
||||||
|
val emissions = service.observeUsers()
|
||||||
|
.take(3)
|
||||||
|
.toList()
|
||||||
|
|
||||||
|
emissions shouldHaveSize 3
|
||||||
|
emissions.last().shouldNotBeEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("searchUsers debounces input") {
|
||||||
|
runTest {
|
||||||
|
val service = SearchService()
|
||||||
|
val queries = MutableSharedFlow<String>()
|
||||||
|
|
||||||
|
val results = mutableListOf<List<User>>()
|
||||||
|
val job = launch {
|
||||||
|
service.searchUsers(queries).collect { results.add(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
queries.emit("a")
|
||||||
|
queries.emit("ab")
|
||||||
|
queries.emit("abc") // Only this should trigger search
|
||||||
|
advanceTimeBy(500)
|
||||||
|
|
||||||
|
results shouldHaveSize 1
|
||||||
|
job.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### TestDispatcher
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
|
|
||||||
|
class DispatcherTest : FunSpec({
|
||||||
|
test("uses test dispatcher for controlled execution") {
|
||||||
|
val dispatcher = StandardTestDispatcher()
|
||||||
|
|
||||||
|
runTest(dispatcher) {
|
||||||
|
var completed = false
|
||||||
|
|
||||||
|
launch {
|
||||||
|
delay(1000)
|
||||||
|
completed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
completed shouldBe false
|
||||||
|
advanceTimeBy(1000)
|
||||||
|
completed shouldBe true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Property-Based Testing
|
||||||
|
|
||||||
|
### Kotest Property Testing
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
import io.kotest.core.spec.style.FunSpec
|
||||||
|
import io.kotest.property.Arb
|
||||||
|
import io.kotest.property.arbitrary.*
|
||||||
|
import io.kotest.property.forAll
|
||||||
|
import io.kotest.property.checkAll
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
|
|
||||||
|
// Note: The serialization roundtrip test below requires the User data class
|
||||||
|
// to be annotated with @Serializable (from kotlinx.serialization).
|
||||||
|
|
||||||
|
class PropertyTest : FunSpec({
|
||||||
|
test("string reverse is involutory") {
|
||||||
|
forAll<String> { s ->
|
||||||
|
s.reversed().reversed() == s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("list sort is idempotent") {
|
||||||
|
forAll(Arb.list(Arb.int())) { list ->
|
||||||
|
list.sorted() == list.sorted().sorted()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("serialization roundtrip preserves data") {
|
||||||
|
checkAll(Arb.bind(Arb.string(1..50), Arb.string(5..100)) { name, email ->
|
||||||
|
User(name = name, email = "$email@test.com")
|
||||||
|
}) { user ->
|
||||||
|
val json = Json.encodeToString(user)
|
||||||
|
val decoded = Json.decodeFromString<User>(json)
|
||||||
|
decoded shouldBe user
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom Generators
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
val userArb: Arb<User> = Arb.bind(
|
||||||
|
Arb.string(minSize = 1, maxSize = 50),
|
||||||
|
Arb.email(),
|
||||||
|
Arb.enum<Role>(),
|
||||||
|
) { name, email, role ->
|
||||||
|
User(
|
||||||
|
id = UserId(UUID.randomUUID().toString()),
|
||||||
|
name = name,
|
||||||
|
email = Email(email),
|
||||||
|
role = role,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val moneyArb: Arb<Money> = Arb.bind(
|
||||||
|
Arb.long(1L..1_000_000L),
|
||||||
|
Arb.enum<Currency>(),
|
||||||
|
) { amount, currency ->
|
||||||
|
Money(amount, currency)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data-Driven Testing
|
||||||
|
|
||||||
|
### withData in Kotest
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
class ParserTest : FunSpec({
|
||||||
|
context("parsing valid dates") {
|
||||||
|
withData(
|
||||||
|
"2026-01-15" to LocalDate(2026, 1, 15),
|
||||||
|
"2026-12-31" to LocalDate(2026, 12, 31),
|
||||||
|
"2000-01-01" to LocalDate(2000, 1, 1),
|
||||||
|
) { (input, expected) ->
|
||||||
|
parseDate(input) shouldBe expected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
context("rejecting invalid dates") {
|
||||||
|
withData(
|
||||||
|
nameFn = { "rejects '$it'" },
|
||||||
|
"not-a-date",
|
||||||
|
"2026-13-01",
|
||||||
|
"2026-00-15",
|
||||||
|
"",
|
||||||
|
) { input ->
|
||||||
|
shouldThrow<DateParseException> {
|
||||||
|
parseDate(input)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test Lifecycle and Fixtures
|
||||||
|
|
||||||
|
### BeforeTest / AfterTest
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
class DatabaseTest : FunSpec({
|
||||||
|
lateinit var db: Database
|
||||||
|
|
||||||
|
beforeSpec {
|
||||||
|
db = Database.connect("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1")
|
||||||
|
transaction(db) {
|
||||||
|
SchemaUtils.create(UsersTable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
afterSpec {
|
||||||
|
transaction(db) {
|
||||||
|
SchemaUtils.drop(UsersTable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeTest {
|
||||||
|
transaction(db) {
|
||||||
|
UsersTable.deleteAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("insert and retrieve user") {
|
||||||
|
transaction(db) {
|
||||||
|
UsersTable.insert {
|
||||||
|
it[name] = "Alice"
|
||||||
|
it[email] = "alice@example.com"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val users = transaction(db) {
|
||||||
|
UsersTable.selectAll().map { it[UsersTable.name] }
|
||||||
|
}
|
||||||
|
|
||||||
|
users shouldContain "Alice"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Kotest Extensions
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// Reusable test extension
|
||||||
|
class DatabaseExtension : BeforeSpecListener, AfterSpecListener {
|
||||||
|
lateinit var db: Database
|
||||||
|
|
||||||
|
override suspend fun beforeSpec(spec: Spec) {
|
||||||
|
db = Database.connect("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1")
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun afterSpec(spec: Spec) {
|
||||||
|
// cleanup
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class UserRepositoryTest : FunSpec({
|
||||||
|
val dbExt = DatabaseExtension()
|
||||||
|
register(dbExt)
|
||||||
|
|
||||||
|
test("save and find user") {
|
||||||
|
val repo = UserRepository(dbExt.db)
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Kover Coverage
|
||||||
|
|
||||||
|
### Gradle Configuration
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// build.gradle.kts
|
||||||
|
plugins {
|
||||||
|
id("org.jetbrains.kotlinx.kover") version "0.9.7"
|
||||||
|
}
|
||||||
|
|
||||||
|
kover {
|
||||||
|
reports {
|
||||||
|
total {
|
||||||
|
html { onCheck = true }
|
||||||
|
xml { onCheck = true }
|
||||||
|
}
|
||||||
|
filters {
|
||||||
|
excludes {
|
||||||
|
classes("*.generated.*", "*.config.*")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
verify {
|
||||||
|
rule {
|
||||||
|
minBound(80) // Fail build below 80% coverage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Coverage Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run tests with coverage
|
||||||
|
./gradlew koverHtmlReport
|
||||||
|
|
||||||
|
# Verify coverage thresholds
|
||||||
|
./gradlew koverVerify
|
||||||
|
|
||||||
|
# XML report for CI
|
||||||
|
./gradlew koverXmlReport
|
||||||
|
|
||||||
|
# View HTML report (use the command for your OS)
|
||||||
|
# macOS: open build/reports/kover/html/index.html
|
||||||
|
# Linux: xdg-open build/reports/kover/html/index.html
|
||||||
|
# Windows: start build/reports/kover/html/index.html
|
||||||
|
```
|
||||||
|
|
||||||
|
### Coverage Targets
|
||||||
|
|
||||||
|
| Code Type | Target |
|
||||||
|
|-----------|--------|
|
||||||
|
| Critical business logic | 100% |
|
||||||
|
| Public APIs | 90%+ |
|
||||||
|
| General code | 80%+ |
|
||||||
|
| Generated / config code | Exclude |
|
||||||
|
|
||||||
|
## Ktor testApplication Testing
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
class ApiRoutesTest : FunSpec({
|
||||||
|
test("GET /users returns list") {
|
||||||
|
testApplication {
|
||||||
|
application {
|
||||||
|
configureRouting()
|
||||||
|
configureSerialization()
|
||||||
|
}
|
||||||
|
|
||||||
|
val response = client.get("/users")
|
||||||
|
|
||||||
|
response.status shouldBe HttpStatusCode.OK
|
||||||
|
val users = response.body<List<UserResponse>>()
|
||||||
|
users.shouldNotBeEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("POST /users creates user") {
|
||||||
|
testApplication {
|
||||||
|
application {
|
||||||
|
configureRouting()
|
||||||
|
configureSerialization()
|
||||||
|
}
|
||||||
|
|
||||||
|
val response = client.post("/users") {
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody(CreateUserRequest("Alice", "alice@example.com"))
|
||||||
|
}
|
||||||
|
|
||||||
|
response.status shouldBe HttpStatusCode.Created
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all tests
|
||||||
|
./gradlew test
|
||||||
|
|
||||||
|
# Run specific test class
|
||||||
|
./gradlew test --tests "com.example.UserServiceTest"
|
||||||
|
|
||||||
|
# Run specific test
|
||||||
|
./gradlew test --tests "com.example.UserServiceTest.getUser returns user when found"
|
||||||
|
|
||||||
|
# Run with verbose output
|
||||||
|
./gradlew test --info
|
||||||
|
|
||||||
|
# Run with coverage
|
||||||
|
./gradlew koverHtmlReport
|
||||||
|
|
||||||
|
# Run detekt (static analysis)
|
||||||
|
./gradlew detekt
|
||||||
|
|
||||||
|
# Run ktlint (formatting check)
|
||||||
|
./gradlew ktlintCheck
|
||||||
|
|
||||||
|
# Continuous testing
|
||||||
|
./gradlew test --continuous
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
**DO:**
|
||||||
|
- Write tests FIRST (TDD)
|
||||||
|
- Use Kotest's spec styles consistently across the project
|
||||||
|
- Use MockK's `coEvery`/`coVerify` for suspend functions
|
||||||
|
- Use `runTest` for coroutine testing
|
||||||
|
- Test behavior, not implementation
|
||||||
|
- Use property-based testing for pure functions
|
||||||
|
- Use `data class` test fixtures for clarity
|
||||||
|
|
||||||
|
**DON'T:**
|
||||||
|
- Mix testing frameworks (pick Kotest and stick with it)
|
||||||
|
- Mock data classes (use real instances)
|
||||||
|
- Use `Thread.sleep()` in coroutine tests (use `advanceTimeBy`)
|
||||||
|
- Skip the RED phase in TDD
|
||||||
|
- Test private functions directly
|
||||||
|
- Ignore flaky tests
|
||||||
|
|
||||||
|
## Integration with CI/CD
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# GitHub Actions example
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: 'temurin'
|
||||||
|
java-version: '21'
|
||||||
|
|
||||||
|
- name: Run tests with coverage
|
||||||
|
run: ./gradlew test koverXmlReport
|
||||||
|
|
||||||
|
- name: Verify coverage
|
||||||
|
run: ./gradlew koverVerify
|
||||||
|
|
||||||
|
- name: Upload coverage
|
||||||
|
uses: codecov/codecov-action@v5
|
||||||
|
with:
|
||||||
|
files: build/reports/kover/report.xml
|
||||||
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Remember**: Tests are documentation. They show how your Kotlin code is meant to be used. Use Kotest's expressive matchers to make tests readable and MockK for clean mocking of dependencies.
|
||||||
Reference in New Issue
Block a user