Skip to content

Bridge

The Bridge component provides direct access to your application's dependency injection (DI) container from within your tests. This enables you to resolve and use any bean or service registered in your application, making it possible to test internal state, verify side effects, or set up test data through application services.

Overview

When testing an application end-to-end, you often need to:

  • Verify internal state that isn't exposed through APIs
  • Access application services to set up test data
  • Invoke domain services directly to test business logic
  • Replace time-dependent implementations for deterministic tests
  • Verify side effects that happen within the application

The Bridge provides a type-safe way to access any component from your application's DI container.

Configuration

Bridge is built into the framework starters, so no extra dependency is needed.

dependencies {
    testImplementation("com.trendyol:stove-spring-testing-e2e:$version")
}
dependencies {
    testImplementation("com.trendyol:stove-ktor-testing-e2e:$version")
}

Setup

Enable Bridge in your TestSystem configuration:

TestSystem()
  .with {
    httpClient { HttpClientSystemOptions(baseUrl = "http://localhost:8080") }

    bridge()  // Enable access to DI container

    springBoot(
      runner = { params -> myApp.run(params) },
      withParameters = listOf("server.port=8080")
    )
  }
  .run()

Framework Support

Spring Boot

For Spring Boot applications, Bridge provides access to the ApplicationContext:

// Bridge resolves beans from ApplicationContext
using<UserService> {
    // 'this' is the UserService bean from Spring context
    findById(123)
}

Under the hood, it uses ApplicationContext.getBean():

class SpringBridgeSystem(testSystem: TestSystem) : BridgeSystem<ApplicationContext>(testSystem) {
    override fun <D : Any> get(klass: KClass<D>): D = ctx.getBean(klass.java)
}

Ktor

Ktor Bridge supports multiple dependency injection frameworks with automatic detection:

  • Koin - Popular DI framework for Kotlin
  • Ktor-DI - Ktor's native DI plugin
  • Custom - Any DI framework via custom resolver
// Bridge resolves beans from your DI container
using<UserRepository> {
    // 'this' is the UserRepository from your DI
    save(user)
}

DI Framework Setup

Using Koin:

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:

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:

// For any other DI framework (Kodein, Dagger, etc.)
TestSystem()
    .with {
        bridge { application, type ->
            // type is KType - preserves generic info like List<T>
            myDiContainer.resolve(type)
        }
        ktor(runner = { params -> MyApp.run(params) })
    }
    .run()

Generic Type Resolution

Bridge preserves generic type information, enabling resolution of types like List<Service>:

// Works with Koin or Ktor-DI
using<List<PaymentService>> {
    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:

object MyApp {
    fun run(
        args: Array<String>,
        testModules: List<Module> = 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<TimeProvider>(override = true) { FixedTimeProvider() }
                            single<EmailService>(override = true) { MockEmailService() }
                        }
                    )
                )
            }
        )
    }
    .run()

Ktor-DI - Using Dependencies Block:

object MyApp {
    fun run(
        args: Array<String>,
        testDependencies: (DependencyRegistrar.() -> Unit)? = null  // Accept test registrations
    ): Application {
        return embeddedServer(Netty, port = args.getPort()) {
            install(DI) {
                dependencies {
                    // Production dependencies
                    provide<UserService> { UserServiceImpl() }
                    provide<TimeProvider> { 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<TimeProvider> { FixedTimeProvider() }
                    provide<EmailService> { MockEmailService() }
                }
            }
        )
    }
    .run()

Test Dependency Patterns

  • Koin: Use override = true in test modules to replace production beans
  • Ktor-DI: Later provide<T> calls override earlier ones
  • Both frameworks support the pattern of passing test-specific configuration to your app's run function

Usage

Single Bean Access

Access a single bean and perform operations:

TestSystem.validate {
    using<UserService> {
        // 'this' refers to UserService
        val user = findById(123)
        user.name shouldBe "John Doe"
        user.email shouldBe "john@example.com"
    }
}

Multiple Bean Access

Access multiple beans in a single block (up to 5 beans supported):

TestSystem.validate {
    // Two beans
    using<UserService, OrderService> { userService, orderService ->
        val user = userService.findById(123)
        val orders = orderService.findByUserId(123)
        orders.size shouldBeGreaterThan 0
    }

    // Three beans
    using<UserService, ProductService, InventoryService> { users, products, inventory ->
        val product = products.findById("SKU-123")
        val stock = inventory.getStock(product.id)
        stock shouldBeGreaterThan 0
    }

    // Four beans
    using<A, B, C, D> { a, b, c, d ->
        // Work with all four services
    }

    // Five beans
    using<A, B, C, D, E> { a, b, c, d, e ->
        // Work with all five services
    }
}

Capturing Values for Later Use

When you need to capture a value from inside the using block for later use, declare a variable outside the block and assign it inside:

TestSystem.validate {
    // Declare variable outside, assign inside
    var userId: Long = 0
    using<UserService> {
        userId = createUser(CreateUserRequest(name = "John", email = "john@example.com")).id
    }

    // Use the captured value in subsequent operations
    http {
        get<UserResponse>("/users/$userId") { user ->
            user.name shouldBe "John"
        }
    }

    // Capture multiple values
    var user: User? = null
    var token: String? = null
    using<AuthService> {
        user = register(email = "test@example.com", password = "secret")
        token = generateToken(user!!)
    }

    // Or use lateinit for non-nullable types
    lateinit var order: Order
    using<OrderService> {
        order = findById(orderId)
    }

    // Use captured values
    http {
        getResponse("/orders/${order.id}", headers = mapOf("Authorization" to "Bearer $token")) { response ->
            response.status shouldBe 200
        }
    }
}

Variable Capture Pattern

Since using blocks don't return values, use the pattern of declaring variables outside and assigning inside when you need to pass data between blocks.

Use Cases

1. Setting Up Test Data

Use application repositories to set up test data:

test("should return user orders") {
    TestSystem.validate {
        // Create test data using application's repository
        var userId: Long = 0
        using<UserRepository> {
            userId = save(User(name = "Test User", email = "test@example.com")).id
        }

        using<OrderRepository> {
            save(Order(userId = userId, amount = 100.0))
            save(Order(userId = userId, amount = 250.0))
        }

        // Test the API
        http {
            get<List<OrderResponse>>("/users/$userId/orders") { orders ->
                orders.size shouldBe 2
                orders.sumOf { it.amount } shouldBe 350.0
            }
        }
    }
}

2. Verifying Internal State

Verify state that isn't exposed through APIs:

test("should update inventory after order") {
    TestSystem.validate {
        val productId = "PROD-123"

        // Check initial inventory
        var initialStock = 0
        using<InventoryService> {
            initialStock = getStock(productId)
        }

        // Place an order via API
        http {
            postAndExpectBodilessResponse(
                uri = "/orders",
                body = CreateOrderRequest(productId = productId, quantity = 5).some()
            ) { response ->
                response.status shouldBe 201
            }
        }

        // Verify inventory was reduced (internal side effect)
        using<InventoryService> {
            getStock(productId) shouldBe (initialStock - 5)
        }
    }
}

3. Testing Domain Services Directly

Test business logic that may be complex to trigger through APIs:

test("should calculate shipping cost correctly") {
    TestSystem.validate {
        using<ShippingCalculator> {
            // Test various scenarios directly
            calculate(weight = 1.0, destination = "US") shouldBe 5.99
            calculate(weight = 5.0, destination = "US") shouldBe 12.99
            calculate(weight = 1.0, destination = "EU") shouldBe 15.99
        }
    }
}

4. Triggering Scheduled Jobs

Manually trigger scheduled jobs for testing:

test("should process pending orders when scheduler runs") {
    TestSystem.validate {
        // Setup: Create pending orders
        using<OrderRepository> {
            save(Order(status = "PENDING", createdAt = Instant.now().minusHours(2)))
            save(Order(status = "PENDING", createdAt = Instant.now().minusHours(3)))
        }

        // Trigger the scheduled job manually
        using<OrderProcessingScheduler> {
            processPendingOrders()
        }

        // Verify orders were processed
        using<OrderRepository> {
            findByStatus("PENDING").size shouldBe 0
            findByStatus("PROCESSED").size shouldBe 2
        }
    }
}

5. Time Control

Control time-dependent behavior:

// First, create a testable time provider interface
interface TimeProvider {
    fun now(): Instant
}

// Production implementation
class SystemTimeProvider : TimeProvider {
    override fun now(): Instant = Instant.now()
}

// Test implementation
class FixedTimeProvider(private var time: Instant) : TimeProvider {
    override fun now(): Instant = time
    fun advance(duration: Duration) { time = time.plus(duration) }
}

// Register test implementation in your TestSystem setup
addTestDependencies {
    bean<TimeProvider>(isPrimary = true) { FixedTimeProvider(Instant.parse("2024-01-01T00:00:00Z")) }
}

// Use in tests
test("should expire session after timeout") {
    TestSystem.validate {
        // Create session and capture the session ID
        var sessionId: String = ""
        http {
            postAndExpectBody<SessionResponse>("/login", body = credentials.some()) { response ->
                sessionId = response.body().sessionId 
            }
        }

        // Advance time past session timeout
        using<FixedTimeProvider> {
            advance(Duration.ofHours(2))
        }

        // Session should be expired
        http {
            getResponse("/protected", headers = mapOf("Session-ID" to sessionId)) { response ->
                response.status shouldBe 401
            }
        }
    }
}

6. Event Verification

Capture and verify domain events:

// Test event listener (registered via addTestDependencies)
class TestEventCapture {
    private val events = ConcurrentLinkedQueue<Any>()

    @EventListener
    fun capture(event: Any) {
        events.add(event)
    }

    inline fun <reified T> getEvents(): List<T> = events.filterIsInstance<T>()
    fun clear() = events.clear()
}

test("should publish UserCreatedEvent when user registers") {
    TestSystem.validate {
        // Clear previous events
        using<TestEventCapture> { clear() }

        // Perform action
        http {
            postAndExpectBodilessResponse("/users", body = newUser.some()) { 
                it.status shouldBe 201 
            }
        }

        // Verify event was published
        using<TestEventCapture> {
            val events = getEvents<UserCreatedEvent>()
            events.size shouldBe 1
            events.first().email shouldBe newUser.email
        }
    }
}

Test Bean Registration

Register test-specific beans using addTestDependencies:

Spring Boot 2.x / 3.x:

import com.trendyol.stove.testing.e2e.addTestDependencies

TestSystem()
    .with {
        bridge()
        springBoot(
            runner = { params -> 
                runApplication<MyApp>(*params) {
                    addTestDependencies {
                        // Replace production beans with test doubles
                        bean<TimeProvider>(isPrimary = true) { FixedTimeProvider(Instant.now()) }
                        bean<EmailService>(isPrimary = true) { MockEmailService() }

                        // Add test utilities
                        bean<TestEventCapture>()
                        bean<TestDataBuilder>()
                    }
                }
            }
        )
    }
    .run()

Spring Boot 4.x:

import com.trendyol.stove.testing.e2e.addTestDependencies4x

TestSystem()
    .with {
        bridge()
        springBoot(
            runner = { params -> 
                runApplication<MyApp>(*params) {
                    addTestDependencies4x {
                        // Replace production beans with test doubles
                        registerBean<TimeProvider>(primary = true) { FixedTimeProvider(Instant.now()) }
                        registerBean<EmailService>(primary = true) { MockEmailService() }

                        // Add test utilities
                        registerBean<TestEventCapture>()
                        registerBean<TestDataBuilder>()
                    }
                }
            }
        )
    }
    .run()

Alternative: Using addInitializers Directly

For more control, you can use addInitializers with stoveSpringRegistrar:

// Spring Boot 2.x / 3.x
addInitializers(stoveSpringRegistrar {
    bean<TimeProvider>(isPrimary = true) { FixedTimeProvider(Instant.now()) }
    bean<TestEventCapture>()
})

// Spring Boot 4.x
addInitializers(stoveSpring4xRegistrar {
    registerBean<TimeProvider>(primary = true) { FixedTimeProvider(Instant.now()) }
    registerBean<TestEventCapture>()
})

Integration with Other Systems

Bridge works seamlessly with other Stove systems:

test("should process order end-to-end") {
    TestSystem.validate {
        val orderId = UUID.randomUUID().toString()

        // Mock external payment service
        wiremock {
            mockPost("/payments/charge", statusCode = 200, responseBody = PaymentResult(success = true).some())
        }

        // Create order via API
        http {
            postAndExpectBody<OrderResponse>(
                uri = "/orders",
                body = CreateOrderRequest(id = orderId, amount = 99.99).some()
            ) { response ->
                response.status shouldBe 201
            }
        }

        // Verify in database using application's repository
        using<OrderRepository> {
            val order = findById(orderId)
            order.status shouldBe "PAID"
            order.paymentId shouldNotBe null
        }

        // Verify Kafka event
        kafka {
            shouldBePublished<OrderPaidEvent>(atLeastIn = 10.seconds) {
                actual.orderId == orderId
            }
        }

        // Verify in Couchbase (if using)
        couchbase {
            shouldGet<Order>("orders", orderId) { order ->
                order.status shouldBe "PAID"
            }
        }

        // Access domain service for additional verification
        using<OrderAnalytics> {
            getTodaysTotalRevenue() shouldBeGreaterThanOrEqual 99.99
        }
  }
}

Best Practices

1. Use Bridge for Setup, HTTP for Actions

// ✅ Good: Use bridge for setup, HTTP for testing
using<ProductRepository> {
    save(Product(id = "123", name = "Test", price = 99.99))
}
http {
    get<ProductResponse>("/products/123") { product ->
        product.name shouldBe "Test"
    }
}

// ❌ Avoid: Using bridge for everything
using<ProductService> {
    create(product)
    val retrieved = findById("123")  // Not testing actual API
    retrieved.name shouldBe "Test"
}

2. Prefer Application Services Over Direct Repository Access

// ✅ Good: Use application services that encapsulate business logic
using<OrderService> {
    createOrder(CreateOrderRequest(...))  // Triggers all business logic
}

// ⚠️ Be careful: Direct repository access bypasses business logic
using<OrderRepository> {
    save(Order(...))  // No validation, no events, no side effects
}

3. Clean Up Test Data

// Use cleanup functions or explicit cleanup in tests
TestSystem.validate {
    var userId: Long = 0
    using<UserRepository> {
        userId = save(user).id
    }

    try {
        // Test logic
        http { /* ... */ }
    } finally {
        // Cleanup
        using<UserRepository> {
            deleteById(userId)
        }
    }
}

4. Keep Test Beans Minimal

Only replace what's necessary:

// ✅ Good: Replace only time-sensitive components
addTestDependencies {
    bean<Clock>(isPrimary = true) { Clock.fixed(fixedInstant, ZoneId.UTC) }
}

// ❌ Avoid: Replacing too many components (reduces test value)
addTestDependencies {
    bean<UserService>(isPrimary = true) { MockUserService() }
    bean<OrderService>(isPrimary = true) { MockOrderService() }
    bean<PaymentService>(isPrimary = true) { MockPaymentService() }
}

Summary

The Bridge component enables:

Capability Example Use Case
Bean Access Resolve any bean from DI container
State Verification Check internal state not exposed by APIs
Test Setup Create test data using application services
Time Control Replace time providers for deterministic tests
Event Capture Verify domain events were published
Job Triggering Manually trigger scheduled tasks
Service Testing Test domain services directly

Bridge is essential for comprehensive e2e testing, allowing you to verify and control aspects of your application that aren't accessible through external interfaces alone.