From 33c18f2cd5c5d2882699cd5cd9395794ba9c35ab Mon Sep 17 00:00:00 2001
From: Ralf Ueberfuhr <40685729+ueberfuhr@users.noreply.github.com>
Date: Thu, 28 Jul 2022 21:04:26 +0200
Subject: [PATCH] BAEL-5630: Spring Boot 3 Sample (#12449)
* BAEL-5630: Spring Boot 3 Sample
* BAEL-5630: Reorganize project in Maven profiles
* BAEL-5630: Upgrade Maven PMD plugin version
* BAEL-5630: Rename tests to follow naming conventions
---
parent-boot-3/README.md | 3 +
parent-boot-3/pom.xml | 97 ++++++++
pom.xml | 1 +
spring-boot-modules/spring-boot-3/pom.xml | 128 ++++++++++
.../com/baeldung/sample/TodoApplication.java | 11 +
.../sample/boundary/CorsConfiguration.java | 42 ++++
.../boundary/CorsConfigurationData.java | 25 ++
.../boundary/GlobalExceptionHandler.java | 22 ++
.../sample/boundary/TodoDtoMapper.java | 36 +++
.../sample/boundary/TodoRequestDto.java | 17 ++
.../sample/boundary/TodoResponseDto.java | 15 ++
.../sample/boundary/TodosController.java | 83 +++++++
.../DataInitializationConfigurationData.java | 14 ++
.../sample/control/NotFoundException.java | 5 +
.../com/baeldung/sample/control/Todo.java | 24 ++
.../sample/control/TodoEntityMapper.java | 16 ++
.../sample/control/TodosInitializer.java | 28 +++
.../baeldung/sample/control/TodosService.java | 103 +++++++++
.../baeldung/sample/entity/TodoEntity.java | 53 +++++
.../sample/entity/TodosRepository.java | 9 +
.../src/main/resources/application.yml | 21 ++
.../sample/boundary/TodosBoundaryLayer.java | 13 ++
.../TodosControllerApiIntegrationTest.java | 218 ++++++++++++++++++
.../sample/control/TodosControlLayer.java | 13 ++
...sInitializerActivationIntegrationTest.java | 39 ++++
.../TodosServiceDatabaseIntegrationTest.java | 63 +++++
.../control/TodosServiceIntegrationTest.java | 126 ++++++++++
.../sample/shared/EnableBeanValidation.java | 15 ++
28 files changed, 1240 insertions(+)
create mode 100644 parent-boot-3/README.md
create mode 100644 parent-boot-3/pom.xml
create mode 100644 spring-boot-modules/spring-boot-3/pom.xml
create mode 100644 spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/TodoApplication.java
create mode 100644 spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/boundary/CorsConfiguration.java
create mode 100644 spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/boundary/CorsConfigurationData.java
create mode 100644 spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/boundary/GlobalExceptionHandler.java
create mode 100644 spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/boundary/TodoDtoMapper.java
create mode 100644 spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/boundary/TodoRequestDto.java
create mode 100644 spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/boundary/TodoResponseDto.java
create mode 100644 spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/boundary/TodosController.java
create mode 100644 spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/control/DataInitializationConfigurationData.java
create mode 100644 spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/control/NotFoundException.java
create mode 100644 spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/control/Todo.java
create mode 100644 spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/control/TodoEntityMapper.java
create mode 100644 spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/control/TodosInitializer.java
create mode 100644 spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/control/TodosService.java
create mode 100644 spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/entity/TodoEntity.java
create mode 100644 spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/entity/TodosRepository.java
create mode 100644 spring-boot-modules/spring-boot-3/src/main/resources/application.yml
create mode 100644 spring-boot-modules/spring-boot-3/src/test/java/com/baeldung/sample/boundary/TodosBoundaryLayer.java
create mode 100644 spring-boot-modules/spring-boot-3/src/test/java/com/baeldung/sample/boundary/TodosControllerApiIntegrationTest.java
create mode 100644 spring-boot-modules/spring-boot-3/src/test/java/com/baeldung/sample/control/TodosControlLayer.java
create mode 100644 spring-boot-modules/spring-boot-3/src/test/java/com/baeldung/sample/control/TodosInitializerActivationIntegrationTest.java
create mode 100644 spring-boot-modules/spring-boot-3/src/test/java/com/baeldung/sample/control/TodosServiceDatabaseIntegrationTest.java
create mode 100644 spring-boot-modules/spring-boot-3/src/test/java/com/baeldung/sample/control/TodosServiceIntegrationTest.java
create mode 100644 spring-boot-modules/spring-boot-3/src/test/java/com/baeldung/sample/shared/EnableBeanValidation.java
diff --git a/parent-boot-3/README.md b/parent-boot-3/README.md
new file mode 100644
index 0000000000..738d97bdfd
--- /dev/null
+++ b/parent-boot-3/README.md
@@ -0,0 +1,3 @@
+## Parent Boot 2
+
+This is a parent module for all projects using Spring Boot 3.
diff --git a/parent-boot-3/pom.xml b/parent-boot-3/pom.xml
new file mode 100644
index 0000000000..711096fec8
--- /dev/null
+++ b/parent-boot-3/pom.xml
@@ -0,0 +1,97 @@
+
+
+ 4.0.0
+ parent-boot-3
+ 0.0.1-SNAPSHOT
+ parent-boot-3
+ pom
+ Parent for all Spring Boot 3 modules
+
+
+ com.baeldung
+ parent-modules
+ 1.0.0-SNAPSHOT
+
+
+
+
+
+ org.junit
+ junit-bom
+ ${junit-jupiter.version}
+ pom
+ import
+
+
+ org.springframework.boot
+ spring-boot-dependencies
+ ${spring-boot.version}
+ pom
+ import
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+ ${spring-boot.version}
+
+ ${start-class}
+
+
+
+
+
+ repackage
+
+
+
+
+
+
+
+
+
+
+ spring-milestones
+ Spring Milestones
+ https://repo.spring.io/milestone
+
+ false
+
+
+
+
+
+ spring-milestones
+ Spring Milestones
+ https://repo.spring.io/milestone
+
+ false
+
+
+
+
+
+ 3.0.0-M3
+ 5.8.2
+ 3.0.0-M7
+ 1.18.22
+ 17
+ 3.17.0
+
+
+
diff --git a/pom.xml b/pom.xml
index 6f727d0dd0..a4f6a744ea 100644
--- a/pom.xml
+++ b/pom.xml
@@ -1252,6 +1252,7 @@
quarkus-modules/quarkus-jandex
spring-boot-modules/spring-boot-cassandre
spring-boot-modules/spring-boot-camel
+ spring-boot-modules/spring-boot-3
testing-modules/testing-assertions
persistence-modules/fauna
lightrun
diff --git a/spring-boot-modules/spring-boot-3/pom.xml b/spring-boot-modules/spring-boot-3/pom.xml
new file mode 100644
index 0000000000..556168e205
--- /dev/null
+++ b/spring-boot-modules/spring-boot-3/pom.xml
@@ -0,0 +1,128 @@
+
+
+ 4.0.0
+
+ com.baeldung
+ parent-boot-3
+ 0.0.1-SNAPSHOT
+ ../../parent-boot-3
+
+ spring-boot-3-sample
+ 0.0.1-SNAPSHOT
+ spring-boot-3-sample
+ Demo project for Spring Boot
+
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+ org.springframework.boot
+ spring-boot-starter-hateoas
+
+
+ org.springframework.boot
+ spring-boot-starter-validation
+
+
+ org.springframework.boot
+ spring-boot-starter-data-jpa
+
+
+ com.h2database
+ h2
+
+ runtime
+
+
+ org.springframework.boot
+ spring-boot-devtools
+ runtime
+ true
+
+
+ org.springframework.boot
+ spring-boot-configuration-processor
+ true
+
+
+
+ org.projectlombok
+ lombok
+ true
+
+
+ org.mapstruct
+ mapstruct
+ ${mapstruct.version}
+ true
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+
+
+
+ org.mapstruct
+ mapstruct-processor
+ ${mapstruct.version}
+
+
+ org.projectlombok
+ lombok
+ ${lombok.version}
+
+
+
+ org.projectlombok
+ lombok-mapstruct-binding
+ 0.2.0
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+ org.projectlombok
+ lombok
+
+
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+
+
+ 1.5.2.Final
+ com.baeldung.sample.TodoApplication
+
+
+
diff --git a/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/TodoApplication.java b/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/TodoApplication.java
new file mode 100644
index 0000000000..72c9c0e482
--- /dev/null
+++ b/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/TodoApplication.java
@@ -0,0 +1,11 @@
+package com.baeldung.sample;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+@SpringBootApplication
+public class TodoApplication {
+ public static void main(String[] args) {
+ SpringApplication.run(TodoApplication.class, args);
+ }
+
+}
diff --git a/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/boundary/CorsConfiguration.java b/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/boundary/CorsConfiguration.java
new file mode 100644
index 0000000000..e02e3d5442
--- /dev/null
+++ b/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/boundary/CorsConfiguration.java
@@ -0,0 +1,42 @@
+package com.baeldung.sample.boundary;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.bind.annotation.RequestMethod;
+import org.springframework.web.servlet.config.annotation.CorsRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+
+import static java.util.Arrays.stream;
+import static org.springframework.http.HttpHeaders.ACCEPT;
+import static org.springframework.http.HttpHeaders.ACCEPT_LANGUAGE;
+import static org.springframework.http.HttpHeaders.CONTENT_LANGUAGE;
+import static org.springframework.http.HttpHeaders.CONTENT_TYPE;
+import static org.springframework.http.HttpHeaders.IF_MATCH;
+import static org.springframework.http.HttpHeaders.IF_NONE_MATCH;
+import static org.springframework.http.HttpHeaders.LINK;
+import static org.springframework.http.HttpHeaders.LOCATION;
+import static org.springframework.http.HttpHeaders.ORIGIN;
+
+@Configuration
+public class CorsConfiguration {
+
+ @Bean
+ public WebMvcConfigurer corsConfigurer(final CorsConfigurationData allowed) {
+ return new WebMvcConfigurer() {
+
+ @Override
+ public void addCorsMappings(final CorsRegistry registry) {
+ registry.addMapping("/**")
+ .exposedHeaders(LOCATION, LINK)
+ // allow all HTTP request methods
+ .allowedMethods(stream(RequestMethod.values()).map(Enum::name).toArray(String[]::new)) //
+ // allow the commonly used headers
+ .allowedHeaders(ORIGIN, CONTENT_TYPE, CONTENT_LANGUAGE, ACCEPT, ACCEPT_LANGUAGE, IF_MATCH, IF_NONE_MATCH) //
+ // this is stage specific
+ .allowedOrigins(allowed.getOrigins())
+ .allowCredentials(allowed.isCredentials());
+ }
+ };
+ }
+
+}
diff --git a/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/boundary/CorsConfigurationData.java b/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/boundary/CorsConfigurationData.java
new file mode 100644
index 0000000000..1c4ef55e6c
--- /dev/null
+++ b/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/boundary/CorsConfigurationData.java
@@ -0,0 +1,25 @@
+package com.baeldung.sample.boundary;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * The properties from application.yml. You can specify them by the following snippet:
+ *
+ *
+ * server:
+ * endpoints:
+ * api:
+ * v1: /api/v1
+ *
+ */
+@Configuration
+@ConfigurationProperties(prefix = "cors.allow")
+@Data
+public class CorsConfigurationData {
+
+ private String[] origins = { "*" };
+ private boolean credentials = false;
+
+}
diff --git a/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/boundary/GlobalExceptionHandler.java b/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/boundary/GlobalExceptionHandler.java
new file mode 100644
index 0000000000..eb5435656e
--- /dev/null
+++ b/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/boundary/GlobalExceptionHandler.java
@@ -0,0 +1,22 @@
+package com.baeldung.sample.boundary;
+
+import com.baeldung.sample.control.NotFoundException;
+import jakarta.validation.ValidationException;
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.MethodArgumentNotValidException;
+import org.springframework.web.bind.annotation.ControllerAdvice;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.ResponseStatus;
+
+@ControllerAdvice
+public class GlobalExceptionHandler {
+
+ @ExceptionHandler(NotFoundException.class)
+ @ResponseStatus(HttpStatus.NOT_FOUND)
+ protected void handleNotFoundException() {}
+
+ @ExceptionHandler({ValidationException.class, MethodArgumentNotValidException.class})
+ @ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY)
+ protected void handleValidationException() {}
+
+}
diff --git a/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/boundary/TodoDtoMapper.java b/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/boundary/TodoDtoMapper.java
new file mode 100644
index 0000000000..a870eade7b
--- /dev/null
+++ b/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/boundary/TodoDtoMapper.java
@@ -0,0 +1,36 @@
+package com.baeldung.sample.boundary;
+
+import com.baeldung.sample.control.Todo;
+import org.mapstruct.Mapper;
+
+import java.util.Locale;
+
+/**
+ * Dieser Mapper kopiert die Informationen zwischen den Schichten.
+ */
+@Mapper(componentModel = "spring")
+interface TodoDtoMapper {
+
+ TodoResponseDto map(Todo todo);
+
+ default String _mapStatus(Todo.Status status) {
+ return switch (status) {
+ case NEW -> "new";
+ case PROGRESS -> "progress";
+ case COMPLETED -> "completed";
+ case ARCHIVED -> "archived";
+ };
+ }
+
+ Todo map(TodoRequestDto todo, Long id);
+
+ default Todo.Status _mapStatus(String status) {
+ return null == status ? Todo.Status.NEW : switch (status) {
+ case "progress" -> Todo.Status.PROGRESS;
+ case "completed" -> Todo.Status.COMPLETED;
+ case "archived" -> Todo.Status.ARCHIVED;
+ default -> Todo.Status.NEW;
+ };
+ }
+
+}
diff --git a/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/boundary/TodoRequestDto.java b/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/boundary/TodoRequestDto.java
new file mode 100644
index 0000000000..fb7e646772
--- /dev/null
+++ b/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/boundary/TodoRequestDto.java
@@ -0,0 +1,17 @@
+package com.baeldung.sample.boundary;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Pattern;
+import lombok.Data;
+
+import java.time.LocalDate;
+
+@Data
+public class TodoRequestDto {
+ @NotBlank
+ private String title;
+ private String description;
+ private LocalDate dueDate;
+ @Pattern(regexp = "new|progress|completed|archived")
+ private String status;
+}
diff --git a/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/boundary/TodoResponseDto.java b/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/boundary/TodoResponseDto.java
new file mode 100644
index 0000000000..140db7b296
--- /dev/null
+++ b/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/boundary/TodoResponseDto.java
@@ -0,0 +1,15 @@
+package com.baeldung.sample.boundary;
+
+import lombok.Data;
+
+import java.time.LocalDate;
+
+@Data
+public class TodoResponseDto {
+ private Long id;
+ private String title;
+ private String description;
+ private LocalDate dueDate;
+ private String status;
+
+}
diff --git a/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/boundary/TodosController.java b/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/boundary/TodosController.java
new file mode 100644
index 0000000000..7efa7dfee3
--- /dev/null
+++ b/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/boundary/TodosController.java
@@ -0,0 +1,83 @@
+package com.baeldung.sample.boundary;
+
+import com.baeldung.sample.control.NotFoundException;
+import com.baeldung.sample.control.TodosService;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.ResponseStatus;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.net.URI;
+import java.util.Collection;
+import java.util.stream.Collectors;
+
+import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.linkTo;
+import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.methodOn;
+import static org.springframework.http.HttpStatus.NO_CONTENT;
+
+@RestController
+@RequestMapping("/api/v1/todos")
+@RequiredArgsConstructor
+public class TodosController {
+
+ private static final String DEFAULT_MEDIA_TYPE = MediaType.APPLICATION_JSON_VALUE;
+
+ private final TodosService service;
+ // Mapping zwischen den Schichten
+ private final TodoDtoMapper mapper;
+
+ @GetMapping(produces = DEFAULT_MEDIA_TYPE)
+ public Collection findAll() {
+ return service.findAll().stream()
+ .map(mapper::map)
+ .collect(Collectors.toList());
+ }
+
+ @GetMapping(value = "/{id}", produces = DEFAULT_MEDIA_TYPE)
+ public TodoResponseDto findById(
+ @PathVariable("id") final Long id
+ ) {
+ // Action
+ return service.findById(id) //
+ .map(mapper::map) // map to dto
+ .orElseThrow(NotFoundException::new);
+ }
+
+ @PostMapping(consumes = DEFAULT_MEDIA_TYPE)
+ public ResponseEntity create(final @Valid @RequestBody TodoRequestDto item) {
+ // Action
+ final var todo = mapper.map(item, null);
+ final var newTodo = service.create(todo);
+ final var result = mapper.map(newTodo);
+ // Response
+ final URI locationHeader = linkTo(methodOn(TodosController.class).findById(result.getId())).toUri(); // HATEOAS
+ return ResponseEntity.created(locationHeader).body(result);
+ }
+
+ @PutMapping(value = "{id}", consumes = DEFAULT_MEDIA_TYPE)
+ @ResponseStatus(NO_CONTENT)
+ public void update(
+ @PathVariable("id") final Long id,
+ @Valid @RequestBody final TodoRequestDto item
+ ) {
+ service.update(mapper.map(item, id));
+ }
+
+ @DeleteMapping("/{id}")
+ @ResponseStatus(NO_CONTENT)
+ public void delete(
+ @PathVariable("id") final Long id
+ ) {
+ service.delete(id);
+ }
+
+}
diff --git a/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/control/DataInitializationConfigurationData.java b/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/control/DataInitializationConfigurationData.java
new file mode 100644
index 0000000000..5871325714
--- /dev/null
+++ b/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/control/DataInitializationConfigurationData.java
@@ -0,0 +1,14 @@
+package com.baeldung.sample.control;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+@ConfigurationProperties(prefix = "application.data")
+@Data
+public class DataInitializationConfigurationData {
+
+ private boolean initializeOnStartup = true;
+
+}
diff --git a/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/control/NotFoundException.java b/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/control/NotFoundException.java
new file mode 100644
index 0000000000..b06b54f547
--- /dev/null
+++ b/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/control/NotFoundException.java
@@ -0,0 +1,5 @@
+package com.baeldung.sample.control;
+
+public class NotFoundException extends RuntimeException {
+
+}
diff --git a/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/control/Todo.java b/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/control/Todo.java
new file mode 100644
index 0000000000..a972a9a21a
--- /dev/null
+++ b/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/control/Todo.java
@@ -0,0 +1,24 @@
+package com.baeldung.sample.control;
+
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+
+import java.time.LocalDate;
+
+public record Todo(
+ Long id,
+ @NotNull @Size(min = 1) String title,
+ String description,
+ LocalDate dueDate,
+ @NotNull Status status
+) {
+
+ public enum Status {
+ NEW, PROGRESS, COMPLETED, ARCHIVED
+ }
+
+ public Todo(Long id, String title) {
+ this(id, title, null, null, Status.NEW);
+ }
+
+}
diff --git a/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/control/TodoEntityMapper.java b/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/control/TodoEntityMapper.java
new file mode 100644
index 0000000000..37a74f7f85
--- /dev/null
+++ b/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/control/TodoEntityMapper.java
@@ -0,0 +1,16 @@
+package com.baeldung.sample.control;
+
+import com.baeldung.sample.entity.TodoEntity;
+import org.mapstruct.Mapper;
+
+/**
+ * Dieser Mapper kopiert die Informationen zwischen den Schichten.
+ */
+@Mapper(componentModel = "spring")
+interface TodoEntityMapper {
+
+ TodoEntity map(Todo todo);
+
+ Todo map(TodoEntity todo);
+
+}
diff --git a/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/control/TodosInitializer.java b/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/control/TodosInitializer.java
new file mode 100644
index 0000000000..0c7b76d165
--- /dev/null
+++ b/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/control/TodosInitializer.java
@@ -0,0 +1,28 @@
+package com.baeldung.sample.control;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.context.event.ContextRefreshedEvent;
+import org.springframework.context.event.EventListener;
+import org.springframework.stereotype.Component;
+
+@Component
+@RequiredArgsConstructor
+public class TodosInitializer {
+
+ private final TodosService service;
+ /*
+ * we cannot use @Profile("default") because
+ * we are not able inject the bean during test
+ * depending from the profile activation
+ */
+ private final DataInitializationConfigurationData config;
+
+ @EventListener(ContextRefreshedEvent.class)
+ public void initializeTodos() {
+ if (this.config.isInitializeOnStartup() && this.service.count() < 1) {
+ this.service.create(new Todo(null, "Deploy and run the application."));
+ this.service.create(new Todo(null, "Enter some TODO items!"));
+ }
+ }
+
+}
diff --git a/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/control/TodosService.java b/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/control/TodosService.java
new file mode 100644
index 0000000000..e10ef6440f
--- /dev/null
+++ b/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/control/TodosService.java
@@ -0,0 +1,103 @@
+package com.baeldung.sample.control;
+
+import com.baeldung.sample.entity.TodosRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+import java.util.Collection;
+import java.util.Optional;
+
+import static java.util.stream.Collectors.toList;
+
+/**
+ * Ein Service ist ein Singleton auf Control Layer, der in der Boundary von mehreren (REST) Controllern gemeinsam genutzt werden kann.
+ * Dieser hat keinen Bezug mehr zu HTTP.
+ */
+@Service
+@RequiredArgsConstructor
+public class TodosService {
+
+ private final TodoEntityMapper mapper;
+ private final TodosRepository repo;
+
+ /**
+ * Gibt die Anzahl an Datensätzen zurück.
+ * @return die Anzahl an Datensätzen
+ */
+ long count() {
+ return repo.count();
+ }
+
+ /**
+ * Gibt alle Todos zurück.
+ *
+ * @return eine unveränderliche Collection
+ */
+ public Collection findAll() {
+ return repo.findAll().stream()
+ .map(mapper::map)
+ .collect(toList());
+ }
+
+ /**
+ * Durchsucht die Todos nach einer ID.
+ *
+ * @param id die ID
+ * @return das Suchergebnis
+ */
+ public Optional findById(long id) {
+ return repo.findById(id)
+ .map(mapper::map);
+ }
+
+ /**
+ * Fügt ein Item in den Datenbestand hinzu. Dabei wird eine ID generiert.
+ *
+ * @param item das anzulegende Item (ohne ID)
+ * @return das gespeicherte Item (mit ID)
+ * @throws IllegalArgumentException wenn das Item null oder die ID bereits belegt ist
+ */
+ public Todo create(Todo item) {
+ if (null == item || null != item.id()) {
+ throw new IllegalArgumentException("item must exist without any id");
+ }
+ return mapper.map(repo.save(mapper.map(item)));
+ }
+
+ /**
+ * Aktualisiert ein Item im Datenbestand.
+ *
+ * @param item das zu ändernde Item mit ID
+ * @throws IllegalArgumentException
+ * wenn das Item oder dessen ID nicht belegt ist
+ * @throws NotFoundException
+ * wenn das Element mit der ID nicht gefunden wird
+ */
+ public void update(Todo item) {
+ if (null == item || null == item.id()) {
+ throw new IllegalArgumentException("item must exist with an id");
+ }
+ // remove separat, um nicht neue Einträge hinzuzufügen (put allein würde auch ersetzen)
+ if (repo.existsById(item.id())) {
+ repo.save(mapper.map(item));
+ } else {
+ throw new NotFoundException();
+ }
+ }
+
+ /**
+ * Entfernt ein Item aus dem Datenbestand.
+ *
+ * @param id die ID des zu löschenden Items
+ * @throws NotFoundException
+ * wenn das Element mit der ID nicht gefunden wird
+ */
+ public void delete(long id) {
+ if (repo.existsById(id)) {
+ repo.deleteById(id);
+ } else {
+ throw new NotFoundException();
+ }
+ }
+
+}
diff --git a/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/entity/TodoEntity.java b/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/entity/TodoEntity.java
new file mode 100644
index 0000000000..e551876584
--- /dev/null
+++ b/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/entity/TodoEntity.java
@@ -0,0 +1,53 @@
+package com.baeldung.sample.entity;
+
+import jakarta.persistence.Entity;
+import jakarta.persistence.Enumerated;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+import lombok.ToString;
+
+import java.time.LocalDate;
+
+/*
+ * Auch hier wird eine separate Klasse erstellt.
+ * Und auch hier geht es wieder um Unabhängigkeit der beiden Layer (Control und Persistence).
+ * So kann z.B. die Auflösung von Fremdschlüsseln (assignee, priority, topic) in der Persistence Layer erfolgen (JPA unterstützt das), oder aber auch erst in der Control Layer.
+ */
+@Entity(name = "todo")
+@Table(name = "todos")
+// we do not use @Data because hashCode() and equals() might influence JPA's behaviour
+@NoArgsConstructor
+@Getter
+@Setter
+@ToString
+public class TodoEntity {
+
+ public enum StatusEntity {
+ NEW, PROGRESS, COMPLETED, ARCHIVED
+ }
+
+ @GeneratedValue(strategy = GenerationType.AUTO)
+ @Id
+ private Long id;
+ @NotNull
+ @Size(min = 1)
+ private String title;
+ private String description;
+ private LocalDate dueDate;
+ @Enumerated
+ @NotNull
+ private StatusEntity status = StatusEntity.NEW;
+
+ public TodoEntity(Long id, String title) {
+ this.id = id;
+ this.title = title;
+ }
+
+}
diff --git a/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/entity/TodosRepository.java b/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/entity/TodosRepository.java
new file mode 100644
index 0000000000..9f305a61c2
--- /dev/null
+++ b/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/entity/TodosRepository.java
@@ -0,0 +1,9 @@
+package com.baeldung.sample.entity;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+@Repository
+public interface TodosRepository extends JpaRepository {
+
+}
diff --git a/spring-boot-modules/spring-boot-3/src/main/resources/application.yml b/spring-boot-modules/spring-boot-3/src/main/resources/application.yml
new file mode 100644
index 0000000000..9a966a5bbd
--- /dev/null
+++ b/spring-boot-modules/spring-boot-3/src/main/resources/application.yml
@@ -0,0 +1,21 @@
+spring:
+ mvc:
+ throw-exception-if-no-handler-found: true
+ jackson:
+ deserialization:
+ FAIL_ON_UNKNOWN_PROPERTIES: true
+ property-naming-strategy: SNAKE_CASE
+ jpa:
+ open-in-view: false
+ generate-ddl: true
+ show-sql: true
+ hibernate:
+ ddl-auto: update
+ properties:
+ hibernate:
+ dialect: org.hibernate.dialect.H2Dialect
+# Custom Properties
+cors:
+ allow:
+ origins: ${CORS_ALLOWED_ORIGINS:*}
+ credentials: ${CORS_ALLOW_CREDENTIALS:false}
diff --git a/spring-boot-modules/spring-boot-3/src/test/java/com/baeldung/sample/boundary/TodosBoundaryLayer.java b/spring-boot-modules/spring-boot-3/src/test/java/com/baeldung/sample/boundary/TodosBoundaryLayer.java
new file mode 100644
index 0000000000..39797bf2fb
--- /dev/null
+++ b/spring-boot-modules/spring-boot-3/src/test/java/com/baeldung/sample/boundary/TodosBoundaryLayer.java
@@ -0,0 +1,13 @@
+package com.baeldung.sample.boundary;
+
+import org.springframework.boot.test.context.TestConfiguration;
+import org.springframework.context.annotation.ComponentScan;
+
+/**
+ * This class bundles all classes in the boundary layer.
+ * This includes also the generated mapper classes.
+ */
+@TestConfiguration
+@ComponentScan(basePackageClasses = TodosBoundaryLayer.class)
+public class TodosBoundaryLayer {
+}
diff --git a/spring-boot-modules/spring-boot-3/src/test/java/com/baeldung/sample/boundary/TodosControllerApiIntegrationTest.java b/spring-boot-modules/spring-boot-3/src/test/java/com/baeldung/sample/boundary/TodosControllerApiIntegrationTest.java
new file mode 100644
index 0000000000..680b6c85bb
--- /dev/null
+++ b/spring-boot-modules/spring-boot-3/src/test/java/com/baeldung/sample/boundary/TodosControllerApiIntegrationTest.java
@@ -0,0 +1,218 @@
+package com.baeldung.sample.boundary;
+
+import com.baeldung.sample.control.NotFoundException;
+import com.baeldung.sample.control.Todo;
+import com.baeldung.sample.control.TodosService;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.DisplayName;
+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.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.test.context.ContextConfiguration;
+import org.springframework.test.web.servlet.MockMvc;
+
+import java.util.List;
+import java.util.Optional;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * Integrationstests der Todos REST API auf HTTP Layer. Die Anwendung wird als Black Box behandelt.
+ * Somit müssen alle Tests, die einen existierenden Datensatz benötigen, diesen im Test anlegen.
+ * Spring Boot startet den sog. "ApplicationContext" automatisch und bietet Möglichkeiten, die Anwendungsteile vom Test aus aufzurufen.
+ */
+@WebMvcTest
+@ContextConfiguration(classes = TodosBoundaryLayer.class)
+class TodosControllerApiIntegrationTest {
+
+ private static final String BASEURL = "/api/v1/todos"; // URL to Resource
+ private static final String DEFAULT_MEDIA_TYPE = MediaType.APPLICATION_JSON_VALUE;
+
+ @MockBean
+ TodosService service;
+ @Autowired
+ MockMvc mvc; // testing by sending HTTP requests and verifying HTTP responses
+ @Autowired
+ ObjectMapper mapper; // used to render or parse JSON
+
+ /*
+ * Testfall:
+ * - GET auf alle Todos -> 200 OK mit JSON
+ */
+ @DisplayName("GET auf alle Daten (200 OK)")
+ @Test
+ void testFindAllTodos() throws Exception {
+ when(service.findAll()).thenReturn(List.of());
+ mvc
+ .perform(get(BASEURL).accept(DEFAULT_MEDIA_TYPE))
+ .andExpect(status().isOk())
+ .andExpect(content().contentType(DEFAULT_MEDIA_TYPE));
+ }
+
+ /*
+ * Testfall:
+ * - einzelnes Todos auslesen -> kein Fehler
+ */
+ @Test
+ void testFindById() throws Exception {
+ when(service.findById(1L))
+ .thenReturn(Optional.of(new Todo(1L, "test")));
+ mvc
+ .perform(get(BASEURL + "/1").accept(DEFAULT_MEDIA_TYPE))
+ .andExpect(status().isOk())
+ .andExpect(content().contentType(DEFAULT_MEDIA_TYPE))
+ .andExpect(jsonPath("$.title").value("test"));
+ }
+
+ /*
+ * Testfall:
+ * - einzelnes Todos auslesen -> 404
+ */
+ @Test
+ void testFindByIdNotExisting() throws Exception {
+ when(service.findById(1L))
+ .thenReturn(Optional.empty());
+ mvc
+ .perform(get(BASEURL + "/1").accept(DEFAULT_MEDIA_TYPE))
+ .andExpect(status().isNotFound());
+ }
+
+ /*
+ * Testfall:
+ * - Anlegen eines Todos per POST -> 201 mit Location Header
+ */
+ @DisplayName("POST liefert 201 mit Location-Header")
+ @Test
+ void testCreateTodo() throws Exception {
+ // etwas umständlich, die ID zu besetzen, wenn auf dem Service create() aufgerufen wird
+ when(service.create(any())).thenReturn(new Todo(5L, "test-todo"));
+ final var json = "{\"title\":\"test-todo\"}";
+ mvc
+ .perform(post(BASEURL).contentType(DEFAULT_MEDIA_TYPE).content(json))
+ .andExpect(status().isCreated())
+ .andExpect(header().exists(HttpHeaders.LOCATION))
+ .andExpect(jsonPath("$.id").value(5L));
+ }
+
+ /*
+ * Testfall:
+ * - Anlegen eines Todos per POST ohne Titel -> 422
+ */
+ @DisplayName("POST erzeugt kein Todo, wenn kein Titel angegeben ist")
+ @Test
+ void testCreateTodoWithoutTitle() throws Exception {
+ final var json = "{}";
+ mvc
+ .perform(post(BASEURL).contentType(DEFAULT_MEDIA_TYPE).content(json))
+ .andExpect(status().isUnprocessableEntity());
+ verifyNoInteractions(service);
+ }
+
+ /*
+ * Testfall:
+ * - Anlegen eines Todos per POST mit ID -> 400
+ */
+ @DisplayName("POST erzeugt kein Todo, wenn die ID mitgegeben wird (undefinierte Property)")
+ @Test
+ void testCreateTodoWithID() throws Exception {
+ final var json = "{\"id\":1, \"title\":\"test\"}";
+ mvc
+ .perform(post(BASEURL).contentType(DEFAULT_MEDIA_TYPE).content(json))
+ .andExpect(status().isBadRequest());
+ verifyNoInteractions(service);
+ }
+
+ /*
+ * Testfall:
+ * - Anlegen eines Todos per POST mit leerem Titel -> 400
+ */
+ @DisplayName("POST erzeugt kein Todo, wenn Titel weniger als 1 Zeichen hat")
+ @Test
+ void testCreateTodoWithEmptyTitle() throws Exception {
+ final var newTodo = new TodoRequestDto();
+ newTodo.setTitle("");
+ final String json = mapper.writeValueAsString(newTodo);
+ this.mvc //
+ .perform(post(BASEURL).contentType(DEFAULT_MEDIA_TYPE).content(json)) //
+ .andExpect(status().isUnprocessableEntity());
+ }
+
+ /*
+ * Testfall:
+ * - Ändern -> 204
+ */
+ @Test
+ void testUpdateTodo() throws Exception {
+ final var json = "{\"title\":\"test-todo\"}";
+ mvc
+ .perform(put(BASEURL + "/5").contentType(DEFAULT_MEDIA_TYPE).content(json))
+ .andExpect(status().isNoContent());
+ }
+
+ /*
+ * Testfall:
+ * - Ändern -> 404
+ */
+ @Test
+ void testUpdateTodoNotExisting() throws Exception {
+ doThrow(NotFoundException.class).when(service).update(any());
+ final var json = "{\"title\":\"test-todo\"}";
+ mvc
+ .perform(put(BASEURL + "/5").contentType(DEFAULT_MEDIA_TYPE).content(json))
+ .andExpect(status().isNotFound());
+ }
+
+ /*
+ * Testfall:
+ * - Ändern des Todos mit leerem Titel
+ */
+ @Test
+ @DisplayName("PUT mit leerem Titel")
+ void testUpdateWithEmptyTitle() throws Exception {
+ // Act
+ final var json = "{}";
+ mvc
+ .perform(put(BASEURL + "/5").contentType(DEFAULT_MEDIA_TYPE).content(json))
+ .andExpect(status().isUnprocessableEntity());
+ verifyNoInteractions(service);
+ }
+
+ /*
+ * Testfall:
+ * - Löschen -> 204
+ */
+ @Test
+ void testDeleteTodo() throws Exception {
+ mvc
+ .perform(delete(BASEURL + "/5").contentType(DEFAULT_MEDIA_TYPE))
+ .andExpect(status().isNoContent());
+ }
+
+ /*
+ * Testfall:
+ * - Löschen -> 404
+ */
+ @Test
+ void testDeleteTodoNotExisting() throws Exception {
+ doThrow(NotFoundException.class).when(service).delete(anyLong());
+ mvc
+ .perform(delete(BASEURL + "/5").contentType(DEFAULT_MEDIA_TYPE))
+ .andExpect(status().isNotFound());
+ }
+
+}
diff --git a/spring-boot-modules/spring-boot-3/src/test/java/com/baeldung/sample/control/TodosControlLayer.java b/spring-boot-modules/spring-boot-3/src/test/java/com/baeldung/sample/control/TodosControlLayer.java
new file mode 100644
index 0000000000..ac07355eb9
--- /dev/null
+++ b/spring-boot-modules/spring-boot-3/src/test/java/com/baeldung/sample/control/TodosControlLayer.java
@@ -0,0 +1,13 @@
+package com.baeldung.sample.control;
+
+import org.springframework.boot.test.context.TestConfiguration;
+import org.springframework.context.annotation.ComponentScan;
+
+/**
+ * This class bundles all classes in the control layer.
+ * This includes also the generated mapper classes.
+ */
+@TestConfiguration
+@ComponentScan(basePackageClasses = TodosControlLayer.class)
+public class TodosControlLayer {
+}
diff --git a/spring-boot-modules/spring-boot-3/src/test/java/com/baeldung/sample/control/TodosInitializerActivationIntegrationTest.java b/spring-boot-modules/spring-boot-3/src/test/java/com/baeldung/sample/control/TodosInitializerActivationIntegrationTest.java
new file mode 100644
index 0000000000..ff28756e8e
--- /dev/null
+++ b/spring-boot-modules/spring-boot-3/src/test/java/com/baeldung/sample/control/TodosInitializerActivationIntegrationTest.java
@@ -0,0 +1,39 @@
+package com.baeldung.sample.control;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.atLeastOnce;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.mockito.Mockito.when;
+
+@SpringBootTest
+class TodosInitializerActivationIntegrationTest {
+
+ @MockBean
+ TodosService service;
+
+ @BeforeEach
+ void serviceHasEmptyData() {
+ when(service.count()).thenReturn(0L);
+ }
+
+ @AfterEach
+ void serviceHasNoFurtherInteractions() {
+ verifyNoMoreInteractions(service);
+ }
+
+ @Test
+ @DisplayName("data initialization is invoked on default profile")
+ void testIsInvoked() {
+ verify(service).count();
+ verify(service, atLeastOnce()).create(any());
+ }
+
+}
diff --git a/spring-boot-modules/spring-boot-3/src/test/java/com/baeldung/sample/control/TodosServiceDatabaseIntegrationTest.java b/spring-boot-modules/spring-boot-3/src/test/java/com/baeldung/sample/control/TodosServiceDatabaseIntegrationTest.java
new file mode 100644
index 0000000000..4344e4ec3f
--- /dev/null
+++ b/spring-boot-modules/spring-boot-3/src/test/java/com/baeldung/sample/control/TodosServiceDatabaseIntegrationTest.java
@@ -0,0 +1,63 @@
+package com.baeldung.sample.control;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.TestPropertySource;
+import org.springframework.transaction.annotation.Transactional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+@SpringBootTest
+@AutoConfigureTestDatabase
+@Transactional
+@TestPropertySource(
+ properties = {
+ "application.data.initialize-on-startup=false"
+ }
+)
+class TodosServiceDatabaseIntegrationTest {
+
+ @Autowired
+ TodosService service;
+
+ @BeforeEach
+ void assertEmpty() {
+ assertThat(service.count())
+ .isZero();
+ }
+
+ @Test
+ void testCreate() {
+ service.create(new Todo(null, "test"));
+ assertThat(service.count())
+ .isEqualTo(1);
+ assertThat(service.findAll())
+ .hasSize(1)
+ .element(0).extracting(Todo::title).isEqualTo("test");
+ }
+
+ @Test
+ void testFindById() {
+ final var todo = service.create(new Todo(null, "test"));
+ final var result = service.findById(todo.id());
+ assertThat(result)
+ .isNotEmpty()
+ .get().usingRecursiveComparison().isEqualTo(todo);
+ }
+
+ @Test
+ void testDelete() {
+ final var todo = service.create(new Todo(null, "test"));
+ final var id = todo.id();
+ service.delete(id);
+ final var result = service.findById(id);
+ assertThat(result).isEmpty();
+ assertThatThrownBy(() -> service.delete(id))
+ .isInstanceOf(NotFoundException.class);
+ }
+
+}
diff --git a/spring-boot-modules/spring-boot-3/src/test/java/com/baeldung/sample/control/TodosServiceIntegrationTest.java b/spring-boot-modules/spring-boot-3/src/test/java/com/baeldung/sample/control/TodosServiceIntegrationTest.java
new file mode 100644
index 0000000000..49bf34ac80
--- /dev/null
+++ b/spring-boot-modules/spring-boot-3/src/test/java/com/baeldung/sample/control/TodosServiceIntegrationTest.java
@@ -0,0 +1,126 @@
+package com.baeldung.sample.control;
+
+import com.baeldung.sample.entity.TodoEntity;
+import com.baeldung.sample.entity.TodosRepository;
+import org.junit.jupiter.api.Test;
+import org.mockito.Answers;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+
+import java.util.List;
+
+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.ArgumentMatchers.refEq;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.mockito.Mockito.when;
+
+/**
+ * Integrationstests des TodosServices. Die Anwendung wird als Black Box behandelt.
+ * Somit müssen alle Tests, die einen existierenden Datensatz benötigen, diesen im Test anlegen.
+ */
+@SpringBootTest(classes = TodosControlLayer.class)
+class TodosServiceIntegrationTest {
+
+ // dieses Objekt wird als Mock instruiert
+ // Answers.RETURNS_MOCKS ist notwendig, da Methoden in Service-Init-Methode bereits aufgerufen werden
+ @MockBean(answer = Answers.RETURNS_MOCKS)
+ TodosRepository repo;
+
+ @Autowired
+ TodosService service;
+
+ /*
+ * Testfall:
+ * - alle Todos auslesen -> kein Fehler
+ */
+ @Test
+ void testFindAllTodos() {
+ when(repo.findAll()).thenReturn(List.of(new TodoEntity(1L, "test")));
+ final var result = service.findAll();
+ assertThat(result).hasSize(1) //
+ .element(0).extracting(Todo::id, Todo::title).containsExactly(1L, "test");
+ }
+
+ /*
+ * Testfall:
+ * - Anlegen eines Todos -> ID besetzt
+ * - Auslesen -> gefunden mit entsprechenden Werten
+ */
+ @Test
+ @SuppressWarnings("ConstantConditions")
+ void testCreateTodo() {
+ final var newTodo = new Todo(null, "test-todo");
+ when(repo.save(any())).thenReturn(new TodoEntity(5L, "test-todo"));
+ // create
+ final var result = this.service.create(newTodo);
+ // find out id
+ assertThat(result).extracting(Todo::id).isEqualTo(5L);
+ verify(repo).save(refEq(new TodoEntity(null, "test-todo")));
+ }
+
+ /*
+ * Testfall:
+ * - Ändern eines bestehenden Todos
+ * - Aufruf der Repo-Methode und Rückgabewert prüfen
+ */
+ @Test
+ @SuppressWarnings("ConstantConditions")
+ void testUpdateExisting() {
+ final var todo = new Todo(5L, "test-todo");
+ when(repo.existsById(todo.id())).thenReturn(true);
+ // Test
+ this.service.update(todo);
+ // Assert
+ verify(repo).save(refEq(new TodoEntity(5L, "test-todo")));
+ }
+
+ /*
+ * Testfall:
+ * - Ändern eines nicht existenten Todos
+ * - Aufruf der Repo-Methode und Rückgabewert prüfen
+ */
+ @Test
+ void testUpdateNotExisting() {
+ final var todo = new Todo(5L, "test-todo");
+ when(repo.existsById(todo.id())).thenReturn(false);
+ // Test+Assert
+ assertThatThrownBy(() -> this.service.update(todo))
+ .isInstanceOf(NotFoundException.class);
+ verify(repo).existsById(todo.id());
+ verifyNoMoreInteractions(repo);
+ }
+
+ /*
+ * Testfall:
+ * - Löschen eines existenten Todos
+ * - Aufruf der Repo-Methode und Rückgabewert prüfen
+ */
+ @Test
+ void testDeleteExisting() {
+ when(repo.existsById(5L)).thenReturn(true);
+ // Test
+ this.service.delete(5L);
+ // Assert
+ verify(repo).deleteById(5L);
+ }
+
+ /*
+ * Testfall:
+ * - Löschen eines nicht existenten Todos
+ * - Aufruf der Repo-Methode und Rückgabewert prüfen
+ */
+ @Test
+ void testDeleteNotExisting() {
+ when(repo.existsById(5L)).thenReturn(false);
+ // Test+Assert
+ assertThatThrownBy(() -> this.service.delete(5L))
+ .isInstanceOf(NotFoundException.class);
+ verify(repo).existsById(5L);
+ verifyNoMoreInteractions(repo);
+ }
+
+}
diff --git a/spring-boot-modules/spring-boot-3/src/test/java/com/baeldung/sample/shared/EnableBeanValidation.java b/spring-boot-modules/spring-boot-3/src/test/java/com/baeldung/sample/shared/EnableBeanValidation.java
new file mode 100644
index 0000000000..5780ac46fe
--- /dev/null
+++ b/spring-boot-modules/spring-boot-3/src/test/java/com/baeldung/sample/shared/EnableBeanValidation.java
@@ -0,0 +1,15 @@
+package com.baeldung.sample.shared;
+
+import org.springframework.boot.test.context.TestConfiguration;
+import org.springframework.context.annotation.Bean;
+import org.springframework.validation.beanvalidation.MethodValidationPostProcessor;
+
+@TestConfiguration
+public class EnableBeanValidation {
+
+ @Bean
+ public MethodValidationPostProcessor validator() {
+ return new MethodValidationPostProcessor();
+ }
+
+}