© Excellent backgrounds/Shutterstock.com
Testen von Spring-Boot-Applikationen

Hausmittel für den API-Test


Da die verschiedenen Architekturschichten einer Spring-Boot-Anwendung über APIs kommunizieren, müssen auch diese getestet werden. Wir werfen einen Blick auf Spring-eigene und externe Methoden für das Testing. Mockito, REST Assured, MockMvc, HtmlUnit, JSON Test sowie TestEntityManager helfen dabei, API-Tests einfacher und kürzer zu machen.

Zurzeit wird viel über Microservices und Self-contained Systems [1] geredet und geschrieben. Häufig werden diese Services und Systeme mit Spring Boot implementiert. Spring-Boot-Applikationen sind oft nach einem ähnlichen Muster aufgebaut und bestehen gewöhnlich aus mehreren Schichten, die miteinander Informationen austauschen. Für jede dieser Schichten müssen prinzipiell Unit-Tests geschrieben werden und für die gesamte Applikation gegebenenfalls Integrations-, Akzeptanz- und Lasttests. Bedingt durch diese Architektur müssen verschieden APIs getestet werden: die Service-APIs, die REST-APIs und die APIs von anderen Klassen mit einer öffentlichen Schnittstelle innerhalb der Spring-Boot-Applikation. Für jede dieser Schichten gibt es Hilfsmittel, um sie zu testen und die Korrektheit sicherzustellen.

In Spring-Boot-Applikation gibt es meist einen REST-Controller mit öffentlichen Schnittstellen sowie einen internen Service, der die Businesslogik enthält und über Repositories auf eine Datenbank zugreift. Für jede dieser Komponenten gibt es die passenden Testmöglichkeiten. Grafische Oberflächen klammern wir hier einmal aus. In diesem Artikel wird als Beispiel eine Applikation verwendet, die Items verwaltet, die jeweils eine id, eine Beschreibung (description), einen Standort (location) und ein Datum (itemdate) besitzen. Das ItemRepository ist in Listing 1 zu sehen. Zu beachten ist, dass in dem Beispiel die Methode findByLocation nur dazu dient, um später zu zeigen, wie der TestEntityManager verwendet werden kann.

Test als Living Documentation

Man sollte sich beim Schreiben von Tests immer vergegenwärtigen, dass man mit einem Test auch das Verhalten einer Funktionalität zu einem bestimmten Zeitpunkt überprüft. Das heißt, der Test dient nicht nur dazu, eine Funktion zu testen, sondern auch dazu, Verhaltensänderungen festzustellen. Ändert sich beispielsweise das Verhalten einer Methode, sollte ein fehlschlagender Test darauf aufmerksam machen. Auf den ersten Blick wirken deshalb Unit-Tests oft sinnlos, weil offensichtlich ist, dass der zu testende Code richtig ist und macht, was man erwarten würde. Aber in Zukunft könnten sich kleine Dinge am Code oder den Eingangsparametern ändern, sodass sich das Verhalten in einem Ausmaß ändert, das man nicht erwartet hat. Außerdem dient ein Test damit auch indirekt der Dokumentation. Im Kontext von Behavior-driven Development (BDD) bezeichnet man das als Living Documentation. Durch Wahl eines sinnvollen Testnamens dokumentiert man gleichzeitig den Programmcode und erleichtert die zukünftige Pflege und Weiterentwicklung. Es ist ja allgemein bekannt, dass das Javadoc zwar initial geschrieben wird, um das Quality Gate bei Sonar zu schaffen, aber später weder gelesen noch gepflegt wird. Deshalb sind sprechende Methodennamen für Tests der bessere Weg.

Listing 1: ItemRepository

@org.springframework.stereotype.Repository public interface ItemRepository extends Repository<Item, Long> { Item findOne(Long id); List<Item> findAll(); List<Item> findByLocation(String location); Item save(Item item); void delete(Long id); }

Listing 2: ItemService

@Service public class ItemService { public Logger logger = LoggerFactory.getLogger(ItemService.class); private ItemRepository itemRepository; @Autowired public ItemService(ItemRepository itemRepository) { Assert.notNull(itemRepository, "Repository must not be null!"); this.itemRepository = itemRepository; } public List<Item> getAllItems() { return itemRepository.findAll(); } public List<Item> getAllItemsWithPrefixedLocation(String prefix) { List<Item> items = itemRepository.findAll(); items.forEach(item -> item.setLocation(prefix+item.getLocation())); return items; } public List<Item> getItemsByLocation(String locationName) { return itemRepository.findByLocation("Regal "+computeLocation(locationName)); } private String computeLocation(String locationName) { return locationName.toUpperCase(); } public Item create(Item item) throws IllegalArgumentException{ if (item.getId() != null) { throw new IllegalArgumentException("id in Item must be null"); } return itemRepository.save(item); } public Item update(Item todo) { logger.debug(todo.getLocation()); if (itemRepository.findOne(todo.getId()) == null) { return null; } return itemRepository.save(todo); } public void delete(long id) { itemRepository.delete(id); }

Die meisten Methoden reichen den Parameter, der ihnen übergeben wird, einfach an das ItemRepository weiter. Ausnahmen sind die Methoden getAllItemsWithPrefixedLocation, update und create. Zu beachten ist, dass das ItemRepository per Konstruktor-Injection übergeben wurde und nicht per Field Injection. Gerade wenn man Unit-Tests für eine Klasse schreibt, merkt man sehr schnell, dass Konstruktor-Injection der bessere Weg ist. Spätestens dann, wenn man das @Autowired bei der Field Injection einmal vergisst und sich wundert, warum sich Anwendung und Test merkwürdig verhalten, aber man keinen Fehler findet. Mehr dazu findet man in dem Blogpost von Oliver Gierke [2], in dem erklärt wird, warum Field Injection böse ist. Als Letztes benötigt die Beispielanwendung einen REST-Controller, der die Items über ein REST-API zur Verfügung stellt. Der ItemController ist in Listing 3 zu sehen.

Listing 3: ItemController

@RestController public class ItemController { private ItemService itemService; private boolean testFlag; @Autowired public ItemController(ItemService itemService) { Assert.notNull(itemService, "Service must not be null!"); this.itemService = itemService; } @GetMapping(value = "/item") ResponseEntity<List<Item>> getAllItems() { return new ResponseEntity<List<Item>>(itemService.getAllItems(),HttpStatus.OK); } @GetMapping(value = "/item/{location}") ResponseEntity<List<Item>> getItemAtLocation(@PathVariable("location") String location) { return new ResponseEntity<List<Item>>(itemService.getItemsByLocation(location), HttpStatus.OK); } @PostMapping(value = "/item") ResponseEntity<Item> createItem(@RequestBody Item item) { return new ResponseEntity<Item>(itemService.create(item), HttpStatus.CREATED); } @PutMapping(value = "/item") ResponseEntity<Item> updateItem(@RequestBody Item item) { //... Item updatedItem = itemService.update(item);  //... return new ResponseEntity<Item>(updatedItem, HttpStatus.OK); } @DeleteMapping(value = "/item/{id}") public ResponseEntity<Item> deleteItem(@PathVariable("id") long id) { if(testFlag) { itemService.delete(id); } return new ResponseEntity<Item>(HttpStatus.NO_CONTENT); } @GetMapping(value = "/item/{id}/Location.html", produces = MediaType.TEXT_HTML_VALUE) public String getLocationOfItemWithIdHTML(@PathVariable("id") long id) { Item item =itemService.getItemWithId(id); String html = "<html><body><h2>" + item.getLocation() + "</h2></body></html>"; return html; } }

Wegen der Lesbarkeit wurden bei den Methoden bis auf die Methode getLocationOfItemWithIdHTML keine Rückgabeformate definiert. Es wäre a...

Neugierig geworden? Wir haben diese Angebote für dich:

Angebote für Teams

Für Firmen haben wir individuelle Teamlizenzen. Wir erstellen Ihnen gerne ein passendes Angebot.

Das Library-Modell:
IP-Zugang

Das Company-Modell:
Domain-Zugang