testing

star 0

Knowledge base for Spring Boot testing with Mockito, WireMock, and jOOQ

jo-pouradier By jo-pouradier schedule Updated 2/4/2026

name: testing description: Knowledge base for Spring Boot testing with Mockito, WireMock, and jOOQ license: MIT compatibility: opencode metadata: audience: backend-engineers workflow: testing

Testing Skill (Spring Boot + jOOQ)

Comprehensive knowledge for testing Spring Boot applications with jOOQ, using Mockito for unit tests and WireMock for external API mocking.

When to Use

  • Writing unit tests for services and repositories
  • Adding integration tests with real database (Testcontainers)
  • Mocking external APIs with WireMock
  • Testing REST controllers with MockMvc
  • Setting up test infrastructure for jOOQ

Testing Pyramid

        /\
       /  \      @SpringBootTest + WireMock (Few)
      /----\     - Full integration tests
     /      \    - Real DB with Testcontainers
    /--------\   @WebMvcTest / @DataJpaTest (Some)
   /          \  - Slice tests
  /------------\ - Controller or Repository only
 /              \ @ExtendWith(MockitoExtension.class) (Many)
/________________\ - Unit tests with mocks
                   - Fast, isolated

Ratio guideline: 70% unit / 20% slice / 10% integration

Test Structure Pattern

Arrange-Act-Assert (AAA)

@Test
void findById_WhenUserExists_ReturnsUser() {
    // Arrange
    UUID id = UUID.randomUUID();
    UsersRecord record = createUserRecord(id, "test@example.com");
    when(userRepository.findById(id)).thenReturn(Optional.of(record));
    when(userMapper.toResponse(record)).thenReturn(expectedResponse);

    // Act
    UserResponse result = userService.findById(id);

    // Assert
    assertThat(result.getEmail()).isEqualTo("test@example.com");
    verify(userRepository).findById(id);
}

Test Naming Convention

// Pattern: methodName_StateUnderTest_ExpectedBehavior

void findById_WhenUserExists_ReturnsUser()
void findById_WhenUserNotFound_ThrowsResourceNotFoundException()
void create_WhenEmailAlreadyExists_ThrowsDuplicateException()
void delete_WhenUserExists_DeletesSuccessfully()

Unit Testing with Mockito

Service Layer Unit Tests

@ExtendWith(MockitoExtension.class)
class UserServiceImplTest {

    @Mock
    private UserRepository userRepository;

    @Mock
    private UserMapper userMapper;

    @Mock
    private PasswordEncoder passwordEncoder;

    @InjectMocks
    private UserServiceImpl userService;

    @Test
    void findById_WhenUserExists_ReturnsUser() {
        // Arrange
        UUID id = UUID.randomUUID();
        UsersRecord record = new UsersRecord();
        record.setId(id);
        record.setEmail("test@example.com");
        record.setName("Test User");
        record.setStatus("ACTIVE");

        UserResponse expected = UserResponse.builder()
                .id(id)
                .email("test@example.com")
                .name("Test User")
                .build();

        when(userRepository.findById(id)).thenReturn(Optional.of(record));
        when(userMapper.toResponse(record)).thenReturn(expected);

        // Act
        UserResponse result = userService.findById(id);

        // Assert
        assertThat(result).isEqualTo(expected);
        assertThat(result.getEmail()).isEqualTo("test@example.com");
        verify(userRepository).findById(id);
        verify(userMapper).toResponse(record);
    }

    @Test
    void findById_WhenUserNotFound_ThrowsException() {
        // Arrange
        UUID id = UUID.randomUUID();
        when(userRepository.findById(id)).thenReturn(Optional.empty());

        // Act & Assert
        assertThatThrownBy(() -> userService.findById(id))
                .isInstanceOf(ResourceNotFoundException.class)
                .hasMessageContaining("User")
                .hasMessageContaining(id.toString());

        verify(userRepository).findById(id);
        verifyNoInteractions(userMapper);
    }

    @Test
    void create_WhenValidRequest_ReturnsCreatedUser() {
        // Arrange
        CreateUserRequest request = CreateUserRequest.builder()
                .email("new@example.com")
                .password("Password123")
                .name("New User")
                .build();

        UsersRecord inputRecord = new UsersRecord();
        UsersRecord savedRecord = new UsersRecord();
        savedRecord.setId(UUID.randomUUID());
        savedRecord.setEmail("new@example.com");

        UserResponse expected = UserResponse.builder()
                .id(savedRecord.getId())
                .email("new@example.com")
                .build();

        when(userRepository.existsByEmail("new@example.com")).thenReturn(false);
        when(userMapper.toRecord(request)).thenReturn(inputRecord);
        when(passwordEncoder.encode("Password123")).thenReturn("encoded_password");
        when(userRepository.insert(any(UsersRecord.class))).thenReturn(savedRecord);
        when(userMapper.toResponse(savedRecord)).thenReturn(expected);

        // Act
        UserResponse result = userService.create(request);

        // Assert
        assertThat(result.getEmail()).isEqualTo("new@example.com");
        verify(userRepository).existsByEmail("new@example.com");
        verify(passwordEncoder).encode("Password123");
        verify(userRepository).insert(any(UsersRecord.class));
    }

    @Test
    void create_WhenEmailExists_ThrowsDuplicateException() {
        // Arrange
        CreateUserRequest request = CreateUserRequest.builder()
                .email("existing@example.com")
                .password("Password123")
                .name("User")
                .build();

        when(userRepository.existsByEmail("existing@example.com")).thenReturn(true);

        // Act & Assert
        assertThatThrownBy(() -> userService.create(request))
                .isInstanceOf(DuplicateResourceException.class)
                .hasMessageContaining("email");

        verify(userRepository).existsByEmail("existing@example.com");
        verify(userRepository, never()).insert(any());
    }
}

Mockito Annotations Reference

@Mock                    // Creates a mock instance
@Spy                     // Wraps real object, can stub specific methods
@InjectMocks             // Injects mocks into the tested class
@Captor                  // Captures arguments passed to mocks
@MockBean                // Spring Boot: replaces bean in context with mock

Mockito Verification Patterns

// Verify method was called
verify(repository).findById(id);

// Verify method was called exactly N times
verify(repository, times(2)).save(any());

// Verify method was never called
verify(repository, never()).delete(any());

// Verify no more interactions after verified ones
verifyNoMoreInteractions(repository);

// Verify no interactions at all
verifyNoInteractions(mapper);

// Verify call order
InOrder inOrder = inOrder(repository, eventPublisher);
inOrder.verify(repository).save(any());
inOrder.verify(eventPublisher).publish(any());

// Capture arguments
@Captor
ArgumentCaptor<UsersRecord> recordCaptor;

verify(repository).insert(recordCaptor.capture());
UsersRecord captured = recordCaptor.getValue();
assertThat(captured.getEmail()).isEqualTo("test@example.com");

Mockito Stubbing Patterns

// Return value
when(repository.findById(id)).thenReturn(Optional.of(record));

// Return different values on consecutive calls
when(repository.count())
    .thenReturn(0L)
    .thenReturn(1L)
    .thenReturn(2L);

// Throw exception
when(repository.findById(any())).thenThrow(new DataAccessException("DB error") {});

// Answer - dynamic response based on input
when(repository.insert(any(UsersRecord.class))).thenAnswer(invocation -> {
    UsersRecord record = invocation.getArgument(0);
    record.setId(UUID.randomUUID());
    record.setCreatedAt(LocalDateTime.now());
    return record;
});

// Void methods
doNothing().when(repository).deleteById(any());
doThrow(new RuntimeException()).when(repository).deleteById(any());

// Argument matchers
when(repository.findById(any(UUID.class))).thenReturn(Optional.empty());
when(repository.findByEmail(eq("test@example.com"))).thenReturn(Optional.of(record));
when(repository.findByEmail(argThat(email -> email.contains("@")))).thenReturn(Optional.of(record));

Controller Testing with MockMvc

@WebMvcTest (Slice Test)

@WebMvcTest(UserController.class)
class UserControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private ObjectMapper objectMapper;

    @MockBean
    private UserService userService;

    @Test
    void getUser_WhenExists_Returns200() throws Exception {
        // Arrange
        UUID id = UUID.randomUUID();
        UserResponse response = UserResponse.builder()
                .id(id)
                .email("test@example.com")
                .name("Test User")
                .status(UserStatus.ACTIVE)
                .build();

        when(userService.findById(id)).thenReturn(response);

        // Act & Assert
        mockMvc.perform(get("/api/v1/users/{id}", id)
                        .contentType(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.id").value(id.toString()))
                .andExpect(jsonPath("$.email").value("test@example.com"))
                .andExpect(jsonPath("$.name").value("Test User"));

        verify(userService).findById(id);
    }

    @Test
    void getUser_WhenNotFound_Returns404() throws Exception {
        // Arrange
        UUID id = UUID.randomUUID();
        when(userService.findById(id))
                .thenThrow(new ResourceNotFoundException("User", "id", id));

        // Act & Assert
        mockMvc.perform(get("/api/v1/users/{id}", id)
                        .contentType(MediaType.APPLICATION_JSON))
                .andExpect(status().isNotFound())
                .andExpect(jsonPath("$.code").value("RESOURCE_NOT_FOUND"));
    }

    @Test
    void createUser_WithValidRequest_Returns201() throws Exception {
        // Arrange
        CreateUserRequest request = CreateUserRequest.builder()
                .email("new@example.com")
                .password("Password123")
                .name("New User")
                .build();

        UUID createdId = UUID.randomUUID();
        UserResponse response = UserResponse.builder()
                .id(createdId)
                .email("new@example.com")
                .name("New User")
                .build();

        when(userService.create(any(CreateUserRequest.class))).thenReturn(response);

        // Act & Assert
        mockMvc.perform(post("/api/v1/users")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(request)))
                .andExpect(status().isCreated())
                .andExpect(header().exists("Location"))
                .andExpect(jsonPath("$.id").value(createdId.toString()))
                .andExpect(jsonPath("$.email").value("new@example.com"));
    }

    @Test
    void createUser_WithInvalidEmail_Returns400() throws Exception {
        // Arrange
        CreateUserRequest request = CreateUserRequest.builder()
                .email("invalid-email")  // Invalid email format
                .password("Password123")
                .name("User")
                .build();

        // Act & Assert
        mockMvc.perform(post("/api/v1/users")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(request)))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.code").value("VALIDATION_ERROR"))
                .andExpect(jsonPath("$.details.email").exists());

        verifyNoInteractions(userService);
    }

    @Test
    void createUser_WithWeakPassword_Returns400() throws Exception {
        // Arrange
        CreateUserRequest request = CreateUserRequest.builder()
                .email("test@example.com")
                .password("weak")  // Too short, no uppercase/digit
                .name("User")
                .build();

        // Act & Assert
        mockMvc.perform(post("/api/v1/users")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(request)))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.details.password").exists());
    }

    @Test
    void deleteUser_WhenExists_Returns204() throws Exception {
        // Arrange
        UUID id = UUID.randomUUID();
        doNothing().when(userService).delete(id);

        // Act & Assert
        mockMvc.perform(delete("/api/v1/users/{id}", id))
                .andExpect(status().isNoContent());

        verify(userService).delete(id);
    }

    @Test
    void getUsers_WithPagination_Returns200() throws Exception {
        // Arrange
        Page<UserResponse> page = new PageImpl<>(
                List.of(
                        UserResponse.builder().id(UUID.randomUUID()).email("a@test.com").build(),
                        UserResponse.builder().id(UUID.randomUUID()).email("b@test.com").build()
                ),
                PageRequest.of(0, 20),
                2
        );

        when(userService.findAll(0, 20)).thenReturn(page);

        // Act & Assert
        mockMvc.perform(get("/api/v1/users")
                        .param("page", "0")
                        .param("size", "20"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.content").isArray())
                .andExpect(jsonPath("$.content.length()").value(2))
                .andExpect(jsonPath("$.totalElements").value(2));
    }
}

Testing with Security

@WebMvcTest(UserController.class)
@Import(SecurityConfig.class)
class UserControllerSecurityTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private UserService userService;

    @MockBean
    private JwtAuthenticationFilter jwtAuthFilter;

    @Test
    @WithMockUser(username = "admin", roles = {"ADMIN"})
    void adminEndpoint_WithAdminRole_Returns200() throws Exception {
        mockMvc.perform(get("/api/v1/admin/users"))
                .andExpect(status().isOk());
    }

    @Test
    @WithMockUser(username = "user", roles = {"USER"})
    void adminEndpoint_WithUserRole_Returns403() throws Exception {
        mockMvc.perform(get("/api/v1/admin/users"))
                .andExpect(status().isForbidden());
    }

    @Test
    void protectedEndpoint_WithoutAuth_Returns401() throws Exception {
        mockMvc.perform(get("/api/v1/users"))
                .andExpect(status().isUnauthorized());
    }
}

WireMock for External API Mocking

Setup with JUnit 5

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@WireMockTest(httpPort = 8089)
class ExternalApiIntegrationTest {

    @Autowired
    private PaymentService paymentService;  // Uses external payment API

    @Test
    void processPayment_WhenExternalApiSucceeds_ReturnsSuccess() {
        // Arrange - stub external API
        stubFor(post(urlEqualTo("/api/payments"))
                .withHeader("Content-Type", equalTo("application/json"))
                .withRequestBody(matchingJsonPath("$.amount", equalTo("100.00")))
                .willReturn(aResponse()
                        .withStatus(200)
                        .withHeader("Content-Type", "application/json")
                        .withBody("""
                            {
                                "transactionId": "txn_123456",
                                "status": "SUCCESS",
                                "amount": 100.00
                            }
                            """)));

        // Act
        PaymentResult result = paymentService.processPayment(new PaymentRequest("100.00", "USD"));

        // Assert
        assertThat(result.getStatus()).isEqualTo("SUCCESS");
        assertThat(result.getTransactionId()).isEqualTo("txn_123456");

        // Verify the external API was called
        verify(postRequestedFor(urlEqualTo("/api/payments"))
                .withHeader("Content-Type", equalTo("application/json")));
    }

    @Test
    void processPayment_WhenExternalApiTimesOut_ThrowsException() {
        // Arrange - simulate timeout
        stubFor(post(urlEqualTo("/api/payments"))
                .willReturn(aResponse()
                        .withStatus(200)
                        .withFixedDelay(5000)));  // 5 second delay

        // Act & Assert
        assertThatThrownBy(() -> paymentService.processPayment(new PaymentRequest("100.00", "USD")))
                .isInstanceOf(PaymentTimeoutException.class);
    }

    @Test
    void processPayment_WhenExternalApiReturns500_RetriesAndFails() {
        // Arrange - simulate server error
        stubFor(post(urlEqualTo("/api/payments"))
                .willReturn(aResponse()
                        .withStatus(500)
                        .withBody("""
                            {"error": "Internal Server Error"}
                            """)));

        // Act & Assert
        assertThatThrownBy(() -> paymentService.processPayment(new PaymentRequest("100.00", "USD")))
                .isInstanceOf(PaymentFailedException.class);

        // Verify retries happened
        verify(3, postRequestedFor(urlEqualTo("/api/payments")));
    }

    @Test
    void processPayment_WhenExternalApiReturns400_ThrowsValidationException() {
        // Arrange
        stubFor(post(urlEqualTo("/api/payments"))
                .willReturn(aResponse()
                        .withStatus(400)
                        .withHeader("Content-Type", "application/json")
                        .withBody("""
                            {
                                "error": "INVALID_AMOUNT",
                                "message": "Amount must be positive"
                            }
                            """)));

        // Act & Assert
        assertThatThrownBy(() -> paymentService.processPayment(new PaymentRequest("-10.00", "USD")))
                .isInstanceOf(PaymentValidationException.class)
                .hasMessageContaining("INVALID_AMOUNT");
    }
}

WireMock with State (Scenarios)

@Test
void orderFlow_WithStateTransitions_WorksCorrectly() {
    // First call - order pending
    stubFor(get(urlEqualTo("/api/orders/123"))
            .inScenario("Order Flow")
            .whenScenarioStateIs(Scenario.STARTED)
            .willReturn(aResponse()
                    .withStatus(200)
                    .withBody("""
                        {"id": "123", "status": "PENDING"}
                        """))
            .willSetStateTo("ORDER_PROCESSING"));

    // Second call - order processing
    stubFor(get(urlEqualTo("/api/orders/123"))
            .inScenario("Order Flow")
            .whenScenarioStateIs("ORDER_PROCESSING")
            .willReturn(aResponse()
                    .withStatus(200)
                    .withBody("""
                        {"id": "123", "status": "PROCESSING"}
                        """))
            .willSetStateTo("ORDER_COMPLETED"));

    // Third call - order completed
    stubFor(get(urlEqualTo("/api/orders/123"))
            .inScenario("Order Flow")
            .whenScenarioStateIs("ORDER_COMPLETED")
            .willReturn(aResponse()
                    .withStatus(200)
                    .withBody("""
                        {"id": "123", "status": "COMPLETED"}
                        """)));

    // Act & Assert
    assertThat(orderService.getOrderStatus("123")).isEqualTo("PENDING");
    assertThat(orderService.getOrderStatus("123")).isEqualTo("PROCESSING");
    assertThat(orderService.getOrderStatus("123")).isEqualTo("COMPLETED");
}

WireMock Request Matching

// URL matching
stubFor(get(urlEqualTo("/exact/path")));
stubFor(get(urlPathEqualTo("/path")));  // Ignores query params
stubFor(get(urlMatching("/users/[0-9]+")));
stubFor(get(urlPathMatching("/api/v[0-9]+/users")));

// Query parameters
stubFor(get(urlPathEqualTo("/search"))
        .withQueryParam("q", equalTo("test"))
        .withQueryParam("page", matching("[0-9]+")));

// Headers
stubFor(post(anyUrl())
        .withHeader("Authorization", matching("Bearer .*"))
        .withHeader("Content-Type", equalTo("application/json")));

// Request body (JSON)
stubFor(post(urlEqualTo("/api/users"))
        .withRequestBody(matchingJsonPath("$.email"))
        .withRequestBody(matchingJsonPath("$.name", equalTo("John")))
        .withRequestBody(equalToJson("""
            {"email": "test@example.com", "name": "John"}
            """, true, false)));  // ignoreArrayOrder, ignoreExtraElements

WireMock Configuration

// application-test.yml
payment:
  api:
    base-url: http://localhost:8089  # WireMock port

// Or programmatic configuration
@TestConfiguration
class WireMockConfig {

    @Bean
    public WireMockServer wireMockServer() {
        WireMockServer server = new WireMockServer(WireMockConfiguration.options()
                .port(8089)
                .notifier(new ConsoleNotifier(true)));  // Log requests
        server.start();
        return server;
    }
}

Integration Testing with @SpringBootTest + jOOQ

Full Integration Test with Testcontainers

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
@Transactional  // Rollback after each test
class UserIntegrationTest {

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15-alpine")
            .withDatabaseName("testdb")
            .withUsername("test")
            .withPassword("test");

    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
        registry.add("spring.flyway.enabled", () -> "true");
    }

    @Autowired
    private TestRestTemplate restTemplate;

    @Autowired
    private DSLContext dsl;

    @BeforeEach
    void setUp() {
        // Clean up test data
        dsl.deleteFrom(ORDERS).execute();
        dsl.deleteFrom(USERS).execute();
    }

    @Test
    void createUser_WithValidData_ReturnsCreatedUser() {
        // Arrange
        CreateUserRequest request = CreateUserRequest.builder()
                .email("integration@test.com")
                .password("Password123")
                .name("Integration Test")
                .build();

        // Act
        ResponseEntity<UserResponse> response = restTemplate.postForEntity(
                "/api/v1/users",
                request,
                UserResponse.class
        );

        // Assert
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
        assertThat(response.getHeaders().getLocation()).isNotNull();

        UserResponse body = response.getBody();
        assertThat(body).isNotNull();
        assertThat(body.getId()).isNotNull();
        assertThat(body.getEmail()).isEqualTo("integration@test.com");
        assertThat(body.getName()).isEqualTo("Integration Test");

        // Verify in database using jOOQ
        Optional<UsersRecord> dbRecord = dsl.selectFrom(USERS)
                .where(USERS.EMAIL.eq("integration@test.com"))
                .fetchOptional();

        assertThat(dbRecord).isPresent();
        assertThat(dbRecord.get().getName()).isEqualTo("Integration Test");
        assertThat(dbRecord.get().getPassword()).isNotEqualTo("Password123");  // Should be encoded
    }

    @Test
    void getUser_WithExistingUser_ReturnsUser() {
        // Arrange - insert directly with jOOQ
        UUID userId = UUID.randomUUID();
        dsl.insertInto(USERS)
                .set(USERS.ID, userId)
                .set(USERS.EMAIL, "existing@test.com")
                .set(USERS.PASSWORD, "encoded_password")
                .set(USERS.NAME, "Existing User")
                .set(USERS.STATUS, "ACTIVE")
                .set(USERS.CREATED_AT, LocalDateTime.now())
                .execute();

        // Act
        ResponseEntity<UserResponse> response = restTemplate.getForEntity(
                "/api/v1/users/{id}",
                UserResponse.class,
                userId
        );

        // Assert
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(response.getBody().getEmail()).isEqualTo("existing@test.com");
    }

    @Test
    void deleteUser_WithExistingUser_RemovesFromDatabase() {
        // Arrange
        UUID userId = UUID.randomUUID();
        dsl.insertInto(USERS)
                .set(USERS.ID, userId)
                .set(USERS.EMAIL, "todelete@test.com")
                .set(USERS.PASSWORD, "encoded")
                .set(USERS.NAME, "To Delete")
                .set(USERS.STATUS, "ACTIVE")
                .set(USERS.CREATED_AT, LocalDateTime.now())
                .execute();

        // Act
        restTemplate.delete("/api/v1/users/{id}", userId);

        // Assert - verify deleted from database
        boolean exists = dsl.fetchExists(
                dsl.selectFrom(USERS).where(USERS.ID.eq(userId))
        );
        assertThat(exists).isFalse();
    }

    @Test
    void fullUserLifecycle_CreateReadUpdateDelete() {
        // CREATE
        CreateUserRequest createRequest = CreateUserRequest.builder()
                .email("lifecycle@test.com")
                .password("Password123")
                .name("Lifecycle Test")
                .build();

        ResponseEntity<UserResponse> createResponse = restTemplate.postForEntity(
                "/api/v1/users", createRequest, UserResponse.class);

        assertThat(createResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED);
        UUID userId = createResponse.getBody().getId();

        // READ
        ResponseEntity<UserResponse> readResponse = restTemplate.getForEntity(
                "/api/v1/users/{id}", UserResponse.class, userId);

        assertThat(readResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(readResponse.getBody().getEmail()).isEqualTo("lifecycle@test.com");

        // UPDATE
        UpdateUserRequest updateRequest = UpdateUserRequest.builder()
                .name("Updated Name")
                .build();

        restTemplate.put("/api/v1/users/{id}", updateRequest, userId);

        // Verify update
        ResponseEntity<UserResponse> afterUpdate = restTemplate.getForEntity(
                "/api/v1/users/{id}", UserResponse.class, userId);
        assertThat(afterUpdate.getBody().getName()).isEqualTo("Updated Name");

        // DELETE
        restTemplate.delete("/api/v1/users/{id}", userId);

        // Verify deleted
        ResponseEntity<UserResponse> afterDelete = restTemplate.getForEntity(
                "/api/v1/users/{id}", UserResponse.class, userId);
        assertThat(afterDelete.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
    }
}

Repository Integration Test with jOOQ

@SpringBootTest
@Testcontainers
@Transactional
class UserRepositoryIntegrationTest {

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15-alpine");

    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
    }

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private DSLContext dsl;

    @BeforeEach
    void setUp() {
        dsl.deleteFrom(USERS).execute();
    }

    @Test
    void findAll_WithMultipleUsers_ReturnsPaginatedResults() {
        // Arrange
        for (int i = 0; i < 25; i++) {
            dsl.insertInto(USERS)
                    .set(USERS.ID, UUID.randomUUID())
                    .set(USERS.EMAIL, "user" + i + "@test.com")
                    .set(USERS.PASSWORD, "encoded")
                    .set(USERS.NAME, "User " + i)
                    .set(USERS.STATUS, "ACTIVE")
                    .set(USERS.CREATED_AT, LocalDateTime.now().minusMinutes(i))
                    .execute();
        }

        // Act
        List<UsersRecord> page1 = userRepository.findAll(0, 10);
        List<UsersRecord> page2 = userRepository.findAll(1, 10);
        List<UsersRecord> page3 = userRepository.findAll(2, 10);

        // Assert
        assertThat(page1).hasSize(10);
        assertThat(page2).hasSize(10);
        assertThat(page3).hasSize(5);
        assertThat(userRepository.count()).isEqualTo(25);
    }

    @Test
    void insert_WithValidRecord_SetsIdAndTimestamp() {
        // Arrange
        UsersRecord record = new UsersRecord();
        record.setEmail("new@test.com");
        record.setPassword("encoded");
        record.setName("New User");
        record.setStatus("ACTIVE");

        // Act
        UsersRecord inserted = userRepository.insert(record);

        // Assert
        assertThat(inserted.getId()).isNotNull();
        assertThat(inserted.getCreatedAt()).isNotNull();

        // Verify in DB
        Optional<UsersRecord> fromDb = userRepository.findById(inserted.getId());
        assertThat(fromDb).isPresent();
        assertThat(fromDb.get().getEmail()).isEqualTo("new@test.com");
    }

    @Test
    void search_WithCriteria_ReturnsMatchingUsers() {
        // Arrange
        dsl.insertInto(USERS)
                .set(USERS.ID, UUID.randomUUID())
                .set(USERS.EMAIL, "john@example.com")
                .set(USERS.PASSWORD, "encoded")
                .set(USERS.NAME, "John Doe")
                .set(USERS.STATUS, "ACTIVE")
                .set(USERS.CREATED_AT, LocalDateTime.now())
                .execute();

        dsl.insertInto(USERS)
                .set(USERS.ID, UUID.randomUUID())
                .set(USERS.EMAIL, "jane@example.com")
                .set(USERS.PASSWORD, "encoded")
                .set(USERS.NAME, "Jane Doe")
                .set(USERS.STATUS, "INACTIVE")
                .set(USERS.CREATED_AT, LocalDateTime.now())
                .execute();

        // Act & Assert - search by name
        UserSearchCriteria nameCriteria = new UserSearchCriteria();
        nameCriteria.setName("Doe");
        List<UsersRecord> byName = userRepository.search(nameCriteria);
        assertThat(byName).hasSize(2);

        // Act & Assert - search by status
        UserSearchCriteria statusCriteria = new UserSearchCriteria();
        statusCriteria.setStatus(UserStatus.ACTIVE);
        List<UsersRecord> byStatus = userRepository.search(statusCriteria);
        assertThat(byStatus).hasSize(1);
        assertThat(byStatus.get(0).getEmail()).isEqualTo("john@example.com");
    }
}

Test Utilities and Helpers

Test Data Factory

public class TestDataFactory {

    public static UsersRecord createUserRecord() {
        return createUserRecord(UUID.randomUUID(), "test@example.com");
    }

    public static UsersRecord createUserRecord(UUID id, String email) {
        UsersRecord record = new UsersRecord();
        record.setId(id);
        record.setEmail(email);
        record.setPassword("encoded_password");
        record.setName("Test User");
        record.setStatus("ACTIVE");
        record.setCreatedAt(LocalDateTime.now());
        return record;
    }

    public static CreateUserRequest createUserRequest() {
        return CreateUserRequest.builder()
                .email("new@example.com")
                .password("Password123")
                .name("New User")
                .build();
    }

    public static UserResponse createUserResponse(UUID id) {
        return UserResponse.builder()
                .id(id)
                .email("test@example.com")
                .name("Test User")
                .status(UserStatus.ACTIVE)
                .createdAt(LocalDateTime.now())
                .build();
    }
}

Base Integration Test Class

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
@Transactional
public abstract class BaseIntegrationTest {

    @Container
    protected static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15-alpine")
            .withDatabaseName("testdb")
            .withUsername("test")
            .withPassword("test");

    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
    }

    @Autowired
    protected TestRestTemplate restTemplate;

    @Autowired
    protected DSLContext dsl;

    @BeforeEach
    void cleanDatabase() {
        // Clean in correct order (foreign keys)
        dsl.deleteFrom(ORDERS).execute();
        dsl.deleteFrom(USERS).execute();
    }

    protected UUID insertTestUser(String email) {
        UUID id = UUID.randomUUID();
        dsl.insertInto(USERS)
                .set(USERS.ID, id)
                .set(USERS.EMAIL, email)
                .set(USERS.PASSWORD, "encoded")
                .set(USERS.NAME, "Test User")
                .set(USERS.STATUS, "ACTIVE")
                .set(USERS.CREATED_AT, LocalDateTime.now())
                .execute();
        return id;
    }
}

// Usage
class UserIntegrationTest extends BaseIntegrationTest {

    @Test
    void getUser_ReturnsUser() {
        UUID userId = insertTestUser("test@example.com");

        ResponseEntity<UserResponse> response = restTemplate.getForEntity(
                "/api/v1/users/{id}", UserResponse.class, userId);

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
    }
}

Common Gotchas

Gotcha Description Solution
@Transactional not rolling back Test changes persist to DB Ensure test class has @Transactional
jOOQ records not attached store() fails in tests Use record.attach(dsl.configuration())
Testcontainers startup slow Each test class starts new container Use static container with @Container
MockMvc returns 403 Security blocks requests Add @WithMockUser or disable security
WireMock port conflict Multiple tests use same port Use @WireMockTest with dynamic port
Flaky async tests Race conditions Use Awaitility.await() for async assertions
Mock not reset between tests State leaks Use @BeforeEach with reset(mock)

Test Commands

# Run all tests
./mvnw test
./gradlew test

# Run specific test class
./mvnw test -Dtest=UserServiceImplTest
./gradlew test --tests UserServiceImplTest

# Run specific test method
./mvnw test -Dtest=UserServiceImplTest#findById_WhenUserExists_ReturnsUser

# Run with coverage (JaCoCo)
./mvnw test jacoco:report
./gradlew test jacocoTestReport

# Run integration tests only
./mvnw test -Dgroups=integration
./gradlew test -PincludeTags=integration

# Skip tests
./mvnw package -DskipTests
./gradlew build -x test

Essential Test Dependencies (pom.xml)

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

    <!-- Testcontainers -->
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>testcontainers</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>postgresql</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>junit-jupiter</artifactId>
        <scope>test</scope>
    </dependency>

    <!-- WireMock -->
    <dependency>
        <groupId>org.wiremock</groupId>
        <artifactId>wiremock-standalone</artifactId>
        <version>3.3.1</version>
        <scope>test</scope>
    </dependency>

    <!-- Security testing -->
    <dependency>
        <groupId>org.springframework.security</groupId>
        <artifactId>spring-security-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

TO FILL: Your Test Configuration

Coverage Thresholds

# Define your minimum coverage requirements:
#
# global: 80%
# critical_modules:
#   - auth/*: 95%
#   - payments/*: 95%
#   - api/*: 85%

Custom Test Categories

// Define custom tags for test filtering
// @Tag("integration")
// @Tag("slow")
// @Tag("external")
Install via CLI
npx skills add https://github.com/jo-pouradier/dotfiles --skill testing
Repository Details
star Stars 0
call_split Forks 0
navigation Branch main
article Path SKILL.md
More from Creator
jo-pouradier
jo-pouradier Explore all skills →