Stove¶
Stove is an end-to-end testing framework that spins up physical dependencies and your application all together. So you have a control over dependencies via Kotlin code.
In the JVM world, thanks to code interoperability, you application code and test can be written with different JVM languages and can be run together. For example, you can write your application code with Java and write your tests with Kotlin, or Application code with Scala and test with Kotlin, etc. Stove uses this ability and provides a way to write your tests in Kotlin.
Your tests will be infra agnostic, but component aware, so they can use easily necessary physical components with Stove
provided APIs.
All the infra is pluggable, and can be added easily. You can also create your own infra needs by using the
abstractions
that Stove provides.
Having said that, the only dependency is docker
since Stove is
using testcontainers underlying.
You can use JUnit and Kotest for running the tests. You can run all the tests on your CI, too. But that needs DinD(docker-in-docker) integration.
The medium story about the motivation behind the framework: A New Approach to the API End-to-End Testing in Kotlin
Note: Stove is not a replacement for the unit tests, it is a framework for end-to-end/component tests.
What is the problem?¶
In the JVM world, we have a lot of frameworks for the application code, but when it comes to integration/component/e2e testing we don't have a single framework that can be used for all the tech stacks. We have testcontainers but you still need to do lots of plumbing to make it work with your tech stack.
The use-cases that led us develop the Stove are to increase the productivity of the developers while keeping the quality of the codebase high and coherent.
Those use-cases are:
- Kotlin app with Spring-Boot
- Kotlin app with Ktor
- Java app with Spring-Boot
- Java app with Micronaut
- Java app with Quarkus
- Scala app with Spring-Boot
People have different tech stacks and each time when they want to write e2e tests, they need to write a lot of boilerplate code. Alongside the physical components that are needed to be started, we need to write the code to start the application, and the code to access the beans of the application. Stove is here to solve this problem. It provides a single API to write e2e tests for all the tech stacks.
Stove unifies the testing experience whatever you use.
High Level Architecture¶
How to build the source code?¶
- JDK 17+
- Docker for running the tests (please use the latest version)
Getting Started¶
Pre-requisites¶
- JDK 17+
- Docker for running the tests (please use the latest version)
- Kotlin 1.8+
- Gradle or Maven for running the tests, but Gradle is recommended.
- Gradle is the default build tool for Stove, and it is used in the examples.
- If you are using Intellij IDEA, Kotest plugin is recommended.
The framework still under development and is getting matured. It is working well and in use at Trendyol. Besides, the Stove tests are highly likely going to be located under your testing context and the folder, so, it is risk-free to apply and use, give it a try!
$version = please check the current version
Versions are available at Releases
Tip
You can use SNAPSHOT versions for the latest features. You can add the following repository to your build file.
SNAPSHOT versions released with the 1.0.0.{buildNumber}-SNAPSHOT
strategy.
Every physical component that your testing needs is a separate module in Stove. You can add them according to your needs. Stove supports the following components:
repositories {
mavenCentral()
}
dependencies {
// Application Under Test
// Spring Boot
testImplementation("com.trendyol:stove-spring-testing-e2e:$version")
// or
// Ktor
testImplementation("com.trendyol:stove-ktor-testing-e2e:$version")
// Components
testImplementation("com.trendyol:stove-testing-e2e:$version")
testImplementation("com.trendyol:stove-testing-e2e-kafka:$version")
testImplementation("com.trendyol:stove-testing-e2e-mongodb:$version")
testImplementation("com.trendyol:stove-testing-e2e-mssql:$version")
testImplementation("com.trendyol:stove-testing-e2e-postgresql:$version")
testImplementation("com.trendyol:stove-testing-e2e-redis:$version")
testImplementation("com.trendyol:stove-testing-e2e-elasticsearch:$version")
testImplementation("com.trendyol:stove-testing-e2e-couchbase:$version")
testImplementation("com.trendyol:stove-testing-e2e-wiremock:$version")
testImplementation("com.trendyol:stove-testing-e2e-http:$version")
}
How To Write Tests?¶
Stove uses your application entrance point to start your application alongside the physical components. The
application's main
is the entrance point for the applications in general.
Everything starts with the TestSystem
class. You can configure your system with the with
function.
with
function is a lambda that you can configure your system. You can add your physical components.
It is also a place to plug your custom systems that you might want to create.
If you added com.trendyol:stove-testing-e2e-kafka
package, you can use kafka
function in the with
block.
Note
You can add multiple physical components in the with
block. Think of it as a DSL for your test system and a
docker-compose
in Kotlin.
Tip
If you want to jump directly to the examples, you can check the examples in the repository.
Stove has the concept of "Application Aware Testing". It means that Stove is aware of the application's entrance point and that is the only information it needs to start the application.
Application that is being tested is a Spring Boot, Ktor, Micronaut, Quarkus, etc. and is called "Application Under Test (AUT)".
The tests are agnostic to the application's framework. Right now Stove supports Spring Boot, Ktor. But it is easy to add new frameworks.
Note
If you want to add a new framework, you can check the
com.trendyol.stove.testing.e2e.system.abstractions.ApplicationUnderTest
interface.
You can implement this interface for your framework.
Let's create an example for a Spring-Boot application with Kafka and explain the setup flow.
The dependencies we will need in the build.gradle.kts
file are:
dependencies {
testImplementation("com.trendyol:stove-testing-e2e:$version")
testImplementation("com.trendyol:stove-testing-e2e-kafka:$version")
testImplementation("com.trendyol:stove-testing-e2e-http:$version")
testImplementation("com.trendyol:stove-spring-testing-e2e:$version")
}
TestSystem()
.with {
httpClient {
HttpClientSystemOptions(
baseUrl = "http://localhost:8001"
)
}
kafka {
KafkaSystemOptions(
containerOptions = KafkaContainerOptions(tag = "latest"),
) {
listOf(
"kafka.bootstrapServers=${it.bootstrapServers}",
"kafka.isSecure=false",
"kafka.interceptorClasses=${it.interceptorClass}",
"kafka.heartbeatInSeconds=2",
"kafka.autoCreateTopics=true",
"kafka.offset=earliest",
"kafka.secureKafka=false"
)
}
}
bridge()
springBoot( // Application Under Test
runner = { parameters ->
stove.spring.standalone.example.run(parameters)
},
withParameters = listOf(
"server.port=8001",
"logging.level.root=info",
"logging.level.org.springframework.web=info",
"spring.profiles.active=default",
)
)
}
.run()
The typical setup for a Spring Boot application with Kafka is like this. You can see that we have a httpClient
function
that is used for the HTTP client against the application's endpoints.
Then we have a kafka
function that is used for the Kafka setup.
Then we have a bridge
function that is used for accessing the DI container of the application.
Then we have a springBoot
function that is used for the Spring Boot application setup.
httpClient
function¶
It is used for the HTTP client against the application's endpoints. You can configure the base URL of the application. When the application is started, the base URL is used for the HTTP client.
kafka
function¶
It is used for the Kafka setup. You can configure the Kafka container options and the Kafka properties. When the application is started, the Kafka container is started and the Kafka properties are used for the application. We will investigate the Kafka setup in detail in the Kafka section. Your application code should be able to read these properties, and event app code needs to be arranged for this.
Is my application code e2e testing friendly?
In general, to write proper unit tests your code should be testable. This means extracting dependencies to interfaces and using dependency injection.
Injecting time, configuration, and other dependencies is a good practice. This makes your classes testable and you can easily replace the implementations of the interfaces in the tests.
Changing your configuration to be able to read from the environment variables or CLI arguments is also a good practice. Since Stove is also passing down the test configurations and the optimum setup for the tests, your application should be able to read these configurations.
So, don't think that you're changing too much application code just for sake of the Stove tests, you're making your application code e2e test friendly.
bridge
function¶
This function is used for accessing the DI container of the application. When the application is started, the bridge is created and the DI container is accessed in the tests.
If you want to access to the beans of the application, you can simply do:
TestSystem.validate {
using<UserApplicationService> {
this.getUserById(1) shouldBe User(1, "John", "Doe")
}
using<ProductDomainService, ProductRepository> { productDomainService, productRepository ->
productDomainService.getProductById(1) shouldBe Product(1, "Product 1")
productRepository.findById(1) shouldBe Product(1, "Product 1")
}
}
springBoot
function¶
This function is used for the Spring Boot application setup. You can configure the runner function and the parameters of
the application.
When the application is started, the runner function is called with the parameters.
The parameters you see in runner
function are the parameters that are passed to the Spring Boot application when it is
started.
Each physical component exposes its own properties and you can use them in the application.
Here:
kafka {
KafkaSystemOptions(
containerOptions = KafkaContainerOptions(tag = "latest"),
) {
listOf(
"kafka.bootstrapServers=${it.bootstrapServers}",
"kafka.isSecure=false",
"kafka.interceptorClasses=${it.interceptorClass}",
"kafka.heartbeatInSeconds=2",
"kafka.autoCreateTopics=true",
"kafka.offset=earliest",
"kafka.secureKafka=false"
)
}
}
The list of properties are exposed by the Kafka component and you can use them in the application.
The reference it
in this block is the physical component itself and it's exposed properties. Whenever Kafka and
testing suite start, the properties are changed and passed down to the application.
run
function¶
Runs the entire setup. It starts the physical components and the application.
Run the Setup Once
You should run the setup once in your test suite.
You can run it in the @BeforeAll
function of JUnit or implement AbstractProjectConfig#beforeProject
in Kotest.
Teardown is also important to call. You can run it in the @AfterAll
function of JUnit or implement
AbstractProjectConfig#afterProject
in Kotest.
Simply calling TestSystem.stop()
is enough to stop the setup.
Writing Tests¶
After the setup is done, you can write your tests. You can use the validate
function to write your tests.
TestSystem.validate {
http {
get<String>("/hello/index") { actual ->
actual shouldContain "Hi from Stove framework"
}
}
kafka {
shouldBeConsumed<ProductCreatedEvent> { actual ->
actual.productId == 1
}
}
using<UserApplicationService> {
this.getUserById(1) shouldBe User(1, "John", "Doe")
}
using<ProductDomainService, ProductRepository> { productDomainService, productRepository ->
productDomainService.getProductById(1) shouldBe Product(1, "Product 1")
productRepository.findById(1) shouldBe Product(1, "Product 1")
}
kafka {
shouldBePublished<ProductCreatedEvent> { actual ->
actual.productId == 1
}
}
}
That's it! You have up-and-running API, can be tested with Stove. And each test is independent of each other. But they share the same instance of physical component of course, so you need to provide random data for each test. This is a good practice for the tests to be independent of each other.
Application Aware Testing¶
Stove is aware of your application either it is SpringBoot or Ktor, and it is aware of the entrance point of your application.
There are entry point for every application, usually a main
method that is invoked, and starts the application
lifecycle.
If you are publishing your application
as a docker image, docker run ...
basically runs your
application highly likely with a jvm/java
command.
In this approach, we're using the same main
function of your application in the test context to run the application as
full-blown as if it is invoked from outside.
Stove calls your application's main
function like you would call java yourApplicationName.jar --param1 --param2
to
run the application
from the test context. So the runner is JUnit or Kotest.
For Stove to attach properly to your application, application's main function needs to allow that. This does not change behaviour at all, it just opens a door for e2e testing framework to enter.
This approach has lots of benefits besides of providing a debug ability while e2e testing. You can:
- Debug the application code
- Replace the implementations of the interfaces. Useful for time-bounded implementations such as schedulers, background workers, and time itself. you would only have consuming.
- Use and expose application's dependency container to the test context. You can access the beans of the application
easily. Using
bridge
functionality.
Spring Boot¶
You need to add the Stove-Spring dependency to be able to write e2e tests for the Spring application.
Tuning the application's entry point¶
Let's say the application has a standard main
function, here how we will change it:
As you can see from before-after
sections, we have divided the application main function into two parts.
run(args, init)
method is the important point for the testing configuration. init
allows us to override any
dependency
from the testing side that is being time
related or configuration
related. Spring itself opens this configuration
higher order function to the outside.
Also returning ConfigurableApplicationContext
is important for the bridge
functionality that we will use in the
tests.
Tip
Here you can jump immediately to the Spring example application.
Initial Configuration¶
After you've added the dependencies, and configured the application's main
function,
it is time to run your application for the first time from the test-context with Stove.
Setting up Stove for the Runner¶
It implements AbstractProjectConfig
from Kotest framework that allows us to spin up Stove per run. This is project
wide operation and executes only one time, as the name implies beforeProject
.
class Stove : AbstractProjectConfig() {
override suspend fun beforeProject(): Unit =
TestSystem()
.with {
httpClient {
HttpClientSystemOptions (
baseUrl = "http://localhost:8001"
)
}
springBoot(
runner = { parameters ->
/*
* As you remember, we have divided application's main
* function into two parts, main and run.
* We use `run` invocation here.
* */
stove.spring.example.run(parameters)
},
withParameters = listOf(
"server.port=8001",
"logging.level.root=warn",
"logging.level.org.springframework.web=warn",
"spring.profiles.active=default"
)
)
}.run()
override suspend fun afterProject(): Unit = TestSystem.stop()
}
class TestSystemConfig {
@BeforeAll
fun beforeProject() = runBlocking {
TestSystem()
.with {
httpClient {
HttpClientSystemOptions (
baseUrl = "http://localhost:8001"
)
}
springBoot(
runner = { parameters ->
/*
* As you remember, we have divided application's main
* function into two parts, main and run.
* We use `run` invocation here.
* */
stove.spring.example.run(parameters)
},
withParameters = listOf(
"server.port=8001",
"logging.level.root=warn",
"logging.level.org.springframework.web=warn",
"spring.profiles.active=default"
)
)
}.run()
}
@AfterAll
fun afterProject() = runBlocking {
TestSystem.stop()
}
}
In the section of springBoot
function, we have configured the application's entry point, and the parameters that are
passed to the application. stove.spring.example.run(parameters)
is the entrance point of the application.
Like the concept of service under test
from the Test-Driven-Development.
Here we have the similar concept, since we're testing the entire system, it is called Application Under Test
In here we're configuring the Spring Boot application as application under test.
Note
server.port=8001
is a Spring config, TestSystem's baseUrl
needs to match with it, since Http requests are made
against the baseUrl
that is defined. withDefaultHttp
creates a WebClient and uses the baseUrl
that is passed.
Writing Tests¶
Here is an example test that validates http://localhost:$port/hello/index
returns the expected text
That's it! You have up-and-running API, can be tested with Stove.
Tip
DSL scopes can appear more than once while writing the tests. You can access to any DSL assertion scope such as http, kafka, using, etc. as many times as you need.
Example:
TestSystem.validate {
http {
get<String>("/hello/index") { actual ->
actual shouldContain "Hi from Stove framework"
}
}
kafka {
shouldBeConsumed<ProductCreatedEvent> { actual ->
actual.productId == 1
}
}
kafka {
shouldBeConsumed<ProductCreatedEvent> { actual ->
actual.productId == 1
}
}
http {
get<String>("/hello/index") { actual ->
actual shouldContain "Hi from Stove framework"
}
}
using<UserApplicationService> {
this.getUserById(1) shouldBe
}
Ktor¶
Example Setup¶
TestSystem()
.with {
// You can add other components if you need
// We removed for simplicity
ktor(
withParameters = listOf(
"port=8080"
),
runner = { parameters ->
stove.ktor.example.run(parameters) {
addTestSystemDependencies()
}
}
)
}.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.
Tuning the application's entry point¶
Let's say the application has a standard main
function, here how we will change it:
object ExampleApp {
@JvmStatic
fun main(args: Array<String>) {
run(args)
}
fun run(args: Array<String>,
wait: Boolean = true,
configure: org.koin.core.module.Module = module { }
): Application {
val config = loadConfiguration<Env>(args)
return startKtorApplication(config, wait) {
appModule(config, configure)
}
}
}
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.
Note
There are helper methods here for example loadConfiguration
that is used to load the configuration from the environment variables or CLI arguments. And as you can see there is an Env
data class to cast the configuration.
Stove itself does not provide them, but of course we have already used them in our examples, you can find them in the examples.
Tip
Here you can jump immediately to the Ktor example application.
Advanced¶
Global Variables¶
DEFAULT_REGISTRY¶
The default container registry is docker.io
. You can change it by setting the DEFAULT_REGISTRY
variable.
This will effect all the components Stove wide. Or you can set it for each individual component by setting theregistry
property, example for Kafka:
KafkaSystemOptions(
containerOptions = KafkaContainerOptions(
registry = "your.registry.com",
tag = "latest"
),
)
Serializing and Deserializing¶
Each component has its own serialization and deserialization mechanism. You can align Stove's serialization and deserialization mechanism with your application's serialization and deserialization mechanism.
Stove works with multiple serializers and deserializers. The package stove-testing-e2e
provides the following
serializers and deserializers:
- Jackson
- Gson
- Kotlinx
Stove also provides a way to add your own serializer and deserializer. You can implement the StoveSerde<TIn, TOut>
interface
and add your own serializer and deserializer.
StoveSerde
also keeps the reference to the aforementioned libraries:
And also provides default implementations for them:
StoveSerde.jackson.anyByteArraySerde(yourObjectMapper())
StoveSerde.gson.anyByteArraySerde(yourGson())
StoveSerde.kotlinx.anyByteArraySerde(yourJson())
// there is also string serde
StoveSerde.jackson.anyStringSerde(yourObjectMapper())
StoveSerde.gson.anyStringSerde(yourGson())
StoveSerde.kotlinx.anyStringSerde(yourJson())
Replacing Dependencies For Better Testability¶
When it comes to handling the time, no one wants to wait for 30 minutes for a scheduler job, or for a delayed task to be
able to test it.
In these situations what we need to do is advancing
the time, or replacing the effect of the time for our needs. This
may require you to change your code, too. Because, we might need to provide a time-free implementation to an interface,
or we might need to extract it to an interface if not properly implemented.
For example, imagine we have a delayed command executor that accepts a task and a time for it to delay it until it is right time to execute. But, in tests we need to replace this behaviour with the time-effect free implementation.
We would like to by-pass the time-bounded logic inside BackgroundCommandBusImpl, and for e2eTest scope we write:
class NoDelayBackgroundCommandBusImpl(
backgroundMessageEnvelopeDispatcher: BackgroundMessageEnvelopeDispatcher,
backgroundMessageEnvelopeStorage: BackgroundMessageEnvelopeStorage,
lockProvider: CouchbaseLockProvider,
) : BackgroundCommandBusImpl(
backgroundMessageEnvelopeDispatcher,
backgroundMessageEnvelopeStorage,
lockProvider
) {
override suspend fun <TNotification : BackgroundNotification> publish(
notification: TNotification,
options: BackgroundOptions,
) {
super.publish(notification, options.withDelay(0))
}
override suspend fun <TCommand : BackgroundCommand> send(
command: TCommand,
options: BackgroundOptions,
) {
super.send(command, options.withDelay(0))
}
}
Now, it is time to tell to e2eTest system to use NoDelay implementation.
That brings us to initializers.
Writing Your Own TestSystem¶
fun TestSystem.withSchedulerSystem(): TestSystem {
getOrRegister(SchedulerSystem(this))
return this
}
fun TestSystem.scheduler(): SchedulerSystem = getOrNone<SchedulerSystem>().getOrElse {
throw SystemNotRegisteredException(SchedulerSystem::class)
}
class SchedulerSystem(override val testSystem: TestSystem) : AfterRunAware<ApplicationContext>, PluggedSystem {
private lateinit var scheduler: WaitingRoomScheduler
private lateinit var backgroundCommandBus: BackgroundCommandBusImpl
fun advance(): SchedulerSystem {
scheduler.publishValidProducts()
return this
}
fun advanceBackgroundCommandBus(): SchedulerSystem {
backgroundCommandBus.dispatchTimeoutNotifications()
return this
}
override suspend fun afterRun(context: ApplicationContext) {
scheduler = context.getBean()
backgroundCommandBus = context.getBean()
}
override fun close() {}
}
Later you can use it in testing;
Accessing an application dependency with a system¶
As you can see, in the example above, if a system implements
AfterRunAware<ApplicationContext>
then, afterRun
method becomes available, in here we have access to applications
dependency container to resolve any bean we need to use.
override suspend fun afterRun(context: ApplicationContext) {
scheduler = context.getBean()
backgroundCommandBus = context.getBean()
}
Writing a TestInitializer¶
The tests initializers help you to add test scoped beans, basically you can configure the Spring application from the test perspective.
class TestInitializer : BaseApplicationContextInitializer({
bean<YourInstanceToReplace>(isPrimary = true)
bean<NoDelayBackgroundCommandBusImpl>(isPrimary = true) // Optional dependency to alter delayed implementation with 0-wait.
})
fun SpringApplication.addTestDependencies() {
this.addInitializers(TestInitializer())
}
addTestDependencies
is an extension that helps us to register our dependencies in the application.
.springBoot(
runner = { parameters ->
com.trendyol.exampleapp.run(parameters) {
addTestDependencies()
}
},
withParameters = listOf(
"logging.level.root=error",
"logging.level.org.springframework.web=error",
"spring.profiles.active=default",
"server.http2.enabled=false",
"kafka.heartbeatInSeconds=2",
"kafka.autoCreateTopics=true",
"kafka.offset=earliest"
)
)