Spring, Testcontainers & Neo4J in Kotlin

Ondrej Kvasnovsky
3 min readFeb 4, 2024

--

We are going to:

  • Write reusable code for our integration tests using Test containers (using Neo4J but could be any other test container).
  • Setup Gradle to run tests in parallel.
  • Create tests that can run in parallel, in isolation.

Setup

Here is the Gradle build script (for Kotlin), or you can create your own project from scratch using https://start.spring.io/.

import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

plugins {
id("org.springframework.boot") version "3.2.2"
id("io.spring.dependency-management") version "1.1.4"
kotlin("jvm") version "1.9.22"
kotlin("plugin.spring") version "1.9.22"
jacoco
}

group = "com.example"
version = "0.0.1-SNAPSHOT"

java {
sourceCompatibility = JavaVersion.VERSION_21
}

repositories {
mavenCentral()
}

dependencies {
implementation("org.springframework.boot:spring-boot-starter-graphql")
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-data-neo4j")

implementation("com.fasterxml.jackson.module:jackson-module-kotlin")

implementation("org.jetbrains.kotlin:kotlin-reflect")

testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.springframework.boot:spring-boot-testcontainers")
testImplementation("org.springframework:spring-webflux")
testImplementation("org.springframework.graphql:spring-graphql-test")
testImplementation("org.testcontainers:junit-jupiter")
testImplementation("org.testcontainers:neo4j:1.19.4")
}

tasks.withType<KotlinCompile> {
kotlinOptions {
freeCompilerArgs += "-Xjsr305=strict"
jvmTarget = "21"
}
}

tasks.withType<Test> {
useJUnitPlatform()
maxParallelForks = Runtime.getRuntime().availableProcessors()
testLogging {
events("passed", "skipped", "failed")
}
finalizedBy(tasks.jacocoTestReport)
}

tasks.jacocoTestReport {
dependsOn(tasks.test)
}

There are many ways to initialize Testcontainers, but these ones are probably most concise and giving us most control and least code duplications to keep tests as concise, easy to read, and maintain as possible.

Option #1 — SpringBootTest & ApplicationContextInitializer

This approach loads whole spring context before a test method is executed, which means it is a slower option, but it loads the whole context, in case we need that (e.g. for controller/service/repository integration tests).

import org.springframework.boot.test.util.TestPropertyValues
import org.springframework.context.ApplicationContextInitializer
import org.springframework.context.ConfigurableApplicationContext
import org.testcontainers.containers.Neo4jContainer
import org.testcontainers.lifecycle.Startables
import org.testcontainers.utility.DockerImageName
import java.time.Duration

class TestcontainersInitializer : ApplicationContextInitializer<ConfigurableApplicationContext> {

private val neo4jContainer: Neo4jContainer<*> = Neo4jContainer(DockerImageName.parse("neo4j:5"))
.withRandomPassword()
.withStartupTimeout(Duration.ofMinutes(2))

init {
// we can start all the containers here, we have only one in this example
Startables.deepStart(neo4jContainer).join()
}

override fun initialize(applicationContext: ConfigurableApplicationContext) {
println("Initializing Neo4J container for test on: ${neo4jContainer.boltUrl}")
TestPropertyValues.of(
"spring.neo4j.uri=${neo4jContainer.boltUrl}",
"spring.neo4j.authentication.username=neo4j",
"spring.neo4j.authentication.password=${neo4jContainer.adminPassword}",
).applyTo(applicationContext.environment)
}
}

Now we can create an annotation that we will use in our integration tests.

import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.annotation.DirtiesContext
import org.springframework.test.context.ContextConfiguration

@SpringBootTest
@ContextConfiguration(initializers = [TestcontainersInitializer::class])
@DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_EACH_TEST_METHOD)
annotation class IntegrationTest

We dirty the context before each test method, to ensure that each tests is using its own context. That enables us to run the tests in parallel. Here is what we would add to our build.gradle.kts file to enable parallel test execution:

tasks.withType<Test> {
...
maxParallelForks = Runtime.getRuntime().availableProcessors()
}

Integration tests

Here is how our tests could look like while using the test support code we created above:

import com.example.IntegrationTest
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired

@IntegrationTest
class ControllerIntTest(@Autowired val controller: Controller) {

@Test
fun `everything should be OK`() {
val result = controller.isEverythingOk()

assertTrue(result)
}
}

Option #2 — DataNeo4jTest & AbstractNeo4jConfig

This approach only loads

import org.neo4j.driver.AuthTokens
import org.neo4j.driver.Driver
import org.neo4j.driver.GraphDatabase
import org.springframework.boot.test.context.TestConfiguration
import org.springframework.context.annotation.Bean
import org.springframework.data.neo4j.config.AbstractNeo4jConfig
import org.testcontainers.containers.Neo4jContainer
import org.testcontainers.lifecycle.Startables
import org.testcontainers.utility.DockerImageName
import java.time.Duration

@TestConfiguration
class Neo4jTestConfig : AbstractNeo4jConfig() {

private val neo4jContainer: Neo4jContainer<*> = Neo4jContainer(DockerImageName.parse("neo4j:5"))
.withRandomPassword()
.withStartupTimeout(Duration.ofMinutes(2))

init {
Startables.deepStart(neo4jContainer).join()
}

@Bean
override fun driver(): Driver {
println("Creating new driver for Neo4j on ${neo4jContainer.boltUrl}.")
return GraphDatabase.driver(neo4jContainer.boltUrl, AuthTokens.basic("neo4j", neo4jContainer.adminPassword));
}
}

Now can create a helper annotation that encapsulates what we created together with @DataNeo4jTest annotation.

import org.springframework.boot.test.autoconfigure.data.neo4j.DataNeo4jTest
import org.springframework.context.annotation.Import

@DataNeo4jTest
@Import(Neo4jTestConfig::class)
annotation class Neo4jTest()

JUnit5 integration tests

Here is how our tests could look like while using the test support code we created above:

import com.example.IntegrationTest
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired

@Neo4jTest
class ServiceIntTest(@Autowired val service: Service) {
@Test
fun `everything should be OK`() {
val result = service.isEverythingOk()
assertTrue(result)
}
}

If Kotest is used, this https://kotest.io/docs/extensions/test_containers.html should be used.

Performance implications

This is here to make a point, not a detailed performance test. Let’s create 4 simple tests that only insert data into Neo4j and then check if it was successfully saved.

Here are the results after running both @IntegrationTest and @Neo4jTest annotations:

@IntegrationTest -> 43 seconds
@Neo4jTest -> 16 seconds

More reading

--

--

No responses yet