Fachlogik verdrahten: Service-Schicht, Dependency Injection und Tests mit Mockito

https://bildung.social/@oerinformatik/

https://oer-informatik.de/spring-boot-service-dependency-injection

Service-Schicht, Dependency Injection, Konstruktorinjektion, Spring Bean, Transactional, Transaktion, Mockito, Mock, Komponententest, JaCoCo, Abdeckungs-Gate

tl/dr; (ca. 29 min Lesezeit): Bisher speichert der Controller Angebote direkt über die Repositories, und keine der Fachregeln aus Artikel 04 wird geprüft. Jetzt kommt eine Service-Schicht dazwischen: Spring setzt Controller, Services, Repositories und eine Uhr per Konstruktorinjektion zusammen, @Transactional macht aus jeder Buchungsänderung eine Transaktion, und Buchungen lassen sich endlich anfragen, bestätigen und stornieren. Getestet wird der Service mit Mockito ganz ohne Datenbank; ein Abdeckungs-Gate lässt den Build scheitern, wenn die Tests zu wenig prüfen [AI-Assisted]. (Zuletzt geändert am 11.11.2026)

Dieser Artikel ist Teil einer Artikelreihe über ein REST-Backend mit Spring Boot: Überblick und Fahrplan / 01 Anforderungen und API-Vertrag / 02 Projekt anlegen / 03 Codegenerierung / 04 Fachlogik und Komponententest / 05 Build-Qualität und CI / 06 Persistenz mit JPA / 07 Repository-Integrationstest / 08 Service und Dependency Injection / Übungsaufgaben zu 08

Schon fit in diesem Thema? Nutze die Kann-Liste und die Leitfragen unter dem Artikel als Checkliste, ob Du die relevanten Kompetenzen bereits hast.

Dieser Artikel baut auf der Kann-Liste der Artikel Fachlogik und Komponententest, Repository-Integrationstest und Dependency Inversion, Inversion of Control und Dependency Injection auf.

Als Grundlagen zu Service-Schicht und Dependency Injection kann ich nach Durchlesen des Artikels…

  • … die Aufgaben von Controller, Service und Repository unterscheiden und begründen, warum Fachregeln in den Service gehören.

  • … erklären, wie Spring Beans findet (@Service, @RestController, @Configuration mit @Bean) und sie über den Konstruktor in andere Beans einsetzt.

  • … Konstruktor-, Setter- und Feldinjektion unterscheiden und begründen, warum die Konstruktorinjektion empfohlen wird.

  • … einen Komponententest mit Mockito schreiben: Abhängigkeiten durch Mocks ersetzen, Rückgaben mit when(…).thenReturn(…) festlegen und Aufrufe mit verify prüfen.

  • … die Fehlermeldung deuten, wenn Spring eine Abhängigkeit nicht findet.

Darüber hinaus kann ich als Expertenwissen zu Transaktionen und Abdeckung…

  • … erklären, was @Transactional bewirkt (Commit, Rollback bei RuntimeException, automatisches Schreiben geänderter Entities) und warum ein Aufruf innerhalb derselben Klasse keine Transaktion startet.

  • … Zeitabhängigkeiten über eine injizierte Clock testbar machen.

  • … ein Abdeckungs-Gate mit JaCoCo in Gradle, Maven und der Pipeline einrichten und seine Grenzen benennen.

Nach Artikel 07 beantwortet das Backend des Mitfahrlabors Suchanfragen und speichert neue Angebote. Doch der AngebotController greift dafür direkt auf drei Repositories zu, und die Fachregeln aus Artikel 04 prüft dabei niemand: Ein Angebot ab Gütersloh mit 5,00 € Kostenbeitrag wird ebenso gespeichert wie eines von jemandem ohne Führerschein. Buchungen lassen sich noch gar nicht anfragen.

Dieser Artikel schiebt zwischen Controller und Repositories eine Service-Schicht. Sie enthält die Anwendungsfälle (Angebot anlegen, Platz anfragen, Buchung bestätigen) und ruft dafür die Regeln aus dem Paket domain auf. Spring setzt die Teile über Dependency Injection zusammen. Im V-Modell bleibt die Reihe auf der Ebene der Komponententests, diesmal aber für Klassen, die Abhängigkeiten haben: Diese werden im Test durch Mocks ersetzt.

Fahrplan der Artikelreihe im V-Modell; auf der Ebene Komponententest ist Artikel 08 hervorgehoben (mit KI erstellt)

Ausgangspunkt ist das Projekt aus Artikel 07. Das Ergebnis findest Du im Ordner src/08-service-dependency-injection des Repositorys. Die Idee hinter Dependency Injection (Dependency-Inversion-Prinzip, Inversion of Control) erklärt der Artikel Dependency Inversion, Inversion of Control und Dependency Injection; hier geht es darum, wie Spring sie umsetzt.

Wozu eine Service-Schicht?

Schon in Artikel 01 war die Anwendung in Schichten geplant. Jede Schicht hat eine Aufgabe und kennt nur die Schicht darunter:

Schichten des Mitfahrlabor-Backends ab Artikel 08
Schicht Paket Aufgabe weiß nichts von
Web web HTTP-Anfragen annehmen, Modelle des Vertrags in Eingaben umwandeln, Statuscodes setzen Datenbank, Fachregeln
Service service Anwendungsfälle ausführen, Fachregeln aufrufen, Transaktionen festlegen HTTP, JSON
Repository repository Entities laden, suchen und speichern Fachregeln, HTTP
Domäne domain Entities, Fachregeln, fachliche Ausnahmen Spring, HTTP, Datenbankzugriff

Würde der Controller die Fachregeln selbst aufrufen, stünde die Fachlogik zwischen HTTP-Details wie ResponseEntity und URI. Ein zweiter Zugang zur Anwendung, etwa eine Thymeleaf-Oberfläche in einer späteren Artikelreihe, müsste sie dann ein zweites Mal enthalten. Im Service steht sie genau einmal, und sie lässt sich ohne Webserver testen.

Das Klassendiagramm zeigt die neuen Klassen und ihre Abhängigkeiten. Jeder Pfeil ist ein Attribut, das über den Konstruktor gesetzt wird:

UML-Klassendiagramm: AngebotController und BuchungController hängen je von einem Service ab; AngebotService nutzt Angebot-, Nutzer- und Haltepunkt-Repository, BuchungService nutzt Buchung-, Angebot- und Nutzer-Repository und eine Clock; beide Services verwenden die Fachregeln aus domain (mit KI erstellt)

Dependency Injection: Spring setzt die Teile zusammen

Ein Service braucht Repositories, ein Controller braucht einen Service. Die Klassen erzeugen diese Objekte nicht selbst mit new; sie bekommen sie von außen. Das ist Dependency Injection. Wer „von außen“ ist, entscheidet in Spring der Anwendungskontext (ApplicationContext): Er erzeugt beim Start alle Objekte, die er verwalten soll, die sogenannten Beans, und setzt sie ineinander ein.

Woher weiß Spring, welche Klassen Beans sind? Beim Start durchsucht Spring das Paket der Startklasse und alle Unterpakete nach Klassen mit bestimmten Annotationen (Komponentenscan):

Wie Beans im Projekt entstehen
Annotation Bedeutung Beispiel im Projekt
@Component allgemeine Bean (Grundform der anderen)
@Service Bean der Service-Schicht AngebotService, BuchungService
@RestController Bean, die HTTP-Anfragen beantwortet AngebotController, BuchungController
@Configuration mit @Bean-Methoden Klasse, deren Methoden Beans erzeugen UhrKonfiguration liefert eine Clock
(von Spring Data erzeugt) Implementierung eines Repository-Interfaces AngebotRepository usw.

Hat eine Bean genau einen Konstruktor, ruft Spring ihn auf und sucht für jeden Parameter eine passende Bean. Eine Annotation wie @Autowired ist dafür nicht nötig; sie wird erst gebraucht, wenn eine Klasse mehrere Konstruktoren hat. Fehlt eine passende Bean, startet die Anwendung nicht. Entfernt man testweise @Service vor AngebotService, scheitert schon der Smoke-Test contextLoads aus Artikel 02:

***************************
APPLICATION FAILED TO START
***************************

Description:

Parameter 0 of constructor in de.dbh.mitfahrlabor.web.AngebotController required a bean of type 'de.dbh.mitfahrlabor.service.AngebotService' that could not be found.


Action:

Consider defining a bean of type 'de.dbh.mitfahrlabor.service.AngebotService' in your configuration.

Die Meldung nennt genau, was fehlt: Der erste Konstruktorparameter (Parameter 0) des Controllers verlangt einen AngebotService, aber keine Bean dieses Typs ist bekannt.

Drei Wege der Injektion

Spring kann Abhängigkeiten auf drei Wegen einsetzen:

Konstruktor-, Setter- und Feldinjektion im Vergleich
Weg so sieht es aus Bewertung
Konstruktorinjektion private final AngebotService angebotService; und ein Konstruktor mit diesem Parameter empfohlen: Attribut kann final sein, Objekt ist nach dem Konstruktor vollständig, im Test einfach new mit Mocks
Setterinjektion @Autowired public void setAngebotService(AngebotService s) für freiwillige Abhängigkeiten; Objekt kann zeitweise unvollständig sein
Feldinjektion @Autowired private AngebotService angebotService; kurz, aber das Attribut ist von außen nur per Reflection zu setzen; Tests brauchen Spring oder Mockito-Tricks

Die Spring-Boot-Dokumentation empfiehlt die Konstruktorinjektion; alle Klassen dieser Reihe nutzen sie seit Artikel 02. Ein Nebeneffekt: Wird ein Konstruktor sehr lang, ist das ein Hinweis, dass die Klasse zu viele Aufgaben hat (siehe Prinzip der einzigen Verantwortung im Artikel SOLID).

Der AngebotService prüft die Fachregeln

Der AngebotService übernimmt, was bisher im Controller stand, und prüft vor dem Speichern drei Fachregeln: Führerschein (Regel 2), Einwilligung bei Minderjährigen (Regel 3, hier für die Person am Steuer) und den Kostenbeitrag (Regel 1). Die Anzahl der Plätze (Regel 4) prüft schon die Bean Validation aus dem Vertrag.

/** Anwendungsfälle rund um Angebote: anlegen (mit Fachregeln 1–3), suchen, abrufen. */
@Service
@Transactional(readOnly = true)
public class AngebotService {

    private final AngebotRepository angebotRepository;
    private final NutzerRepository nutzerRepository;
    private final HaltepunktRepository haltepunktRepository;

    public AngebotService(AngebotRepository angebotRepository, NutzerRepository nutzerRepository,
            HaltepunktRepository haltepunktRepository) {
        this.angebotRepository = angebotRepository;
        this.nutzerRepository = nutzerRepository;
        this.haltepunktRepository = haltepunktRepository;
    }

    @Transactional
    public Angebot legeAn(NeuesAngebot neu) {
        Nutzer fahrer = nutzerRepository.findById(neu.fahrerId())
                .orElseThrow(() -> new NichtGefundenException("Nutzer", neu.fahrerId()));
        Haltepunkt haltepunkt = haltepunktRepository.findById(neu.haltepunktId())
                .orElseThrow(() -> new NichtGefundenException("Haltepunkt", neu.haltepunktId()));
        if (!fahrer.hatFuehrerschein()) {
            throw new FuehrerscheinFehltException();
        }
        Einwilligungsregel.pruefe(fahrer.getGeburtsdatum(), fahrer.getEinwilligungEltern(), neu.datum());
        Kostenregel.pruefe(neu.kostenbeitragEur(), haltepunkt.getEntfernungKm());
        var angebot = new Angebot(fahrer, haltepunkt, neu.richtung(), neu.datum(), neu.abfahrt(),
                neu.freiePlaetze(), neu.kostenbeitragEur());
        return angebotRepository.save(angebot);
    }

    public List<Angebot> sucheOffene(LocalDate datum, Fahrtrichtung richtung, Long haltepunktId) {
        return angebotRepository.sucheOffene(datum, richtung, haltepunktId);
    }

    public Optional<Angebot> finde(Long angebotId) {
        return angebotRepository.findById(angebotId);
    }
}

Einiges ist neu:

  • orElseThrow wirft eine NichtGefundenException, wenn es die Fahrerin oder den Fahrer bzw. den Haltepunkt nicht gibt. Die Klasse liegt im Paket domain und erbt wie die Ausnahmen aus Artikel 04 von MitfahrlaborException. Neu sind außerdem FuehrerscheinFehltException und BuchungUnzulaessigException (beide FachregelVerletztException, später 422) sowie AngebotNichtBuchbarException (eine ZustandskonfliktException, später 409).
  • Die Fachregeln selbst bleiben in Kostenregel und Einwilligungsregel; der Service ruft sie nur auf. So bleiben die schnellen Komponententests aus Artikel 04 gültig.
  • @Transactional(readOnly = true) an der Klasse und @Transactional an legeAn legen fest, welche Methoden schreiben dürfen (dazu gleich mehr).

Die Eingaben übergibt der Controller als Record NeuesAngebot. So hängt der Service nicht von den aus dem Vertrag erzeugten Klassen ab:

/** Eingabedaten für ein neues Angebot, unabhängig vom Modell des API-Vertrags. */
public record NeuesAngebot(Long fahrerId, Long haltepunktId, Fahrtrichtung richtung, LocalDate datum,
        LocalTime abfahrt, int freiePlaetze, BigDecimal kostenbeitragEur) {
}

Der Controller wird dadurch kürzer: Er wandelt das Modell des Vertrags um, ruft den Service auf und baut die Antwort.

@RestController
public class AngebotController implements AngeboteApi {

    private final AngebotService angebotService;

    public AngebotController(AngebotService angebotService) {
        this.angebotService = angebotService;
    }

    @Override
    public ResponseEntity<Angebot> legeAngebotAn(AngebotNeu neu) {
        var gespeichert = angebotService.legeAn(new NeuesAngebot(neu.getFahrerId(), neu.getHaltepunktId(),
                Fahrtrichtung.valueOf(neu.getRichtung().name()), neu.getDatum(),
                LocalTime.parse(neu.getAbfahrt()), neu.getFreiePlaetze(),
                BigDecimal.valueOf(neu.getKostenbeitragEur())));
        return ResponseEntity.created(URI.create("/v1/angebote/" + gespeichert.getId()))
                .body(zuApi(gespeichert));
    }

Buchungen in Transaktionen

Mit dem BuchungService werden Buchungen möglich. Eine Anfrage prüft, ob das Angebot offen ist, ob jemand die eigene Fahrt bucht oder schon eine Buchung hat (Regel 5, mit existsByAngebotIdAndMitfahrerId aus Artikel 07) und ob bei Minderjährigen die Einwilligung vorliegt:

    @Transactional
    public Buchung frageAn(Long angebotId, Long mitfahrerId) {
        Angebot angebot = angebotRepository.findById(angebotId)
                .orElseThrow(() -> new NichtGefundenException("Angebot", angebotId));
        Nutzer mitfahrer = nutzerRepository.findById(mitfahrerId)
                .orElseThrow(() -> new NichtGefundenException("Nutzer", mitfahrerId));
        if (!angebot.istBuchbar()) {
            throw new AngebotNichtBuchbarException(angebot.getStatus(), angebot.getFreiePlaetze());
        }
        if (angebot.getFahrer().getId().equals(mitfahrerId)) {
            throw new BuchungUnzulaessigException("Die eigene Fahrt kann man nicht buchen.");
        }
        if (buchungRepository.existsByAngebotIdAndMitfahrerId(angebotId, mitfahrerId)) {
            throw new BuchungUnzulaessigException("Für dieses Angebot gibt es schon eine Buchung dieser Person.");
        }
        Einwilligungsregel.pruefe(mitfahrer.getGeburtsdatum(), mitfahrer.getEinwilligungEltern(),
                angebot.getDatum());
        return buchungRepository.save(new Buchung(angebot, mitfahrer, LocalDateTime.now(uhr)));
    }

Ändert sich der Status einer Buchung, hat das Folgen für das Angebot: Eine Bestätigung belegt einen Platz, eine Stornierung nach einer Bestätigung gibt ihn wieder frei. Welche Wechsel erlaubt sind, entscheidet weiterhin Buchungsstatus.wechsleZu aus Artikel 04 (Zustandsdiagramm aus Artikel 01), das die Entity Buchung jetzt in ihrer Methode wechsleZu aufruft. Die freien Plätze verwaltet das Angebot selbst:

    /** Nur offene Angebote nehmen Anfragen an. */
    public boolean istBuchbar() {
        return status == Angebotsstatus.OFFEN;
    }

    /** Eine bestätigte Buchung belegt einen Platz; der letzte Platz macht das Angebot voll. */
    public void belegePlatz() {
        if (!istBuchbar() || freiePlaetze == 0) {
            throw new AngebotNichtBuchbarException(status, freiePlaetze);
        }
        freiePlaetze--;
        if (freiePlaetze == 0) {
            status = Angebotsstatus.VOLL;
        }
    }

    /** Eine stornierte, vorher bestätigte Buchung gibt ihren Platz wieder frei. */
    public void gibPlatzFrei() {
        freiePlaetze++;
        if (status == Angebotsstatus.VOLL) {
            status = Angebotsstatus.OFFEN;
        }
    }

Der Service verbindet beides:

    @Transactional
    public Buchung aendereStatus(Long buchungId, Buchungsstatus ziel) {
        Buchung buchung = buchungRepository.findById(buchungId)
                .orElseThrow(() -> new NichtGefundenException("Buchung", buchungId));
        Buchungsstatus vorher = buchung.getStatus();
        buchung.wechsleZu(ziel);
        if (ziel == Buchungsstatus.BESTAETIGT) {
            buchung.getAngebot().belegePlatz();
        } else if (vorher == Buchungsstatus.BESTAETIGT && ziel == Buchungsstatus.STORNIERT) {
            buchung.getAngebot().gibPlatzFrei();
        }
        return buchung;
    }

Auffällig ist, was fehlt: aendereStatus ruft nirgends save auf. Das ist kein Versehen, sondern die Wirkung der Transaktion.

Was @Transactional bewirkt

Spring legt um jede Bean mit @Transactional einen Proxy: ein Stellvertreterobjekt, das im Anwendungskontext statt der eigentlichen Bean steht. Der Controller bekommt diesen Proxy injiziert. Ruft er aendereStatus auf, beginnt der Proxy eine Transaktion, ruft die echte Methode auf und beendet die Transaktion danach:

UML-Sequenzdiagramm: BuchungController ruft aendereStatus am Proxy des BuchungService auf; der Proxy beginnt eine Transaktion, der Service lädt die Buchung, ruft wechsleZu und belegePlatz auf; nach der Rückkehr schreibt der Proxy beim Commit beide Änderungen in die Datenbank, bei einer RuntimeException rollt er zurück (mit KI erstellt)
  • Commit: Endet die Methode normal, schreibt Hibernate alle Änderungen an geladenen Entities in die Datenbank (Dirty Checking). Im Log stehen dann zwei Anweisungen, obwohl der Code nur Methoden der Entities aufruft:

    Hibernate: update buchung set angebot_id=?,angefragt_am=?,mitfahrer_id=?,status=? where id=?
    Hibernate: update angebot set abfahrt=?,datum=?,fahrer_id=?,freie_plaetze=?,haltepunkt_id=?,kostenbeitrag_eur=?,richtung=?,status=? where id=?
  • Rollback: Wirft die Methode eine RuntimeException (alle Ausnahmen des Mitfahrlabors sind solche), wird nichts geschrieben. Bestätigt jemand eine Anfrage für ein Angebot, dessen letzter Platz inzwischen vergeben ist, hat wechsleZu den Status der Buchung im Speicher schon geändert; belegePlatz wirft dann aber AngebotNichtBuchbarException, und die Buchung bleibt in der Datenbank angefragt. Buchung und Angebot ändern sich also nur gemeinsam oder gar nicht.

  • readOnly: @Transactional(readOnly = true) an der Klasse gilt für alle Methoden ohne eigene Annotation. Hibernate schreibt in solchen Transaktionen keine Änderungen. Wie wichtig die Annotation an aendereStatus ist, zeigt ein Versuch ohne sie: Die Antwort meldet "status":"bestaetigt", aber ein anschließendes GET /v1/buchungen/5 liefert weiterhin "status":"angefragt", und das Angebot hat unverändert drei freie Plätze. Ohne Fehlermeldung ist die Änderung verloren.

Eine Falle hat der Proxy: Er greift nur bei Aufrufen von außen. Ruft eine Methode des Service eine andere @Transactional-Methode derselben Klasse auf (this.aendereStatus(…)), geht der Aufruf am Proxy vorbei, und deren Annotation wirkt nicht. Die Spring-Dokumentation nennt das self-invocation. Deshalb gehört @Transactional an die öffentlichen Methoden, die der Controller aufruft.

Eine Uhr als Bean

Eine Buchung speichert, wann sie angefragt wurde. Mit LocalDateTime.now() wäre diese Zeit im Test nicht vorhersagbar. Deshalb bekommt der BuchungService eine java.time.Clock injiziert, und LocalDateTime.now(uhr) fragt diese Uhr. Weil Clock eine Klasse des JDK ist, kann man sie nicht mit @Component markieren; stattdessen erzeugt eine @Bean-Methode in einer @Configuration-Klasse die Bean:

/** Stellt die Uhr als Bean bereit, damit Tests eine feste Uhr einsetzen können. */
@Configuration
public class UhrKonfiguration {

    public static final ZoneId ZEITZONE = ZoneId.of("Europe/Berlin");

    @Bean
    public Clock uhr() {
        return Clock.system(ZEITZONE);
    }
}

Im Betrieb läuft die echte Uhr in deutscher Zeit; ein Test übergibt eine feste Uhr. Auch der Controller nutzt ZEITZONE, um die gespeicherte LocalDateTime in die date-time-Angabe des Vertrags (mit Zeitzone) umzuwandeln. Für den Flurmonitor in Artikel 09 („Fahrten ab heute“) ist die Uhr ebenfalls schon da.

Ausprobieren

Der neue BuchungController implementiert frageBuchungAn, holeBuchung und aendereBuchungsstatus aus der Schnittstelle BuchungenApi; die Liste der Buchungen eines Angebots folgt in Artikel 09. Für die Statusänderung übersetzt er den Wert des Vertrags in den Buchungsstatus der Domäne:

    @Override
    public ResponseEntity<Buchung> aendereBuchungsstatus(Long buchungId, BuchungStatusAenderung aenderung) {
        var ziel = Buchungsstatus.valueOf(aenderung.getStatus().name());
        return ResponseEntity.ok(zuApi(buchungService.aendereStatus(buchungId, ziel)));
    }

Die Datei buchungen.http im Projekt enthält passende Anfragen. Nach dem Anlegen von Angebot 8 (aus angebote.http, 2 Plätze am 19.11.2026 ab Gütersloh) fragt Lukas Hofmann (Nutzer 4) einen Platz an:

POST http://localhost:8080/v1/angebote/8/buchungen   {"mitfahrerId": 4}

HTTP/1.1 201
Location: /v1/buchungen/6

{"id":6,"angebotId":8,"mitfahrerId":4,"status":"angefragt","angefragtAm":"2026-11-10T01:12:41.306628151+01:00"}

Nach PATCH /v1/buchungen/6 mit {"status": "bestaetigt"} meldet GET /v1/angebote/8 noch einen freien Platz ("freiePlaetze":1), nach einer zweiten Bestätigung "freiePlaetze":0 und "status":"voll". Eine Stornierung macht das Angebot wieder offen.

Und die Fachregeln? Ein Angebot mit 5,00 € Kostenbeitrag wird jetzt abgelehnt, allerdings noch unschön:

HTTP/1.1 500
Content-Type: application/json

{"timestamp":"2026-11-10T00:12:41.148Z","status":500,"error":"Internal Server Error","path":"/v1/angebote"}

Im Log steht der eigentliche Grund:

de.dbh.mitfahrlabor.domain.KostenbeitragZuHochException: Kostenbeitrag 5.0 Euro ist zu hoch, erlaubt sind höchstens 1.86 Euro je Person.

Die Ausnahmen kommen also an der richtigen Stelle, aber noch erreicht keine davon die Nutzerinnen und Nutzer als passender Statuscode. Das gilt auch für eine unbekannte Fahrerin oder einen unbekannten Fahrer, die bis Artikel 07 eine leere 404-Antwort erzeugt haben. Artikel 09 übersetzt die Ausnahmen in 404, 409 und 422 mit Problem Details nach RFC 9457.

Komponententests mit Mockito

Die Services enthalten jetzt die wichtigste Logik der Anwendung. Ein Test mit @SpringBootTest und Datenbank könnte sie prüfen, wäre aber langsam und müsste für jeden Fall passende Daten anlegen. Für einen Komponententest wird der Service stattdessen isoliert: Seine Abhängigkeiten werden durch Mocks ersetzt, also Objekte, die nur so tun, als wären sie ein Repository. Mockito erzeugt sie; die Bibliothek kommt mit dem Test-Starter ins Projekt.

@ExtendWith(MockitoExtension.class)
class BuchungServiceTest {

    /** Feste Uhr: Montag, 16.11.2026, 19:30 Uhr in Deutschland (18:30 UTC). */
    private static final Clock UHR = Clock.fixed(Instant.parse("2026-11-16T18:30:00Z"), ZoneId.of("Europe/Berlin"));

    @Mock
    private BuchungRepository buchungRepository;

    @Mock
    private AngebotRepository angebotRepository;

    @Mock
    private NutzerRepository nutzerRepository;

    private BuchungService buchungService;

    // … Testdaten im @BeforeEach, siehe unten
}
  • @ExtendWith(MockitoExtension.class) aktiviert Mockito für die Testklasse, ohne Spring zu starten.
  • @Mock erzeugt für jedes Attribut einen Mock. Ein Mock gibt standardmäßig „leere“ Werte zurück (null, false, leere Optional, leere Listen).
  • Weil der Service eine Clock braucht, baut der Test ihn selbst mit new und einer festen Uhr. Das geht nur, weil er seine Abhängigkeiten über den Konstruktor bekommt. (Ohne die Uhr würde @InjectMocks am Attribut reichen; so macht es der AngebotServiceTest.)

Die Testdaten sind Entities aus dem Szenario. Sie haben ohne Datenbank keine ID; der Service vergleicht aber IDs (eigene Fahrt). ReflectionTestUtils aus Spring setzt das private Attribut deshalb im Test:

    @BeforeEach
    void setUp() {
        buchungService = new BuchungService(buchungRepository, angebotRepository, nutzerRepository, UHR);
        ben = nutzer(1L, "Funkenflug", LocalDate.of(2008, 1, 17), true);
        lukas = nutzer(4L, "Bassline", LocalDate.of(2006, 10, 9), false);
        lina = nutzer(8L, "Rotkehlchen", LocalDate.of(2008, 11, 23), false);
        var guetersloh = new Haltepunkt("Hauptbahnhof, ZOB Steig 3", "Gütersloh", new BigDecimal("18.6"));
        angebot = new Angebot(ben, guetersloh, Fahrtrichtung.ZUR_SCHULE, LocalDate.of(2026, 11, 19),
                LocalTime.of(7, 0), 2, new BigDecimal("1.80"));
        ReflectionTestUtils.setField(angebot, "id", 1L);
    }

    /** Entities ohne Datenbank haben keine ID; für den Test setzen wir sie von Hand. */
    private static Nutzer nutzer(Long id, String pseudonym, LocalDate geburtsdatum, boolean fuehrerschein) {
        var nutzer = new Nutzer(pseudonym, "Vorname", "Nachname", geburtsdatum, null, fuehrerschein);
        ReflectionTestUtils.setField(nutzer, "id", id);
        return nutzer;
    }

Ein Test besteht dann aus drei Teilen: Rückgaben der Mocks festlegen (Stubbing mit when(…).thenReturn(…)), den Service aufrufen, das Ergebnis prüfen. thenAnswer(aufruf -> aufruf.getArgument(0)) gibt beim Speichern das übergebene Objekt zurück, so wie es ein echtes Repository tun würde:

    private void angebotUndNutzerVorhanden(Nutzer mitfahrer) {
        when(angebotRepository.findById(1L)).thenReturn(Optional.of(angebot));
        when(nutzerRepository.findById(mitfahrer.getId())).thenReturn(Optional.of(mitfahrer));
    }

    @Test
    void frageAnSpeichertBuchungMitZeitDerUhr() {
        angebotUndNutzerVorhanden(lukas);
        when(buchungRepository.save(any(Buchung.class))).thenAnswer(aufruf -> aufruf.getArgument(0));

        Buchung buchung = buchungService.frageAn(1L, 4L);

        assertThat(buchung.getStatus()).isEqualTo(Buchungsstatus.ANGEFRAGT);
        assertThat(buchung.getMitfahrer()).isSameAs(lukas);
        assertThat(buchung.getAngefragtAm()).isEqualTo(LocalDateTime.of(2026, 11, 16, 19, 30));
        assertThat(angebot.getFreiePlaetze()).isEqualTo(2); // Platz erst bei Bestätigung belegt
    }

Bei Regelverstößen interessiert nicht nur die Ausnahme, sondern auch, was nicht passiert ist. verify(…, never()) prüft, dass save nie aufgerufen wurde:

    @Test
    void frageAnLehntZweiteBuchungDerselbenPersonAb() {
        angebotUndNutzerVorhanden(lukas);
        when(buchungRepository.existsByAngebotIdAndMitfahrerId(1L, 4L)).thenReturn(true);

        assertThatThrownBy(() -> buchungService.frageAn(1L, 4L))
                .isInstanceOf(BuchungUnzulaessigException.class)
                .hasMessageContaining("schon eine Buchung");
        verify(buchungRepository, never()).save(any());
    }

Beim Anlegen eines Angebots fängt ein ArgumentCaptor das Objekt ab, das an save ging, damit der Test seine Werte prüfen kann:

    @Test
    void legeAnSpeichertGueltigesAngebot() {
        when(nutzerRepository.findById(1L)).thenReturn(Optional.of(ben));
        when(haltepunktRepository.findById(1L)).thenReturn(Optional.of(guetersloh));
        when(angebotRepository.save(any(Angebot.class))).thenAnswer(aufruf -> aufruf.getArgument(0));

        Angebot angebot = angebotService.legeAn(angebotMitBeitrag("1.86"));

        ArgumentCaptor<Angebot> gespeichert = ArgumentCaptor.forClass(Angebot.class);
        verify(angebotRepository).save(gespeichert.capture());
        assertThat(gespeichert.getValue()).isSameAs(angebot);
        assertThat(angebot.getFahrer()).isSameAs(ben);
        assertThat(angebot.getKostenbeitragEur()).isEqualByComparingTo("1.86");
        assertThat(angebot.getStatus()).isEqualTo(Angebotsstatus.OFFEN);
    }

    @Test
    void legeAnLehntZuHohenBeitragAbUndSpeichertNichts() {
        when(nutzerRepository.findById(1L)).thenReturn(Optional.of(ben));
        when(haltepunktRepository.findById(1L)).thenReturn(Optional.of(guetersloh));

        assertThatThrownBy(() -> angebotService.legeAn(angebotMitBeitrag("1.87")))
                .isInstanceOf(KostenbeitragZuHochException.class)
                .hasMessageContaining("höchstens 1.86 Euro");
        verify(angebotRepository, never()).save(any());
    }

Die Tests für aendereStatus brauchen keine Datenbank, um die Wirkung auf das Angebot zu prüfen, weil die Logik in den Entities steckt:

    @Test
    void stornierenNachBestaetigungGibtPlatzFrei() {
        angebot.belegePlatz();
        angebot.belegePlatz();
        var buchung = buchungVorhanden(5L);
        ReflectionTestUtils.setField(buchung, "status", Buchungsstatus.BESTAETIGT);

        buchungService.aendereStatus(5L, Buchungsstatus.STORNIERT);

        assertThat(angebot.getFreiePlaetze()).isEqualTo(1);
        assertThat(angebot.getStatus()).isEqualTo(Angebotsstatus.OFFEN);
    }

Insgesamt prüfen 11 Tests den BuchungService (Anfrage gespeichert, eigene Fahrt, zweite Buchung, Einwilligung, volles Angebot, unbekanntes Angebot, Bestätigen, letzter Platz, Stornieren, Ablehnen, unzulässiger Wechsel) und 5 den AngebotService.

Mockito arbeitet in der MockitoExtension mit strengen Regeln (strict stubs): Ein festgelegter Rückgabewert, den der Test nie abruft, lässt den Test scheitern. Legt der Test für das unbekannte Angebot zusätzlich nutzerRepository.findById(4L) fest, obwohl der Service nach der ersten Ausnahme gar nicht mehr nach dem Nutzer fragt, meldet Mockito:

org.mockito.exceptions.misusing.UnnecessaryStubbingException: 
Unnecessary stubbings detected.
Clean & maintainable test code requires zero unnecessary code.
Following stubbings are unnecessary (click to navigate to relevant line of code):
  1. -> at de.dbh.mitfahrlabor.service.BuchungServiceTest.frageAnMeldetUnbekanntesAngebot(BuchungServiceTest.java:144)

Das ist hilfreich: Ein überflüssiger Stub zeigt oft, dass der Test etwas anderes prüft, als sein Name sagt.

Was ein Test mit Mocks nicht prüfen kann, ist das Zusammenspiel mit Spring und der Datenbank. Ob @Transactional an der richtigen Stelle steht, sieht der Mockito-Test nicht; der Versuch ohne die Annotation (oben) wäre grün geblieben. Dafür gibt es die Integrationstests: Artikel 07 für die Repositories, Artikel 10 für die Web-Schicht und Artikel 11 für die ganze Anwendung.

Mockito als Java-Agent

Beim ersten Testlauf warnt Mockito: „Mockito is currently self-attaching to enable the inline-mock-maker. This will no longer work in future releases of the JDK.“ Mockito hängt sich zur Laufzeit als Agent in die JVM ein, um auch finale Klassen nachbilden zu können; JDK 25 erlaubt das nur noch mit Warnung. Die Lösung ist, Mockito beim Start der Test-JVM ausdrücklich als Agent zu laden:

// Mockito als Java-Agent laden (statt Selbstanhängen zur Laufzeit, das JDK 25 nur noch mit Warnung erlaubt)
val mockitoAgent = configurations.create("mockitoAgent")
dependencies {
    mockitoAgent("org.mockito:mockito-core") { isTransitive = false }
}

tasks.withType<Test> {
    useJUnitPlatform()
    jvmArgs("-javaagent:${mockitoAgent.asPath}")
}

Die Version von mockito-core (5.23.0) kommt in beiden Builds aus dem Abhängigkeitsmanagement von Spring Boot. Danach laufen die Tests ohne Warnung.

Tests ausführen

Die neuen Tests laufen mit allen anderen:

.\gradlew.bat test

BUILD SUCCESSFUL in 1m 30s

Den Testbericht mit allen 65 Tests findest Du unter build/reports/tests/test/index.html.

Zu den 49 Tests aus Artikel 07 kommen 16 Service-Tests, zusammen 65. Die Service-Tests brauchen zusammen weniger als eine Sekunde; den größten Teil der Laufzeit kosten weiterhin der Smoke-Test und die Repository-Tests, die Spring und H2 starten.

Ein Abdeckungs-Gate für den Build

Seit Artikel 05 misst JaCoCo, wie viel Code die Tests ausführen. Der Bericht allein ändert aber nichts, wenn niemand hineinschaut. Ein Abdeckungs-Gate (coverage gate) macht daraus eine Regel: Liegt die Abdeckung unter einem Mindestwert, scheitert der Build. Das Projekt legt zwei Regeln fest, gemessen ohne den erzeugten Code im Paket api:

  • mindestens 70 % der Anweisungen (instructions) des ganzen Projekts,
  • mindestens 90 % der Zweige (branches) in den Paketen domain und service, weil dort die Fachlogik steht.

// Qualität 4: Abdeckungs-Gate – check scheitert, wenn die Tests zu wenig Code ausführen
tasks.jacocoTestCoverageVerification {
    dependsOn(tasks.test)
    classDirectories.setFrom(tasks.jacocoTestReport.get().classDirectories)
    violationRules {
        rule {
            limit {
                counter = "INSTRUCTION"
                minimum = "0.70".toBigDecimal()
            }
        }
        rule {
            element = "PACKAGE"
            includes = listOf("de.dbh.mitfahrlabor.domain", "de.dbh.mitfahrlabor.service")
            limit {
                counter = "BRANCH"
                minimum = "0.90".toBigDecimal()
            }
        }
    }
}
tasks.check { dependsOn(tasks.jacocoTestCoverageVerification) }

Die Werte sind nicht beliebig: Nach diesem Artikel liegt das Projekt bei 71 % der Anweisungen (684 von 960) und bei 91 % bzw. 93 % der Zweige in domain (30 von 33) und service (13 von 14). Das Gate liegt knapp darunter. So bleibt der Build grün, aber jeder neue Code ohne Tests fällt auf. Steigt die Abdeckung, kann das Team die Werte nachziehen, aber nie senken, um einen Build zu retten.

Mit check bzw. verify läuft das Gate jetzt bei jedem Build mit:

.\gradlew.bat check

Wie das aussieht, wenn Tests fehlen, zeigt ein Versuch: Der Test stornierenNachBestaetigungGibtPlatzFrei wird mit @Disabled abgeschaltet. Damit führt kein Test mehr gibPlatzFrei und den Stornierungszweig in aendereStatus aus, und alle drei Regeln schlagen an:

> Task :jacocoTestCoverageVerification FAILED
[ant:jacocoReport] Rule violated for bundle mitfahrlabor: instructions covered ratio is 0.69, but expected minimum is 0.70
[ant:jacocoReport] Rule violated for package de.dbh.mitfahrlabor.domain: branches covered ratio is 0.87, but expected minimum is 0.90
[ant:jacocoReport] Rule violated for package de.dbh.mitfahrlabor.service: branches covered ratio is 0.78, but expected minimum is 0.90
…
BUILD FAILED in 1m 8s

Ein Gate misst nur, ob Code ausgeführt wurde, nicht ob die Tests das Ergebnis prüfen. Ein Test ohne assertThat würde die Abdeckung genauso erhöhen. Mehr dazu, was Anweisungs- und Zweigüberdeckung aussagen, steht im Artikel Codecoverage.

In der Pipeline gehört das Gate in den Job test, weil nur dort die Messdaten des Testlaufs vorliegen. Das Ziel jacoco:check@check ruft in Maven die Ausführung mit der ID check aus dem pom.xml auf:

test:
  stage: test
  script:
    # Tests ausführen und Mindestabdeckung prüfen (Abdeckungs-Gate)
    - ./gradlew test jacocoTestCoverageVerification

Unterschreitet ein Merge Request die Mindestabdeckung, wird der Job test rot, und die Stufen quality und package laufen gar nicht erst. Der Job test-mariadb bleibt ohne Gate; er prüft die Datenbank, nicht die Abdeckung.

Nächster Artikel

Die Fachregeln greifen jetzt, aber jeder Verstoß endet als 500 Internal Server Error. Im nächsten Artikel bekommen die Ausnahmen ihre Statuscodes aus dem Vertrag (404, 409, 422) mit Fehlerbeschreibungen nach RFC 9457 (Problem Details). Außerdem entstehen die übrigen Endpunkte, darunter der Flurmonitor und das Absagen von Angeboten, und das Umwandeln zwischen Entities und Vertragsmodellen zieht in eigene Mapper-Klassen um.

Leitfragen

  1. Welche Aufgaben hat die Service-Schicht, und was gehört nicht hinein?

Der Service führt Anwendungsfälle aus (Angebot anlegen, Platz anfragen, Status ändern), ruft dafür Repositories und Fachregeln auf und legt die Transaktionen fest. HTTP-Details wie Statuscodes, ResponseEntity, URIs oder die Modelle des Vertrags gehören in den Controller; die Regeln selbst bleiben im Paket domain. siehe Abschnitt „Wozu eine Service-Schicht?“

  1. Wie findet Spring die Beans, und wie kommen sie in den Konstruktor einer anderen Bean?

Beim Start durchsucht Spring das Paket der Startklasse und seine Unterpakete nach Klassen mit @Component, @Service, @RestController oder @Configuration; Methoden mit @Bean in @Configuration-Klassen liefern weitere Beans, Spring Data erzeugt die Repositories. Hat eine Bean einen einzigen Konstruktor, ruft Spring ihn mit passenden Beans als Parametern auf. Fehlt eine, startet die Anwendung nicht („required a bean of type … that could not be found“). siehe Abschnitt „Dependency Injection: Spring setzt die Teile zusammen“

  1. Warum ist die Konstruktorinjektion der Feldinjektion vorzuziehen?

Die Attribute können final sein, das Objekt ist nach dem Konstruktor vollständig, und die Abhängigkeiten sind in der Signatur sichtbar. Im Komponententest erzeugt man das Objekt einfach mit new und übergibt Mocks oder eine feste Uhr. Feldinjektion lässt sich nur per Reflection von außen füllen, also nur mit Spring oder Mockito. siehe Abschnitt „Drei Wege der Injektion“

  1. Was bewirkt @Transactional an aendereStatus, und was passiert, wenn die Annotation fehlt?

Ein Proxy beginnt vor der Methode eine Transaktion. Endet sie normal, schreibt Hibernate beim Commit alle Änderungen an geladenen Entities (Buchung und Angebot), auch ohne save. Bei einer RuntimeException wird zurückgerollt, sodass Buchung und Angebot nur gemeinsam geändert werden. Fehlt die Annotation, gilt readOnly = true der Klasse: Die Antwort zeigt den neuen Status, gespeichert wird er aber nicht. siehe Abschnitt „Was @Transactional bewirkt“

  1. Warum bekommt der BuchungService eine Clock injiziert, und wie entsteht diese Bean?

Mit LocalDateTime.now() hinge das Ergebnis von der aktuellen Zeit ab und wäre im Test nicht vorhersagbar. Mit LocalDateTime.now(uhr) kann der Test eine feste Uhr übergeben (Clock.fixed(…)). Weil Clock eine Klasse des JDK ist, erzeugt sie eine @Bean-Methode in der @Configuration-Klasse UhrKonfiguration. siehe Abschnitt „Eine Uhr als Bean“

  1. Wozu dienen @Mock, when(…).thenReturn(…), verify(…, never()) und ArgumentCaptor in einem Komponententest?

@Mock ersetzt eine Abhängigkeit durch ein Objekt ohne eigene Logik; when(…).thenReturn(…) legt fest, was es bei einem bestimmten Aufruf zurückgibt. verify prüft, ob und wie oft eine Methode aufgerufen wurde, mit never() etwa, dass bei einem Regelverstoß nichts gespeichert wurde. Ein ArgumentCaptor fängt das übergebene Objekt ab, damit der Test dessen Werte prüfen kann. siehe Abschnitt „Komponententests mit Mockito“

  1. Was prüft das Abdeckungs-Gate, wie wählt man die Grenzwerte, und was kann es nicht prüfen?

JaCoCo vergleicht nach dem Testlauf die Abdeckung mit Mindestwerten (hier 70 % der Anweisungen im ganzen Projekt, 90 % der Zweige in domain und service) und lässt check bzw. verify und den Pipeline-Job test scheitern, wenn sie unterschritten werden. Die Grenzen liegen knapp unter dem aktuellen Stand und werden nur angehoben. Das Gate misst, ob Code ausgeführt wurde, nicht ob die Tests das Ergebnis prüfen. siehe Abschnitt „Ein Abdeckungs-Gate für den Build“


Hinweis zur Nachnutzung als Open Educational Resource (OER)

Dieser Artikel und seine Texte, Bilder, Grafiken, Code und sonstiger Inhalt sind - sofern nicht anders angegeben - lizenziert unter CC BY 4.0. Nennung gemäß TULLU-Regel bitte wie folgt: “Fachlogik verdrahten: Service-Schicht, Dependency Injection und Tests mit Mockito” von oer-informatik.de (H. Stein), Lizenz: CC BY 4.0. Der Artikel wurde unter https://oer-informatik.de/spring-boot-service-dependency-injection veröffentlicht, die Quelltexte sind in weiterverarbeitbarer Form verfügbar im Repository unter https://gitlab.com/oer-informatik/java-springboot/Backend. Erstellt mit KI-Assistenz; von mir umfassend redaktionell überarbeitet, geprüft und freigegeben. Stand: 11.11.2026.

[Kommentare zum Artikel lesen, schreiben] / [Artikel teilen] / [gitlab-Issue zum Artikel schreiben]

Kommentare gerne per Mastodon, Verbesserungsvorschläge per gitlab issue (siehe oben). Beitrag teilen: