Skip to content

Instantly share code, notes, and snippets.

@sflandergan
Last active February 1, 2026 16:19
Show Gist options
  • Select an option

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

Select an option

Save sflandergan/fb143432c6ad2c3c22cf874ec1c610a1 to your computer and use it in GitHub Desktop.
AGENTS.md Template for Spring Boot Java

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 methods: Each method does one thing well
    • Decompose long methods: Break into smaller, well-named helper methods
    • Orchestrating methods: Main method reads like a table of contents, delegating to helpers
    • Extract complex conditions: Long boolean expressions become well-named methods
    • Descriptive names: Method names clearly express intent
  • Use comments and JavaDoc sparsely - prefer self-documenting code with clear naming
  • Comments should explain "why" not "what"
  • Document class purpose when helpful, but well-named methods/parameters should be self-explanatory
  • Clean up unused imports after code changes
  • Only include imports actually used
  • Organize imports: standard library, third-party, project imports
  • 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

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 be package-protected (no access modifier) to enforce they are only accessed within their feature package
  • DTOs should only be used inside the Rest Controller layer
  • Services should work with the entity model

Java and Spring Boot Usage

  • Use Java 21 or later features when applicable (e.g., records, sealed classes, pattern matching)
  • Use var when assigning a variable to a constructor call
  • Don't use var when you can directly assign to a field
  • Leverage Spring Boot 3.x features and best practices
  • Use Spring Boot starters for quick project setup and dependency management
  • When adding new dependencies - see DEPENDENCY-MANAGEMENT.md for version property management

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

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)
  • 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 method in the DTOs to convert the DTO to an entity if needed
  • When working with JSON DTOs - see JSON-MODEL-TESTING.md for testing JSON serialization/deserialization

Pagination

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

Configuration and Properties

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

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

Timeout Configuration

  • Always set timeouts to prevent indefinite blocking
  • Configure three types of timeouts:
    • Connection timeout: Time to establish connection (e.g., 5 seconds)
    • Read timeout: Time to read response (e.g., 10 seconds)
    • Response timeout: Overall request timeout (e.g., 15 seconds)
  • Make timeout values configurable via @ConfigurationProperties
  • Example configuration in application.yaml:
    external-service:
      base-url: https://api.example.com
      timeout:
        connection: 5s
        read: 10s
        response: 15s

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

Best Practices

  • Create a dedicated client class per external service (e.g., WeatherApiClient)
  • Place client classes in the infrastructure layer within the feature package
  • Use @ConfigurationProperties for external service configuration
  • Add retry logic using Resilience4j @Retry annotation when appropriate
  • Test HTTP clients using WireMock or MockWebServer in integration tests
  • Document external service dependencies in feature package documentation

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

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
  • See ENTITY_TEST_DATA.md for the recommended test subclass pattern and detailed examples

Controller Tests

  • Every controller must have a controller test - see CONTROLLER_TESTING.md for comprehensive patterns
  • Test class name: <ControllerName>Test
  • Use @WebMvcTest to test only the web layer
  • Use @MockBean to mock service dependencies
  • Test all endpoints (GET, POST, PUT, DELETE)
  • Test validation (400 Bad Request for invalid input)
  • Test error handling (404 Not Found, 409 Conflict, etc.)
  • Test pagination parameters
  • Verify service method calls using verify()
  • Use descriptive test method names: methodName_shouldDoSomething_whenCondition
  • Example: createDevice_shouldReturn400WhenNameIsBlank

Service Tests

  • Every service must have a unit test - see SERVICE_TESTING.md for comprehensive patterns
  • Test class name: <ServiceName>Test
  • Use Mockito for mocking dependencies
  • Test all public methods with happy path, edge cases, and error scenarios
  • Use descriptive test method names: methodName_shouldDoSomething_whenCondition

Repository Integration Tests

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

JSON Model Classes

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

Logging and Monitoring

  • Use SLF4J for logging.
  • 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
  • Use Spring Boot Actuator for application monitoring and metrics.
  • Use trace IDs to follow requests - leverage Spring Boot's built-in tracing (Micrometer Tracing) to track the flow of a single request across multiple services and log statements
  • Include trace IDs in log output using MDC (Mapped Diagnostic Context) or Spring Boot's automatic trace ID propagation
  • Trace IDs allow debugging and monitoring without exposing user information

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 [email protected] 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 Java 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.java
  • Examples:
    • DeviceControllerTest.java for DeviceController
    • UserControllerTest.java for UserController

Test Annotation

Use @WebMvcTest to test only the web layer:

@WebMvcTest(DeviceController.class)
class DeviceControllerTest {
    @Autowired
    private MockMvc mockMvc;
    
    @MockBean
    private 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 DeviceService deviceService;

@MockBean
private UserService userService;

Test Coverage Requirements

Test all aspects of controller behavior:

1. All Endpoints

Test every HTTP endpoint with various scenarios:

@Test
void getAllDevices_shouldReturnListOfDevices() throws Exception {
    // Arrange
    var devices = List.of(createDevice(1L), createDevice(2L));
    when(deviceService.findAll()).thenReturn(devices);
    
    // Act & Assert
    mockMvc.perform(get("/api/devices"))
        .andExpect(status().isOk())
        .andExpect(content().contentType(MediaType.APPLICATION_JSON))
        .andExpect(jsonPath("$").isArray())
        .andExpect(jsonPath("$.length()").value(2));
    
    verify(deviceService, times(1)).findAll();
}

@Test
void getDeviceById_shouldReturnDevice_whenFound() throws Exception {
    // Arrange
    var device = createDevice(1L, "Device 1");
    when(deviceService.findById(1L)).thenReturn(device);
    
    // Act & Assert
    mockMvc.perform(get("/api/devices/1"))
        .andExpect(status().isOk())
        .andExpect(jsonPath("$.id").value(1))
        .andExpect(jsonPath("$.name").value("Device 1"));
    
    verify(deviceService).findById(1L);
}

@Test
void createDevice_shouldCreateDevice_whenValidInput() throws Exception {
    // Arrange
    var deviceDto = new DeviceDto("New Device", "sensor");
    var createdDevice = createDevice(1L, "New Device");
    when(deviceService.create(any())).thenReturn(createdDevice);
    
    // Act & Assert
    mockMvc.perform(post("/api/devices")
            .contentType(MediaType.APPLICATION_JSON)
            .content(objectMapper.writeValueAsString(deviceDto)))
        .andExpect(status().isCreated())
        .andExpect(jsonPath("$.id").value(1))
        .andExpect(jsonPath("$.name").value("New Device"));
    
    verify(deviceService).create(any());
}

@Test
void deleteDevice_shouldRemoveDevice() throws Exception {
    // Arrange
    doNothing().when(deviceService).delete(1L);
    
    // Act & Assert
    mockMvc.perform(delete("/api/devices/1"))
        .andExpect(status().isNoContent());
    
    verify(deviceService).delete(1L);
}

2. Input Validation

Test validation rules for request bodies and parameters:

@Test
void createDevice_shouldReturn400_whenNameIsBlank() throws Exception {
    // Arrange
    var invalidDto = new DeviceDto("", "sensor");
    
    // Act & Assert
    mockMvc.perform(post("/api/devices")
            .contentType(MediaType.APPLICATION_JSON)
            .content(objectMapper.writeValueAsString(invalidDto)))
        .andExpect(status().isBadRequest());
    
    verify(deviceService, never()).create(any());
}

@Test
void createDevice_shouldReturn400_whenRequiredFieldIsMissing() throws Exception {
    // Arrange
    var invalidJson = "{\"name\": \"Device\"}"; // missing 'type'
    
    // Act & Assert
    mockMvc.perform(post("/api/devices")
            .contentType(MediaType.APPLICATION_JSON)
            .content(invalidJson))
        .andExpect(status().isBadRequest());
}

3. Error Handling

Test exception handling and error responses:

@Test
void getDeviceById_shouldReturn404_whenDeviceNotFound() throws Exception {
    // Arrange
    when(deviceService.findById(999L)).thenThrow(new DeviceNotFoundException("Device not found"));
    
    // Act & Assert
    mockMvc.perform(get("/api/devices/999"))
        .andExpect(status().isNotFound());
    
    verify(deviceService).findById(999L);
}

@Test
void createDevice_shouldReturn409_whenDeviceAlreadyExists() throws Exception {
    // Arrange
    var deviceDto = new DeviceDto("Existing Device", "sensor");
    when(deviceService.create(any())).thenThrow(new DeviceAlreadyExistsException("Device already exists"));
    
    // Act & Assert
    mockMvc.perform(post("/api/devices")
            .contentType(MediaType.APPLICATION_JSON)
            .content(objectMapper.writeValueAsString(deviceDto)))
        .andExpect(status().isConflict());
}

@Test
void createDevice_shouldReturn500_whenUnexpectedErrorOccurs() throws Exception {
    // Arrange
    var deviceDto = new DeviceDto("Device", "sensor");
    when(deviceService.create(any())).thenThrow(new RuntimeException("Unexpected error"));
    
    // Act & Assert
    mockMvc.perform(post("/api/devices")
            .contentType(MediaType.APPLICATION_JSON)
            .content(objectMapper.writeValueAsString(deviceDto)))
        .andExpect(status().isInternalServerError());
}

4. Pagination Parameters

Test pagination query parameters when applicable:

@Test
void getAllDevices_shouldSupportPaginationParameters() throws Exception {
    // Arrange
    var page = Page.of(List.of(createDevice(1L)), 0, 10, 1);
    when(deviceService.findAll(anyInt(), anyInt())).thenReturn(page);
    
    // Act & Assert
    mockMvc.perform(get("/api/devices?page=0&size=10"))
        .andExpect(status().isOk())
        .andExpect(jsonPath("$.content").isArray())
        .andExpect(jsonPath("$.page").value(0))
        .andExpect(jsonPath("$.size").value(10))
        .andExpect(jsonPath("$.totalElements").value(1));
    
    verify(deviceService).findAll(0, 10);
}

@Test
void getAllDevices_shouldUseDefaultPagination_whenParametersNotProvided() throws Exception {
    // Arrange
    var page = Page.of(List.of(createDevice(1L)), 0, 20, 1);
    when(deviceService.findAll(anyInt(), anyInt())).thenReturn(page);
    
    // Act & Assert
    mockMvc.perform(get("/api/devices"))
        .andExpect(status().isOk());
    
    verify(deviceService).findAll(0, 20); // default values
}

Test Function Naming

Use descriptive test method names following the pattern:

void methodName_shouldBehavior_whenCondition()

Examples:

void getAllDevices_shouldReturnEmptyList_whenNoDevicesExist()
void createDevice_shouldReturn400_whenNameExceedsMaxLength()
void updateDevice_shouldUpdateDevice_whenValidInputProvided()
void deleteDevice_shouldReturn404_whenDeviceNotFound()

Verifying Service Calls

Always verify that service methods are called with expected parameters:

verify(deviceService, times(1)).findAll();
verify(deviceService).findById(1L);
verify(deviceService, never()).create(any()); // should not be called

MockMvc Usage

Use MockMvc's fluent API for readable tests:

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

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

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

// DELETE request
mockMvc.perform(delete("/api/devices/1"))
    .andExpect(status().isNoContent());

JSON Assertions

Use jsonPath for asserting JSON response content:

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

Complete Example

import com.fasterxml.jackson.databind.ObjectMapper;
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.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@WebMvcTest(DeviceController.class)
class DeviceControllerTest {
    
    @Autowired
    private MockMvc mockMvc;
    
    @MockBean
    private DeviceService deviceService;
    
    @Autowired
    private ObjectMapper objectMapper;
    
    @Test
    void getAllDevices_shouldReturnListOfDevices() throws Exception {
        // Arrange
        var devices = List.of(
            createDevice(1L, "Device 1"),
            createDevice(2L, "Device 2")
        );
        when(deviceService.findAll()).thenReturn(devices);
        
        // Act & Assert
        mockMvc.perform(get("/api/devices"))
            .andExpect(status().isOk())
            .andExpect(content().contentType(MediaType.APPLICATION_JSON))
            .andExpect(jsonPath("$").isArray())
            .andExpect(jsonPath("$.length()").value(2))
            .andExpect(jsonPath("$[0].id").value(1))
            .andExpect(jsonPath("$[0].name").value("Device 1"));
        
        verify(deviceService, times(1)).findAll();
    }
    
    @Test
    void getDeviceById_shouldReturn404_whenDeviceNotFound() throws Exception {
        // Arrange
        when(deviceService.findById(999L)).thenThrow(new DeviceNotFoundException("Device not found"));
        
        // Act & Assert
        mockMvc.perform(get("/api/devices/999"))
            .andExpect(status().isNotFound());
        
        verify(deviceService).findById(999L);
    }
    
    @Test
    void createDevice_shouldCreateDevice_whenValidInput() throws Exception {
        // Arrange
        var deviceDto = new DeviceDto("New Device", "sensor");
        var createdDevice = createDevice(1L, "New Device", "sensor");
        when(deviceService.create(any())).thenReturn(createdDevice);
        
        // Act & Assert
        mockMvc.perform(post("/api/devices")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(deviceDto)))
            .andExpect(status().isCreated())
            .andExpect(jsonPath("$.id").value(1))
            .andExpect(jsonPath("$.name").value("New Device"));
        
        verify(deviceService).create(any());
    }
    
    @Test
    void createDevice_shouldReturn400_whenNameIsBlank() throws Exception {
        // Arrange
        var invalidDto = new DeviceDto("", "sensor");
        
        // Act & Assert
        mockMvc.perform(post("/api/devices")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(invalidDto)))
            .andExpect(status().isBadRequest());
        
        verify(deviceService, never()).create(any());
    }
    
    @Test
    void deleteDevice_shouldRemoveDevice() throws Exception {
        // Arrange
        doNothing().when(deviceService).delete(1L);
        
        // Act & Assert
        mockMvc.perform(delete("/api/devices/1"))
            .andExpect(status().isNoContent());
        
        verify(deviceService).delete(1L);
    }
    
    private Device createDevice(Long id) {
        return createDevice(id, "Test Device", "sensor");
    }
    
    private Device createDevice(Long id, String name) {
        return createDevice(id, name, "sensor");
    }
    
    private Device createDevice(Long id, String name, String type) {
        return new Device(id, name, 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
  9. Use Mockito: Use @MockBean for mocking and Mockito's when(), verify(), times(), never() for stubbing and verification

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 -->
    <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>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, commons-lang3)
  • 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)
  • Jackson modules managed by parent (e.g., jackson-databind, jackson-datatype-jsr310)
  • 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>
    <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>com.squareup.okhttp3</groupId>
        <artifactId>okhttp</artifactId>
        <version>${okhttp.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>com.fasterxml.jackson.core</groupId>
        <artifactId>jackson-databind</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 (POJOs or records 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 Java properties (camelCase)
  • Validate Jackson annotations (@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/java/
  • Name test files as {ModelClassName}Test.java
  • Example: MattermostPost.javaMattermostPostTest.java

Required Tests

Each model class should have at least two test methods:

  1. Marshalling Test: marshal{ModelName}_shouldSerializeToJsonCorrectly

    • Creates a model instance with representative data
    • Serializes to JSON string
    • Compares against expected JSON structure
  2. Unmarshalling Test: unmarshalJson_shouldDeserializeTo{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.databind.ObjectMapper;
import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

class YourModelTest {

    private final ObjectMapper objectMapper = new ObjectMapper();

    @Test
    void marshalYourModel_shouldSerializeToJsonCorrectly() throws Exception {
        var model = new YourModel(
            "123",
            "value",
            42
        );
        
        var json = objectMapper.writeValueAsString(model);
        
        var expectedJson = """
            {
                "id":"123",
                "property_name":"value",
                "another_property":42
            }
            """;
        
        assertThat(objectMapper.readTree(json))
            .isEqualTo(objectMapper.readTree(expectedJson));
    }

    @Test
    void unmarshalJson_shouldDeserializeToYourModelCorrectly() throws Exception {
        var json = """
            {
                "id":"123",
                "property_name":"value",
                "another_property":42
            }
            """;
        
        var expected = new YourModel(
            "123",
            "value",
            42
        );
        
        var result = objectMapper.readValue(json, YourModel.class);
        
        assertThat(result).isEqualTo(expected);
    }
}

Best Practices

JSON Formatting

  • Multi-line format: Always format JSON with one attribute per line for readability
  • Use text blocks: Java 15+ text blocks automatically handle indentation
  • Consistent indentation: Use tabs or spaces consistently (prefer tabs)

Good Example:

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

Bad Example:

var expectedJson = "{\"id\":\"123\",\"property_name\":\"value\",\"nested\":{\"key\":\"value\"}}";

Field Naming

  • Use @JsonProperty on fields or record components to map JSON field names
    • This ensures proper serialization/deserialization
    • Example: @JsonProperty("user_id") String userId maps snake_case to camelCase
  • Verify that @JsonProperty annotations correctly map snake_case JSON fields to camelCase Java 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
void marshalModel_shouldIncludeComputedProperties() throws Exception {
    var model = new Model("D");
    
    var json = objectMapper.writeValueAsString(model);
    
    var expectedJson = """
        {
            "type":"D",
            "isDirectMessage":true
        }
        """;
    
    assertThat(objectMapper.readTree(json))
        .isEqualTo(objectMapper.readTree(expectedJson));
}

@Test
void unmarshalJson_shouldComputeProperties() throws Exception {
    var json = "{\"type\":\"D\"}";
    
    var result = objectMapper.readValue(json, Model.class);
    
    assertThat(result.getType()).isEqualTo("D");
    assertThat(result.isDirectMessage()).isTrue();
}

Nested Objects and Collections

For complex nested structures:

@Test
void marshalParent_shouldIncludeNestedStructures() throws Exception {
    var model = new Parent(
        "1",
        List.of(
            new Child("child1"),
            new Child("child2")
        ),
        Map.of("key1", "value1", "key2", "value2")
    );
    
    var json = objectMapper.writeValueAsString(model);
    
    var expectedJson = """
        {
            "id":"1",
            "children":[
                {"name":"child1"},
                {"name":"child2"}
            ],
            "metadata":{
                "key1":"value1",
                "key2":"value2"
            }
        }
        """;
    
    assertThat(objectMapper.readTree(json))
        .isEqualTo(objectMapper.readTree(expectedJson));
}

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)
assertThat(objectMapper.readTree(json))
    .isEqualTo(objectMapper.readTree(expectedJson));

// Strict comparison (use when order matters)
assertThat(json).isEqualTo(expectedJson);

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 "UnrecognizedPropertyException"

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

Solution: Verify JSON field names match the annotations exactly

public record User(
    @JsonProperty("user_id")  // Must match JSON: "user_id"
    String userId
) {}

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
public class Model {
    private String type;
    
    @JsonIgnore
    public boolean isSpecial() {
        return "S".equals(type);
    }
}

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
assertThat(objectMapper.readTree(json))
    .isEqualTo(objectMapper.readTree(expectedJson));

// Bad - sensitive to field order
assertThat(json).isEqualTo(expectedJson);

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.java - Basic model with multiple fields
  • MattermostUserTest.java - Model with optional fields
  • MattermostChannelTest.java - Model with computed properties
  • MattermostWebSocketEventTest.java - 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 Java 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.java
  • Examples:
    • DeviceServiceTest.java for DeviceService
    • TaskAssignmentServiceTest.java for TaskAssignmentService

Test Structure

Dependencies and Mocking

  • Use 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 final TaskAssignmentRepository repository = mock(TaskAssignmentRepository.class);
    private final Clock clock = Clock.fixed(Instant.parse("2024-01-15T10:00:00Z"), ZoneId.of("UTC"));
    private final TaskAssignmentService service = new TaskAssignmentService(repository, clock);
    
    @Test
    void findTaskHistory_shouldReturnTasksWithinDateRange() {
        // Arrange
        when(repository.findByMattermostUserIdAndDateRange(any(), any(), any())).thenReturn(List.of(task));
        
        // Act
        var result = service.findTaskHistory(userId, 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 method names following the pattern:

void methodName_shouldDoSomething_whenCondition()

Examples:

void createDevice_shouldSaveDevice_whenValidInputProvided()
void findDeviceById_shouldThrowException_whenDeviceNotFound()
void updateDevice_shouldUpdateOnlyChangedFields()
void deleteDevice_shouldNotDelete_whenDeviceHasActiveAssignments()

Assertions

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

Example Test Class

import org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;

import java.time.Clock;
import java.time.Instant;
import java.time.ZoneId;
import java.util.List;
import java.util.Optional;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;

class DeviceServiceTest {
    
    private final JpaDeviceRepository deviceRepository = mock(JpaDeviceRepository.class);
    private final Clock clock = Clock.fixed(Instant.parse("2024-01-15T10:00:00Z"), ZoneId.of("UTC"));
    private final DeviceService service = new DeviceService(deviceRepository, clock);
    
    @Test
    void findAll_shouldReturnAllDevices() {
        // Arrange
        var devices = List.of(
            createDevice(1L, "Device 1"),
            createDevice(2L, "Device 2")
        );
        when(deviceRepository.findAll()).thenReturn(devices);
        
        // Act
        var result = service.findAll();
        
        // Assert
        assertThat(result).hasSize(2);
        assertThat(result).containsExactlyInAnyOrder(devices.get(0), devices.get(1));
        verify(deviceRepository, times(1)).findAll();
    }
    
    @Test
    void findById_shouldReturnDevice_whenFound() {
        // Arrange
        var device = createDevice(1L, "Device 1");
        when(deviceRepository.findById(1L)).thenReturn(Optional.of(device));
        
        // Act
        var result = service.findById(1L);
        
        // Assert
        assertThat(result).isEqualTo(device);
        verify(deviceRepository).findById(1L);
    }
    
    @Test
    void findById_shouldThrowException_whenDeviceNotFound() {
        // Arrange
        when(deviceRepository.findById(999L)).thenReturn(Optional.empty());
        
        // Act & Assert
        assertThatThrownBy(() -> service.findById(999L))
            .isInstanceOf(DeviceNotFoundException.class);
        verify(deviceRepository).findById(999L);
    }
    
    @Test
    void create_shouldSaveDeviceWithCurrentTimestamp() {
        // Arrange
        var device = createDevice("New Device");
        var expectedTimestamp = clock.instant();
        when(deviceRepository.save(any())).thenReturn(device);
        
        // Act
        var result = service.create(device);
        
        // Assert
        assertThat(result).isEqualTo(device);
        var captor = ArgumentCaptor.forClass(Device.class);
        verify(deviceRepository).save(captor.capture());
        assertThat(captor.getValue().getCreatedAt()).isEqualTo(expectedTimestamp);
    }
    
    @Test
    void delete_shouldRemoveDevice_whenExists() {
        // Arrange
        var device = createDevice(1L);
        when(deviceRepository.findById(1L)).thenReturn(Optional.of(device));
        doNothing().when(deviceRepository).delete(device);
        
        // Act
        service.delete(1L);
        
        // Assert
        verify(deviceRepository).findById(1L);
        verify(deviceRepository).delete(device);
    }
    
    private Device createDevice(Long id) {
        return createDevice(id, "Test Device", "sensor");
    }
    
    private Device createDevice(String name) {
        return createDevice(1L, name, "sensor");
    }
    
    private Device createDevice(Long id, String name) {
        return createDevice(id, name, "sensor");
    }
    
    private Device createDevice(Long id, String name, String type) {
        return new Device(id, name, 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