DZone
Thanks for visiting DZone today,
Edit Profile
  • Manage Email Subscriptions
  • How to Post to DZone
  • Article Submission Guidelines
Sign Out View Profile
  • Post an Article
  • Manage My Drafts
Newsletter
Log In / Join
Refcards Trend Reports
Events Video Library
Refcards
Trend Reports

Events

View Events Video Library
  1. DZone
  2. Coding
  3. Languages
  4. Testing Java and Spring Applications With Kotlin: Low-Risk to Adoption

Testing Java and Spring Applications With Kotlin: Low-Risk to Adoption

The low-risk path to Kotlin adoption: write tests for your Java and Spring Boot app in Kotlin with JUnit, MockK, and Kotest. No production changes needed.

By 
Aniket Bhattacharyea user avatar
Aniket Bhattacharyea
·
Oct. 20, 26 · Analysis
Likes (0)
Comment
Save
Tweet
Share
62 Views

Teams considering or committed to adopting Kotlin often plan a migration as a series of rewrites, swapping Java services for Kotlin counterparts one microservice at a time. In practice, that kind of plan tends to stall before it produces any shipped code.

Kotlin's role in the JVM ecosystem is expanding because it doesn't require that kind of commitment. Companies such as Netflix, Adobe, and DoorDash have adopted it incrementally, alongside existing JVM systems, rather than through a full rewrite. The lowest-risk starting point is tests. They give you Kotlin's language benefits, including null-safety, data classes, and expressive lambdas, without touching anything that runs in production.

This guide walks through adding Kotlin to an existing Java + Spring Boot project for test sources only. You'll configure Gradle, write JUnit 5 tests in Kotlin, use MockK for Kotlin-native mocking, and apply Kotest assertions to improve test readability. Your production services don't change, and your team starts experiencing Kotlin in a bounded, no-risk context.

Why Testing Is the Safest Place to Introduce Kotlin

Kotlin test sources are isolated from the production application artifact and don't require Kotlin in the production runtime. They can still affect CI and test infrastructure, but leave production services untouched. That separation from production makes tests the right entry point for a language your team hasn't yet committed to.

Introducing Kotlin in test sources means that your existing Java services remain completely unchanged, with no refactoring, no migration risk, and no regressions in production paths. It also allows your team to experiment with real Kotlin syntax on tests they'd be writing anyway, rather than on contrived examples. Developer buy-in builds faster when engineers are writing tests they actually want to write. Kotlin tests tend to be shorter, more readable, and easier to scan at code review.

Leadership and platform teams are skeptical of language changes for good reasons. The proposal "we want to use Kotlin for tests" carries almost no risk to evaluate, since it doesn't touch production or your deployment infrastructure. It's a clean, bounded experiment, and the pattern has been validated by engineering organizations across the industry.

Diagram

Adding Kotlin to an Existing Java + Spring Boot Project

You don't need to migrate your entire build to use Kotlin in tests. If you're running a standard Spring Boot + Gradle project, you add the Kotlin JVM plugin, pull in kotlin("stdlib") and io.mockk:mockk at test scope, and IntelliJ IDEA recognizes src/test/kotlin as a source set automatically.

Open the project in IntelliJ IDEA and update your build.gradle.kts, adding the Kotlin JVM plugin in the plugins block and the test-scoped dependencies:

Kotlin
 
plugins {
java
id("org.springframework.boot") version "4.1.0" // example version, check for latest
id("io.spring.dependency-management") version "1.1.5" // example version, check for latest
// Add the Kotlin JVM plugin alongside your existing Java plugin
kotlin("jvm") version "2.4.10" // example version, check kotlinlang.org for latest stable
}

dependencies {
// Your existing production dependencies remain untouched
implementation("org.springframework.boot:spring-boot-starter-web")

// Existing test dependency
    testImplementation("org.springframework.boot:spring-boot-starter-test")



    // Required for coroutines

    testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.11.0")

// Kotlin stdlib (test scope only)
testImplementation(kotlin("stdlib"))

// MockK: Kotlin-native mocking library (check GitHub for latest stable)
testImplementation("io.mockk:mockk:1.14.11")

// SpringMockK: @MockkBean and @SpykBean for Spring context tests (check GitHub for latest stable)
testImplementation("com.ninja-squad:springmockk:5.0.1")

// Kotest assertions, no need for the full test runner (check kotest.io for latest stable)
testImplementation("io.kotest:kotest-assertions-core:6.2.3")
}

tasks.withType<Test> {
useJUnitPlatform()
}


Always check the Kotlin and library documentation for the latest stable releases before pinning versions in a new project.

If you’re using Maven, you need to add the kotlin-maven-plugin:

XML
 
<properties>
    <kotlin.version>2.4.10</kotlin.version>
</properties>

<dependencies>
    <!-- Existing Spring Boot test dependencies -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>

    <!-- Kotlin standard library, test scope -->
    <dependency>
        <groupId>org.jetbrains.kotlin</groupId>
        <artifactId>kotlin-stdlib</artifactId>
        <version>${kotlin.version}</version>
        <scope>test</scope>
    </dependency>

    <!-- MockK -->
    <dependency>
        <groupId>io.mockk</groupId>
        <artifactId>mockk</artifactId>
        <version>1.14.11</version>
        <scope>test</scope>
    </dependency>

    <!-- Kotest assertions -->
    <dependency>
        <groupId>io.kotest</groupId>
        <artifactId>kotest-assertions-core</artifactId>
        <version>6.2.3</version>
        <scope>test</scope>
    </dependency>
</dependencies>

<build>
    <plugins>
        <plugin>
            <groupId>org.jetbrains.kotlin</groupId>
            <artifactId>kotlin-maven-plugin</artifactId>
            <version>${kotlin.version}</version>
            <executions>
                <execution>
                    <id>test-compile</id>
                    <phase>test-compile</phase>
                    <goals>
                        <goal>test-compile</goal>
                    </goals>
                    <configuration>
                        <sourceDirs>
                            <source>src/test/kotlin</source>
                            <source>src/test/java</source>
                        </sourceDirs>
                    </configuration>
                </execution>
            </executions>
        </plugin>

        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-surefire-plugin</artifactId>
        </plugin>
    </plugins>
</build>


After saving, IntelliJ IDEA displays a notification to reload the Gradle project. Use Shift+Cmd+I on macOS or Ctrl+Shift+O on Windows and Linux to trigger the sync. Once complete, IntelliJ IDEA automatically recognizes src/test/kotlin as a source set, allowing you to use Kotlin alongside the existing Java test infrastructure, like base classes, testcontainer setup, test data factories, etc.

Writing JUnit 6 Tests in Kotlin

Writing JUnit 6 tests in Kotlin removes a lot of the friction of test setup. The language's expressiveness lets you focus on the scenario and expected behavior instead. Consider a Spring service that manages orders. Test code often spends more time constructing test data than testing behavior. 

In Java, creating several variations of the same object can require repeatedly calling setters:

Java
 
Order order = new Order();
order.setId(1L);
order.setCustomerId(42L);
order.setTotal(new BigDecimal("99.99"));
order.setStatus(OrderStatus.PENDING);

Order cancelledOrder = new Order();
cancelledOrder.setId(1L);
cancelledOrder.setCustomerId(42L);
cancelledOrder.setTotal(new BigDecimal("99.99"));
cancelledOrder.setStatus(OrderStatus.CANCELLED);


The setup becomes even more repetitive as a test suite introduces additional scenarios. A test for a large order, for example, would need to repeat the same boilerplate just to change the total.

Kotlin's named and default arguments can already reduce that repetition, but you can go further by creating a small test-data DSL that describes orders in terms of the scenarios your tests care about:

Kotlin
 
fun orderScenario(block: OrderScenario.() -> Unit): Order =
    OrderScenario().apply(block).build()

class OrderScenario {
    var id: Long = 1L
    var customerId: Long = 42L
    var total: BigDecimal = BigDecimal("99.99")
    var status: OrderStatus = OrderStatus.PENDING

    fun pending() {
        status = OrderStatus.PENDING
    }

    fun confirmed() {
        status = OrderStatus.CONFIRMED
    }

    fun cancelled() {
        status = OrderStatus.CANCELLED
    }

    fun forCustomer(id: Long) {
        customerId = id
    }

    fun withTotal(amount: String) {
        total = BigDecimal(amount)
    }

    fun build() = Order(id, customerId, total, status)
}


Tests can now describe the data they need as scenarios:

Kotlin
 
val pendingOrder = orderScenario {
    forCustomer(42L)
    withTotal("99.99")
    pending()
}

val cancelledOrder = orderScenario {
    forCustomer(42L)
    cancelled()
}

val largeOrder = orderScenario {
    withTotal("999.99")
    pending()
}


The DSL uses Kotlin's lambdas with receivers to make OrderScenario's properties and functions available directly inside the orderScenario { ... } block. Default values provide sensible test data, while individual tests override only the attributes relevant to their scenario. Most importantly, the production Order class can remain completely unchanged in Java.

This approach becomes particularly useful as a test suite grows. Instead of accumulating specialized factory methods such as cancelledOrder(), largePendingOrder(), or confirmedOrderForCustomer(), the DSL gives tests a small vocabulary for composing those scenarios themselves. The test describes *what state it needs* without repeating the mechanics of constructing the Java object.

A complete JUnit 6 test can then combine the scenario DSL with MockK and Kotest assertions:

Kotlin
 
import io.kotest.matchers.shouldBe
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import org.junit.jupiter.api.Test

internal class OrderServiceTest {
    private val repository = mockk<OrderRepository>()
    private val orderService = OrderService(repository)

    @Test
    fun `returns order when found by id`() {
        val order = orderScenario {
            forCustomer(42L)
            withTotal("99.99")
            pending()
        }

        every { repository.findById(order.id) } returns order

        val result = orderService.getOrder(order.id)

        result shouldBe order

        verify(exactly = 1) {
            repository.findById(order.id)
        }
    }

    @Test
    fun `throws when order is not found`() {
        every { repository.findById(any()) } returns null

        shouldThrow<NoSuchElementException> {
            orderService.getOrder(99L)
        }
    }
}


Backtick-delimited test names (`returns order when found by id`) let you write descriptive test names directly in the function declaration, so the name appears naturally in test runner output. This can also save you from using JUnit's `@Named` annotation when you want a readable test name.

When a Java repository method returns a nullable `Order?`, the Kotlin compiler forces the test code to account for the null case before using the result. That can surface mismatches between the test and the Java API contract at compile time rather than as unexpected `NullPointerException`s during test execution.

A full guide is available in the docs.

MockK for Kotlin-Idiomatic Mocking

Mockito remains a viable choice when adding Kotlin tests to a Java application, but MockK provides an API designed around Kotlin's syntax. Its concise every { ... } returns ... and verify { ... } constructs make mocked interactions easier to read, while allowing Kotlin tests to mock existing Java classes without changing the production code.

This can be particularly useful when testing Java Spring applications that use Reactor. The production application can remain entirely in Java and continue to return Mono or Flux. Kotlin is only introduced in the test suite. MockK handles the mocked dependencies, while Reactor's StepVerifier verifies the resulting publishers.

Consider a Java service that returns an order reactively:

Java
 
public Mono<Order> findOrder(Long id) {
    return repository.findById(id);
}


The service can be tested from Kotlin without changing its implementation:

Kotlin
 
import io.kotest.matchers.shouldBe
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import org.junit.jupiter.api.Test
import reactor.core.publisher.Mono
import reactor.test.StepVerifier

internal class OrderServiceTest {
    private val repository = mockk<OrderRepository>()
    private val orderService = OrderService(repository)

    @Test
    fun `returns order when found`() {
        val order = Order(
            id = 1L,
            customerId = 42L,
            total = BigDecimal("99.99"),
            status = OrderStatus.CONFIRMED
        )

        every { repository.findById(1L) } returns Mono.just(order)

        val result = orderService.findOrder(1L)

        StepVerifier.create(result)
            .expectNext(order)
            .verifyComplete()

        verify(exactly = 1) {
            repository.findById(1L)
        }
    }
}


The production service and repository can remain in Java. The Kotlin test uses MockK's Kotlin-friendly syntax to define the mock behavior, while StepVerifier checks that the returned Mono emits the expected value and completes successfully.

The same pattern applies to Flux-returning APIs. For example, a test can verify multiple emitted values without manually subscribing to the publisher:

Kotlin
 
every { repository.findAll() } returns Flux.just(order1, order2)

StepVerifier.create(orderService.findAll())
    .expectNext(order1, order2)
    .verifyComplete()


MockK doesn't replace Reactor's testing tools. Each library handles a different part of the test. MockK provides concise mocking, while StepVerifier provides assertions over Reactor publishers. Kotlin brings those APIs together in a compact test that's focused on the behavior being verified.

In practice, that means you can adopt Kotlin for the test source set, use MockK to mock your existing Java dependencies, and test reactive Java code without introducing Kotlin or coroutines into the production application.

Kotest Assertions for Cleaner Test Intent

You don't need Kotest's full test runner to benefit from its assertion library. The kotest-assertions-core dependency adds infix matchers (shouldBe, shouldNotBe, shouldThrow, shouldContain) that layer on top of your standard JUnit 5 tests.

Compare JUnit's assertion style with Kotest's:

Kotlin
 
// JUnit 5 assertions
Assertions.assertEquals(OrderStatus.CONFIRMED, result.status)
Assertions.assertNotNull(result.id)

// Kotest matchers (same assertions, different shape)
result.status shouldBe OrderStatus.CONFIRMED
result.id shouldNotBe null


The Kotest versions read in the same order as a sentence describing what you expect. That ordering makes the intent visible without parsing the argument order of assertEquals.

shouldThrow is particularly useful for testing Spring service exception paths:

Kotlin
 
val exception = shouldThrow<NoSuchElementException> {
    orderService.getOrder(99L)
}
exception.message shouldBe "Order 99 not found"


The matcher returns the thrown exception, so you can chain further assertions on its properties without additional try/catch blocks.

Spring Boot Integration Tests With @MockkBean

For Spring slice tests (@WebMvcTest, @DataJpaTest, or a partial @SpringBootTest), you swap Spring's @MockBean for @MockkBean from the springmockk library. It integrates with Spring's test context the same way @MockBean does, but creates MockK instances:

Kotlin
 
package com.example.demo.controller

import com.example.demo.model.Order
import com.example.demo.model.OrderStatus
import com.example.demo.service.OrderService
import io.mockk.every
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.get
import com.ninjasquad.springmockk.MockkBean
import java.math.BigDecimal
import java.util.NoSuchElementException

@WebMvcTest(OrderController::class)
internal class OrderControllerTest {

    @Autowired
    private lateinit var mockMvc: MockMvc

    @MockkBean
    private lateinit var orderService: OrderService

    @Test
    fun `GET order returns 200 with order data`() {
        val order = Order(
            1L,
            42L,
            BigDecimal("99.99"),
            OrderStatus.CONFIRMED
        )

        every { orderService.getOrder(1L) } returns order

        mockMvc.get("/orders/1")
            .andExpect {
                status { isOk() }
                jsonPath("$.id") { value(1) }
                jsonPath("$.customerId") { value(42) }
                jsonPath("$.total") { value(99.99) }
                jsonPath("$.status") { value("CONFIRMED") }
            }
    }

    @Test
    fun `GET missing order returns 500`() {
        every {
            orderService.getOrder(99L)
        } throws NoSuchElementException("Order 99 not found")

        mockMvc.get("/orders/99")
            .andExpect {
                status { is5xxServerError() }
            }
    }
}


The @MockkBean annotation replaces the service in the Spring context for the duration of the test. IntelliJ IDEA resolves the Kotlin DSL for MockMvc (mockMvc.get(...)) with full autocomplete support, removing the friction of looking up chained method names.


Getting Started With Kotlin in Tests

Introducing Kotlin through test sources, before any production code changes, is a validated incremental strategy. The tooling is mature, the Gradle setup is a handful of lines, and the first Kotlin test can ship the same day you configure the dependency.

Over time, writing Kotlin tests can change how your team thinks about the language. Developers who spend a few months writing Kotlin tests start asking why they can't use data classes in production. They miss null safety in Java service code. Gradual adoption happens organically, driven by genuine preference rather than a top-down mandate.

JetBrains and the Spring team have an official strategic partnership focused on improving the developer experience for backend engineers. If you want to explore what Kotlin can do for your team beyond the test suite, kotlinlang.org is the right place to start.


Comments

The likes didn't load as expected. Please refresh the page and try again.

  • RSS
  • X
  • Facebook

ABOUT US

  • About DZone
  • Support and feedback
  • Community research

ADVERTISE

  • Advertise with DZone

CONTRIBUTE ON DZONE

  • Article Submission Guidelines
  • Become a Contributor
  • Core Program
  • Visit the Writers' Zone

LEGAL

  • Terms of Service
  • Privacy Policy

CONTACT US

  • 3343 Perimeter Hill Drive
  • Suite 215
  • Nashville, TN 37211
  • [email protected]

Let's be friends:

  • RSS
  • X
  • Facebook