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
This commit is contained in:
Ralf Ueberfuhr 2022-07-28 21:04:26 +02:00 committed by GitHub
parent c0d5df745e
commit 33c18f2cd5
28 changed files with 1240 additions and 0 deletions

3
parent-boot-3/README.md Normal file
View File

@ -0,0 +1,3 @@
## Parent Boot 2
This is a parent module for all projects using Spring Boot 3.

97
parent-boot-3/pom.xml Normal file
View File

@ -0,0 +1,97 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<artifactId>parent-boot-3</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>parent-boot-3</name>
<packaging>pom</packaging>
<description>Parent for all Spring Boot 3 modules</description>
<parent>
<groupId>com.baeldung</groupId>
<artifactId>parent-modules</artifactId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.junit</groupId>
<artifactId>junit-bom</artifactId>
<version>${junit-jupiter.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring-boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<pluginManagement>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>${spring-boot.version}</version>
<configuration>
<mainClass>${start-class}</mainClass>
<!-- this is necessary as we're not using the Boot parent -->
</configuration>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</pluginManagement>
</build>
<repositories>
<repository>
<id>spring-milestones</id>
<name>Spring Milestones</name>
<url>https://repo.spring.io/milestone</url>
<snapshots>
<enabled>false</enabled>
</snapshots>
</repository>
</repositories>
<pluginRepositories>
<pluginRepository>
<id>spring-milestones</id>
<name>Spring Milestones</name>
<url>https://repo.spring.io/milestone</url>
<snapshots>
<enabled>false</enabled>
</snapshots>
</pluginRepository>
</pluginRepositories>
<properties>
<spring-boot.version>3.0.0-M3</spring-boot.version>
<junit-jupiter.version>5.8.2</junit-jupiter.version>
<maven-surefire-plugin.version>3.0.0-M7</maven-surefire-plugin.version>
<lombok.version>1.18.22</lombok.version>
<java.version>17</java.version>
<maven-pmd-plugin.version>3.17.0</maven-pmd-plugin.version>
</properties>
</project>

View File

@ -1252,6 +1252,7 @@
<module>quarkus-modules/quarkus-jandex</module>
<module>spring-boot-modules/spring-boot-cassandre</module>
<module>spring-boot-modules/spring-boot-camel</module>
<module>spring-boot-modules/spring-boot-3</module>
<module>testing-modules/testing-assertions</module>
<module>persistence-modules/fauna</module>
<module>lightrun</module>

View File

@ -0,0 +1,128 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.baeldung</groupId>
<artifactId>parent-boot-3</artifactId>
<version>0.0.1-SNAPSHOT</version>
<relativePath>../../parent-boot-3</relativePath>
</parent>
<artifactId>spring-boot-3-sample</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>spring-boot-3-sample</name>
<description>Demo project for Spring Boot</description>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-hateoas</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<!-- Treiber: nicht gegen diese API programmieren, erst zur Laufzeit benötigt -->
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
<!-- depends on javax.servlet, see https://github.com/springdoc/springdoc-openapi/issues/1284
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-ui</artifactId>
<version>1.6.9</version>
</dependency>
-->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>${mapstruct.version}</version>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
</dependency>
</dependencies>
<build>
<pluginManagement>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${mapstruct.version}</version>
</path>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</path>
<!-- This is needed when using Lombok 1.18.16 and above -->
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok-mapstruct-binding</artifactId>
<version>0.2.0</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</pluginManagement>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
<properties>
<mapstruct.version>1.5.2.Final</mapstruct.version>
<start-class>com.baeldung.sample.TodoApplication</start-class>
</properties>
</project>

View File

@ -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);
}
}

View File

@ -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());
}
};
}
}

View File

@ -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:
*
* <pre>
* server:
* endpoints:
* api:
* v1: /api/v1
* </pre>
*/
@Configuration
@ConfigurationProperties(prefix = "cors.allow")
@Data
public class CorsConfigurationData {
private String[] origins = { "*" };
private boolean credentials = false;
}

View File

@ -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() {}
}

View File

@ -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;
};
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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<TodoResponseDto> 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<TodoResponseDto> 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);
}
}

View File

@ -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;
}

View File

@ -0,0 +1,5 @@
package com.baeldung.sample.control;
public class NotFoundException extends RuntimeException {
}

View File

@ -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);
}
}

View File

@ -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);
}

View File

@ -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!"));
}
}
}

View File

@ -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<Todo> 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<Todo> 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();
}
}
}

View File

@ -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;
}
}

View File

@ -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<TodoEntity, Long> {
}

View File

@ -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}

View File

@ -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 {
}

View File

@ -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());
}
}

View File

@ -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 {
}

View File

@ -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());
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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();
}
}