Java Clean Architecture Masterclass

Java Clean Architecture Masterclass28-29 May

Join

Stove logo

Stove

Kotlin-first end-to-end testing for JVM and polyglot applications.
Boot the application under test, wire real dependencies, and assert the full runtime flow from one DSL.

Release Homebrew Snapshot codecov OpenSSF Scorecard

stove {
  // Call API and verify response
  http {
    postAndExpectBodilessResponse("/orders", body = CreateOrderRequest(userId, productId).some()) {
      it.status shouldBe 201
    }
  }

  // Verify database state
  postgresql {
    shouldQuery<Order>("SELECT * FROM orders WHERE user_id = '$userId'", mapper = { row ->
      Order(row.string("status"))
    }) {
      it.first().status shouldBe "CONFIRMED"
    }
  }

  // Verify event was published
  kafka {
    shouldBePublished<OrderCreatedEvent> {
      actual.userId == userId
    }
  }

  // Access application beans directly
  using<InventoryService> {
    getStock(productId) shouldBe 9
  }
}

Why Stove?

The JVM ecosystem has mature frameworks for building applications, but end-to-end test setup is still fragmented. Testcontainers can start infrastructure, but most teams still write their own lifecycle code for container startup, runtime configuration, application boot, cleanup, and assertions. That boilerplate usually looks different for every framework.

Stove puts those pieces behind one lifecycle. You register the systems your app talks to, then register one AUT runner. Stove starts or connects to the systems, exposes their runtime configuration to framework/process/container AUT runners, boots or targets the application under test (AUT), and gives your tests a single Kotlin DSL for driving and verifying the flow. A system is a Stove dependency, client, mock, or observability module such as HTTP, PostgreSQL, Kafka, WireMock, tracing, or dashboard. An AUT runner registers how Stove starts or targets the app.

Stove works with Java, Kotlin, and Scala applications across Spring Boot, Ktor, Micronaut, and Quarkus. The same test DSL also supports non-JVM applications through process/container runners, or targets already-running applications with providedApplication(). Because assertions are system-oriented rather than framework-specific, teams can verify HTTP APIs, async message flows, database side effects, external service calls, and traces without rewriting the test model for each stack.

What Stove does:

Dashboard (New in 0.23.0)

Stove Dashboard is a local UI and API for end-to-end test runs. When the stove CLI is running and dashboard { } is registered, it receives events from your test JVM, stores run data in SQLite, and shows timelines, system snapshots, and traces in one place. Trace data still requires the tracing setup shown below.

https://github.com/user-attachments/assets/14597dc6-e9d4-43ab-8cfa-578ab3c3e6df

Quick start

# 1) Install and start the Dashboard CLI
brew install Trendyol/trendyol-tap/stove
stove

# 2) Run your tests and open the dashboard
./gradlew test
# http://localhost:4040
// build.gradle.kts
plugins {
  id("com.trendyol.stove.tracing") version "$stoveVersion"
}

dependencies {
  testImplementation(platform("com.trendyol:stove-bom:$version"))
  testImplementation("com.trendyol:stove-extensions-kotest")  // or stove-extensions-junit
  testImplementation("com.trendyol:stove-dashboard")
  testImplementation("com.trendyol:stove-tracing")
}

stoveTracing {
  serviceName.set("product-api")
}
// Kotest
class StoveConfig : AbstractProjectConfig() {
  override val extensions = listOf(StoveKotestExtension())
  override suspend fun beforeProject() {
    Stove().with {
      dashboard { DashboardSystemOptions(appName = "product-api") }
      tracing { enableSpanReceiver() } // recommended
    }.run()
  }
  override suspend fun afterProject() = Stove.stop()
}

// JUnit
@ExtendWith(StoveJUnitExtension::class)
abstract class BaseE2ETest { /* Stove().with { ... }.run() in @BeforeAll */ }

Keep stove-cli, the Stove BOM, the tracing Gradle plugin, and your Stove test dependencies on the same Stove version. The dashboard warns on version mismatches, but aligning versions avoids missing or inconsistent dashboard data.

See Dashboard docs and 0.23.0 release notes for full details.

Getting Started

1. Add dependencies

dependencies {
  // Import BOM for version management
  testImplementation(platform("com.trendyol:stove-bom:$version"))
  
  // Core and framework starter
  testImplementation("com.trendyol:stove")
  testImplementation("com.trendyol:stove-spring")  // or stove-ktor, stove-micronaut, stove-quarkus
  
  // Component modules
  testImplementation("com.trendyol:stove-postgres")
  testImplementation("com.trendyol:stove-mysql")
  testImplementation("com.trendyol:stove-kafka")
}

Snapshots: As of 5th June 2025, Stove's snapshot packages are hosted on Central Sonatype.

repositories {
  maven("https://central.sonatype.com/repository/maven-snapshots")
}

2. Configure Stove (runs once before the e2e suite)

class StoveConfig : AbstractProjectConfig() {
  override suspend fun beforeProject() = Stove()
    .with {
      httpClient {
        HttpClientSystemOptions(baseUrl = "http://localhost:8080")
      }
      postgresql {
        PostgresqlOptions(
          cleanup = { it.execute("TRUNCATE orders, users") },
          configureExposedConfiguration = { listOf("spring.datasource.url=${it.jdbcUrl}") }
        ).migrations {
          register<CreateUsersTable>()
        }
      }
      kafka {
        KafkaSystemOptions(
          cleanup = { it.deleteTopics(listOf("orders")) },
          configureExposedConfiguration = { listOf("kafka.bootstrapServers=${it.bootstrapServers}") }
        ).migrations {
          register<CreateOrdersTopic>()
        }
      }
      bridge()
      springBoot(runner = { params ->
        myApp.run(params) { addTestDependencies() }
      })
    }.run()

  override suspend fun afterProject() = Stove.stop()
}

3. Write tests

test("should process order") {
  stove {
    http {
      get<Order>("/orders/123") {
        it.status shouldBe "CONFIRMED"
      }
    }
    postgresql {
      shouldQuery<Order>("SELECT * FROM orders", mapper = { row ->
        Order(row.string("status"))
      }) {
        it.size shouldBe 1
      }
    }
    kafka {
      shouldBePublished<OrderCreatedEvent> {
        actual.orderId == "123"
      }
    }
  }
}

Writing Tests

All assertions happen inside stove { }. Each block resolves the system registered in Stove().with { ... }, so test code stays focused on the behavior under test instead of client construction or container plumbing.

HTTP

http {
  get<User>("/users/$id") {
    it.name shouldBe "John"
  }
  postAndExpectBodilessResponse("/users", body = request.some()) {
    it.status shouldBe 201
  }
  postAndExpectBody<User>("/users", body = request.some()) {
    it.id shouldNotBe null
  }
}

Database

postgresql {  // also: mysql, mongodb, couchbase, mssql, elasticsearch, redis
  shouldExecute("INSERT INTO users (name) VALUES ('Jane')")
  shouldQuery<User>("SELECT * FROM users", mapper = { row ->
    User(row.string("name"))
  }) {
    it.size shouldBe 1
  }
}

Kafka

kafka {
  publish("orders.created", OrderCreatedEvent(orderId = "123"))
  shouldBeConsumed<OrderCreatedEvent> {
    actual.orderId == "123"
  }
  shouldBePublished<OrderConfirmedEvent> {
    actual.orderId == "123"
  }
}

External API Mocking

wiremock {
  mockGet("/external-api/users/1", responseBody = User(id = 1, name = "John").some())
  mockPost("/external-api/notify", statusCode = 202)
}

Application Beans

For supported JVM frameworks, bridge() exposes the application DI container so a test can inspect or call beans after driving the public API:

using<OrderService> { processOrder(orderId) }
using<UserRepo, EmailService> { userRepo, emailService ->
  userRepo.findById(id) shouldNotBe null
}

Reporting

When the Kotest or JUnit extension is registered, Stove enriches failures with an execution report. The report records the timeline of Stove operations and the latest snapshots each system can provide:

Example Report
╔══════════════════════════════════════════════════════════════════════════════════════════════════╗
║                                   STOVE TEST EXECUTION REPORT                                    ║
║                                                                                                  ║
║ Test: should create new product when send product create request from api for the allowed        ║
║ supplier                                                                                         ║
║ ID: ExampleTest::should create new product when send product create request from api for the     ║
║ allowed supplier                                                                                 ║
║ Status: FAILED                                                                                   ║
╠══════════════════════════════════════════════════════════════════════════════════════════════════╣
║                                                                                                  ║
║ TIMELINE                                                                                         ║
║ ────────                                                                                         ║
║                                                                                                  ║
║ 12:41:12.371 ✓ PASSED [WireMock] Register stub: GET /suppliers/99/allowed                        ║
║     Output: kotlin.Unit                                                                          ║
║     Metadata: {statusCode=200, responseHeaders={}}                                               ║
║                                                                                                  ║
║ 12:41:13.405 ✓ PASSED [HTTP] POST /api/product/create                                            ║
║     Input: ProductCreateRequest(id=1, name=product name, supplierId=99)                          ║
║     Output: kotlin.Unit                                                                          ║
║     Metadata: {status=200, headers={}}                                                           ║
║                                                                                                  ║
║ 12:41:13.424 ✓ PASSED [Kafka] shouldBePublished<ProductCreatedEvent>                             ║
║     Output: ProductCreatedEvent(id=1, name=product name, supplierId=99, createdDate=Thu Jan 08   ║
║     12:41:12 CET 2026, type=ProductCreatedEvent)                                                 ║
║     Metadata: {timeout=5s}                                                                       ║
║                                                                                                  ║
║ 12:41:13.455 ✗ FAILED [Couchbase] Get document                                                   ║
║     Input: {id=product:1}                                                                        ║
║     Error: expected:<100L> but was:<99L>                                                         ║
║                                                                                                  ║
╠══════════════════════════════════════════════════════════════════════════════════════════════════╣
║                                                                                                  ║
║ SYSTEM SNAPSHOTS                                                                                 ║
║ ────────────────                                                                                 ║
║                                                                                                  ║
║ ┌─ HTTP ──────────────────────────────────────────────────────────────────────────────────────── ║
║                                                                                                  ║
║   No detailed state available                                                                    ║
║                                                                                                  ║
║ ┌─ COUCHBASE ─────────────────────────────────────────────────────────────────────────────────── ║
║                                                                                                  ║
║   No detailed state available                                                                    ║
║                                                                                                  ║
║ ┌─ KAFKA ─────────────────────────────────────────────────────────────────────────────────────── ║
║                                                                                                  ║
║   Consumed: 0                                                                                    ║
║   Published: 1                                                                                   ║
║   Committed: 0                                                                                   ║
║                                                                                                  ║
║   State Details:                                                                                 ║
║     consumed: 0 item(s)                                                                          ║
║     published: 1 item(s)                                                                         ║
║       [0]                                                                                        ║
║         id: 376db940-a367-4419-a628-4754c9466421                                                 ║
║         topic: stove-standalone-example.productCreated.1                                         ║
║         key: 1                                                                                   ║
║         headers: {X-EventType=ProductCreatedEvent, X-MessageId=29902970-056d-4ae9-9a84-...}      ║
║         message: {"id":1,"name":"product name","supplierId":99,...}                              ║
║     committed: 0 item(s)                                                                         ║
║                                                                                                  ║
║ ┌─ WIREMOCK ──────────────────────────────────────────────────────────────────────────────────── ║
║                                                                                                  ║
║   Registered stubs: 0                                                                            ║
║   Served requests: 0 (matched: 0)                                                                ║
║   Unmatched requests: 0                                                                          ║
║                                                                                                  ║
╚══════════════════════════════════════════════════════════════════════════════════════════════════╝

Features:

Test Framework Extensions:

Use the provided extensions to automatically enrich failures:

// Kotest - register in project config
class StoveConfig : AbstractProjectConfig() {
  override val extensions = listOf(StoveKotestExtension())
}

// JUnit 5 - annotate test class
@ExtendWith(StoveJUnitExtension::class)
class MyTest { ... }

Configuration:

Stove(
  StoveOptions(
    reportingEnabled = true,           // Enable/disable reporting (default: true)
    dumpReportOnTestFailure = true,    // Enrich failures with report (default: true)
    failureRenderer = PrettyConsoleRenderer  // Custom renderer (default: PrettyConsoleRenderer)
  )
).with { ... }

Tracing

When tracing is enabled, failed tests can show the execution call chain inside your application: controllers, services, database calls, Kafka publish/consume spans, and the failure point, powered by OpenTelemetry:

EXECUTION TRACE (Call Chain)
═══════════════════════════════════════════════════════════════════
✓ POST (377ms)
  ✓ POST /api/product/create (361ms)
    ✓ ProductController.create (141ms)
      ✓ ProductCreator.create (0ms)
      ✓ KafkaProducer.send (137ms)
        ✓ orders.created publish (81ms)
          ✗ orders.created process (82ms)  ← FAILURE POINT

Setup (two steps):

// 1. In your Stove config
tracing { enableSpanReceiver() }

// 2. In build.gradle.kts
plugins { id("com.trendyol.stove.tracing") version "$stoveVersion" }
stoveTracing { serviceName.set("my-service") }

Validate traces in tests:

tracing {
    shouldContainSpan("OrderService.processOrder")
    shouldNotHaveFailedSpans()
    executionTimeShouldBeLessThan(500.milliseconds)
}

For in-process JVM applications launched by Stove with the tracing Gradle plugin, no application-code changes are required. The plugin attaches the OpenTelemetry Java agent to the test JVM and configures the agent endpoint for the application under test.

AI Agent Integration

Stove's execution reports and tracing data are structured and deterministic, making them ideal for AI agent workflows. When an AI agent runs e2e tests during implementation, it can parse the failure reports — including the full execution trace, system snapshots, and timeline — to understand exactly what went wrong inside the application. This enables agents to iterate on fixes with precise feedback rather than guessing from opaque test failures.

When stove is running, it also exposes a local read-only MCP endpoint at http://localhost:4040/mcp. Agents can call stove_failures first, then drill into a specific run_id + test_id for timeline, trace, and snapshot evidence. MCP is optional: if it is unavailable or incomplete, agents should fall back to normal test output, Stove failure reports, and logs.

Agent Skills: Stove ships with a ready-to-use Claude Code skill that teaches AI agents how to set up and write Stove e2e tests. Copy the .claude/skills/stove/ directory into your project's .claude/skills/ folder, and your AI coding agent will know how to configure systems, write tests, enable tracing, and build custom systems — following all Stove conventions automatically.

Configuration

Framework Setup

Spring BootKtor
springBoot(
  runner = { params ->
    myApp.run(params) {
      addTestDependencies()
    }
  }
)
ktor(
  runner = { params ->
    run(params, shouldWait = false)
  }
)
MicronautQuarkus
micronaut(
  runner = { params ->
    myApp.run(params)
  }
)
quarkus(
  runner = { params ->
    MyApp.main(params)
  }
)

Container Reuse

Speed up local development by keeping reusable dependency containers running between test runs:

Stove { keepDependenciesRunning() }.with { ... }

Cleanup

Run cleanup logic when Stove stops at suite teardown:

postgresql {
  PostgresqlOptions(cleanup = { it.execute("TRUNCATE users") }, ...)
}

kafka {
  KafkaSystemOptions(cleanup = { it.deleteTopics(listOf("test-topic")) }, ...)
}

Available for Kafka, PostgreSQL, MySQL, MongoDB, Couchbase, Cassandra, MSSQL, Elasticsearch, Redis.

Migrations

Run system migrations during suite startup before the application under test receives dependency configuration:

postgresql {
  PostgresqlOptions(...)
   .migrations {
      register<CreateUsersTable>()
      register<CreateOrdersTable>()
  }
}

Available for Kafka, PostgreSQL, MySQL, MongoDB, Couchbase, Cassandra, MSSQL, Elasticsearch, Redis.

Provided Instances

Connect to existing infrastructure instead of starting Testcontainers (useful when CI already provides shared services):

postgresql { PostgresqlOptions.provided(jdbcUrl = "jdbc:postgresql://ci-db:5432/test", ...) }
kafka { KafkaSystemOptions.provided(bootstrapServers = "ci-kafka:9092", ...) }

Tip: When using provided instances, use migrations to create isolated test schemas and cleanups to remove test data afterwards. This ensures test isolation on shared infrastructure.

Complete Example

test("should create order with payment processing") {
  stove {
    val userId = UUID.randomUUID().toString()
    val productId = UUID.randomUUID().toString()

    // 1. Seed database
    postgresql {
      shouldExecute("INSERT INTO users (id, name) VALUES ('$userId', 'John')")
      shouldExecute("INSERT INTO products (id, price, stock) VALUES ('$productId', 99.99, 10)")
    }

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

    // 3. Call API
    http {
      postAndExpectBody<OrderResponse>(
        "/orders",
        body = CreateOrderRequest(userId, productId).some()
      ) {
        it.status shouldBe 201
      }
    }

    // 4. Verify database
    postgresql {
      shouldQuery<Order>("SELECT * FROM orders WHERE user_id = '$userId'", mapper = { row ->
        Order(row.string("status"))
      }) {
        it.first().status shouldBe "CONFIRMED"
      }
    }

    // 5. Verify event published
    kafka {
      shouldBePublished<OrderCreatedEvent> {
        actual.userId == userId
      }
    }

    // 6. Verify via application service
    using<InventoryService> { getStock(productId) shouldBe 9 }
  }
}

Reference

Supported Components

Category Components
Databases PostgreSQL, MySQL, MongoDB, Couchbase, Cassandra, MSSQL, Elasticsearch, Redis
Messaging Kafka
HTTP Built-in client, WebSockets, WireMock
gRPC Client (grpc-kotlin), Mock Server (native)
Frameworks Spring Boot, Ktor, Micronaut, Quarkus

Feature Matrix

Component Migrations Cleanup Provided Instance Pause/Unpause
PostgreSQL
MySQL
MSSQL
MongoDB
Couchbase
Cassandra
Elasticsearch
Redis
Kafka
WireMock n/a n/a n/a n/a
HTTP Client n/a n/a n/a n/a
gRPC Mock n/a n/a n/a n/a
FAQ

Can I use Stove with Java applications?
Yes. Your application can be Java, Scala, or any JVM language. Tests are written in Kotlin for the DSL.

Does Stove replace Testcontainers?
No. Stove uses Testcontainers underneath and adds the unified DSL on top.

How slow is the first run?
First run pulls Docker images (~1-2 min). Use keepDependenciesRunning() for instant subsequent runs.

Can I run tests in parallel?
Yes, with unique test data per test. See provided instances docs.

Resources

Community

Used by:

  1. Trendyol: Leading e-commerce platform, Turkey

Using Stove? Open a PR to add your company.

Contributions: Issues and PRs welcome
License: Apache 2.0

Note: Production-ready and used at scale. API still evolving; breaking changes possible in minor releases with migration guides.

Join libs.tech

...and unlock some superpowers

GitHub

We won't share your data with anyone else.