Spring Beans testen mit JUnit und Mockito
Automatisiertes Testen von Spring Beans kann anfangs schwierig sein. Daher zeige ich euch in diesem Artikel, wie ihr die typischen Herausforderungen beim Testen von void-Methoden, Datenbank- und Backend-Interaktionen mit Spring Boot und Mockito in den Griff bekommt.
Unit-Tests mit dem Mockito Mock-Framework
Wenn wir in einer Klasse oder Spring Bean Methoden haben, die eine Datenbank oder die Verfügbarkeit eines Backends benötigen, ist das für Unit-Tests ein Problem. Unit-Tests konzentrieren sich immer auf die kleinste Einheit und die schließt Datenbanken und Backends aus. Um solche Methoden trotzdem Testen zu können, gibt es Mocks. Ein Mock ersetzt die Datenbank oder das Backend und liefert von uns vorbereitete Testdaten zurück. Weitere Infos zu Mocks findet ihr hier: https://de.wikipedia.org/wiki/Mock-Objekt
Mockito ist eines der beliebtesten Mock-Frameworks für Java oder Kotlin und bietet eine einfache API zum Erstellen, Konfigurieren und Interagieren mit Mocks an. Daher wird Mockito standardmäßig in Spring Boot Projekten (in der aktuellen Version 2.5.3) automatisch geladen und kann direkt in JUnit-Tests verwendet werden. Weitere Details zu Mockito findet ihr hier: https://site.mockito.org/
In den nächsten Abschnitten zeige ich in welchen Situationen und wie ihr Mockito Mocks in euren JUnit 5 Tests verwenden könnt. Mit JUnit 4 oder einem anderen Java Test-Framework funktioniert Mockito genau so. Ihr müsstet nur die wenigen JUnit 5 spezifischen Aspekte anpassen. Tipps zu JUnit 5 und wie ihr Clean Code in Unit-Tests schreibt, erkläre ich hier: JUnit5andSpringBootTest.html
In den folgenden Testfällen testen wie die Spring Bean der Klasse EmployeeService. Diese Bean verwendet eine Datenbank mit Hilfe der injizierten EmployeeRepository Repository-Bean, siehe dazu auch SpringDataAndMongo.html. Im Unit-Test ersetzen wir diese Repository-Bean durch eine Mock-Bean, da wir keine Datenbank-Verbindung im Unit-Test haben wollen. Außerdem bekommt EmployeeService eine zweite Bean EmployeePrinter per Dependency Injection injiziert. Im Unit-Test wird diese ebenfalls durch eine Mock-Bean ersetzt. Die Basics zu Spring Beans und Dependeny Injection erkläre ich hier: spring-beans-und-dependency-injection.html.
Klassendiagramm mit allen im Artikeln gezeigten Beans, Testklassen und Mock-Beans |
Unit-Test einer Datenbank Interaktion
@Service
public class EmployeeService {
@Autowired private EmployeeRepository repo;
@Autowired private EmployeePrinter printer;
public Optional<String> firstNameOf(String empNo) {
var result = repo.findByEmpNo(empNo);
if (result != null && result.getFullName() != null)
return Optional.of(
result.getFullName().split(" ")[0]);
return Optional.empty();
}
@SpringBootTest(classes = EmployeeService.class)
@ExtendWith(MockitoExtension.class)
class EmployeeServiceTest {
@MockBean private EmployeeRepository repoMock;
@MockBean private EmployeePrinter printerMock;
@Autowired private EmployeeService service;
private Employee testEmployee1 = new Employee();
private Employee testEmployee2 = new Employee();
@BeforeEach
void createTestData() {
testEmployee1.setEmpNo("1898");
testEmployee1.setFullName("Elmar Brauch");
testEmployee1.setHireDate(Instant.now());
testEmployee2.setEmpNo("001");
testEmployee2.setFullName("Tony Stark");
testEmployee2.setHireDate(Instant.EPOCH);
}
@Test
void firstNameOf() {
when(repoMock.findByEmpNo("001"))
.thenReturn(testEmployee2);
when(repoMock.findByEmpNo(startsWith("1")))
.thenReturn(testEmployee1);
assertEquals("Tony", service.firstNameOf("001").get());
assertEquals("Elmar", service.firstNameOf("111").get());
assertTrue(service.firstNameOf("abc").isEmpty());
}
- @SpringBootTest(classes = EmployeeService.class) startet den Spring IoC Container und erzeugt eine Bean aus der Klasse EmployeeService. Diese Bean injiziere ich mit @Autowired im JUnit-Test im Attribut service, welches ich anschließend in allen folgenden Tests nutzen werde.
- Mit @MockBean erzeugt Mockito automatisch einen Mock, der im Spring IoC Container als Bean registriert wird. Das mache ich im hier gezeigten JUnit-Test zwei Mal, für jede mit @Autowired annotierte Dependency Injection in der zu testenden EmployeeService Klasse.
- MockitoExtension kümmert sich unter anderem um die Initialisierung der Mocks. Im hier gezeigten Test ist das nicht zwingend notwendig.
- Die Mockito when Methode (static import) erzeugt ein OngoingStubbing Objekt mit dem ich das Verhalten des Mocks implementiere. Die zu testende Methode firstNameOf verwendet die Spring Repository Bean EmployeeRepository, um ein Employee Objekt aus der Datenbank zu lesen. Die EmployeeRepository Bean wurde im SpringBootTest durch die MockBean repoMock ersetzt. Das Verhalten von repoMock definiere ich mit when und thenReturn. Diese beiden Methoden sind relativ selbst erklärend. Wird die Methode findByEmpNo auf der MockBean mit dem String "001" als Parameterwert aufgerufen, so wird testEmployee2 zurückgegeben. Die Testdaten werden vor jedem Test in der selbstgeschriebenen Methode createTestData erzeugt.
- Der an die MockBean übergebene Parameter muss nicht immer exakt (equals) definiert werden. Mit startsWith kann auch ein Objekt für alle String-Parameter mit dem gleichen Prefix zurückgegeben werden - hier testEmployee1. In den folgenden Abschnitten zeige ich noch ein paar Alternative Methoden zur Beschreibung der Parameter.
- Ohne definiertes Verhalten liefert der Mock null zurück, das zeige ich in der letzten Zeile mit "abc" als Eingabeparameter.
Unit-Test mit Backend Kommunikation
Unit-Tests von Datenbank- oder Backend-Exceptions
@Test
void firstNameOfWithException() {
when(repoMock.findByEmpNo(any()))
.thenThrow(MongoExecutionTimeoutException.class);
assertThrows(MongoExecutionTimeoutException.class,
() -> service.firstNameOf("001"));
}
- Mit when definieren wir, wie zuvor, das Verhalten der Mock-Bean repoMock. Wird die Methode findByEmpNo mit einem beliebigen Parameter any aufgerufen, so wird durch thenThrow festgelegt, dass eine MongoExecutionTimeoutException instanziiert und geworfen wird. Da dieses Mock-Verhalten innerhalb eines JUnit 5 Tests definiert wird, gilt es nicht in anderen JUnit Tests und produziert so auch keine ungewollten Seiteneffekte.
- Mit assertThrows testen wir, dass die erwartete Exception vom Mock geworfen wurde und durch die zu testenden Methode firstNameOf unverändert durchfliegt. Gäbe es in firstNameOf eine Fehlerbehandlung, so würden wir diese natürlich testen und durch andere Assertions prüfen.
Unit-Test von void-Methoden, die andere Beans benutzen
public void printEmployees() {
var employeePage = repo.findAll(Pageable.ofSize(5));
employeePage.forEach(printer::print);
}
@Test
void printEmployees() {
when(repoMock.findAll(any(Pageable.class)))
.thenReturn(new PageImpl<>(
List.of(testEmployee1, testEmployee1)));
service.printEmployees();
verify(printerMock, times(2)).print(testEmployee1);
}
- Wie zuvor wird mit when und thenReturn das Verhalten des Repository-Mocks definiert.
- Danach wird die zu testende Methode ausgeführt. Die Methode interagiert nun mit beiden Mocks. Da wir für den EmployeePrinter-Mock kein Verhalten definiert haben, wird die print im Standard-Verhalten einfach gemockt ausgeführt. Daher passiert nichts, da es eine void-Methode ist.
- Mit der Mockito Methode verify (static import) können wir nun das Innenleben der Methode printEmployees testen. Dazu legt die Methode times fest, wie häufig die gemockte Methode print mit dem Parameter testEmployee1 bzw. einem Parameter, der equals testEmployee1 ist, aufgerufen werden muss. Wird print mit dem Parameter testEmployee1 nicht exakt 2 Mal aufgerufen, so schlägt der Test fehl.
@Test
void printEmployees() {
when(repoMock.findAll(any(Pageable.class)))
.thenReturn(new PageImpl<>(List.of(testEmployee1, testEmployee1)))
.thenReturn(new PageImpl<>(List.of(testEmployee2)));
service.printEmployees();
verify(printerMock, times(2)).print(testEmployee1);
service.printEmployees();
service.printEmployees();
service.printEmployees();
verify(printerMock, times(3)).print(testEmployee2);
}
- Beim zweiten, dritten und vierten Aufruf der printEmployees Methode wird hier jeweils die im zweiten (und damit letzten) thenReturn definierte PageImpl Instanz zurückgegeben - das wird auch mittels verify und times überprüft.
Übergebene Parameter mittels Captor fangen und testen
Wenn die void-Methode mit ihrer eigenen Logik Objekte erzeugt oder Parameter verändert und diese dann an andere Beans übergibt, haben wir eine weitere Test-Herausforderung. Die Methode storeNewEmployee zeigt das exemplarisch, da sie anhand eines Parameters ein anderes Objekt erzeugt und in der Datenbank speichert ohne das Ergebnis zurückzugeben:
public void storeNewEmployee(String name) {
var employee = new Employee();
employee.setEmpNo("00X");
employee.setFullName("Mr. " + name.toUpperCase());
employee.setHireDate(Instant.now());
repo.save(employee);
}
Mockito bietet uns mit dem ArgumentCaptor auch für diese void-Methode eine Testmöglichkeit, indem es die Parameter fängt, die an die Mocks übergeben werden:
@Captor ArgumentCaptor<Employee> empCaptor;
@Test
void storeNewEmployee() {
service.storeNewEmployee("Parker");
verify(repoMock).save(empCaptor.capture());
var newEmployee = empCaptor.getValue();
assertEquals("Mr. PARKER", newEmployee.getFullName());
assertTrue(newEmployee.getHireDate().isBefore(Instant.now()));
}
- Die @Captor Annotation instanziiert automatisch für uns einen ArgumentCaptor für ein Employee Objekt.
- Im Test führe ich dann als nächstes die zu testende Methode storeNewEmployee aus. Dazu musste ich vorab kein spezielles Mock-Verhalten implementieren, da wir uns für die Rückgabe der save Methode hier nicht interessieren.
- Der Eingabeparameter der save Methode ist aber interessant, da auf ihn die Logik der Methode storeNewEmployee angewendet wurde. Mit der Mockito verify Methode (static import) und der der capture Methode an unserem empCaptor fangen wir also (auch nachträglich) das an store übergebene Employee Objekt.
- getValue liefert das zuletzt am Mock gefangene Objekt. Auf diese Weise hole ich die Employee Instanz, die innerhalb der void-Methode generiert und manipuliert wurde. Mit Assertions kann ich dann sicherstellen, dass die innere Logik korrekt ausgeführt wurde.
Kommentare