Skip to content

Instantly share code, notes, and snippets.

@sflandergan
Created January 16, 2026 10:03
Show Gist options
  • Select an option

  • Save sflandergan/3eab31b1e9efe8d405b03102e7f5b7a5 to your computer and use it in GitHub Desktop.

Select an option

Save sflandergan/3eab31b1e9efe8d405b03102e7f5b7a5 to your computer and use it in GitHub Desktop.
AGENTS.md Template for Spring Boot Kotlin

Project structure

  • Use a domain-driven design approach
  • Keep track of the features in the AGENTS.md file (package, base URL, entities only)
  • Use a package by feature approach bundling rest controllers, services, repositories, models, configurations
    • Each feature MUST have its own dedicated package under com.deviceinsight.template.<feature-name>
    • NEVER mix features in the same package - each feature is completely self-contained
    • Package naming: use lowercase, plural or singular based on domain concept (e.g., devices, devicegroups, users, orders)
    • All feature code (controllers, services, repositories, DTOs, exceptions, configurations) goes in the feature package
    • Example: devices/ and devicegroups/ are separate packages, not mixed together
    • When creating a new feature, ALWAYS create a new package - do not add to existing feature packages
    • Exception: User may explicitly instruct to add functionality to an existing package if it extends that feature

Features

  • Device Management
    • Package: com.deviceinsight.template.devices
    • Base URL: /api/devices
    • Entities: Device (id, name, type, capabilities)

Managing AGENTS.md Size

  • Current status: This file fits comfortably in AI context windows
  • When to split: Consider restructuring when this file reaches ~800-1000 lines or ~40-50K tokens
  • Splitting strategy:
    • Keep AGENTS.md as the high-level index with feature list and pointers
    • Move detailed patterns to `` (already doing this well)
    • Always maintain clear pointers from AGENTS.md to detailed documentation

Pattern Documentation

Detailed patterns and guidelines are documented in ``:

  • CONTROLLER_TESTING.md - Controller testing patterns with MockMvc, validation, and error handling
  • DATABASE_SCHEMA.md - Database schema design guidelines (naming conventions, keys, indexing, data types, JPA entity mapping)
  • DEPENDENCY-MANAGEMENT.md - Maven dependency and version management
  • ENTITY_TEST_DATA.md - Test subclass pattern for creating entity test data without reflection
  • JSON-MODEL-TESTING.md - Marshalling and unmarshalling tests for JSON model classes
  • PAGINATION.md - Pagination implementation patterns
  • REPOSITORY_TESTING.md - Repository integration testing patterns and base class usage
  • SERVICE_TESTING.md - Service unit testing patterns with mocking and test coverage guidelines

General

  • When you've adapted a single class or implemented a new test, run the test with mvn test -Dtest=YourTestClassName
  • When there is a corresponding integration test, run it with mvn verify -Dit.test=YourIntegrationTestClassName
  • Run all tests with mvn verify when you've changed large parts of the code
  • When you should reproduce a bug, write a test that fails and then fix the bug
  • If mvn verify fails, check the container logs in target/application-container.log and target/postgres-container.log for debugging

Code Style and Structure

  • Write code like a book with clear narrative flow from high-level intent to low-level details:
    • Short, focused functions: Each function does one thing well
    • Decompose long functions: Break into smaller, well-named helper functions
    • Orchestrating functions: Main function reads like a table of contents, delegating to helpers
    • Extract complex conditions: Long boolean expressions become well-named functions
    • Descriptive names: Function names clearly express intent
  • Use comments and KDoc sparsely - prefer self-documenting code with clear naming
  • Comments should explain "why" not "what"
  • Document class purpose when helpful, but well-named functions/parameters should be self-explanatory
  • Only create interface when there will be multiple implementations or requirements from a framework
  • Do not add a Impl suffix to interface implementation. Use a name based on the technology used to implement the interface, e.g. JpaUserRepository or InMemoryUserRepository
  • Clean up unused imports after code changes and organize: standard library, third-party, project imports

Layered Architecture

  • Follow a strict layered architecture: Interfaces Layer → calls → Services Layer → uses → Infrastructure Layer
  • Services should not directly access repositories from other service packages
  • Interfaces should not directly access repositories; they must use services
  • Repositories must use internal modifier to enforce they are only accessed within their feature package
    • Mark repository interfaces as internal
    • Mark service constructors as internal constructor when they take internal repositories
    • Mark configuration classes as internal when they wire internal components
    • This enforces module-level encapsulation and prevents cross-package repository access
  • DTOs should only be used inside the Rest Controller layer
  • Services should work with the entity model

Kotlin

  • Use Kotlin 2.2 or later features when applicable
  • Write idiomatic code that leverages Kotlin's unique capabilities and built-in features
  • Reference: Kotlin idioms and conventions

Prefer Kotlin Built-in Features Over Third-Party Libraries

  • Leverage Kotlin's Standard Library before reaching for third-party libraries (collections, ranges, sequences, scope functions)
  • Reduce dependencies: Minimize external libraries when Kotlin's built-in features provide the required functionality
  • Use built-in methods like isEmpty(), isNotEmpty(), toList() instead of manual implementations

Scope Functions

  • Use scope functions (let, apply, run, with, also) for cleaner, more concise code
  • let: Chaining and handling nullable types
  • apply: Configure objects after initialization
  • run: Execute calculations and return a value
  • also: Perform side-effects without altering the object

Type Safety and Null Safety

  • Prefer data classes for DTOs and immutable data carriers
  • Use sealed classes for controlled type hierarchies
  • Leverage Kotlin's null safety features - avoid using !! operator
  • Use nullable types (e.g., String?) only when necessary and handle null values explicitly
  • Use val for immutable properties, var only when mutability is required
  • Prefer typed parameters over strings in service methods
  • Parse strings at the boundary (controllers), not in services
  • Examples of preferred types: LocalTime, LocalDate, ZoneId, Instant, Duration
  • Use value classes (@JvmInline) to represent specific types and avoid parameter mix-ups
  • Example:
    @JvmInline
    value class UserId(val id: String)
    
    @JvmInline
    value class Email(val value: String)
    
    fun sendEmail(userId: UserId, email: Email) // Type-safe, prevents mixing up parameters

Unused Variables

  • ALWAYS use _ for unused caught exceptions: catch (_: IllegalArgumentException)
    • ❌ Bad: catch (e: IllegalArgumentException) when e is not used
    • ✅ Good: catch (_: IllegalArgumentException)
    • Only use a named variable if you log or use the exception: catch (e: Exception) { logger.error(e) { ... } }
  • Use _ for unused lambda parameters: map.forEach { _, value -> ... }

Package-Level Functions Over Companion Objects

  • Prefer package-level functions for factory methods and utilities instead of companion object methods
  • More idiomatic Kotlin style following stdlib conventions (like listOf(), mapOf())
  • Better discoverability with IDE autocomplete
  • Cleaner, less verbose API
  • Bad (companion object):
    value class UserId private constructor(val value: String) {
        companion object {
            fun create(value: String) = UserId(value)
            fun fromString(value: String) = UserId(value)
        }
    }
    // Usage: UserId.create("123")
  • Good (package-level functions):
    fun createUserId(value: String) = UserId(value)
    fun userIdFromString(value: String) = UserId(value)
    
    @JvmInline
    value class UserId internal constructor(val value: String)
    
    // Usage: createUserId("123")
  • Use internal constructor to force usage of package-level factory functions
  • Companion objects are still appropriate for constants and type-specific operations

Readability Patterns

  • Use expression body syntax for single-line methods - omit braces and return keyword
  • Omit return type when it's clear from context - let type inference work for you
  • Use named arguments for better readability when calling functions with multiple parameters
  • Use when expressions instead of complex if-else chains
  • Prefer Kotlin's collection operations (map, filter, etc.) over imperative loops
  • Return early pattern: Check exception cases at the start of methods and return early to separate error handling from main logic
  • Break logic across multiple lines: Use intermediate variables with descriptive names instead of cramming logic into a single line

Extension Functions

  • Leverage Kotlin's extension functions to add functionality to existing classes
  • Write extension functions for clean code without modifying third-party or system classes
  • Avoid "Utils" classes - use extension functions instead for better organization and discoverability

Spring Framework Integration

  • Leverage Spring Boot 3.x features and best practices
  • Use Spring Boot starters for quick project setup and dependency management
  • Use @ConfigurationProperties with constructor binding for type-safe configuration

Bean Configuration

  • Use constructor injection over field injection for better testability
  • Never use @Component, @Service, or @Repository annotations on classes
  • Always use @Configuration classes to explicitly define beans and manage their lifecycles
  • This provides explicit control over bean creation, dependencies, and conditional logic
  • Reuse configuration classes in tests if possible
  • Good:
    // In MyFeatureConfiguration.kt
    @Configuration
    class MyFeatureConfiguration {
        @Bean
        fun myService(repository: MyRepository) = MyService(repository)
    }
    
    // Service class - no annotations
    class MyService(private val repository: MyRepository) { ... }
  • Bad:
    @Service  // Don't use stereotype annotations
    class MyService(private val repository: MyRepository) { ... }

Open Modifier for Spring

  • Kotlin classes and methods are final by default, but Spring requires them to be open for CGLIB proxying
  • Do not use kotlin-maven-allopen plugin - explicitly mark classes/methods as open instead
  • Mark as open: @Configuration classes, @Bean methods, @Transactional methods, and service classes with @Transactional methods
  • Private methods do not need to be open - Spring only proxies public methods

Transaction Management

  • Transactions should be started on service layer
  • Multiple service calls can participate in the same transaction

Rest Controllers

  • Use DTOs for request and response
  • Name DTOs with Dto suffix (e.g., UserDto, OrderDto)
  • Use Kotlin data classes for DTOs
  • Implement input validation using Bean Validation (e.g., @Valid, custom validators)
  • Implement proper exception handling using @ControllerAdvice and @ExceptionHandler
  • Apply a RESTful API design (proper use of HTTP methods, status codes, etc.)
  • Return a http status 409 when a resource already exists
  • Return a http status 404 when a resource does not exist
  • Return a http status 400 when a request is invalid
  • Return a http status 500 when an internal server error occurs
  • Use Springdoc OpenAPI (formerly Swagger) for API documentation
  • Implement a toEntity function in the DTOs to convert the DTO to an entity if needed

JSON Model Classes

  • Use Kotlin data classes for JSON models (DTOs, API request/response objects)
  • Always use @param:JsonProperty instead of @JsonProperty for constructor parameters in data classes (ensures proper deserialization with Jackson)
  • Use @JsonIgnoreProperties(ignoreUnknown = true) to handle additional fields gracefully
  • Map snake_case JSON field names to camelCase Kotlin properties

Configuration and Properties

  • Use application.yaml for configuration.
  • Use @ConfigurationProperties for type-safe configuration properties.

Pagination

  • Prefer keyset pagination (seek method) over offset-based pagination for better performance
  • See PAGINATION.md for detailed implementation patterns and examples

HTTP Client Usage

  • Use Spring's RestClient (Spring Boot 3.2+) or WebClient for calling external HTTP services
  • Create HTTP client beans in a @Configuration class within the feature package
  • Always set timeouts to prevent indefinite blocking (connection, read, and response timeouts)
  • Make timeout values configurable via @ConfigurationProperties
  • Create a dedicated client class per external service in the infrastructure layer
  • Test HTTP clients using WireMock or MockWebServer in integration tests
  • Document external service dependencies in feature package documentation

Error Handling

  • Ask the user how to handle errors for each external service integration:
    • Option 1: Fallback strategy - Use cached data, default values, or alternative service
    • Option 2: Propagate error - Throw custom exception and let caller handle it
    • Option 3: Circuit breaker - Use Resilience4j to prevent cascading failures
    • Option 4: Retry - Use Resilience4j to retry failed requests
  • Log all external service errors at ERROR level with context (URL, status code, error message)
  • Wrap external service exceptions in custom domain exceptions (e.g., ExternalServiceException)
  • Never expose raw HTTP client exceptions to the REST API layer
  • Return appropriate HTTP status codes:
    • 503 Service Unavailable when external service is down
    • 504 Gateway Timeout when external service times out
    • 500 Internal Server Error for unexpected errors

Database Schema and Migrations

  • Follow database schema design guidelines - see DATABASE_SCHEMA.md for comprehensive rules on naming conventions, keys, indexing, data types, and JPA entity mapping
  • Use Flyway for database schema versioning and migrations
  • Name migration files: V<timestamp with format VYYYYMMDDHHmm>_description.sql (e.g., V202511140900_add_asset_table.sql)
  • Place migration files in src/main/resources/db/migration/
  • Keep migrations idempotent when possible
  • Never modify existing migration files after they've been applied

Testing

  • Write unit tests using JUnit 5 and Spring Boot Test
  • Use MockMvc for testing web layers
  • Always create tests when implementing new features
  • Always update tests when modifying existing code

AssertJ Assertions

  • Use AssertJ for all assertions - NOT JUnit assertions (assertEquals, assertTrue, etc.)
  • Import: import org.assertj.core.api.Assertions.assertThat
  • AssertJ provides fluent, readable assertions with better error messages
  • Common patterns:
    • Equality: assertThat(actual).isEqualTo(expected)
    • Null checks: assertThat(value).isNull() or assertThat(value).isNotNull()
    • Boolean: assertThat(condition).isTrue() or assertThat(condition).isFalse()
    • Collections size: assertThat(list).hasSize(3) or assertThat(list).isEmpty()
    • Collections content: assertThat(list).contains(item) or assertThat(list).containsExactly(item1, item2)
    • String checks: assertThat(text).startsWith("prefix") or assertThat(text).contains("substring")
    • Collection predicates: assertThat(list).allMatch { condition } or assertThat(list).anyMatch { condition }
    • Negation: assertThat(list).noneMatch { condition }
  • Example:
    // Bad - JUnit assertions
    assertEquals(3, list.size)
    assertTrue(list.contains(item))
    assertNotNull(result)
    
    // Good - AssertJ assertions
    assertThat(list).hasSize(3)
    assertThat(list).contains(item)
    assertThat(result).isNotNull

Entity Test Data Creation

  • When you need to create entity instances with specific field values in tests (e.g., entities with private setters, immutable fields, or generated IDs)
  • Never use reflection to set entity fields in tests
  • Use helper functions (e.g., createDevice(id = 1, name = "Test")) or the test subclass pattern
  • See ENTITY_TEST_DATA.md for the recommended test subclass pattern and detailed examples

Controller Tests

  • Every controller must have a controller test - test class name: <ControllerName>Test
  • See CONTROLLER_TESTING.md for detailed patterns, MockMvc usage, validation testing, and error handling examples

JSON Model Classes

  • Every JSON model class must have marshalling and unmarshalling tests
  • See JSON-MODEL-TESTING.md for detailed guidance, templates, and examples

Repository Integration Tests

  • Every repository must have an integration test - test class name: <RepositoryName>IT
  • All repository integration tests must extend the RepositoryIT abstract base class
  • See REPOSITORY_TESTING.md for details

Service Unit Tests

  • Every service class MUST have a corresponding unit test (*ServiceTest.kt or *Test.kt)
  • See SERVICE_TESTING.md for detailed patterns, examples, and test coverage guidelines

Logging and Monitoring

  • Use KotlinLogging (kotlin-logging-jvm) for logging: private val logger = KotlinLogging.logger {}
  • Initialize logger using: private val logger = KotlinLogging.logger {}
  • Keep logging in the service layer
  • Write INFO level logs when entities are modified
  • Write DEBUG level logs when entities are retrieved
  • Write WARN level logs when recoverable errors occur
  • Write ERROR level logs when unrecoverable errors occur
  • Control logging via log level configuration - do not use property-based debug flags
  • Use Spring Boot Actuator for application monitoring and metrics
  • Use trace IDs to follow requests - leverage Spring Boot's built-in tracing (Micrometer Tracing) with MDC

Logging Patterns

  • Use post-action logging with .also {} for read operations - log after the action with result info (found/not found, counts)
  • Include result counts: "Found ${teams.size} teams with status channel configured"
  • Explain impact in log messages: "No teams configured - skipping scheduled updates" (not just "No teams configured")
  • Good: findAll().filter { ... }.also { logger.debug { "Found ${it.size} items" } }
  • Bad: logger.debug { "Fetching items" }; return findAll() (no result info)

GDPR Compliance

  • Never log personal data or user information to ensure GDPR compliance
  • Never log user IDs - this includes primary keys, usernames, email addresses, or any other user identifiers
  • Log only technical information: entity types, operation types, counts, status codes, error types
  • Example compliant log: "Device created successfully" or "Failed to update device: validation error"
  • Example non-compliant log: "Device created for user 12345" or "User john.doe@example.com logged in"
  • When debugging is necessary, use anonymized identifiers or aggregate metrics instead of real user data

Maven Dependency Management

  • ⚠️ REQUIRED: Read DEPENDENCY-MANAGEMENT.md before adding new dependencies
  • Always define dependency versions as properties in the <properties> section of pom.xml
  • Use the naming pattern: <artifactId>.version for property names
  • Reference properties using ${property.name} syntax in dependency declarations
  • Dependencies managed by Spring Boot parent (like spring-boot-starter-web) don't need explicit version properties
  • See DEPENDENCY-MANAGEMENT.md for detailed guidelines, examples, and troubleshooting

Controller Testing

This document describes the patterns and guidelines for writing tests for REST controllers in the Spring Boot Kotlin template.

Overview

Every controller must have a corresponding test to ensure the web layer behaves correctly, validates input properly, handles errors appropriately, and integrates with services as expected.

Test Class Naming

  • Pattern: <ControllerClassName>Test.kt
  • Examples:
    • DeviceControllerTest.kt for DeviceController
    • UserControllerTest.kt for UserController

Test Annotation

Use @WebMvcTest to test only the web layer:

@WebMvcTest(DeviceController::class)
class DeviceControllerTest {
    @Autowired
    private lateinit var mockMvc: MockMvc
    
    @MockBean
    private lateinit var deviceService: DeviceService
    
    // tests...
}

Benefits of @WebMvcTest:

  • Loads only the web layer (controllers, filters, advice)
  • Does not load services, repositories, or database configuration
  • Fast test execution
  • Forces proper layering through mocking

Mocking Dependencies

  • Use @MockBean to mock service dependencies
  • Mock all services that the controller depends on
  • Do not mock repositories directly - controllers should only interact with services
@MockBean
private lateinit var deviceService: DeviceService

@MockBean
private lateinit var userService: UserService

Test Coverage Requirements

Test all aspects of controller behavior:

1. All Endpoints

Test every HTTP endpoint with various scenarios:

@Test
fun `GET devices should return list of devices`() {
    // Arrange
    val devices = listOf(createDevice(id = 1), createDevice(id = 2))
    every { deviceService.findAll() } returns devices
    
    // Act & Assert
    mockMvc.get("/api/devices")
        .andExpect {
            status { isOk() }
            content { contentType(MediaType.APPLICATION_JSON) }
            jsonPath("$") { isArray() }
            jsonPath("$.length()") { value(2) }
        }
    
    verify(exactly = 1) { deviceService.findAll() }
}

@Test
fun `GET device by id should return device when found`() {
    // Arrange
    val device = createDevice(id = 1, name = "Device 1")
    every { deviceService.findById(1) } returns device
    
    // Act & Assert
    mockMvc.get("/api/devices/1")
        .andExpect {
            status { isOk() }
            jsonPath("$.id") { value(1) }
            jsonPath("$.name") { value("Device 1") }
        }
    
    verify { deviceService.findById(1) }
}

@Test
fun `POST device should create device with valid input`() {
    // Arrange
    val deviceDto = DeviceDto(name = "New Device", type = "sensor")
    val createdDevice = createDevice(id = 1, name = "New Device")
    every { deviceService.create(any()) } returns createdDevice
    
    // Act & Assert
    mockMvc.post("/api/devices") {
        contentType = MediaType.APPLICATION_JSON
        content = objectMapper.writeValueAsString(deviceDto)
    }.andExpect {
        status { isCreated() }
        jsonPath("$.id") { value(1) }
        jsonPath("$.name") { value("New Device") }
    }
    
    verify { deviceService.create(any()) }
}

@Test
fun `DELETE device should remove device`() {
    // Arrange
    every { deviceService.delete(1) } returns Unit
    
    // Act & Assert
    mockMvc.delete("/api/devices/1")
        .andExpect {
            status { isNoContent() }
        }
    
    verify { deviceService.delete(1) }
}

2. Input Validation

Test validation rules for request bodies and parameters:

@Test
fun `POST device should return 400 when name is blank`() {
    // Arrange
    val invalidDto = DeviceDto(name = "", type = "sensor")
    
    // Act & Assert
    mockMvc.post("/api/devices") {
        contentType = MediaType.APPLICATION_JSON
        content = objectMapper.writeValueAsString(invalidDto)
    }.andExpect {
        status { isBadRequest() }
    }
    
    verify(exactly = 0) { deviceService.create(any()) }
}

@Test
fun `POST device should return 400 when required field is missing`() {
    // Arrange
    val invalidJson = """{"name": "Device"}""" // missing 'type'
    
    // Act & Assert
    mockMvc.post("/api/devices") {
        contentType = MediaType.APPLICATION_JSON
        content = invalidJson
    }.andExpect {
        status { isBadRequest() }
    }
}

3. Error Handling

Test exception handling and error responses:

@Test
fun `GET device by id should return 404 when device not found`() {
    // Arrange
    every { deviceService.findById(999) } throws DeviceNotFoundException("Device not found")
    
    // Act & Assert
    mockMvc.get("/api/devices/999")
        .andExpect {
            status { isNotFound() }
        }
    
    verify { deviceService.findById(999) }
}

@Test
fun `POST device should return 409 when device already exists`() {
    // Arrange
    val deviceDto = DeviceDto(name = "Existing Device", type = "sensor")
    every { deviceService.create(any()) } throws DeviceAlreadyExistsException("Device already exists")
    
    // Act & Assert
    mockMvc.post("/api/devices") {
        contentType = MediaType.APPLICATION_JSON
        content = objectMapper.writeValueAsString(deviceDto)
    }.andExpect {
        status { isConflict() }
    }
}

@Test
fun `POST device should return 500 when unexpected error occurs`() {
    // Arrange
    val deviceDto = DeviceDto(name = "Device", type = "sensor")
    every { deviceService.create(any()) } throws RuntimeException("Unexpected error")
    
    // Act & Assert
    mockMvc.post("/api/devices") {
        contentType = MediaType.APPLICATION_JSON
        content = objectMapper.writeValueAsString(deviceDto)
    }.andExpect {
        status { isInternalServerError() }
    }
}

4. Pagination Parameters

Test pagination query parameters when applicable:

@Test
fun `GET devices should support pagination parameters`() {
    // Arrange
    val page = Page.of(listOf(createDevice(id = 1)), 0, 10, 1)
    every { deviceService.findAll(any(), any()) } returns page
    
    // Act & Assert
    mockMvc.get("/api/devices?page=0&size=10")
        .andExpect {
            status { isOk() }
            jsonPath("$.content") { isArray() }
            jsonPath("$.page") { value(0) }
            jsonPath("$.size") { value(10) }
            jsonPath("$.totalElements") { value(1) }
        }
    
    verify { deviceService.findAll(0, 10) }
}

@Test
fun `GET devices should use default pagination when parameters not provided`() {
    // Arrange
    val page = Page.of(listOf(createDevice(id = 1)), 0, 20, 1)
    every { deviceService.findAll(any(), any()) } returns page
    
    // Act & Assert
    mockMvc.get("/api/devices")
        .andExpect {
            status { isOk() }
        }
    
    verify { deviceService.findAll(0, 20) } // default values
}

Test Function Naming

Use descriptive test function names with backticks:

fun `HTTP_METHOD endpoint should behavior when condition`()

Examples:

fun `GET devices should return empty list when no devices exist`()
fun `POST device should return 400 when name exceeds max length`()
fun `PUT device should update device when valid input provided`()
fun `DELETE device should return 404 when device not found`()

Verifying Service Calls

Always verify that service methods are called with expected parameters:

verify(exactly = 1) { deviceService.findAll() }
verify { deviceService.findById(1) }
verify(exactly = 0) { deviceService.create(any()) } // should not be called

MockMvc DSL

Use Kotlin's MockMvc DSL for readable tests:

// GET request
mockMvc.get("/api/devices/1")
    .andExpect {
        status { isOk() }
        jsonPath("$.id") { value(1) }
    }

// POST request
mockMvc.post("/api/devices") {
    contentType = MediaType.APPLICATION_JSON
    content = objectMapper.writeValueAsString(dto)
}.andExpect {
    status { isCreated() }
}

// PUT request
mockMvc.put("/api/devices/1") {
    contentType = MediaType.APPLICATION_JSON
    content = objectMapper.writeValueAsString(dto)
}.andExpect {
    status { isOk() }
}

// DELETE request
mockMvc.delete("/api/devices/1")
    .andExpect {
        status { isNoContent() }
    }

JSON Assertions

Use jsonPath for asserting JSON response content:

.andExpect {
    jsonPath("$.id") { value(1) }
    jsonPath("$.name") { value("Device") }
    jsonPath("$.items") { isArray() }
    jsonPath("$.items.length()") { value(3) }
    jsonPath("$.items[0].id") { value(1) }
    jsonPath("$.active") { value(true) }
    jsonPath("$.price") { value(19.99) }
    jsonPath("$.tags") { isEmpty() }
}

Complete Example

import com.fasterxml.jackson.databind.ObjectMapper
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.boot.test.context.TestConfiguration
import org.springframework.context.annotation.Bean
import org.springframework.http.MediaType
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.delete
import org.springframework.test.web.servlet.get
import org.springframework.test.web.servlet.post

@WebMvcTest(DeviceController::class)
class DeviceControllerTest {
    
    @Autowired
    private lateinit var mockMvc: MockMvc
    
    @Autowired
    private lateinit var deviceService: DeviceService
    
    @Autowired
    private lateinit var objectMapper: ObjectMapper
    
    @TestConfiguration
    class TestConfig {
        @Bean
        fun deviceService(): DeviceService = mockk()
    }
    
    @Test
    fun `GET devices should return list of devices`() {
        // Arrange
        val devices = listOf(
            createDevice(id = 1, name = "Device 1"),
            createDevice(id = 2, name = "Device 2")
        )
        every { deviceService.findAll() } returns devices
        
        // Act & Assert
        mockMvc.get("/api/devices")
            .andExpect {
                status { isOk() }
                content { contentType(MediaType.APPLICATION_JSON) }
                jsonPath("$") { isArray() }
                jsonPath("$.length()") { value(2) }
                jsonPath("$[0].id") { value(1) }
                jsonPath("$[0].name") { value("Device 1") }
            }
        
        verify(exactly = 1) { deviceService.findAll() }
    }
    
    @Test
    fun `GET device by id should return 404 when device not found`() {
        // Arrange
        every { deviceService.findById(999) } throws DeviceNotFoundException("Device not found")
        
        // Act & Assert
        mockMvc.get("/api/devices/999")
            .andExpect {
                status { isNotFound() }
            }
        
        verify { deviceService.findById(999) }
    }
    
    @Test
    fun `POST device should create device with valid input`() {
        // Arrange
        val deviceDto = DeviceDto(name = "New Device", type = "sensor")
        val createdDevice = createDevice(id = 1, name = "New Device", type = "sensor")
        every { deviceService.create(any()) } returns createdDevice
        
        // Act & Assert
        mockMvc.post("/api/devices") {
            contentType = MediaType.APPLICATION_JSON
            content = objectMapper.writeValueAsString(deviceDto)
        }.andExpect {
            status { isCreated() }
            jsonPath("$.id") { value(1) }
            jsonPath("$.name") { value("New Device") }
        }
        
        verify { deviceService.create(any()) }
    }
    
    @Test
    fun `POST device should return 400 when name is blank`() {
        // Arrange
        val invalidDto = DeviceDto(name = "", type = "sensor")
        
        // Act & Assert
        mockMvc.post("/api/devices") {
            contentType = MediaType.APPLICATION_JSON
            content = objectMapper.writeValueAsString(invalidDto)
        }.andExpect {
            status { isBadRequest() }
        }
        
        verify(exactly = 0) { deviceService.create(any()) }
    }
    
    @Test
    fun `DELETE device should remove device`() {
        // Arrange
        every { deviceService.delete(1) } returns Unit
        
        // Act & Assert
        mockMvc.delete("/api/devices/1")
            .andExpect {
                status { isNoContent() }
            }
        
        verify { deviceService.delete(1) }
    }
    
    private fun createDevice(
        id: Long = 1,
        name: String = "Test Device",
        type: String = "sensor"
    ) = Device(id = id, name = name, type = type)
}

Best Practices

  1. Test the contract, not the implementation: Focus on HTTP behavior, not internal logic
  2. Mock at the service layer: Controllers should only depend on services, not repositories
  3. Test all HTTP methods: GET, POST, PUT, PATCH, DELETE
  4. Test all status codes: 200, 201, 204, 400, 404, 409, 500, etc.
  5. Verify service interactions: Always use verify() to ensure services are called correctly
  6. Use ObjectMapper: Serialize/deserialize DTOs consistently
  7. Test headers: Verify Content-Type, Location (for POST), etc.
  8. Isolation: Each test should be independent

Related Documentation

  • SERVICE_TESTING.md - Service unit testing patterns
  • JSON-MODEL-TESTING.md - Testing JSON marshalling/unmarshalling for DTOs
  • AGENTS.md - Main project guidelines including AssertJ assertion patterns

Database Schema Design Guidelines

This document provides comprehensive guidelines for designing database schemas in this Spring Boot application.

Table Naming Conventions

Use Plural Names

  • Tables should use plural names to represent collections of entities
  • Examples: devices, users, orders, order_items
  • Rationale: A table contains multiple rows, so plural naming is more intuitive

Use Snake Case

  • All table names must use snake_case
  • Examples: device_configurations, user_preferences, audit_logs
  • Never use camelCase or PascalCase for table names

Column Naming Conventions

Use Snake Case

  • All column names must use snake_case
  • Examples: created_at, updated_at, device_name, user_email
  • This ensures consistency with PostgreSQL conventions and improves readability

Primary Keys

  • Use id as the primary key column name for single-column primary keys
  • Type: BIGSERIAL (auto-incrementing 64-bit integer) or UUID
  • Prefer BIGSERIAL for most cases unless you need distributed ID generation
  • Example:
    CREATE TABLE devices (
        id BIGSERIAL PRIMARY KEY,
        name VARCHAR(255) NOT NULL
    );

Foreign Keys

  • Use <referenced_table_singular>_id pattern for foreign key columns
  • Examples: device_id, user_id, order_id
  • Always add foreign key constraints with appropriate ON DELETE/ON UPDATE actions
  • Example:
    CREATE TABLE device_readings (
        id BIGSERIAL PRIMARY KEY,
        device_id BIGINT NOT NULL,
        reading_value DECIMAL(10, 2) NOT NULL,
        CONSTRAINT fk_device FOREIGN KEY (device_id) 
            REFERENCES devices(id) ON DELETE CASCADE
    );

Timestamps

  • Always include audit timestamp columns: created_at and updated_at
  • Type: TIMESTAMP WITH TIME ZONE (or TIMESTAMPTZ)
  • Set created_at with DEFAULT CURRENT_TIMESTAMP
  • Update updated_at using triggers or application logic
  • Example:
    CREATE TABLE devices (
        id BIGSERIAL PRIMARY KEY,
        name VARCHAR(255) NOT NULL,
        created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
        updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
    );

Boolean Columns

  • Use positive, descriptive names: is_active, is_enabled, has_permission
  • Avoid negative names like is_not_active or disabled
  • Type: BOOLEAN
  • Always set a default value: DEFAULT FALSE or DEFAULT TRUE

Enum Columns

  • Use VARCHAR with CHECK constraints instead of PostgreSQL ENUM types
  • Rationale: VARCHAR with CHECK is more flexible for schema evolution
  • Example:
    CREATE TABLE devices (
        id BIGSERIAL PRIMARY KEY,
        device_type VARCHAR(50) NOT NULL,
        CONSTRAINT chk_device_type CHECK (device_type IN ('SENSOR', 'ACTUATOR', 'GATEWAY'))
    );

Indexing Strategy

Primary Keys

  • Primary keys automatically create a unique index
  • No additional action needed

Foreign Keys

  • Always create indexes on foreign key columns for join performance
  • Example:
    CREATE INDEX idx_device_readings_device_id ON device_readings(device_id);

Unique Constraints

  • Use unique constraints for natural keys (e.g., email, username, external IDs)
  • Example:
    CREATE TABLE users (
        id BIGSERIAL PRIMARY KEY,
        email VARCHAR(255) NOT NULL,
        CONSTRAINT uq_users_email UNIQUE (email)
    );
  • Unique constraints automatically create a unique index

Query Performance

  • Create indexes for frequently queried columns
  • Consider composite indexes for multi-column queries
  • Example:
    CREATE INDEX idx_devices_type_status ON devices(device_type, status);

Partial Indexes

  • Use partial indexes for filtered queries to save space
  • Example:
    CREATE INDEX idx_devices_active ON devices(name) WHERE is_active = TRUE;

Constraint Naming Conventions

Use consistent prefixes for constraint names:

  • Primary Key: pk_<table_name>

    CONSTRAINT pk_devices PRIMARY KEY (id)
  • Foreign Key: fk_<table_name>_<referenced_table>

    CONSTRAINT fk_device_readings_devices FOREIGN KEY (device_id) REFERENCES devices(id)
  • Unique: uq_<table_name>_<column_name(s)>

    CONSTRAINT uq_users_email UNIQUE (email)
  • Check: chk_<table_name>_<column_name>

    CONSTRAINT chk_devices_type CHECK (device_type IN ('SENSOR', 'ACTUATOR'))
  • Index: idx_<table_name>_<column_name(s)>

    CREATE INDEX idx_devices_type ON devices(device_type);

Data Types

String Columns

  • Use VARCHAR(n) with appropriate length for bounded strings
  • Use TEXT for unbounded strings (descriptions, comments, JSON)
  • Examples:
    • Names, emails: VARCHAR(255)
    • Short codes: VARCHAR(50)
    • Descriptions: TEXT

Numeric Columns

  • Integers: SMALLINT (2 bytes), INTEGER (4 bytes), BIGINT (8 bytes)
  • Decimals: DECIMAL(precision, scale) for exact values (money, measurements)
  • Floating Point: REAL or DOUBLE PRECISION for approximate values
  • Choose the smallest type that fits your data range

Date and Time

  • Always use TIMESTAMPTZ (timestamp with time zone) for timestamps
  • Use DATE only for dates without time component (birthdays, etc.)
  • Use TIME only for time without date component (business hours, etc.)
  • Never store timestamps as strings or integers

JSON Data

  • Use JSONB (not JSON) for JSON data
  • JSONB is more efficient for querying and indexing
  • Example:
    CREATE TABLE devices (
        id BIGSERIAL PRIMARY KEY,
        capabilities JSONB NOT NULL DEFAULT '{}'::JSONB
    );

Relationships

One-to-Many

  • Add foreign key column in the "many" side table
  • Example: One device has many readings
    CREATE TABLE devices (
        id BIGSERIAL PRIMARY KEY,
        name VARCHAR(255) NOT NULL
    );
    
    CREATE TABLE device_readings (
        id BIGSERIAL PRIMARY KEY,
        device_id BIGINT NOT NULL,
        reading_value DECIMAL(10, 2) NOT NULL,
        CONSTRAINT fk_device_readings_devices FOREIGN KEY (device_id) 
            REFERENCES devices(id) ON DELETE CASCADE
    );

Many-to-Many

  • Create a junction table with foreign keys to both tables
  • Junction table name: <table1_singular>_<table2_singular> (alphabetically ordered)
  • Example: Devices can have many tags, tags can be on many devices
    CREATE TABLE devices (
        id BIGSERIAL PRIMARY KEY,
        name VARCHAR(255) NOT NULL
    );
    
    CREATE TABLE tags (
        id BIGSERIAL PRIMARY KEY,
        name VARCHAR(100) NOT NULL
    );
    
    CREATE TABLE device_tag (
        device_id BIGINT NOT NULL,
        tag_id BIGINT NOT NULL,
        PRIMARY KEY (device_id, tag_id),
        CONSTRAINT fk_device_tag_devices FOREIGN KEY (device_id) 
            REFERENCES devices(id) ON DELETE CASCADE,
        CONSTRAINT fk_device_tag_tags FOREIGN KEY (tag_id) 
            REFERENCES tags(id) ON DELETE CASCADE
    );

One-to-One

  • Add foreign key with unique constraint in either table
  • Prefer adding to the dependent entity
  • Example: User has one profile
    CREATE TABLE users (
        id BIGSERIAL PRIMARY KEY,
        email VARCHAR(255) NOT NULL
    );
    
    CREATE TABLE user_profiles (
        id BIGSERIAL PRIMARY KEY,
        user_id BIGINT NOT NULL,
        bio TEXT,
        CONSTRAINT uq_user_profiles_user_id UNIQUE (user_id),
        CONSTRAINT fk_user_profiles_users FOREIGN KEY (user_id) 
            REFERENCES users(id) ON DELETE CASCADE
    );

Referential Integrity

ON DELETE Actions

Choose appropriate action based on business logic:

  • CASCADE: Delete child records when parent is deleted

    • Use for dependent entities (e.g., order items when order is deleted)
  • RESTRICT: Prevent deletion if child records exist

    • Use for important relationships (e.g., prevent deleting user with orders)
  • SET NULL: Set foreign key to NULL when parent is deleted

    • Use when child can exist independently (e.g., optional category)
  • NO ACTION: Similar to RESTRICT but checked at end of transaction

    • Default behavior, rarely used explicitly

Example:

-- Cascade: readings are meaningless without device
CONSTRAINT fk_device_readings_devices FOREIGN KEY (device_id) 
    REFERENCES devices(id) ON DELETE CASCADE

-- Restrict: prevent deleting user with orders
CONSTRAINT fk_orders_users FOREIGN KEY (user_id) 
    REFERENCES users(id) ON DELETE RESTRICT

-- Set null: device can exist without category
CONSTRAINT fk_devices_categories FOREIGN KEY (category_id) 
    REFERENCES categories(id) ON DELETE SET NULL

Schema Evolution

Adding Columns

  • New columns should be nullable or have default values
  • Example:
    ALTER TABLE devices ADD COLUMN firmware_version VARCHAR(50);
    ALTER TABLE devices ADD COLUMN is_active BOOLEAN NOT NULL DEFAULT TRUE;

Removing Columns

  • Drop columns in separate migration from code changes
  • Ensure no code references the column before dropping
  • Example:
    ALTER TABLE devices DROP COLUMN old_field;

Renaming Columns

  • Avoid renaming when possible (requires coordinated deployment)
  • If necessary, use multi-step process:
    1. Add new column
    2. Copy data
    3. Update code to use new column
    4. Drop old column

Changing Column Types

  • Be cautious with type changes (may require data migration)
  • Example:
    -- Safe: increasing VARCHAR length
    ALTER TABLE devices ALTER COLUMN name TYPE VARCHAR(500);
    
    -- Risky: changing type may fail if data incompatible
    ALTER TABLE devices ALTER COLUMN status TYPE VARCHAR(50);

JPA Entity Mapping

Table and Column Annotations

Map JPA entities to database schema using annotations:

@Entity
@Table(name = "devices")
public class Device {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(name = "device_name", nullable = false, length = 255)
    private String deviceName;
    
    @Column(name = "device_type", nullable = false, length = 50)
    private String deviceType;
    
    @Column(name = "is_active", nullable = false)
    private Boolean isActive = true;
    
    @Column(name = "created_at", nullable = false, updatable = false)
    private Instant createdAt;
    
    @Column(name = "updated_at", nullable = false)
    private Instant updatedAt;
    
    // Constructors, getters, setters, etc.
}

Naming Strategy

  • Spring Boot uses SpringPhysicalNamingStrategy by default
  • Converts camelCase to snake_case automatically
  • You can omit @Column(name = "...") if field name matches after conversion
  • Example: deviceNamedevice_name (automatic)

Explicit Naming

  • Always use explicit @Table(name = "...") for clarity
  • Use explicit @Column(name = "...") when:
    • Database column name doesn't follow camelCase → snake_case pattern
    • You want to make the mapping explicit for documentation
    • Column name is a reserved keyword

Best Practices Summary

  1. Use plural table names in snake_case
  2. Use snake_case for all columns
  3. Always include id, created_at, updated_at columns
  4. Use BIGSERIAL for primary keys (or UUID if needed)
  5. Follow <table_singular>_id pattern for foreign keys
  6. Always create indexes on foreign keys
  7. Use TIMESTAMPTZ for timestamps, never strings or integers
  8. Use JSONB for JSON data, not JSON
  9. Use VARCHAR with CHECK constraints instead of ENUM types
  10. Choose appropriate ON DELETE actions for foreign keys
  11. Name constraints consistently with prefixes (pk_, fk_, uq_, chk_)
  12. Make new columns nullable or with defaults for safe migrations
  13. Use explicit @Table annotations in JPA entities
  14. Test migrations on a copy of production data before deploying

Migration Checklist

Before creating a new migration:

  • Table names are plural and snake_case
  • Column names are snake_case
  • Primary key is id BIGSERIAL
  • Foreign keys follow <table>_id pattern
  • Timestamps are created_at and updated_at with TIMESTAMPTZ
  • Foreign keys have appropriate ON DELETE actions
  • Indexes created for foreign keys
  • Unique constraints for natural keys
  • Constraints have proper naming (pk_, fk_, uq_, chk_)
  • New columns are nullable or have defaults
  • Migration is idempotent when possible

Maven Dependency Management

This document describes the best practices for managing dependencies in this Maven project.

Version Property Management

Always define dependency versions as properties in the <properties> section of pom.xml.

Property Naming Convention

Use descriptive property names following the pattern: <artifactId>.version

Property Organization

Group properties logically in the <properties> section:

<properties>
    <!-- dependency versions -->
    <kotlin-logging-jvm.version>7.0.0</kotlin-logging-jvm.version>
    <okhttp.version>4.12.0</okhttp.version>
    <springdoc-openapi-starter-webmvc-ui.version>2.7.0</springdoc-openapi-starter-webmvc-ui.version>
    
    <!-- plugin versions -->
    <docker-maven-plugin.version>0.48.0</docker-maven-plugin.version>
</properties>

Referencing Properties

Reference properties in dependencies using ${property.name} syntax:

<dependencies>
    <dependency>
        <groupId>io.github.oshai</groupId>
        <artifactId>kotlin-logging-jvm</artifactId>
        <version>${kotlin-logging-jvm.version}</version>
    </dependency>
    
    <dependency>
        <groupId>com.squareup.okhttp3</groupId>
        <artifactId>okhttp</artifactId>
        <version>${okhttp.version}</version>
    </dependency>
</dependencies>

When to Define Version Properties

✅ DO define version properties for:

  • External libraries with explicit versions (e.g., okhttp, kotlin-logging-jvm)
  • Libraries where you want to control the version independently
  • Maven plugins (e.g., docker-maven-plugin)
  • Transitive dependency overrides

❌ DO NOT define version properties for:

  • Dependencies inherited from Spring Boot parent POM (e.g., spring-boot-starter-web, spring-boot-starter-validation)
  • Kotlin standard libraries managed by parent (kotlin-stdlib, kotlin-reflect)
  • Jackson modules managed by parent (jackson-module-kotlin)
  • Any dependency where version is omitted (parent manages it)

How to check if a dependency needs a version property:

  1. If the <version> tag is present in the dependency → move it to a property
  2. If the <version> tag is absent → dependency is managed by parent, no property needed

Adding a New Dependency

When adding a new dependency to pom.xml:

Step 1: Check if version is needed

# Check if the dependency is managed by the parent POM
mvn help:effective-pom | grep -A 5 "<artifactId>your-artifact-id</artifactId>"

Step 2: Add version property (if needed)

If the dependency requires an explicit version, add it to the <properties> section:

<properties>
    <!-- ...existing properties... -->
    
    <!-- dependency versions -->
    <your-artifact-id.version>1.2.3</your-artifact-id.version>
</properties>

Step 3: Add dependency

<dependencies>
    <!-- ...existing dependencies... -->
    
    <dependency>
        <groupId>com.example</groupId>
        <artifactId>your-artifact-id</artifactId>
        <version>${your-artifact-id.version}</version>
    </dependency>
</dependencies>

Step 4: Verify

mvn dependency:tree

Benefits

  • Centralized version management - All versions in one place
  • Easy updates - Update multiple dependencies by changing one property
  • Clear overview - See all external library versions at a glance
  • Consistency - Follow established patterns across the project
  • Reduced errors - Avoid version conflicts and inconsistencies
  • Better maintenance - Easier dependency upgrades

Example: Current Dependencies

With Version Properties (External Libraries)

<properties>
    <kotlin-logging-jvm.version>7.0.0</kotlin-logging-jvm.version>
    <okhttp.version>4.12.0</okhttp.version>
    <springdoc-openapi-starter-webmvc-ui.version>2.7.0</springdoc-openapi-starter-webmvc-ui.version>
</properties>

<dependencies>
    <dependency>
        <groupId>io.github.oshai</groupId>
        <artifactId>kotlin-logging-jvm</artifactId>
        <version>${kotlin-logging-jvm.version}</version>
    </dependency>
</dependencies>

Without Version (Parent-Managed)

<dependencies>
    <!-- No version needed - managed by Spring Boot parent -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    
    <dependency>
        <groupId>org.jetbrains.kotlin</groupId>
        <artifactId>kotlin-stdlib</artifactId>
    </dependency>
</dependencies>

Troubleshooting

Problem: Dependency version conflict

# View dependency tree to identify conflicts
mvn dependency:tree

# View effective POM to see resolved versions
mvn help:effective-pom

Problem: Property not resolving

  • Check property name matches exactly: ${artifactId.version}
  • Ensure property is defined in <properties> section
  • Verify XML syntax is correct (no typos, proper closing tags)

Problem: Unsure if parent manages dependency

# Check parent POM for dependency management
mvn help:effective-pom | grep -A 10 "dependencyManagement"

Related Documentation

Entity Test Data Creation Pattern

Overview

This document describes the recommended approach for creating test entities with pre-set IDs without using reflection.

The Problem

In tests, we often need to create entity instances with specific ID values to verify behavior. However, entity IDs are typically managed by JPA and should not be publicly settable.

The Solution: Test Subclass Pattern

Step 1: Make the id field protected

In your entity class, change the id field from private to protected:

@Entity
@Table(name = "devices")
public class Device {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    protected Long id;  // protected instead of private
    
    // ... rest of the entity
}

Step 2: Create a Test Subclass

Within your test class, create a static nested subclass that extends the entity and sets the id in its constructor:

@WebMvcTest(DeviceController.class)
class DeviceControllerTest {

    // ... test methods ...

    private Device createDevice(Long id, String name, String type, Set<String> capabilities) {
        return new TestDevice(id, name, type, capabilities);
    }

    static class TestDevice extends Device {
        TestDevice(Long id, String name, String type, Set<String> capabilities) {
            super(name, type, capabilities);
            this.id = id;
        }
    }
}

Benefits

  • No reflection: Cleaner, more maintainable test code
  • Type-safe: The compiler can verify the code
  • Encapsulation preserved: The id field remains protected, not publicly settable
  • Clear intent: The test subclass makes it explicit that this is test-specific behavior

Anti-Pattern: Don't Use Reflection

Avoid this approach:

private Device createDevice(Long id, String name, String type, Set<String> capabilities) {
    var device = new Device(name, type, capabilities);
    try {
        var idField = Device.class.getDeclaredField("id");
        idField.setAccessible(true);
        idField.set(device, id);
    } catch (Exception e) {
        throw new RuntimeException(e);
    }
    return device;
}

This is fragile, verbose, and bypasses type safety.

When to Use This Pattern

Use this pattern in:

  • Controller tests (@WebMvcTest) - when mocking service layer responses
  • Service tests - when mocking repository responses
  • Any unit test where you need entities with specific IDs

Do NOT use this pattern in:

  • Integration tests - let the database assign IDs naturally
  • Production code - IDs should only be set by JPA

JSON Model Testing

Overview

Every JSON model class (data classes with Jackson annotations) must have comprehensive marshalling and unmarshalling tests to ensure proper serialization and deserialization.

Purpose

  • Verify correct mapping between JSON field names (often snake_case) and Kotlin properties (camelCase)
  • Validate Jackson annotations (@param:JsonProperty, @JsonIgnoreProperties, etc.)
  • Ensure data integrity during serialization/deserialization cycles
  • Catch breaking changes in JSON structure early
  • Document the expected JSON format

Test Structure

File Organization

  • Place tests in the same package as the model class under src/test/kotlin/
  • Name test files as {ModelClassName}Test.kt
  • Example: MattermostPost.ktMattermostPostTest.kt

Required Tests

Each model class should have at least two test methods:

  1. Marshalling Test: should marshal {ModelName} to JSON correctly

    • Creates a model instance with representative data
    • Serializes to JSON string
    • Compares against expected JSON structure
  2. Unmarshalling Test: should unmarshal JSON to {ModelName} correctly

    • Defines JSON string input
    • Deserializes to model instance
    • Asserts equality with expected object

Test Template

package com.example.model

import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test

class YourModelTest {

    private val objectMapper = jacksonObjectMapper()

    @Test
    fun `should marshal YourModel to JSON correctly`() {
        val model = YourModel(
            id = "123",
            propertyName = "value",
            anotherProperty = 42
        )
        
        val json = objectMapper.writeValueAsString(model)
        
        val expectedJson = """
            {
                "id":"123",
                "property_name":"value",
                "another_property":42
            }
        """.trimIndent()
        
        assertEquals(objectMapper.readTree(expectedJson), objectMapper.readTree(json))
    }

    @Test
    fun `should unmarshal JSON to YourModel correctly`() {
        val json = """
            {
                "id":"123",
                "property_name":"value",
                "another_property":42
            }
        """.trimIndent()
        
        val expected = YourModel(
            id = "123",
            propertyName = "value",
            anotherProperty = 42
        )
        
        val result: YourModel = objectMapper.readValue(json)
        
        assertEquals(expected, result)
    }
}

Best Practices

JSON Formatting

  • Multi-line format: Always format JSON with one attribute per line for readability
  • Use trimIndent(): Strip leading whitespace from multi-line strings
  • Consistent indentation: Use tabs or spaces consistently (prefer tabs)

Good Example:

val expectedJson = """
    {
        "id":"123",
        "property_name":"value",
        "nested":{
            "key":"value"
        }
    }
""".trimIndent()

Bad Example:

val expectedJson = """{"id":"123","property_name":"value","nested":{"key":"value"}}"""

Field Naming

  • Always use @param:JsonProperty instead of @JsonProperty for constructor parameters in data classes
    • This ensures proper deserialization in Kotlin data classes
    • Example: @param:JsonProperty("user_id") maps to userId property
  • Verify that @param:JsonProperty annotations correctly map snake_case JSON fields to camelCase Kotlin properties
  • Include all fields that are serialized, including computed properties

Test Data

  • Use realistic but simple test data
  • Include all required fields
  • Consider optional fields (test with and without them)
  • Test edge cases like empty strings, null values, empty collections

Computed Properties

If your model has computed properties (methods that are serialized):

@Test
fun `should marshal Model with computed properties`() {
    val model = Model(type = "D")
    
    val json = objectMapper.writeValueAsString(model)
    
    val expectedJson = """
        {
            "type":"D",
            "isDirectMessage":true
        }
    """.trimIndent()
    
    assertEquals(objectMapper.readTree(expectedJson), objectMapper.readTree(json))
}

@Test
fun `should unmarshal and compute properties`() {
    val json = """{"type":"D"}""".trimIndent()
    
    val result: Model = objectMapper.readValue(json)
    
    assertEquals("D", result.type)
    assertEquals(true, result.isDirectMessage())
}

Nested Objects and Collections

For complex nested structures:

@Test
fun `should marshal nested structures`() {
    val model = Parent(
        id = "1",
        children = listOf(
            Child(name = "child1"),
            Child(name = "child2")
        ),
        metadata = mapOf("key1" to "value1", "key2" to "value2")
    )
    
    val json = objectMapper.writeValueAsString(model)
    
    val expectedJson = """
        {
            "id":"1",
            "children":[
                {"name":"child1"},
                {"name":"child2"}
            ],
            "metadata":{
                "key1":"value1",
                "key2":"value2"
            }
        }
    """.trimIndent()
    
    assertEquals(objectMapper.readTree(expectedJson), objectMapper.readTree(json))
}

Assertion Strategy

  • Use objectMapper.readTree() for comparison to handle JSON formatting differences
  • This approach ignores whitespace and attribute ordering
  • For stricter testing, compare JSON strings directly
// Flexible comparison (recommended)
assertEquals(objectMapper.readTree(expectedJson), objectMapper.readTree(json))

// Strict comparison (use when order matters)
assertEquals(expectedJson, json)

Running Tests

Individual Test

mvn test -Dtest=YourModelTest

All Model Tests

mvn test -Dtest=*ModelTest

Pattern-based Tests

mvn test -Dtest=Mattermost*Test

Common Issues and Solutions

Issue: Test fails with "MissingKotlinParameterException"

Cause: JSON field name doesn't match @param:JsonProperty annotation

Solution: Verify JSON field names match the annotations exactly

data class User(
    @param:JsonProperty("user_id")  // Must match JSON: "user_id"
    val userId: String
)

Issue: Extra fields in serialized JSON

Cause: Computed properties or methods are being serialized

Solution:

  1. Include them in expected JSON, or
  2. Use @JsonIgnore to exclude them
data class Model(
    val type: String
) {
    @JsonIgnore
    fun isSpecial() = type == "S"
}

Issue: Assertion fails due to field ordering

Cause: JSON field order differs between expected and actual

Solution: Use readTree() comparison instead of string comparison

// Good - ignores field order
assertEquals(objectMapper.readTree(expectedJson), objectMapper.readTree(json))

// Bad - sensitive to field order
assertEquals(expectedJson, json)

Issue: Build cache causing test failures

Cause: Maven cache contains outdated compiled classes

Solution: Clean build before running tests

mvn clean test -Dtest=YourModelTest

Examples

See existing test files for reference:

  • MattermostPostTest.kt - Basic model with multiple fields
  • MattermostUserTest.kt - Model with optional fields
  • MattermostChannelTest.kt - Model with computed properties
  • MattermostWebSocketEventTest.kt - Model with nested maps

Checklist

Before considering tests complete, verify:

  • Both marshalling and unmarshalling tests exist
  • JSON is formatted with one attribute per line
  • All serialized fields are included in expected JSON
  • Test data is realistic and representative
  • Tests pass with mvn clean test -Dtest=YourModelTest
  • Field name mapping (snake_case ↔ camelCase) is correct
  • Optional fields are tested appropriately
  • Computed properties are handled correctly
  • Nested structures are properly validated

Pagination Pattern

Overview

Prefer keyset pagination (seek method) over offset-based pagination for better performance. Keyset pagination uses indexed columns in WHERE clauses for constant-time performance regardless of page depth.

Keyset Pagination (Seek Method)

Concept

  • Use the last record's values from the current page as the starting point for the next page
  • Requires stable, indexed sort columns (e.g., score DESC, id ASC)
  • Build WHERE conditions using the last record's values: WHERE (score, id) < (lastScore, lastId)
  • Always include a unique column (like id) in the sort to ensure deterministic ordering

Advantages

  • Constant performance: O(1) regardless of page depth
  • No skipped/duplicate records: Even when data changes between requests
  • Efficient database queries: Uses indexes effectively
  • Scalable: Works well with millions of records

Disadvantages

  • No random page access: Can't jump to page 5 directly
  • More complex implementation: Requires tracking cursor values
  • Client complexity: Client must pass cursor values

Spring Data Implementation

Repository Layer

Simple Keyset Pagination (Single Sort Column)

public interface DeviceRepository extends JpaRepository<Device, Long> {
    
    // First page - no cursor needed
    @Query("""
        SELECT d FROM Device d
        ORDER BY d.id ASC
        """)
    List<Device> findFirstPage(Pageable pageable);
    
    // Next pages - use last ID as cursor
    @Query("""
        SELECT d FROM Device d
        WHERE d.id > :lastId
        ORDER BY d.id ASC
        """)
    List<Device> findNextPage(@Param("lastId") Long lastId, Pageable pageable);
}

Complex Keyset Pagination (Multiple Sort Columns)

public interface PlayerRepository extends JpaRepository<Player, Long> {
    
    // First page query
    @Query("""
        SELECT p FROM Player p 
        WHERE p.gameId = :gameId 
        ORDER BY p.score DESC, p.id DESC
        """)
    List<Player> findFirstPage(@Param("gameId") Long gameId, Pageable pageable);
    
    // Next page with composite cursor
    @Query("""
        SELECT p FROM Player p 
        WHERE p.gameId = :gameId 
        AND (p.score < :lastScore OR (p.score = :lastScore AND p.id < :lastId))
        ORDER BY p.score DESC, p.id DESC
        """)
    List<Player> findNextPage(
        @Param("gameId") Long gameId,
        @Param("lastScore") Integer lastScore,
        @Param("lastId") Long lastId,
        Pageable pageable
    );
}

Service Layer

Simple Implementation

public class DeviceService {
    
    private final DeviceRepository deviceRepository;
    
    public PageResult<DeviceDto> getDevices(Long lastId, int pageSize) {
        // Request one extra item to determine if there are more pages
        Pageable pageable = PageRequest.of(0, pageSize + 1);
        
        List<Device> devices = (lastId == null)
            ? deviceRepository.findFirstPage(pageable)
            : deviceRepository.findNextPage(lastId, pageable);
        
        // Check if we got more items than requested
        boolean hasMore = devices.size() > pageSize;
        
        // Limit the result to the requested page size
        List<DeviceDto> dtos = devices.stream()
            .limit(pageSize)
            .map(this::toDto)
            .toList();
        
        return new PageResult<>(dtos, hasMore);
    }
}

Complex Implementation

public class PlayerService {
    
    private final PlayerRepository playerRepository;
    
    public PageResult<PlayerDto> getPlayers(Long gameId, Integer lastScore, Long lastId, int pageSize) {
        // Request one extra item to determine if there are more pages
        Pageable pageable = PageRequest.of(0, pageSize + 1);
        
        List<Player> players = (lastScore == null || lastId == null)
            ? playerRepository.findFirstPage(gameId, pageable)
            : playerRepository.findNextPage(gameId, lastScore, lastId, pageable);
        
        // Check if we got more items than requested
        boolean hasMore = players.size() > pageSize;
        
        // Limit the result to the requested page size
        List<PlayerDto> dtos = players.stream()
            .limit(pageSize)
            .map(this::toDto)
            .toList();
        
        return new PageResult<>(dtos, hasMore);
    }
}

Response DTO

public record PageResult<T>(List<T> items, boolean hasMore) {}

REST Controller

Simple Pagination

@RestController
@RequestMapping("/api/devices")
public class DeviceController {
    
    private final DeviceService deviceService;
    
    @GetMapping
    public PageResult<DeviceDto> getDevices(
        @RequestParam(required = false) Long lastId,
        @RequestParam(defaultValue = "10") int pageSize) {
        
        return deviceService.getDevices(lastId, pageSize);
    }
}

Complex Pagination

@RestController
@RequestMapping("/api/players")
public class PlayerController {
    
    private final PlayerService playerService;
    
    @GetMapping
    public PageResult<PlayerDto> getPlayers(
        @RequestParam Long gameId,
        @RequestParam(required = false) Integer lastScore,
        @RequestParam(required = false) Long lastId,
        @RequestParam(defaultValue = "10") int pageSize) {
        
        return playerService.getPlayers(gameId, lastScore, lastId, pageSize);
    }
}

Mixed Sort Directions

For mixed ASC/DESC ordering (e.g., score DESC, id ASC), the WHERE clause becomes more complex:

@Query("""
    SELECT p FROM Player p 
    WHERE p.gameId = :gameId 
    AND (p.score < :lastScore OR (p.score = :lastScore AND p.id > :lastId))
    ORDER BY p.score DESC, p.id ASC
    """)
List<Player> findNextPage(
    @Param("gameId") Long gameId,
    @Param("lastScore") Integer lastScore,
    @Param("lastId") Long lastId,
    Pageable pageable
);

Logic for Mixed Directions

  • First column DESC, second ASC: (score < lastScore OR (score = lastScore AND id > lastId))
  • Both DESC: (score < lastScore OR (score = lastScore AND id < lastId))
  • Both ASC: (score > lastScore OR (score = lastScore AND id > lastId))

Index Requirements

Critical for Performance

Ensure composite indexes exist on sort columns:

-- For score DESC, id DESC
CREATE INDEX idx_player_game_score ON players(game_id, score DESC, id DESC);

-- For simple id ASC
CREATE INDEX idx_device_id ON devices(id ASC);

Index Best Practices

  • Index column order must match the query's ORDER BY clause
  • Include filter columns (e.g., game_id) at the start of the index
  • Use DESC/ASC in index definition to match query direction
  • Test query plans with EXPLAIN to verify index usage

Client Usage Examples

First Request

GET /api/devices?pageSize=10

Response:

{
  "items": [
    {"id": 1, "name": "Device-001"},
    {"id": 2, "name": "Device-002"},
    ...
    {"id": 10, "name": "Device-010"}
  ],
  "hasMore": true
}

Next Page Request

GET /api/devices?lastId=10&pageSize=10

Response:

{
  "items": [
    {"id": 11, "name": "Device-011"},
    {"id": 12, "name": "Device-012"},
    ...
    {"id": 20, "name": "Device-020"}
  ],
  "hasMore": true
}

Complex Pagination Example

GET /api/players?gameId=123&pageSize=10

Response:

{
  "items": [
    {"id": 5, "score": 1000, "name": "Player-005"},
    {"id": 3, "score": 950, "name": "Player-003"},
    ...
  ],
  "hasMore": true
}

Next page:

GET /api/players?gameId=123&lastScore=850&lastId=7&pageSize=10

Offset-Based Pagination

When to Use

  • Small datasets (< 10,000 records)
  • Random page access required (e.g., page numbers in UI)
  • Simpler client requirements

Implementation

public interface DeviceRepository extends JpaRepository<Device, Long> {
    // Spring Data provides this automatically
}

// Service
public Page<DeviceDto> getDevices(int page, int size) {
    Pageable pageable = PageRequest.of(page, size);
    Page<Device> devicePage = deviceRepository.findAll(pageable);
    return devicePage.map(this::toDto);
}

// Controller
@GetMapping
public Page<DeviceDto> getDevices(
    @RequestParam(defaultValue = "0") int page,
    @RequestParam(defaultValue = "10") int size) {
    return deviceService.getDevices(page, size);
}

Disadvantages

  • Performance degrades: O(n) where n is the page number
  • Skipped/duplicate records: When data changes between requests
  • Memory overhead: Database must skip all previous records
  • Not scalable: Poor performance with deep pagination

Testing Pagination

Repository Tests

@Test
void shouldFindFirstPageOrderedById() {
    deviceRepository.save(new Device("Device-001", "sensor", Set.of("read")));
    deviceRepository.save(new Device("Device-002", "actuator", Set.of("write")));
    deviceRepository.save(new Device("Device-003", "sensor", Set.of("read", "write")));
    
    Pageable pageable = PageRequest.of(0, 2);
    List<Device> firstPage = deviceRepository.findFirstPage(pageable);
    
    assertThat(firstPage).hasSize(2);
    assertThat(firstPage.get(0).getId()).isLessThan(firstPage.get(1).getId());
}

@Test
void shouldFindNextPageAfterLastId() {
    Device saved1 = deviceRepository.save(new Device("Device-001", "sensor", Set.of("read")));
    Device saved2 = deviceRepository.save(new Device("Device-002", "actuator", Set.of("write")));
    Device saved3 = deviceRepository.save(new Device("Device-003", "sensor", Set.of("read", "write")));
    
    Pageable pageable = PageRequest.of(0, 2);
    List<Device> nextPage = deviceRepository.findNextPage(saved1.getId(), pageable);
    
    assertThat(nextPage).hasSize(2);
    assertThat(nextPage).extracting(Device::getId)
            .containsExactly(saved2.getId(), saved3.getId());
}

Best Practices

Always Include a Unique Column

// Good - includes unique id
ORDER BY score DESC, id DESC

// Bad - score might not be unique
ORDER BY score DESC

Validate Page Size

public PageResult<DeviceDto> getDevices(Long lastId, int pageSize) {
    if (pageSize < 1 || pageSize > 100) {
        throw new IllegalArgumentException("Page size must be between 1 and 100");
    }
    // ... rest of implementation
}

Document Cursor Parameters

@GetMapping
@Operation(summary = "Get devices with pagination")
public PageResult<DeviceDto> getDevices(
    @Parameter(description = "ID of the last device from previous page")
    @RequestParam(required = false) Long lastId,
    @Parameter(description = "Number of items per page (1-100)")
    @RequestParam(defaultValue = "10") int pageSize) {
    return deviceService.getDevices(lastId, pageSize);
}

Handle Edge Cases

  • Empty result sets
  • Last page (hasMore = false)
  • Invalid cursor values
  • Concurrent modifications

Repository Integration Testing Pattern

Overview

Every repository must have an integration test that verifies database operations against a real PostgreSQL instance using Testcontainers.

Naming Convention

  • Test class name: <RepositoryName>IT
  • Example: JpaDeviceRepositoryJpaDeviceRepositoryIT

Base Class

All repository integration tests must extend the RepositoryIT abstract base class.

What RepositoryIT Provides

  • Shared PostgreSQL container: Uses Testcontainers with PostgreSQL 17 Alpine
  • Separate from docker-maven-plugin: Independent container lifecycle from application integration tests
  • @DataJpaTest configuration: Configures only the JPA layer for focused testing
  • @ServiceConnection: Spring Boot 3.1+ feature that automatically configures datasource properties from the container
  • Manual lifecycle management: Container is started in static block and reused across all test classes
  • Flyway migrations: Database schema is created using production Flyway migrations

Container Sharing and Lifecycle

The PostgreSQL container is managed manually with .withReuse(true):

  • One container instance is shared across all repository integration test classes
  • Container starts once in the static initializer block when RepositoryIT is first loaded
  • Container is reused across all test classes (via Testcontainers reuse feature)
  • Improves test performance by avoiding repeated container startup
  • Each test class still gets a clean database state via @BeforeEach cleanup
  • @ServiceConnection eliminates the need for manual @DynamicPropertySource configuration
  • Note: The container is NOT managed by @Testcontainers annotation to avoid lifecycle conflicts when multiple test classes run in parallel

Test Structure

Basic Template

class JpaDeviceRepositoryIT extends RepositoryIT {
    
    @Autowired
    private JpaDeviceRepository deviceRepository;
    
    @BeforeEach
    void setUp() {
        deviceRepository.deleteAll();
    }
    
    @Test
    void shouldSaveAndFindDeviceById() {
        Device device = new Device("Sensor-001", "temperature", Set.of("read"));
        Device savedDevice = deviceRepository.save(device);
        
        Optional<Device> foundDevice = deviceRepository.findById(savedDevice.getId());
        assertThat(foundDevice).isPresent();
        assertThat(foundDevice.get().getName()).isEqualTo("Sensor-001");
    }
}

Required Elements

1. Extend RepositoryIT

class JpaDeviceRepositoryIT extends RepositoryIT {

2. Inject Repository

@Autowired
private JpaDeviceRepository deviceRepository;

3. Clean Up Between Tests

@BeforeEach
void setUp() {
    deviceRepository.deleteAll();
}

What to Test

CRUD Operations

  • Create: Save new entities and verify they're persisted
  • Read: Find by ID, find all, custom queries
  • Update: Modify entities and verify changes are saved
  • Delete: Remove entities and verify they're gone

Custom Query Methods

Test all custom @Query methods, especially:

  • Pagination queries (first page, next page)
  • Existence checks (existsByXxx)
  • Complex filters and joins
  • Sorting behavior

Edge Cases

  • Empty results (no data found)
  • Empty collections (e.g., empty capabilities)
  • Non-existent IDs
  • Boundary conditions for pagination

Example: Comprehensive Test Coverage

class JpaDeviceRepositoryIT extends RepositoryIT {
    
    @Autowired
    private JpaDeviceRepository deviceRepository;
    
    @BeforeEach
    void setUp() {
        deviceRepository.deleteAll();
    }
    
    @Test
    void shouldSaveAndFindDeviceById() {
        Device device = new Device("Sensor-001", "temperature", Set.of("read", "write"));
        Device savedDevice = deviceRepository.save(device);
		
		assertThat(savedDevice.getId()).isNotNull();
		assertThat(deviceRepository.findById(savedDevice.getId())).isPresent();
    }
    
    @Test
    void shouldFindAllDevices() {
        deviceRepository.save(new Device("Device-001", "sensor", Set.of("read")));
        deviceRepository.save(new Device("Device-002", "actuator", Set.of("write")));

		assertThat(deviceRepository.findAll()).hasSize(2);
    }
    
    @Test
    void shouldCheckIfDeviceExistsByName() {
		var device = new Device("UniqueDevice", "sensor", Set.of("read"));
		deviceRepository.save(device);

		assertThat(deviceRepository.existsByName(device.getName())).isTrue();
		assertThat(deviceRepository.existsByName("NonExistentDevice")).isFalse();
    }
    
    @Test
    void shouldFindFirstPageOrderedById() {
        deviceRepository.save(new Device("Device-001", "sensor", Set.of("read")));
        deviceRepository.save(new Device("Device-002", "actuator", Set.of("write")));
        deviceRepository.save(new Device("Device-003", "sensor", Set.of("read", "write")));
        
        Pageable pageable = PageRequest.of(0, 2);
        List<Device> firstPage = deviceRepository.findFirstPage(pageable);
        
        assertThat(firstPage).hasSize(2);
        assertThat(firstPage.get(0).getId()).isLessThan(firstPage.get(1).getId());
    }
    
    @Test
    void shouldFindNextPageAfterLastId() {
        Device saved1 = deviceRepository.save(new Device("Device-001", "sensor", Set.of("read")));
        Device saved2 = deviceRepository.save(new Device("Device-002", "actuator", Set.of("write")));
        Device saved3 = deviceRepository.save(new Device("Device-003", "sensor", Set.of("read", "write")));
        
        Pageable pageable = PageRequest.of(0, 2);
        List<Device> nextPage = deviceRepository.findNextPage(saved1.getId(), pageable);
        
        assertThat(nextPage).hasSize(2);
        assertThat(nextPage).extracting(Device::getId)
                .containsExactly(saved2.getId(), saved3.getId());
    }
    
    @Test
    void shouldDeleteDevice() {
        Device savedDevice = deviceRepository.save(new Device("ToDelete", "sensor", Set.of("read")));
        
        deviceRepository.deleteById(savedDevice.getId());
        
        assertThat(deviceRepository.findById(savedDevice.getId())).isEmpty();
    }
    
    @Test
    void shouldUpdateDevice() {
        Device device = deviceRepository.save(new Device("Original", "sensor", Set.of("read")));
        
        device.setName("Updated");
        device.setType("actuator");
        Device updatedDevice = deviceRepository.save(device);
        
        assertThat(updatedDevice.getName()).isEqualTo(device.getName());
        assertThat(updatedDevice.getType()).isEqualTo(device.getType());
    }
    
    @Test
    void shouldHandleEmptyCapabilities() {
        Device device = deviceRepository.save(new Device("NoCapabilities", "sensor", Set.of()));
        
        Optional<Device> foundDevice = deviceRepository.findById(device.getId());
        assertThat(foundDevice).isPresent();
        assertThat(foundDevice.get().getCapabilities()).isEmpty();
    }
    
    @Test
    void shouldReturnEmptyListWhenNoDevicesExist() {
		assertThat(deviceRepository.findAll()).isEmpty();
    }
    
    @Test
    void shouldReturnEmptyOptionalWhenDeviceNotFound() {
		assertThat(deviceRepository.findById(999L)).isEmpty();
    }
}

Running Tests

Single Repository Test

mvn verify -Dit.test=JpaDeviceRepositoryIT

All Integration Tests

mvn verify

Best Practices

Data Isolation

  • Always use @BeforeEach with repository.deleteAll() to ensure clean state
  • Don't rely on test execution order
  • Each test should be independent

Assertions

  • Use AssertJ's fluent assertions (assertThat)
  • Test both positive and negative cases
  • Verify not just existence but also data correctness

Performance

  • Use static container (default in RepositoryIT) for better performance
  • Avoid unnecessary data setup in tests
  • Keep test data minimal but sufficient

Naming

  • Use descriptive test method names: shouldDoSomethingWhenCondition
  • Example: shouldReturnEmptyListWhenNoDevicesExist
  • Test names should read like documentation

Troubleshooting

Container Not Starting

  • Check Docker is running
  • Verify network connectivity
  • Check container logs in test output

Flyway Migration Errors

  • Ensure migrations are in src/main/resources/db/migration/
  • Verify migration file naming: V<timestamp>__description.sql
  • Check migration SQL syntax

Test Failures

  • Verify @BeforeEach cleanup is running
  • Check for data dependencies between tests
  • Review Hibernate SQL logs in test output

Service Unit Testing

This document describes the patterns and guidelines for writing unit tests for service classes in the Spring Boot Kotlin template.

Overview

Every service class MUST have a corresponding unit test to ensure business logic is correct and maintainable. Service tests are pure unit tests that use mocks for all dependencies, allowing fast execution and focused testing of service logic.

Test Class Naming

  • Pattern: <ServiceClassName>Test.kt
  • Examples:
    • DeviceServiceTest.kt for DeviceService
    • TaskAssignmentServiceTest.kt for TaskAssignmentService

Test Structure

Dependencies and Mocking

  • Use MockK or Mockito for mocking dependencies
  • Mock all service dependencies (repositories, other services, external clients)
  • Use constructor injection to provide mocks to the service under test

Clock Injection for Time-Dependent Logic

When services have time-dependent logic, inject a Clock instance to enable deterministic testing:

class TaskAssignmentServiceTest {
    private val repository: TaskAssignmentRepository = mockk()
    private val clock: Clock = Clock.fixed(Instant.parse("2024-01-15T10:00:00Z"), ZoneId.of("UTC"))
    private val service = TaskAssignmentService(repository, clock)
    
    @Test
    fun `findTaskHistory should return tasks within date range`() {
        // Arrange
        every { repository.findByMattermostUserIdAndDateRange(any(), any(), any()) } returns listOf(task)
        
        // Act
        val result = service.findTaskHistory(userId, days = 14)
        
        // Assert
        assertThat(result).hasSize(1)
        verify { repository.findByMattermostUserIdAndDateRange(userId, startDate, endDate) }
    }
}

Test Coverage Requirements

Test all public methods with various scenarios:

1. Happy Path

  • Test the normal, expected flow with valid inputs
  • Verify correct return values and side effects

2. Edge Cases

  • Empty lists or collections
  • Null values (when applicable)
  • Boundary conditions (min/max values, limits)
  • Single vs. multiple items

3. Error Cases

  • Exceptions thrown by dependencies
  • Validation failures
  • Business rule violations
  • Resource not found scenarios

Test Function Naming

Use descriptive test function names with backticks following the pattern:

fun `methodName should do something when condition`()

Examples:

fun `createDevice should save device when valid input provided`()
fun `findDeviceById should throw exception when device not found`()
fun `updateDevice should update only changed fields`()
fun `deleteDevice should not delete when device has active assignments`()

Assertions

  • Always use AssertJ for assertions (see main AGENTS.md for AssertJ patterns)
  • Verify mock interactions using verify() to ensure dependencies are called correctly
  • Use assertThat() for fluent, readable assertions

Example Test Class

import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import java.time.Clock
import java.time.Instant
import java.time.ZoneId

class DeviceServiceTest {
    
    private val deviceRepository: JpaDeviceRepository = mockk()
    private val clock: Clock = Clock.fixed(Instant.parse("2024-01-15T10:00:00Z"), ZoneId.of("UTC"))
    private val service = DeviceService(deviceRepository, clock)
    
    @Test
    fun `findAll should return all devices`() {
        // Arrange
        val devices = listOf(
            createDevice(id = 1, name = "Device 1"),
            createDevice(id = 2, name = "Device 2")
        )
        every { deviceRepository.findAll() } returns devices
        
        // Act
        val result = service.findAll()
        
        // Assert
        assertThat(result).hasSize(2)
        assertThat(result).containsExactlyInAnyOrder(devices[0], devices[1])
        verify(exactly = 1) { deviceRepository.findAll() }
    }
    
    @Test
    fun `findById should return device when found`() {
        // Arrange
        val device = createDevice(id = 1, name = "Device 1")
        every { deviceRepository.findById(1) } returns Optional.of(device)
        
        // Act
        val result = service.findById(1)
        
        // Assert
        assertThat(result).isEqualTo(device)
        verify { deviceRepository.findById(1) }
    }
    
    @Test
    fun `findById should throw exception when device not found`() {
        // Arrange
        every { deviceRepository.findById(999) } returns Optional.empty()
        
        // Act & Assert
        assertThrows<DeviceNotFoundException> {
            service.findById(999)
        }
        verify { deviceRepository.findById(999) }
    }
    
    @Test
    fun `create should save device with current timestamp`() {
        // Arrange
        val device = createDevice(name = "New Device")
        val expectedTimestamp = clock.instant()
        every { deviceRepository.save(any()) } returns device
        
        // Act
        val result = service.create(device)
        
        // Assert
        assertThat(result).isEqualTo(device)
        verify { deviceRepository.save(match { it.createdAt == expectedTimestamp }) }
    }
    
    @Test
    fun `delete should remove device when exists`() {
        // Arrange
        val device = createDevice(id = 1)
        every { deviceRepository.findById(1) } returns Optional.of(device)
        every { deviceRepository.delete(device) } returns Unit
        
        // Act
        service.delete(1)
        
        // Assert
        verify { deviceRepository.findById(1) }
        verify { deviceRepository.delete(device) }
    }
    
    private fun createDevice(
        id: Long = 1,
        name: String = "Test Device",
        type: String = "sensor"
    ) = Device(id = id, name = name, type = type)
}

Best Practices

  1. Isolation: Each test should be independent and not rely on other tests
  2. Arrange-Act-Assert: Structure tests with clear sections for setup, execution, and verification
  3. One assertion per test: Focus each test on a single behavior (though multiple assertThat() calls for the same result are fine)
  4. Mock verification: Always verify that mocked dependencies are called with expected parameters
  5. Test data helpers: Create helper functions (like createDevice()) to build test data consistently
  6. Avoid reflection: Never use reflection to set entity fields in tests (see ENTITY_TEST_DATA.md for alternatives)

Related Documentation

  • ENTITY_TEST_DATA.md - Test subclass pattern for creating entity test data
  • REPOSITORY_TESTING.md - Repository integration testing patterns
  • JSON-MODEL-TESTING.md - Testing JSON marshalling/unmarshalling
  • AGENTS.md - Main project guidelines including AssertJ assertion patterns
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment