Trendyol / stove

Stove: The easiest way of writing e2e/component tests for your JVM back-end API with Kotlin
https://trendyol.github.io/stove/
Apache License 2.0
166 stars 12 forks source link
component-testing e2e-testing integration-testing kotlin ktor spring-boot test test-automation testcontainers testing testing-framework testing-tools

Stove

The easiest way of writing e2e/component tests for your back-end API in Kotlin

What is 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.

[!NOTE] Some people tend to call these tests as integration tests, some call them as component tests, some call them as end-to-end tests. In this documentation, we will use the term end-to-end tests. We think that the e2e/component tests term is more serving to the purpose of message we want to convey.

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:

People have different tech stacks and each time when they want to write e2e tests, they need to write a lot of boilerplate code. 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.

For more info and how to use: Check the documentation

[!WARNING] Stove is under development and, despite being heavily tested, its API isn't yet stabilized; breaking changes might happen on minor releases. However, we will always provide migration guides.

Report any issue or bug in the GitHub repository.

codecov

Supports

Physical dependencies:

Frameworks:

Show me the code

📹 Youtube Session about how to use (Turkish)

Setting-up all the physical dependencies with application

TestSystem() {
    if (isRunningLocally()) {
        enableReuseForTestContainers()

        // this will keep the dependencies running
        // after the tests are finished,
        // so next run will be blazing fast :)
        keepDendenciesRunning()
    }
}.with {
    // Enables http client 
    // to make real http calls 
    // against the application under test
    httpClient {
        HttpClientSystemOptions(
          baseUrl = "http://localhost:8001",
        )
    }

    // Enables Couchbase physically 
    // and exposes the configuration 
    // to the application under test
    couchbase {
        CouchbaseSystemOptions(
            defaultBucket = "Stove",
            configureExposedConfiguration = { cfg -> listOf("couchbase.hosts=${cfg.hostsWithPort}") },
        )
    }

    // Enables Kafka physically 
    // and exposes the configuration 
    // to the application under test
    kafka {
        KafkaSystemOptions(
            configureExposedConfiguration = { cfg -> listOf("kafka.bootstrapServers=${cfg.boostrapServers}") },
        )
    }

    // Enables Wiremock on the given port 
    // and provides configurable mock HTTP server 
    // for your external API calls
    wiremock {
        WireMockSystemOptions(
            port = 9090,
            removeStubAfterRequestMatched = true,
            afterRequest = { e, _, _ ->
                logger.info(e.request.toString())
            },
        )
    }

    // The Application Under Test. 
    // Enables Spring Boot application 
    // to be run with the given parameters.
    springBoot(
        runner = { parameters ->
            stove.spring.example.run(parameters) { it.addTestSystemDependencies() }
        },
        withParameters = listOf(
            "server.port=8001",
            "logging.level.root=warn",
            "logging.level.org.springframework.web=warn",
            "spring.profiles.active=default",
            "kafka.heartbeatInSeconds=2",
        ),
    )
}.run()

Testing the entire application with physical dependencies

TestSystem.validate {
    wiremock {
        mockGet("/example-url", responseBody = None, statusCode = 200)
    }

    http {
        get<String>("/hello/index") { actual ->
            actual shouldContain "Hi from Stove framework"
            println(actual)
        }
    }

    couchbase {
        shouldQuery<Any>("SELECT * FROM system:keyspaces") { actual ->
            println(actual)
        }
    }

    kafka {
        shouldBePublished<ExampleMessage> {
            actual.aggregateId == 123 
                    && metadata.topic = "example-topic" 
                    && metadata.headers["example-header"] == "example-value"
        }
        shouldBeConsumed<ExampleMessage> {
            actual.aggregateId == 123
                    && metadata.topic = "example-topic"
                    && metadata.headers["example-header"] == "example-value"
        }
    }

    couchbase {
        save(collection = "Backlogs", id = "id-of-backlog", instance = Backlog("id-of-backlog"))
    }

    http {
        postAndExpectBodilessResponse("/backlog/reserve") { actual ->
            actual.status.shouldBe(200)
        }
    }

    kafka {
        shouldBeConsumed<ProductCreated> {
            actual.aggregateId == expectedId
        }
    }
}