diff --git a/build.gradle.kts b/build.gradle.kts index 6fe054cac..b48ae17d1 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -31,7 +31,10 @@ apiValidation { "spring-4x-kafka-tests", "spring-4x-tests", "spring-3x-tests", - "spring-2x-tests" + "spring-2x-tests", + "ktor-di-tests", + "ktor-koin-tests", + "ktor-test-fixtures", ) } kover { diff --git a/docs/Components/02-kafka.md b/docs/Components/02-kafka.md index 06f3462aa..310962b49 100644 --- a/docs/Components/02-kafka.md +++ b/docs/Components/02-kafka.md @@ -230,28 +230,52 @@ messages. When all the configuration is done, it is time to tell to application to use our `TestSystemInterceptor` and configuration values. -#### TestSystemInterceptor and TestInitializer +#### TestSystemInterceptor and Bean Registration + +Register the interceptor and serde using `addTestDependencies`: + +**Spring Boot 2.x / 3.x:** ```kotlin -class TestInitializer : BaseApplicationContextInitializer({ - bean(isPrimary = true) - bean { StoveSerde.jackson.anyByteArraySerde(yourObjectMapper()) } // or any serde that implements StoveSerde -}) +import com.trendyol.stove.testing.e2e.addTestDependencies -fun SpringApplication.addTestDependencies() { - this.addInitializers(TestInitializer()) -} +springBoot( + runner = { parameters -> + runApplication(*parameters) { + addTestDependencies { + bean(isPrimary = true) + bean { StoveSerde.jackson.anyByteArraySerde(yourObjectMapper()) } + } + } + }, ``` -#### Configuring the SystemUnderTest and Parameters +**Spring Boot 4.x:** + +```kotlin +import com.trendyol.stove.testing.e2e.addTestDependencies4x + +springBoot( + runner = { parameters -> + runApplication(*parameters) { + addTestDependencies4x { + registerBean(primary = true) + registerBean { StoveSerde.jackson.anyByteArraySerde(yourObjectMapper()) } + } + } + }, +``` -`addTestDependencies` is an extension that helps us to register our dependencies in the application. +#### Configuring the SystemUnderTest and Parameters -```kotlin hl_lines="4" +```kotlin hl_lines="4-8" springBoot( runner = { parameters -> - com.trendyol.exampleapp.run(parameters) { - addTestDependencies() // Enable TestInitializer with extensions call + runApplication(*parameters) { + addTestDependencies { + bean(isPrimary = true) + bean { StoveSerde.jackson.anyByteArraySerde(yourObjectMapper()) } + } } }, withParameters = listOf( diff --git a/docs/Components/10-bridge.md b/docs/Components/10-bridge.md index 7309b8a5b..7d1c4dcfa 100644 --- a/docs/Components/10-bridge.md +++ b/docs/Components/10-bridge.md @@ -77,24 +77,172 @@ class SpringBridgeSystem(testSystem: TestSystem) : BridgeSystem { - // 'this' is the UserRepository from Koin + // 'this' is the UserRepository from your DI save(user) } ``` -Under the hood, it uses Koin's `getKoin().get()`: +#### DI Framework Setup + +**Using Koin:** ```kotlin -class KtorBridgeSystem(testSystem: TestSystem) : BridgeSystem(testSystem) { - override fun get(klass: KClass): D = ctx.getKoin().get(klass) +dependencies { + testImplementation("io.insert-koin:koin-ktor:$koinVersion") } + +// In your test setup - bridge() auto-detects Koin +TestSystem() + .with { + bridge() + ktor(runner = { params -> MyApp.run(params) }) + } + .run() ``` +**Using Ktor-DI:** + +```kotlin +dependencies { + testImplementation("io.ktor:ktor-server-di:$ktorVersion") +} + +// In your test setup - bridge() auto-detects Ktor-DI +TestSystem() + .with { + bridge() + ktor(runner = { params -> MyApp.run(params) }) + } + .run() +``` + +**Using Custom Resolver:** + +```kotlin +// For any other DI framework (Kodein, Dagger, etc.) +TestSystem() + .with { + bridge { application, type -> + // type is KType - preserves generic info like List + myDiContainer.resolve(type) + } + ktor(runner = { params -> MyApp.run(params) }) + } + .run() +``` + +#### Generic Type Resolution + +Bridge preserves generic type information, enabling resolution of types like `List`: + +```kotlin +// Works with Koin or Ktor-DI +using> { + forEach { service -> service.pay(order) } +} +``` + +#### Registering Test Dependencies in Ktor + +Unlike Spring Boot's unified `addTestDependencies`, Ktor test dependency registration differs by DI framework: + +**Koin - Using Modules:** + +```kotlin +object MyApp { + fun run( + args: Array, + testModules: List = emptyList() // Accept test modules + ): Application { + return embeddedServer(Netty, port = args.getPort()) { + install(Koin) { + modules( + productionModule, + *testModules.toTypedArray() // Add test modules + ) + } + configureRouting() + }.start(wait = false).application + } +} + +// In your test setup +TestSystem() + .with { + bridge() + ktor( + runner = { params -> + MyApp.run( + params, + testModules = listOf( + module { + // Override production beans with test doubles + single(override = true) { FixedTimeProvider() } + single(override = true) { MockEmailService() } + } + ) + ) + } + ) + } + .run() +``` + +**Ktor-DI - Using Dependencies Block:** + +```kotlin +object MyApp { + fun run( + args: Array, + testDependencies: (DependencyRegistrar.() -> Unit)? = null // Accept test registrations + ): Application { + return embeddedServer(Netty, port = args.getPort()) { + install(DI) { + dependencies { + // Production dependencies + provide { UserServiceImpl() } + provide { SystemTimeProvider() } + + // Apply test overrides if provided + testDependencies?.invoke(this) + } + } + configureRouting() + }.start(wait = false).application + } +} + +// In your test setup +TestSystem() + .with { + bridge() + ktor( + runner = { params -> + MyApp.run(params) { + // Override production beans with test doubles + provide { FixedTimeProvider() } + provide { MockEmailService() } + } + } + ) + } + .run() +``` + +!!! tip "Test Dependency Patterns" + - **Koin**: Use `override = true` in test modules to replace production beans + - **Ktor-DI**: Later `provide` calls override earlier ones + - Both frameworks support the pattern of passing test-specific configuration to your app's run function + ## Usage ### Single Bean Access @@ -318,10 +466,10 @@ class FixedTimeProvider(private var time: Instant) : TimeProvider { fun advance(duration: Duration) { time = time.plus(duration) } } -// Register test implementation in TestInitializer -class TestInitializer : BaseApplicationContextInitializer({ +// Register test implementation in your TestSystem setup +addTestDependencies { bean(isPrimary = true) { FixedTimeProvider(Instant.parse("2024-01-01T00:00:00Z")) } -}) +} // Use in tests test("should expire session after timeout") { @@ -354,7 +502,7 @@ test("should expire session after timeout") { Capture and verify domain events: ```kotlin -// Test event listener (registered in TestInitializer) +// Test event listener (registered via addTestDependencies) class TestEventCapture { private val events = ConcurrentLinkedQueue() @@ -389,66 +537,80 @@ test("should publish UserCreatedEvent when user registers") { } ``` -## Test Initializers +## Test Bean Registration + +Register test-specific beans using `addTestDependencies`: -Use `BaseApplicationContextInitializer` to register test-specific beans: +**Spring Boot 2.x / 3.x:** ```kotlin -class TestInitializer : BaseApplicationContextInitializer({ - // Replace production beans with test doubles - bean(isPrimary = true) { FixedTimeProvider(Instant.now()) } - bean(isPrimary = true) { MockEmailService() } - - // Add test utilities - bean() - bean() -}) +import com.trendyol.stove.testing.e2e.addTestDependencies -fun SpringApplication.addTestDependencies() { - addInitializers(TestInitializer()) -} +TestSystem() + .with { + bridge() + springBoot( + runner = { params -> + runApplication(*params) { + addTestDependencies { + // Replace production beans with test doubles + bean(isPrimary = true) { FixedTimeProvider(Instant.now()) } + bean(isPrimary = true) { MockEmailService() } + + // Add test utilities + bean() + bean() + } + } + } + ) + } + .run() +``` + +**Spring Boot 4.x:** + +```kotlin +import com.trendyol.stove.testing.e2e.addTestDependencies4x -// In TestSystem configuration TestSystem() .with { bridge() springBoot( runner = { params -> - myApp.run(params) { addTestDependencies() } + runApplication(*params) { + addTestDependencies4x { + // Replace production beans with test doubles + registerBean(primary = true) { FixedTimeProvider(Instant.now()) } + registerBean(primary = true) { MockEmailService() } + + // Add test utilities + registerBean() + registerBean() + } + } } ) } .run() ``` -### Extending Initializers +### Alternative: Using `addInitializers` Directly -You can extend initializers with additional registrations: +For more control, you can use `addInitializers` with `stoveSpringRegistrar`: ```kotlin -class TestInitializer : BaseApplicationContextInitializer({ - bean(isPrimary = true) -}) { - init { - // Add more registrations - register { - bean() - bean(isPrimary = true) { FixedClock(Instant.now()) } - } - } - - // React to application events - override fun onEvent(event: ApplicationEvent) { - when (event) { - is ContextRefreshedEvent -> println("Context refreshed") - } - } - - // Execute when application is ready - override fun applicationReady(applicationContext: GenericApplicationContext) { - println("Application is ready for testing") - } -} +// Spring Boot 2.x / 3.x +addInitializers(stoveSpringRegistrar { + bean(isPrimary = true) { FixedTimeProvider(Instant.now()) } + bean() +}) + +// Spring Boot 4.x +addInitializers(stoveSpring4xRegistrar { + registerBean(primary = true) { FixedTimeProvider(Instant.now()) } + registerBean() +}) ``` ## Integration with Other Systems @@ -569,16 +731,16 @@ Only replace what's necessary: ```kotlin // ✅ Good: Replace only time-sensitive components -class TestInitializer : BaseApplicationContextInitializer({ +addTestDependencies { bean(isPrimary = true) { Clock.fixed(fixedInstant, ZoneId.UTC) } -}) +} // ❌ Avoid: Replacing too many components (reduces test value) -class TestInitializer : BaseApplicationContextInitializer({ +addTestDependencies { bean(isPrimary = true) { MockUserService() } bean(isPrimary = true) { MockOrderService() } bean(isPrimary = true) { MockPaymentService() } -}) +} ``` ## Summary diff --git a/docs/best-practices.md b/docs/best-practices.md index bf8ddea26..80f19eea5 100644 --- a/docs/best-practices.md +++ b/docs/best-practices.md @@ -14,9 +14,7 @@ src/ ├── test/kotlin/ # Unit tests └── test-e2e/kotlin/ # E2E tests with Stove ├── config/ - │ └── TestConfig.kt - ├── setup/ - │ └── TestInitializer.kt + │ └── TestConfig.kt # Contains TestSystem setup with addTestDependencies ├── features/ │ ├── OrderE2ETest.kt │ ├── UserE2ETest.kt diff --git a/docs/getting-started.md b/docs/getting-started.md index 3473bda42..a2a1ec7ba 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -31,6 +31,9 @@ dependencies { testImplementation("com.trendyol:stove-spring-testing-e2e:$stoveVersion") // OR testImplementation("com.trendyol:stove-ktor-testing-e2e:$stoveVersion") + // For Ktor, also add your preferred DI framework: + testImplementation("io.insert-koin:koin-ktor:$koinVersion") // Koin + // OR testImplementation("io.ktor:ktor-server-di:$ktorVersion") // Ktor-DI // Add components you need testImplementation("com.trendyol:stove-testing-e2e-http:$stoveVersion") @@ -71,17 +74,18 @@ Stove needs to start your application from the test context. This requires a sma } ``` -=== "Ktor" +=== "Ktor with Koin" ```kotlin // Before fun main() { embeddedServer(Netty, port = 8080) { + install(Koin) { modules(appModule) } configureRouting() }.start(wait = true) } - // After + // After - Accept test modules for overriding beans object MyApp { @JvmStatic fun main(args: Array) = run(args) @@ -89,13 +93,48 @@ Stove needs to start your application from the test context. This requires a sma fun run( args: Array, wait: Boolean = true, - configure: Application.() -> Unit = {} + testModules: List = emptyList() ): Application { - // Your application setup return embeddedServer(Netty, port = args.getPort()) { + install(Koin) { + modules(appModule, *testModules.toTypedArray()) + } + configureRouting() + }.start(wait = wait).application + } + } + ``` + +=== "Ktor with Ktor-DI" + + ```kotlin + // Before + fun main() { + embeddedServer(Netty, port = 8080) { + install(DI) { dependencies { provide { MyServiceImpl() } } } + configureRouting() + }.start(wait = true) + } + + // After - Accept test dependency overrides + object MyApp { + @JvmStatic + fun main(args: Array) = run(args) + + fun run( + args: Array, + wait: Boolean = true, + testDependencies: (DependencyRegistrar.() -> Unit)? = null + ): Application { + return embeddedServer(Netty, port = args.getPort()) { + install(DI) { + dependencies { + provide { MyServiceImpl() } + testDependencies?.invoke(this) // Apply test overrides + } + } configureRouting() - configure() - }.start(wait = wait) + }.start(wait = wait).application } } ``` diff --git a/docs/index.md b/docs/index.md index 70eec5c89..d93ca253a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -660,34 +660,60 @@ That's it! You have up-and-running API, can be tested with Stove. dependencies { testImplementation("com.trendyol:stove-ktor-testing-e2e:$version") + // Add your preferred DI framework (one of): + testImplementation("io.insert-koin:koin-ktor:$koinVersion") // Koin + // OR + testImplementation("io.ktor:ktor-server-di:$ktorVersion") // Ktor-DI + // You can add other components if you need } ``` +!!! note "DI Framework Required" + Ktor Bridge requires either Koin or Ktor-DI to be on the classpath. The `bridge()` function auto-detects which one is available. + #### Example Setup -```kotlin -TestSystem() - .with { - // You can add other components if you need - // We removed for simplicity +=== "Koin" - ktor( - withParameters = listOf( - "port=8080" - ), - runner = { parameters -> - stove.ktor.example.run(parameters) { - addTestSystemDependencies() - } - } - ) - }.run() -``` + ```kotlin + TestSystem() + .with { + bridge() // Auto-detects Koin + ktor( + withParameters = listOf("port=8080"), + runner = { parameters -> + stove.ktor.example.run( + parameters, + testModules = listOf( + module { + single(override = true) { FixedTimeProvider() } + } + ) + ) + } + ) + }.run() + ``` -After you've added `stove-ktor-testing-e2e` dependency, and configured the application's `main` function for Stove to -enter, -it is time to run your application for the first time from the test-context with Stove. +=== "Ktor-DI" + + ```kotlin + TestSystem() + .with { + bridge() // Auto-detects Ktor-DI + ktor( + withParameters = listOf("port=8080"), + runner = { parameters -> + stove.ktor.example.run(parameters) { + provide { FixedTimeProvider() } + } + } + ) + }.run() + ``` + +After you've added `stove-ktor-testing-e2e` dependency and your preferred DI framework, and configured the application's `main` function for Stove to enter, it is time to run your application for the first time from the test-context with Stove. #### Tuning the application's entry point @@ -707,30 +733,57 @@ Let's say the application has a standard `main` function, here how we will chang } ``` -=== "After" +=== "After (Koin)" ```kotlin object ExampleApp { @JvmStatic - fun main(args: Array) { - run(args) + fun main(args: Array) = run(args) + + fun run( + args: Array, + wait: Boolean = true, + testModules: List = emptyList() // Accept test modules + ): Application { + val config = loadConfiguration(args) + return embeddedServer(Netty, port = config.port) { + install(Koin) { + modules(appModule, *testModules.toTypedArray()) + } + configureRouting() + }.start(wait = wait).application } + } + ``` + +=== "After (Ktor-DI)" + + ```kotlin + object ExampleApp { + @JvmStatic + fun main(args: Array) = run(args) - fun run(args: Array, - wait: Boolean = true, - configure: org.koin.core.module.Module = module { } - ): Application { - val config = loadConfiguration(args) - return startKtorApplication(config, wait) { - appModule(config, configure) - } + fun run( + args: Array, + wait: Boolean = true, + testDependencies: (DependencyRegistrar.() -> Unit)? = null // Accept test overrides + ): Application { + val config = loadConfiguration(args) + return embeddedServer(Netty, port = config.port) { + install(DI) { + dependencies { + provide { MyServiceImpl() } + testDependencies?.invoke(this) // Apply test overrides + } + } + configureRouting() + }.start(wait = wait).application } } ``` As you can see from `before-after` sections, we have divided the application main function into two parts. -`run(args, wait, configure)` method is the important point for the testing configuration. `configure` allows us to -override any dependency from the testing side that is being `time` related or `configuration` related. +The `run` method accepts parameters for test configuration, allowing you to override dependencies from the testing side (e.g., time-related or configuration-related beans). !!! note @@ -848,9 +901,9 @@ class NoDelayBackgroundCommandBusImpl( } ``` -Now, it is time to tell to e2eTest system to use NoDelay implementation. +Now, it is time to tell the e2e test system to use the NoDelay implementation. -That brings us to initializers. +This is done by registering test dependencies (see "Registering Test Dependencies" section below). ### Writing Your Own TestSystem @@ -924,29 +977,46 @@ override suspend fun afterRun(context: ApplicationContext) { } ``` -### Writing a TestInitializer +### Registering Test Dependencies + +You can add test-scoped beans to configure the Spring application from the test perspective using `addTestDependencies`: -The tests initializers help you to add test scoped beans, basically you can configure the Spring application from the -test perspective. +**Spring Boot 2.x / 3.x:** ```kotlin -class TestInitializer : BaseApplicationContextInitializer({ - bean(isPrimary = true) - bean(isPrimary = true) // Optional dependency to alter delayed implementation with 0-wait. -}) +import com.trendyol.stove.testing.e2e.addTestDependencies -fun SpringApplication.addTestDependencies() { - this.addInitializers(TestInitializer()) +runApplication(*params) { + addTestDependencies { + bean(isPrimary = true) + bean(isPrimary = true) + } } ``` -`addTestDependencies` is an extension that helps us to register our dependencies in the application. +**Spring Boot 4.x:** -```kotlin hl_lines="4" -.springBoot( +```kotlin +import com.trendyol.stove.testing.e2e.addTestDependencies4x + +runApplication(*params) { + addTestDependencies4x { + registerBean(primary = true) + registerBean(primary = true) + } +} +``` + +`addTestDependencies` / `addTestDependencies4x` are extensions that help us register our dependencies in the application. + +```kotlin hl_lines="4-7" +springBoot( runner = { parameters -> - com.trendyol.exampleapp.run(parameters) { - addTestDependencies() + runApplication(*parameters) { + addTestDependencies { + bean(isPrimary = true) + bean(isPrimary = true) + } } }, withParameters = listOf( diff --git a/docs/release-notes/0.20.0.md b/docs/release-notes/0.20.0.md index bdbfa41e1..3a0a40931 100644 --- a/docs/release-notes/0.20.0.md +++ b/docs/release-notes/0.20.0.md @@ -8,6 +8,8 @@ This release introduces: - **Unified Spring Modules**: Single modules that work across Spring Boot 2.x, 3.x, and 4.x - **New Bean Registration DSL**: `stoveSpring4xRegistrar` for Spring Boot 4.x (since `BeanDefinitionDsl` is deprecated) - **Runtime Version Checks**: Clear error messages when Spring Boot/Kafka is missing from classpath +- **Ktor DI Flexibility**: Support for Koin, Ktor-DI, or custom resolvers +- **Generic Type Resolution**: `using>` now works correctly with full generic type preservation --- @@ -81,9 +83,127 @@ addInitializers(stoveSpring4xRegistrar { registerBean() }) --- +### Ktor DI Flexibility + +Stove's Ktor module now supports multiple dependency injection systems. Previously, Koin was required. Now you can use: + +1. **Koin** (existing support) +2. **Ktor-DI** (new built-in support) +3. **Custom resolver** (any DI framework) + +Both Koin and Ktor-DI are now `compileOnly` dependencies - you bring your preferred DI system. + +**Using Koin:** + +```kotlin +dependencies { + testImplementation("io.insert-koin:koin-ktor:$koinVersion") +} + +// In your test setup +bridge() // Auto-detects Koin +``` + +**Using Ktor-DI:** + +```kotlin +dependencies { + testImplementation("io.ktor:ktor-server-di:$ktorVersion") +} + +// In your test setup +bridge() // Auto-detects Ktor-DI +``` + +**Using a Custom Resolver:** + +For any other DI framework (Kodein, Dagger, manual, etc.): + +```kotlin +bridge { application, type -> + // Your custom resolution logic - type is KType preserving generics + myDiContainer.resolve(type) +} +``` + +--- + +### Generic Type Resolution in Bridge System + +The `using` function now properly preserves generic type information, allowing you to resolve types like `List`: + +```kotlin +// Register multiple implementations +provide> { + listOf(StripePaymentService(), PayPalPaymentService()) +} + +// Resolve with full generic type preserved +validate { + using> { + forEach { service -> service.pay(order) } + } +} +``` + +This works by using `KType` instead of `KClass` internally, which preserves generic type parameters that would otherwise be lost due to JVM type erasure. + +**For custom BridgeSystem implementations:** Override `getByType(type: KType)` to support generic types. The default implementation falls back to `get(klass: KClass)`. + +--- + +### Ktor Test Dependency Registration + +Unlike Spring Boot's unified `addTestDependencies`, Ktor test dependency registration differs by DI framework: + +**Koin:** + +```kotlin +// In your app - accept test modules +fun run(args: Array, testModules: List = emptyList()): Application { + return embeddedServer(Netty, port = args.getPort()) { + install(Koin) { modules(appModule, *testModules.toTypedArray()) } + }.start(wait = false).application +} + +// In tests - pass test modules with overrides +ktor(runner = { params -> + MyApp.run(params, testModules = listOf( + module { + single(override = true) { FixedTimeProvider() } + } + )) +}) +``` + +**Ktor-DI:** + +```kotlin +// In your app - accept test dependencies +fun run(args: Array, testDeps: (DependencyRegistrar.() -> Unit)? = null): Application { + return embeddedServer(Netty, port = args.getPort()) { + install(DI) { + dependencies { + provide { MyServiceImpl() } + testDeps?.invoke(this) // Later provides override earlier ones + } + } + }.start(wait = false).application +} + +// In tests - pass test overrides +ktor(runner = { params -> + MyApp.run(params) { + provide { FixedTimeProvider() } + } +}) +``` + +--- + ### Runtime Version Checks -When Spring Boot or Spring Kafka is missing from the classpath, Stove now provides clear error messages: +When Spring Boot, Spring Kafka, or Ktor DI is missing from the classpath, Stove now provides clear error messages: ``` ═══════════════════════════════════════════════════════════════════════════════ @@ -190,6 +310,22 @@ This is simpler - no need to create a separate class. ### Notes +#### BridgeSystem API Enhancement + +The `BridgeSystem` abstract class now has a new method `getByType(type: KType)` which is used by `resolve()` to support generic types. If you have a custom `BridgeSystem` implementation: + +- **No action required** if you only use simple types - the default implementation falls back to `get(klass: KClass)` +- **Override `getByType(type: KType)`** if you want to support generic types like `List`, `Map`, etc. + +```kotlin +// Example for custom bridge +override fun getByType(type: KType): D { + // Use type.classifier for KClass + // Use type.arguments for generic parameters + return myDiFramework.resolve(type) +} +``` + #### Dead Letter Topic Naming Convention (Spring Kafka) Be aware that Spring Kafka changed the default DLT (Dead Letter Topic) naming convention between versions: diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 13a867845..cca3b1311 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -302,10 +302,10 @@ Message was not consumed within timeout 1. **Verify Kafka interceptor is configured:** ```kotlin - // In TestInitializer - class TestInitializer : BaseApplicationContextInitializer({ + // In your TestSystem setup + addTestDependencies { bean(isPrimary = true) - }) + } ``` 2. **Check topic names:** diff --git a/docs/writing-custom-systems.md b/docs/writing-custom-systems.md index 6b5f70176..1afc0ae07 100644 --- a/docs/writing-custom-systems.md +++ b/docs/writing-custom-systems.md @@ -196,15 +196,15 @@ suspend fun ValidationDsl.tasks( ### Step 4: Register the Listener Bean -In your test initializer, register the listener as a Spring bean: +In your test setup, register the listener as a Spring bean using `addTestDependencies`: ```kotlin -class TestInitializer : BaseApplicationContextInitializer({ - bean(isPrimary = true) -}) +import com.trendyol.stove.testing.e2e.addTestDependencies -fun SpringApplication.addTestDependencies() { - this.addInitializers(TestInitializer()) +runApplication(*params) { + addTestDependencies { + bean(isPrimary = true) + } } ``` @@ -398,10 +398,7 @@ suspend fun ValidationDsl.domainEvents( ### Step 4: Register and Use ```kotlin -// TestInitializer -class TestInitializer : BaseApplicationContextInitializer({ - bean(isPrimary = true) -}) +import com.trendyol.stove.testing.e2e.addTestDependencies // Configuration TestSystem() @@ -409,7 +406,13 @@ TestSystem() httpClient { HttpClientSystemOptions(...) } domainEvents() // Register custom system springBoot( - runner = { params -> myApp.run(params) { addTestDependencies() } } + runner = { params -> + runApplication(*params) { + addTestDependencies { + bean(isPrimary = true) + } + } + } ) } .run() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ec9ff7c9b..3a734188f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -102,6 +102,7 @@ ktor-server-host-common = { module = "io.ktor:ktor-server-host-common", version. ktor-server = { module = "io.ktor:ktor-server", version.ref = "ktor" } ktor-server-call-logging = { module = "io.ktor:ktor-server-call-logging", version.ref = "ktor" } ktor-server-netty = { module = "io.ktor:ktor-server-netty", version.ref = "ktor" } +ktor-server-di = { module = "io.ktor:ktor-server-di", version.ref = "ktor" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } diff --git a/lib/stove-testing-e2e/api/stove-testing-e2e.api b/lib/stove-testing-e2e/api/stove-testing-e2e.api index 1328033cb..ff1374ed3 100644 --- a/lib/stove-testing-e2e/api/stove-testing-e2e.api +++ b/lib/stove-testing-e2e/api/stove-testing-e2e.api @@ -465,6 +465,7 @@ public abstract class com/trendyol/stove/testing/e2e/system/BridgeSystem : com/t public fun close ()V public fun executeWithReuseCheck (Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun get (Lkotlin/reflect/KClass;)Ljava/lang/Object; + public fun getByType (Lkotlin/reflect/KType;)Ljava/lang/Object; protected final fun getCtx ()Ljava/lang/Object; public fun getTestSystem ()Lcom/trendyol/stove/testing/e2e/system/TestSystem; protected final fun setCtx (Ljava/lang/Object;)V diff --git a/lib/stove-testing-e2e/src/main/kotlin/com/trendyol/stove/testing/e2e/system/BridgeSystem.kt b/lib/stove-testing-e2e/src/main/kotlin/com/trendyol/stove/testing/e2e/system/BridgeSystem.kt index ab52b1d76..6b9d2455d 100644 --- a/lib/stove-testing-e2e/src/main/kotlin/com/trendyol/stove/testing/e2e/system/BridgeSystem.kt +++ b/lib/stove-testing-e2e/src/main/kotlin/com/trendyol/stove/testing/e2e/system/BridgeSystem.kt @@ -4,6 +4,8 @@ import arrow.core.getOrElse import com.trendyol.stove.testing.e2e.system.abstractions.* import com.trendyol.stove.testing.e2e.system.annotations.StoveDsl import kotlin.reflect.KClass +import kotlin.reflect.KType +import kotlin.reflect.typeOf /** * A system that provides a bridge between the test system and the application context. @@ -34,16 +36,36 @@ abstract class BridgeSystem( ctx = context } + /** + * Resolves a dependency by KClass. + * Override this for basic type resolution without generic support. + */ abstract fun get(klass: KClass): D + /** + * Resolves a dependency by KType, preserving generic type information. + * Override this to support generic types like List, Map, etc. + * Default implementation falls back to KClass-based resolution. + * + * @param type the full KType including generic parameters + * @return the resolved dependency + */ + @Suppress("UNCHECKED_CAST") + open fun getByType(type: KType): D { + val klass = type.classifier as? KClass + ?: throw IllegalArgumentException("Cannot resolve type: $type") + return get(klass) + } + /** * Resolves a bean of the specified type from the application context. + * Uses KType to preserve generic type information (e.g., List). * * @param T the type of bean to resolve. * @return the resolved bean. */ @PublishedApi - internal inline fun resolve(): D = get(D::class) + internal inline fun resolve(): D = getByType(typeOf()) /** * Executes the specified block using the resolved bean. diff --git a/settings.gradle.kts b/settings.gradle.kts index a7fbc50e5..5d7994be9 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -17,6 +17,9 @@ include( ) include( "starters:ktor:stove-ktor-testing-e2e", + "starters:ktor:tests:ktor-test-fixtures", + "starters:ktor:tests:ktor-koin-tests", + "starters:ktor:tests:ktor-di-tests", "starters:spring:stove-spring-testing-e2e", "starters:spring:stove-spring-testing-e2e-kafka", "starters:spring:tests:spring-test-fixtures", diff --git a/starters/ktor/stove-ktor-testing-e2e/api/stove-ktor-testing-e2e.api b/starters/ktor/stove-ktor-testing-e2e/api/stove-ktor-testing-e2e.api index 2e0d03e6e..e0266e5ce 100644 --- a/starters/ktor/stove-ktor-testing-e2e/api/stove-ktor-testing-e2e.api +++ b/starters/ktor/stove-ktor-testing-e2e/api/stove-ktor-testing-e2e.api @@ -1,3 +1,10 @@ +public final class com/trendyol/stove/testing/e2e/DependencyResolvers { + public static final field INSTANCE Lcom/trendyol/stove/testing/e2e/DependencyResolvers; + public final fun autoDetect ()Lkotlin/jvm/functions/Function2; + public final fun getKoin ()Lkotlin/jvm/functions/Function2; + public final fun getKtorDi ()Lkotlin/jvm/functions/Function2; +} + public final class com/trendyol/stove/testing/e2e/KtorApplicationUnderTest : com/trendyol/stove/testing/e2e/system/abstractions/ApplicationUnderTest { public fun (Lcom/trendyol/stove/testing/e2e/system/TestSystem;Lkotlin/jvm/functions/Function1;Ljava/util/List;)V public fun start (Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; @@ -10,12 +17,20 @@ public final class com/trendyol/stove/testing/e2e/KtorApplicationUnderTestKt { } public final class com/trendyol/stove/testing/e2e/KtorBridgeSystem : com/trendyol/stove/testing/e2e/system/BridgeSystem, com/trendyol/stove/testing/e2e/system/abstractions/AfterRunAwareWithContext, com/trendyol/stove/testing/e2e/system/abstractions/PluggedSystem { - public fun (Lcom/trendyol/stove/testing/e2e/system/TestSystem;)V + public fun (Lcom/trendyol/stove/testing/e2e/system/TestSystem;Lkotlin/jvm/functions/Function2;)V public fun get (Lkotlin/reflect/KClass;)Ljava/lang/Object; + public fun getByType (Lkotlin/reflect/KType;)Ljava/lang/Object; public fun getTestSystem ()Lcom/trendyol/stove/testing/e2e/system/TestSystem; } public final class com/trendyol/stove/testing/e2e/KtorBridgeSystemKt { - public static final fun bridge-hQma78k (Lcom/trendyol/stove/testing/e2e/system/TestSystem;)Lcom/trendyol/stove/testing/e2e/system/TestSystem; + public static final fun bridge-PmNtuJU (Lcom/trendyol/stove/testing/e2e/system/TestSystem;Lkotlin/jvm/functions/Function2;)Lcom/trendyol/stove/testing/e2e/system/TestSystem; + public static synthetic fun bridge-PmNtuJU$default (Lcom/trendyol/stove/testing/e2e/system/TestSystem;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lcom/trendyol/stove/testing/e2e/system/TestSystem; +} + +public final class com/trendyol/stove/testing/e2e/KtorDiCheck { + public static final field INSTANCE Lcom/trendyol/stove/testing/e2e/KtorDiCheck; + public final fun isKoinAvailable ()Z + public final fun isKtorDiAvailable ()Z } diff --git a/starters/ktor/stove-ktor-testing-e2e/build.gradle.kts b/starters/ktor/stove-ktor-testing-e2e/build.gradle.kts index 2af1f5f2e..3054c44d8 100644 --- a/starters/ktor/stove-ktor-testing-e2e/build.gradle.kts +++ b/starters/ktor/stove-ktor-testing-e2e/build.gradle.kts @@ -1,5 +1,8 @@ dependencies { api(projects.lib.stoveTestingE2e) implementation(libs.ktor.server.host.common) - implementation(libs.koin.ktor) + + // Both DI systems as compileOnly - users bring their preferred DI at runtime + compileOnly(libs.koin.ktor) + compileOnly(libs.ktor.server.di) } diff --git a/starters/ktor/stove-ktor-testing-e2e/src/main/kotlin/com/trendyol/stove/testing/e2e/DependencyResolvers.kt b/starters/ktor/stove-ktor-testing-e2e/src/main/kotlin/com/trendyol/stove/testing/e2e/DependencyResolvers.kt new file mode 100644 index 000000000..d9b3c3896 --- /dev/null +++ b/starters/ktor/stove-ktor-testing-e2e/src/main/kotlin/com/trendyol/stove/testing/e2e/DependencyResolvers.kt @@ -0,0 +1,62 @@ +package com.trendyol.stove.testing.e2e + +import io.ktor.server.application.* +import io.ktor.server.plugins.di.* +import io.ktor.util.reflect.* +import org.koin.ktor.ext.getKoin +import kotlin.reflect.* + +/** + * Type alias for a dependency resolver function. + * Takes an Application and a KType, returns the resolved dependency. + * KType preserves generic type information (e.g., List). + */ +typealias DependencyResolver = (Application, KType) -> Any + +/** + * Default resolver implementations for supported DI frameworks. + */ +object DependencyResolvers { + /** + * Resolver for Koin DI framework. + */ + val koin: DependencyResolver = { application, type -> + val klass = type.classifier as? KClass<*> + ?: error("Cannot resolve type: $type") + application.getKoin().get(klass) + } + + /** + * Resolver for Ktor-DI framework. + * Uses full KType to preserve generic type information. + */ + val ktorDi: DependencyResolver = { application, type -> + require(application.attributes.contains(DependencyRegistryKey)) { + "Ktor-DI not installed in application. Make sure to install(DI) { ... } in your application." + } + val klass = type.classifier as? KClass<*> + ?: error("Cannot resolve type: $type") + val typeInfo = TypeInfo(klass, type) + application.dependencies.getBlocking(DependencyKey(type = typeInfo)) + } + + /** + * Auto-detects and returns the appropriate resolver based on available DI frameworks. + * Prefers Ktor-DI over Koin if both are available. + * Detection is deferred to runtime to ensure classpath is fully resolved. + */ + fun autoDetect(): DependencyResolver = { application, type -> + val resolver = when { + KtorDiCheck.isKtorDiAvailable() -> ktorDi + + KtorDiCheck.isKoinAvailable() -> koin + + else -> error( + "No supported DI framework found. " + + "Add either Koin (io.insert-koin:koin-ktor) or Ktor-DI (io.ktor:ktor-server-di) to your classpath, " + + "or provide a custom resolver via bridge(resolver = { app, type -> ... })" + ) + } + resolver(application, type) + } +} diff --git a/starters/ktor/stove-ktor-testing-e2e/src/main/kotlin/com/trendyol/stove/testing/e2e/KtorBridgeSystem.kt b/starters/ktor/stove-ktor-testing-e2e/src/main/kotlin/com/trendyol/stove/testing/e2e/KtorBridgeSystem.kt index 9b351ad14..c8a053432 100644 --- a/starters/ktor/stove-ktor-testing-e2e/src/main/kotlin/com/trendyol/stove/testing/e2e/KtorBridgeSystem.kt +++ b/starters/ktor/stove-ktor-testing-e2e/src/main/kotlin/com/trendyol/stove/testing/e2e/KtorBridgeSystem.kt @@ -1,25 +1,61 @@ +@file:Suppress("UNCHECKED_CAST") + package com.trendyol.stove.testing.e2e import com.trendyol.stove.testing.e2e.system.* import com.trendyol.stove.testing.e2e.system.abstractions.* import com.trendyol.stove.testing.e2e.system.annotations.StoveDsl import io.ktor.server.application.* -import org.koin.ktor.ext.* -import kotlin.reflect.KClass +import kotlin.reflect.* +import kotlin.reflect.full.starProjectedType /** * A system that provides a bridge between the test system and the application context. + * Supports Koin, Ktor-DI, or a custom resolver for dependency resolution. * * @property testSystem the test system to bridge. + * @property resolver the dependency resolver function to use. */ @StoveDsl class KtorBridgeSystem( - override val testSystem: TestSystem + override val testSystem: TestSystem, + private val resolver: DependencyResolver ) : BridgeSystem(testSystem), PluggedSystem, AfterRunAwareWithContext { - override fun get(klass: KClass): D = ctx.getKoin().get(klass) + /** + * Resolves a dependency by KClass (fallback, loses generic info). + */ + override fun get(klass: KClass): D = resolver(ctx, klass.starProjectedType) as D + + /** + * Resolves a dependency by KType, preserving generic type information. + * This allows resolving types like List correctly. + */ + override fun getByType(type: KType): D = resolver(ctx, type) as D } +/** + * Registers the Ktor bridge system with automatic DI detection or a custom resolver. + * Supports Koin and Ktor-DI out of the box. + * + * Example usage with auto-detect: + * ```kotlin + * bridge() // Auto-detects Koin or Ktor-DI + * ``` + * + * Example usage with custom resolver: + * ```kotlin + * bridge { application, klass -> + * application.myCustomDi.resolve(klass) + * } + * ``` + * + * @param resolver a function that takes an Application and KClass and returns the resolved dependency. + * Defaults to auto-detecting Koin or Ktor-DI. + * @throws IllegalStateException if no DI framework is available and no custom resolver is provided. + */ @StoveDsl -fun WithDsl.bridge(): TestSystem = this.testSystem.withBridgeSystem(KtorBridgeSystem(this.testSystem)) +fun WithDsl.bridge( + resolver: DependencyResolver = DependencyResolvers.autoDetect() +): TestSystem = this.testSystem.withBridgeSystem(KtorBridgeSystem(this.testSystem, resolver)) diff --git a/starters/ktor/stove-ktor-testing-e2e/src/main/kotlin/com/trendyol/stove/testing/e2e/KtorDiCheck.kt b/starters/ktor/stove-ktor-testing-e2e/src/main/kotlin/com/trendyol/stove/testing/e2e/KtorDiCheck.kt new file mode 100644 index 000000000..6a1e1748e --- /dev/null +++ b/starters/ktor/stove-ktor-testing-e2e/src/main/kotlin/com/trendyol/stove/testing/e2e/KtorDiCheck.kt @@ -0,0 +1,28 @@ +@file:Suppress("TooGenericExceptionCaught", "SwallowedException") + +package com.trendyol.stove.testing.e2e + +/** + * Checks which DI system is available on the classpath. + */ +object KtorDiCheck { + /** + * Returns true if Koin is available on the classpath. + */ + fun isKoinAvailable(): Boolean = try { + Class.forName("org.koin.ktor.ext.ApplicationExtKt") + true + } catch (_: ClassNotFoundException) { + false + } + + /** + * Returns true if Ktor-DI is available on the classpath. + */ + fun isKtorDiAvailable(): Boolean = try { + Class.forName("io.ktor.server.plugins.di.DependencyInjectionConfig") + true + } catch (_: ClassNotFoundException) { + false + } +} diff --git a/starters/ktor/tests/ktor-di-tests/api/ktor-di-tests.api b/starters/ktor/tests/ktor-di-tests/api/ktor-di-tests.api new file mode 100644 index 000000000..e69de29bb diff --git a/starters/ktor/tests/ktor-di-tests/build.gradle.kts b/starters/ktor/tests/ktor-di-tests/build.gradle.kts new file mode 100644 index 000000000..9eb4a0ac9 --- /dev/null +++ b/starters/ktor/tests/ktor-di-tests/build.gradle.kts @@ -0,0 +1,15 @@ +dependencies { + api(projects.starters.ktor.stoveKtorTestingE2e) + implementation(libs.ktor.server.netty) + implementation(libs.ktor.server.di) + testImplementation(testFixtures(projects.starters.ktor.tests.ktorTestFixtures)) +} + +dependencies { + testImplementation(libs.slf4j.simple) +} + +tasks.test.configure { + systemProperty("kotest.framework.config.fqn", "com.trendyol.stove.testing.e2e.ktor.KtorDiStove") +} + diff --git a/starters/ktor/tests/ktor-di-tests/src/test/kotlin/com/trendyol/stove/testing/e2e/ktor/Stove.kt b/starters/ktor/tests/ktor-di-tests/src/test/kotlin/com/trendyol/stove/testing/e2e/ktor/Stove.kt new file mode 100644 index 000000000..562f0d06f --- /dev/null +++ b/starters/ktor/tests/ktor-di-tests/src/test/kotlin/com/trendyol/stove/testing/e2e/ktor/Stove.kt @@ -0,0 +1,23 @@ +package com.trendyol.stove.testing.e2e.ktor + +import com.trendyol.stove.testing.e2e.bridge +import com.trendyol.stove.testing.e2e.ktor +import com.trendyol.stove.testing.e2e.system.TestSystem +import io.kotest.core.config.AbstractProjectConfig + +class KtorDiStove : AbstractProjectConfig() { + override suspend fun beforeProject(): Unit = + TestSystem() + .with { + bridge() // Auto-detects Ktor-DI + ktor( + runner = { params -> + KtorDiTestApp.run(params) + } + ) + }.run() + + override suspend fun afterProject(): Unit = TestSystem.stop() +} + +class KtorDiBridgeSystemTests : BridgeSystemTests(KtorDiStove()) diff --git a/starters/ktor/tests/ktor-di-tests/src/test/kotlin/com/trendyol/stove/testing/e2e/ktor/app.kt b/starters/ktor/tests/ktor-di-tests/src/test/kotlin/com/trendyol/stove/testing/e2e/ktor/app.kt new file mode 100644 index 000000000..1df4a57a9 --- /dev/null +++ b/starters/ktor/tests/ktor-di-tests/src/test/kotlin/com/trendyol/stove/testing/e2e/ktor/app.kt @@ -0,0 +1,40 @@ +package com.trendyol.stove.testing.e2e.ktor + +import io.ktor.server.application.* +import io.ktor.server.engine.* +import io.ktor.server.netty.* +import io.ktor.server.plugins.di.* +import org.junit.platform.commons.logging.LoggerFactory +import java.net.ServerSocket + +/** + * Test Ktor application using Ktor-DI for dependency injection. + */ +object KtorDiTestApp { + private val logger = LoggerFactory.getLogger(KtorDiTestApp::class.java) + + fun run(args: Array): Application { + logger.info { "Starting Ktor-DI test application with args: ${args.joinToString(" ")}" } + val port = findAvailablePort() + val applicationEngine = embeddedServer(Netty, port = port, host = "localhost") { + dependencies { + provide { SystemTimeGetUtcNow() } + provide { ExampleService(resolve()) } + provide { TestConfig() } + + // Multiple payment service implementations as List + provide> { + listOf( + StripePaymentService(), + PayPalPaymentService(), + SquarePaymentService() + ) + } + } + } + applicationEngine.start(wait = false) + return applicationEngine.application + } + + private fun findAvailablePort(): Int = ServerSocket(0).use { it.localPort } +} diff --git a/starters/ktor/tests/ktor-di-tests/src/test/resources/simplelogger.properties b/starters/ktor/tests/ktor-di-tests/src/test/resources/simplelogger.properties new file mode 100644 index 000000000..56f452aef --- /dev/null +++ b/starters/ktor/tests/ktor-di-tests/src/test/resources/simplelogger.properties @@ -0,0 +1,5 @@ +org.slf4j.simpleLogger.defaultLogLevel=info +org.slf4j.simpleLogger.showDateTime=true +org.slf4j.simpleLogger.dateTimeFormat=yyyy-MM-dd HH:mm:ss.SSS +org.slf4j.simpleLogger.showShortLogName=true + diff --git a/starters/ktor/tests/ktor-koin-tests/api/ktor-koin-tests.api b/starters/ktor/tests/ktor-koin-tests/api/ktor-koin-tests.api new file mode 100644 index 000000000..e69de29bb diff --git a/starters/ktor/tests/ktor-koin-tests/build.gradle.kts b/starters/ktor/tests/ktor-koin-tests/build.gradle.kts new file mode 100644 index 000000000..534c1b721 --- /dev/null +++ b/starters/ktor/tests/ktor-koin-tests/build.gradle.kts @@ -0,0 +1,15 @@ +dependencies { + api(projects.starters.ktor.stoveKtorTestingE2e) + implementation(libs.ktor.server.netty) + implementation(libs.koin.ktor) + testImplementation(testFixtures(projects.starters.ktor.tests.ktorTestFixtures)) +} + +dependencies { + testImplementation(libs.slf4j.simple) +} + +tasks.test.configure { + systemProperty("kotest.framework.config.fqn", "com.trendyol.stove.testing.e2e.ktor.KoinStove") +} + diff --git a/starters/ktor/tests/ktor-koin-tests/src/test/kotlin/com/trendyol/stove/testing/e2e/ktor/Stove.kt b/starters/ktor/tests/ktor-koin-tests/src/test/kotlin/com/trendyol/stove/testing/e2e/ktor/Stove.kt new file mode 100644 index 000000000..cd67f8169 --- /dev/null +++ b/starters/ktor/tests/ktor-koin-tests/src/test/kotlin/com/trendyol/stove/testing/e2e/ktor/Stove.kt @@ -0,0 +1,23 @@ +package com.trendyol.stove.testing.e2e.ktor + +import com.trendyol.stove.testing.e2e.bridge +import com.trendyol.stove.testing.e2e.ktor +import com.trendyol.stove.testing.e2e.system.TestSystem +import io.kotest.core.config.AbstractProjectConfig + +class KoinStove : AbstractProjectConfig() { + override suspend fun beforeProject(): Unit = + TestSystem() + .with { + bridge() // Auto-detects Koin + ktor( + runner = { params -> + KoinTestApp.run(params) + } + ) + }.run() + + override suspend fun afterProject(): Unit = TestSystem.stop() +} + +class KoinBridgeSystemTests : BridgeSystemTests(KoinStove()) diff --git a/starters/ktor/tests/ktor-koin-tests/src/test/kotlin/com/trendyol/stove/testing/e2e/ktor/app.kt b/starters/ktor/tests/ktor-koin-tests/src/test/kotlin/com/trendyol/stove/testing/e2e/ktor/app.kt new file mode 100644 index 000000000..9a1dea670 --- /dev/null +++ b/starters/ktor/tests/ktor-koin-tests/src/test/kotlin/com/trendyol/stove/testing/e2e/ktor/app.kt @@ -0,0 +1,45 @@ +package com.trendyol.stove.testing.e2e.ktor + +import io.ktor.server.application.* +import io.ktor.server.engine.* +import io.ktor.server.netty.* +import org.junit.platform.commons.logging.LoggerFactory +import org.koin.dsl.module +import org.koin.ktor.plugin.Koin +import java.net.ServerSocket + +/** + * Test Ktor application using Koin for dependency injection. + */ +object KoinTestApp { + private val logger = LoggerFactory.getLogger(KoinTestApp::class.java) + + fun run(args: Array): Application { + logger.info { "Starting Koin test application with args: ${args.joinToString(" ")}" } + val port = findAvailablePort() + val applicationEngine = embeddedServer(Netty, port = port, host = "localhost") { + install(Koin) { + modules( + module { + single { SystemTimeGetUtcNow() } + single { ExampleService(get()) } + single { TestConfig() } + + // Multiple payment service implementations as List + single> { + listOf( + StripePaymentService(), + PayPalPaymentService(), + SquarePaymentService() + ) + } + } + ) + } + } + applicationEngine.start(wait = false) + return applicationEngine.application + } + + private fun findAvailablePort(): Int = ServerSocket(0).use { it.localPort } +} diff --git a/starters/ktor/tests/ktor-koin-tests/src/test/resources/simplelogger.properties b/starters/ktor/tests/ktor-koin-tests/src/test/resources/simplelogger.properties new file mode 100644 index 000000000..56f452aef --- /dev/null +++ b/starters/ktor/tests/ktor-koin-tests/src/test/resources/simplelogger.properties @@ -0,0 +1,5 @@ +org.slf4j.simpleLogger.defaultLogLevel=info +org.slf4j.simpleLogger.showDateTime=true +org.slf4j.simpleLogger.dateTimeFormat=yyyy-MM-dd HH:mm:ss.SSS +org.slf4j.simpleLogger.showShortLogName=true + diff --git a/starters/ktor/tests/ktor-test-fixtures/api/ktor-test-fixtures.api b/starters/ktor/tests/ktor-test-fixtures/api/ktor-test-fixtures.api new file mode 100644 index 000000000..e69de29bb diff --git a/starters/ktor/tests/ktor-test-fixtures/build.gradle.kts b/starters/ktor/tests/ktor-test-fixtures/build.gradle.kts new file mode 100644 index 000000000..9a90ffd70 --- /dev/null +++ b/starters/ktor/tests/ktor-test-fixtures/build.gradle.kts @@ -0,0 +1,14 @@ +plugins { + `java-test-fixtures` +} + +dependencies { + testFixturesApi(projects.starters.ktor.stoveKtorTestingE2e) + testFixturesApi(libs.kotest.runner.junit5) + testFixturesApi(libs.ktor.server.host.common) + + // DI systems as compileOnly - version provided by consuming module + testFixturesCompileOnly(libs.koin.ktor) + testFixturesCompileOnly(libs.ktor.server.di) +} + diff --git a/starters/ktor/tests/ktor-test-fixtures/src/testFixtures/kotlin/com/trendyol/stove/testing/e2e/ktor/BridgeSystemTests.kt b/starters/ktor/tests/ktor-test-fixtures/src/testFixtures/kotlin/com/trendyol/stove/testing/e2e/ktor/BridgeSystemTests.kt new file mode 100644 index 000000000..90c251439 --- /dev/null +++ b/starters/ktor/tests/ktor-test-fixtures/src/testFixtures/kotlin/com/trendyol/stove/testing/e2e/ktor/BridgeSystemTests.kt @@ -0,0 +1,62 @@ +package com.trendyol.stove.testing.e2e.ktor + +import com.trendyol.stove.testing.e2e.system.TestSystem.Companion.validate +import com.trendyol.stove.testing.e2e.system.using +import io.kotest.core.config.AbstractProjectConfig +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder +import io.kotest.matchers.shouldBe +import java.math.BigDecimal + +/** + * Shared bridge system tests that work with both Koin and Ktor-DI. + * Each DI variant module only needs to provide the Stove setup configuration. + */ +abstract class BridgeSystemTests( + private val stoveSetup: AbstractProjectConfig +) : ShouldSpec({ + beforeSpec { + stoveSetup.beforeProject() + } + + afterSpec { + stoveSetup.afterProject() + } + + should("resolve service from DI container") { + validate { + using { + whatIsTheTime() shouldBe GetUtcNow.frozenTime + } + } + } + + should("resolve multiple dependencies") { + validate { + using { getUtcNow, exampleService -> + getUtcNow() shouldBe GetUtcNow.frozenTime + exampleService.whatIsTheTime() shouldBe GetUtcNow.frozenTime + } + } + } + + should("resolve config from DI container") { + validate { + using { + message shouldBe "Hello from Stove!" + } + } + } + + should("resolve multiple instances of same interface") { + validate { + using> { + val order = Order("order-123", BigDecimal("99.99")) + val results = map { it.pay(order) } + + results.map { it.provider } shouldContainExactlyInAnyOrder listOf("Stripe", "PayPal", "Square") + results.all { it.success } shouldBe true + } + } + } + }) diff --git a/starters/ktor/tests/ktor-test-fixtures/src/testFixtures/kotlin/com/trendyol/stove/testing/e2e/ktor/TestDomain.kt b/starters/ktor/tests/ktor-test-fixtures/src/testFixtures/kotlin/com/trendyol/stove/testing/e2e/ktor/TestDomain.kt new file mode 100644 index 000000000..cc1264856 --- /dev/null +++ b/starters/ktor/tests/ktor-test-fixtures/src/testFixtures/kotlin/com/trendyol/stove/testing/e2e/ktor/TestDomain.kt @@ -0,0 +1,67 @@ +package com.trendyol.stove.testing.e2e.ktor + +import java.math.BigDecimal +import java.time.Instant + +/** + * Common test domain classes for Ktor bridge tests. + */ + +fun interface GetUtcNow { + companion object { + val frozenTime: Instant = Instant.parse("2021-01-01T00:00:00Z") + } + + operator fun invoke(): Instant +} + +class SystemTimeGetUtcNow : GetUtcNow { + override fun invoke(): Instant = GetUtcNow.frozenTime +} + +class ExampleService( + private val getUtcNow: GetUtcNow +) { + fun whatIsTheTime(): Instant = getUtcNow() +} + +data class TestConfig( + val message: String = "Hello from Stove!" +) + +/** + * Domain classes for testing multi-instance resolution. + */ +data class Order( + val id: String, + val amount: BigDecimal +) + +data class PaymentResult( + val provider: String, + val success: Boolean +) + +interface PaymentService { + val providerName: String + + fun pay(order: Order): PaymentResult +} + +class StripePaymentService : PaymentService { + override val providerName = "Stripe" + + override fun pay(order: Order) = PaymentResult(providerName, true) +} + +class PayPalPaymentService : PaymentService { + override val providerName = "PayPal" + + override fun pay(order: Order) = PaymentResult(providerName, true) +} + +class SquarePaymentService : PaymentService { + override val providerName = "Square" + + override fun pay(order: Order) = PaymentResult(providerName, true) +}