Co-Author: Fabian Bösel
Das Testen eines Systems hat das Ziel, Fehler beim Entwickeln zu minimieren und Fehlerursachen schneller zu finden. Ein Softwaresystem sollte auf mehreren Ebenen getestet werden, wobei sich die jeweiligen Anforderungen unterscheiden und verschiedenen Zielen dienen. In diesem Blog-Post stellen wir unsere Teststrategie vor und zeigen ausführlich, wie wir diese auf unsere in Spring-Boot geschriebenen Java-Backends anwenden.
Ein klassischer Ansatz zur Abstraktion der verschiedenen Tests veranschaulicht die sog. Testpyramide, die diese in drei Ebenen gliedert:
Auf der untersten Ebene befinden sich Unit-Tests. Sie sind einfach zu schreiben, können schnell ausgeführt werden und werden dazu eingesetzt, einzelne Komponenten unabhängig vom weiteren System zu testen, um eine hohe Testabdeckung zu erreichen. Die Entwickler sollten die zugehörigen Unit-Tests möglichst bei jeder Änderung ausführen und aktualisieren. So lassen sich potenzielle Fehlerursachen einfach identifizieren. Unit-Tests eignen sich besonders zum Testen von Methoden, wobei deren Abhängigkeiten gemocked sind.
Integrationtests befinden sich eine Abstraktionsschicht darüber und testen Komponenten im Zusammenspiel mit deren Abhängigkeiten. In diese Kategorie fallen sowohl Tests, die verwendete Frameworks mit einbeziehen (z.B.: Spring MVC), als auch Oberflächentests, wie Cypress- oder Selenium-Tests. Abhängigkeiten werden dabei nicht oder nur teilweise gemockt. In der Regel sind solche Tests nicht nur aufwändiger zu schreiben, sondern brauchen auch mehr Zeit bei der Ausführung. Deshalb bietet es sich an, diese automatisiert bei jedem Push in das Code-Repository auszuführen.
Die Spitze der Testpyramide bilden die manuellen Tests, wobei ein menschlicher Tester das System auf Benutzer-Ebene testet, um zu gewährleisten, dass bestimmte Anforderungen an das Endsystem, wie vorgesehen funktionieren, ohne dabei auf die konkrete Implementierung einzugehen. Diese Art von Tests dauern am längsten und sind aus Projektsicht am teuersten.
Mit dem Vorgehen, die Abdeckung anhand der Ausführungskosten zu skalieren lässt sich die Qualität des Produktes am effizientesten erhöhen.
Unser Ziel ist es, die manuellen Tests bei den React Anwendungen so gut es geht zu reduzieren, weshalb wir zur Automatisierung das Oberflächentestframework Cypress einsetzen.
Die Backends sind in Java geschrieben und basieren auf dem Spring-Boot Framework. Wie in unserem Beispielprojekt zu sehen ist, gliedert sich die Backendstruktur für jede Domäne in die drei Ebenen “api”, “domain” und “infrastructure”, wobei unsere Business-Logik (Services & Facades) in der domain-Ebene zu finden ist. Für das Mapping der Objektrepräsentationen zwischen den Ebenen (Transport Objekte, Domain Objekte und Entities) setzen wir Mapstruct ein. Die Entities werden mit der Java Persistence API (JPA) in eine Postgres-Datenbank synchronisiert. Da die eingesetzten Frameworks selbst über eine hohe Testabdeckung verfügen, macht es wenig Sinn, deren Funktionsweise mit eigenen Tests zu überprüfen. Unser Hauptaugenmerk liegt vielmehr auf unserer Business-Logik, die für jede Domäne die grundlegenden Datenoperationen (CRUD: Create, Read, Update, Delete) verwaltet.
Zusammengefasst fokussieren wir uns sowohl frontend- als auch backendseitig auf Integrationstests. Um diese besser voneinander differenzieren zu können, beizeichnen wir die Oberflächentests als “End-To-End-Tests” und die Service-/ Facadetests der Backends als “Component-Tests”. Da die Component-Tests gleichzeitig den Code mittesten, der in private Service-Methoden ausgelagert ist, sind klassische Unit-Tests in unserem Projekt nur noch Tests zur Datenvalidierung. Somit haben wir bildlich gesprochen keine Testpyramide im klassischen Sinne mehr, sondern einen “Testdiamanten”.
Unsere Erfahrung hat gezeigt, dass Component-Tests nur dann aussagekräftig sind, wenn sie auch unsere Infrastrukturschicht mittesten. Deshalb starten wir unter Verwendung von Spring-Boot-Data-JPA Tests eine H2-Datenbank und testen so indirekt die von Mapstruct generierten Mapper und unsere JPA Repositories mit.
Alle Backend-Tests sind nach dem Arrange/Act/Assert-Pattern aufgebaut. Diese übersichtliche Aufteilung gliedert die Testfunktionen in drei einheitliche Teilbereiche.
1. Arrange: Führt Aktionen durch, welche am Anfang des Tests für das Setup und die Initialisierung des Test-Prozesses nötig sind, wie zum Beispiel die Aufbereitung von für den Test erforderlichen Daten.
2. Act: Ruft zu testende Funktion wird auf. Dies kann zum Beispiel die Ausführung einer Funktion sein oder die Interaktion mit einem anderen System.
3. Assert: Überprüft den vorliegenden Zustand mit dem gewünschten Ergebnis. Dabei wird festgestellt, ob ein Test erfolgreich ist oder fehlschlägt.
Als Testframework wird in diesem Projekt JUnit5 verwendet. Domänenfremde Abhängigkeiten werden durch eine von dem Mock-Framework Mockito bereitgestellte Dummy-Implementierung ersetzt.
Grundsätzlich sollten Tests in sich geschlossen sein, weshalb die JPA-Tests die Datenstände nach jedem Tests zurücksetzen. Beim Testen von Service-Komponenten ist dieses Verhalten jedoch unerwünscht, weil sonst bei jedem Read, Update oder Delete erst wieder Daten eingespielt werden müssten. Zusammen mit der Order-Funktionalität von JUnit kann realisiert werden, dass solche Tests mit den Daten eines zuvor ausgeführten anderen Test arbeiten.
Veranschaulichen lässt sich dies gut anhand unseres Service-Tests im Beispielprojekt:
package io.miragon.example.base.project.domain; @DisplayName("ProjectService") @Import({ProjectMapperImpl.class}) @TestMethodOrder(MethodOrderer.OrderAnnotation.class) public class ProjectServiceTest extends MiragonServiceTest { private ProjectService projectService; @Autowired private ProjectMapper projectMapper; @Autowired private ProjectRepository projectRepository; @BeforeEach public void initService() { projectService = new ProjectService( projectRepository, projectMapper ); } // saves ids for created projects private static Map<Integer, String> savedProjectIds = new HashMap<>(); @Order(1) @DisplayName("createProject() creates new project") @ParameterizedTest(name = "Creating Project with customer={0} and address={1}") @MethodSource("io.miragon.example.base.project.testdata.NewProjectAggregator#newProjectDataProvider") @Rollback(false) public void testCreateProject(@AggregateWith(NewProjectAggregator.class) NewProject newProject) { // Act Project savedProject = this.projectService.createProject(newProject); savedProjectIds.put(savedProjectIds.size(), savedProject.getId()); // Assert assertNotNull(savedProject.getId()); assertEquals(newProject.getCustomer(), savedProject.getCustomer()); assertEquals(newProject.getAddress(), savedProject.getAddress()); } @Test @Order(2) @DisplayName("updateProject() updates project") public void testUpdateProject() { // Arrange UpdateProject updateProject = UpdateProject.builder() .customer("Lambor Gini") .address("2931 Milano, Spagettistr. 2") .build(); // Act Project savedProject = this.projectService.updateProject(savedProjectIds.get(0), updateProject); // Assert assertEquals(savedProjectIds.get(0), savedProject.getId()); assertEquals(updateProject.getCustomer(), savedProject.getCustomer()); assertEquals(updateProject.getAddress(), savedProject.getAddress()); } @Test @Order(2) @DisplayName("deleteProject() deletes project") public void testDeleteProject() { // Act this.projectService.deleteProject(savedProjectIds.get(0)); // Assert assertThrows(ObjectNotFoundException.class, () -> projectService.verifyProjectExists(savedProjectIds.get(0))); } }
Der beim Anlegen der neuen Projekte verwendete ParameterizedTest ist ein Feature von JUnit 5. Dieser kann im Zusammenspiel mit einem sogenannten ArgumentsAggregator fertige Objekte entgegennehmen. Dadurch bleibt der Code innerhalb der Testklasse sauber und übersichtlich.
package io.miragon.example.base.project.testdata; public class NewProjectAggregator implements ArgumentsAggregator { public static Stream<Arguments> newProjectDataProvider() { return Stream.of( Arguments.of("Seppl GmbH", "Bachstraße 1, 82941 Hintadupfing"), Arguments.of("Schorschi AG", "Bohnenallee 62, 72310 Greifenhofen"), Arguments.of("Vinzenz Mur", "Kuhstraße 12, 10329 Hofen") ); } @Override public Object aggregateArguments(ArgumentsAccessor accessor, ParameterContext context) throws ArgumentsAggregationException { return NewProject.builder() .customer(accessor.getString(0)) .address(accessor.getString(1)) .build(); } }
The first and most fundamental rule in security is ‘NEVER TRUST USER INPUT’.
Deshalb validieren wir die Transport-Objekte, die an unsere REST-Controller übergeben werden mit dem von Spring Validation Framework. Die folgenden Codesegmente zeigen beispielhaft, wie man Validierungs-Annotationen verwendet und diese testet.
package io.miragon.example.base.project.api.transport; @Getter @Builder @ToString @AllArgsConstructor @Schema(description = "Data to create a new io.miragon.example.base.project") public class NewProjectTO { @NotNull @NotBlank private final String customer; @NotNull @NotBlank private final String address; }
package io.miragon.example.base.project.api.resource; @Slf4j @Validated @RestController @RequiredArgsConstructor @Tag(name = "Project Controller") @RequestMapping("/api/project") public class ProjectController { private final ProjectService projectService; private final ProjectApiMapper projectMapper; @Transactional @PostMapping() @Operation(summary = "Create a new project") public ResponseEntity<ProjectTO> createNewProject(@RequestBody @Valid final NewProjectTO projectTO) { log.debug("Received request to create a new project: {}", projectTO); final NewProject newProject = this.projectMapper.mapToNewProject(projectTO); return ResponseEntity.ok(this.projectMapper.mapToTO(this.projectService.createProject(newProject))); } }
package io.miragon.example.base.project.api; @DisplayName("NewProjectTO Validation") public class NewProjectToValidationTest extends MiragonValidationTest { @Test @DisplayName("Check valid object") public void checkValid() { // Arrange NewProjectTO validNewProject = NewProjectTO.builder() .customer("Something") .address("Something") .build(); // Act Set<ConstraintViolation<NewProjectTO>> constraintViolations = validator.validate(validNewProject); // Assert assertEquals(0, constraintViolations.size()); } @Test @DisplayName("Check invalid: missing customer") public void checkMissingCustomerInvalid() { // Arrange NewProjectTO validNewProject = NewProjectTO.builder() .address("Something") .build(); // Act Set<ConstraintViolation<NewProjectTO>> constraintViolations = validator.validate(validNewProject); // Assert assertEquals(2, constraintViolations.size()); assertThat( constraintViolations.stream().map(ConstraintViolation::getMessage).collect(Collectors.toList()), Matchers.containsInAnyOrder("must not be blank", "must not be null") ); } }
Damit sind wir schon am Ende unseres heutigen Posts angekommen. Danke, dass du bis hier hin dabeigeblieben bist! Den vollständigen Code findest du, wie immer, in unserem Beispielprojekt auf Github.
Rechtliches