Zurück zur WebseiteInsights

Component-Tests mit JUnit

By Andreas Riepl
Published in Softwareentwicklung
July 01, 2022
3 min read
Component-Tests mit JUnit

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.

Klassischer Ansatz einer Teststrategie

Ein klassischer Ansatz zur Abstraktion der verschiedenen Tests veranschaulicht die sog. Testpyramide, die diese in drei Ebenen gliedert:


Testpyramide
Testpyramide

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.

Unsere Teststrategie

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”.


Miragon Testdiamant
Miragon Testdiamant

Testen eines Spring Boot CRUD Services

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.

Codebeispiel

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

Testen der Datenvalidierung einer Spring Anwendung

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.


Tags

#softwareentwicklung#testing#junit
Previous Article
DigiWF - Landeshauptstadt München
Andreas Riepl

Andreas Riepl

Fullstack Entwickler

Inhalte

1
Klassischer Ansatz einer Teststrategie
2
Unsere Teststrategie
3
Testen eines Spring Boot CRUD Services
4
Testen der Datenvalidierung einer Spring Anwendung

Ähnliche Beiträge

Wie sich komplexe Switch-Case-Strukturen durch Interfaces und Dependency Injection vermeiden lassen
October 08, 2024
3 min
© 2024, All Rights Reserved.

Links

StartseiteLösungenBPM TrainingKarriereInsights

Social Media