Testing 3 min read

Getting Started with Unit Testing in Kotlin Spring Boot Applications

Learn how to write effective unit tests for your Kotlin Spring Boot application using JUnit 5 and Mockito, with practical examples and best practices

Unit testing is essential for maintaining reliable and maintainable Spring Boot applications. In this guide, we'll explore how to write effective unit tests using JUnit 5, Mockito, and AssertJ with Kotlin.

What is Unit Testing?

Unit testing is the practice of testing individual components or units of your code in isolation. In object-oriented programming, a "unit" is typically a single class or function. The goal is to verify that each unit works as intended, independent of other parts of the system.

Writing unit tests forces you to think about your code's design. If a piece of code is difficult to test, it often indicates potential design issues:

  • Complex methods that do too many things
  • Tight coupling between components
  • Hidden dependencies
  • Poor separation of concerns

This aligns with Clean Code principles, particularly:

  • Single Responsibility Principle: Each class should have one reason to change
  • Dependency Inversion: High-level modules shouldn't depend on low-level modules
  • Interface Segregation: Clients shouldn't depend on methods they don't use

When you follow these principles, your code becomes naturally testable. Testable code is typically cleaner code, and clean code is inherently more testable.

Setting Up the Testing Environment

First, add the necessary dependencies to your build.gradle.kts:

dependencies {
    testImplementation("org.springframework.boot:spring-boot-starter-test")
    testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
    testImplementation("io.mockk:mockk:1.13.8")
    testImplementation("com.ninja-squad:springmockk:4.0.2")
}

The spring-boot-starter-test dependency includes:

  • JUnit 5: The testing framework
  • Mockito: For creating mock objects
  • AssertJ: For fluent assertions
  • Spring Test: For testing Spring components

MockK is included as an alternative to Mockito, designed specifically for Kotlin with support for coroutines and better null-safety.

Understanding the Basic Test Structure

Let's look at a basic test class structure in Kotlin:

import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.BeforeEach

class CalculatorTest {
    private lateinit var calculator: Calculator
    
    @BeforeEach
    fun setup() {
        calculator = Calculator()
    }
    
    @Test
    fun `should add two numbers correctly`() {
        // given
        val a = 2
        val b = 3
        
        // when
        val result = calculator.add(a, b)
        
        // then
        assertEquals(5, result)
    }
    
    @Test
    fun `should throw IllegalArgumentException when dividing by zero`() {
        // given
        val a = 10
        val b = 0
        
        // when/then
        assertThrows<IllegalArgumentException> {
            calculator.divide(a, b)
        }
    }
}

Key components of the test structure:

  1. Test Class: Named with the suffix "Test"
  2. Test Functions: Annotated with @Test
  3. Setup Functions: Annotated with @BeforeEach for common initialization
  4. Descriptive Names: Using backticks for readable test names
  5. Given-When-Then: Structure for organizing test logic

JUnit 5 Annotations

Common annotations you'll use:

@Test // Marks a method as a test case
@BeforeEach // Runs before each test method
@AfterEach // Runs after each test method
@BeforeAll // Runs once before all test methods (must be on companion object in Kotlin)
@AfterAll // Runs once after all test methods (must be on companion object in Kotlin)
@Disabled // Temporarily disables a test
@DisplayName("Custom test name") // Provides a custom name for test reporting

Example with multiple annotations:

class UserServiceTest {
    companion object {
        @JvmStatic
        @BeforeAll
        fun setupClass() {
            // Run once before all tests
        }
    }
    
    private lateinit var userService: UserService
    
    @BeforeEach
    fun setup() {
        userService = UserService()
    }
    
    @Test
    @DisplayName("Should create user with valid data")
    fun createUser_withValidData_shouldSucceed() {
        // Test implementation
    }
    
    @Test
    @Disabled("Feature not implemented yet")
    fun `should update user profile`() {
        // Test implementation
    }
}

Assertions with AssertJ

AssertJ provides a fluent API for assertions that's more readable than standard JUnit assertions:

import org.assertj.core.api.Assertions.*

class ProductTest {
    @Test
    fun `should validate product properties`() {
        // given
        val product = Product(
            id = 1,
            name = "Laptop",
            price = BigDecimal("999.99"),
            categories = listOf("Electronics", "Computers")
        )
        
        // then
        assertThat(product.name)
            .isNotBlank()
            .startsWith("L")
            .hasSize(6)
        
        assertThat(product.price)
            .isGreaterThan(BigDecimal.ZERO)
            .isLessThan(BigDecimal("1000.00"))
        
        assertThat(product.categories)
            .hasSize(2)
            .contains("Electronics")
            .doesNotContain("Food")
    }
}

AssertJ makes tests more readable and provides helpful error messages when assertions fail.

Testing Best Practices

  1. Follow the AAA Pattern:
    • Arrange: Set up the test data
    • Act: Execute the method being tested
    • Assert: Verify the results
  2. One Assert Per Test:
    • Focus each test on a single behavior
    • Makes it easier to identify what failed
    • Exception: When testing related properties of an object
  3. Meaningful Names:
    • Use descriptive test names that explain the scenario
    • Include expected behavior in the name
    • Use underscores or backticks for readability
  4. Keep Tests Independent:
    • Each test should run independently
    • Don't rely on test execution order
    • Reset state in @BeforeEach when needed