Über Unit- und Integration-Tests – Ein Liebesbrief an die Units

Integration tests determine if independently developed units of software work correctly when they are connected to each other.

Integration-Tests ermitteln, ob unabhängig voneinander entwickelte Softwareeinheiten korrekt funktionieren, wenn sie miteinander verbunden werden.

https://martinfowler.com/bliki/IntegrationTest.html

Mit diesen Worten eröffnete Martin Fowler im Januar 2018 einen Post über Tests, genauer Integration-Tests. Natürlich nicht ohne Kontext, denn direkt darauf folgt:

The term has become blurred even by the diffuse standards of the software industry, so I’ve been wary of using it in my writing.

Der Begriff ist selbst nach den diffusen Maßstäben der Software-Industrie verwaschen, daher war ich vorsichtig, ihn in meinen Texten zu verwenden.

https://martinfowler.com/bliki/IntegrationTest.html

Ich stoße, nun nahezu drei Jahre später häufig auf dasselbe. Die Begriffe “Integration-Test” und “Unit-Test” haben zur gleichen Zeit so viel und so wenig Aussagekraft wie es nur erdenklich ist. Einige dieser häufigen Probleme möchte ich hier ins Rampenlicht stellen und auch eine semantische und praktische Alternative dafür aufzeigen.

Damit versuche ich dann Unit-Tests die Liebe zurückzugeben, die sie eigentlich verdient haben. Aber natürlich nicht “einfach so”, sondern wohldefiniert und so, dass man sich allgemein verständigen kann, ohne dass die Begriffe so verwaschen sind, dass keiner genau weiß, was gemeint ist.

Ich konzentriere mich hier auf Enterprise-Entwicklung. Für Framework-Entwicklungen sind die Aussagen hier nicht gedacht, auch wenn sie in großen Teilen so angewendet werden können. Für Code-Beispiele verwende ich hier Kotlin im Kontext eines Spring-Boot-Projektes, aufgebaut mit Maven.

Da es ein langer Text ist, empfehle ich ein Heißgetränk der Wahl und eine gemütliche Position zu diesem Text.

Was ist denn jetzt was?

Jeder hat mit Sicherheit eine Vorstellung, was was ist. Was ein Unit-Test ist und was ein Integration-Test ist. Unit-Tests testen die einzelnen Units und Integration-Tests testen die Integration dieser Units. Richtig?

Uff, was für eine Lawine. Aber ich verwette meinen letztes (und einziges) Hemd darauf, dass jeder so etwas mindestens schon ein mal gehört hat, richtig? Lasst uns das mal auseinander nehmen und anfangen mit den Unit-Tests. Was genau ist jetzt eine Unit? Dazu hat auch jeder eine etwas andere Meinung. Ein paar Beispiele dafür (besonders aus deutschen Quellen) sind:

Beim Unit-Test wird einen Teil des Codes isoliert. Die einzelnen Codes werden auf Funktionalitäten überprüft. Die Tests validieren das Verhalten und die Funktion des Codes.

https://de.yeeply.com/blog/unit-test-in-der-softwareentwicklung-was-ist-zu-beachten/

Ein Unit-Test deckt eine möglichst kleine Einheit wie etwa eine Methode, ein Modul oder eine Klasse ab. Die klassische Definition, der auch Kent Beck in seinem Buch “Test-driven Development by Example” [2] folgt, erlaubt auch größere Einheiten durch Unit-Tests zu testen. Vladimir Khorikov verwendet den Begriff “Unit of Work“, um eine Menge an Klassen und Methoden zu bezeichnen, die ein Unit-Test abdeckt. Die “London School” hingegen verlangt, dass das SUT (System Under Test) nur von Wertobjekten abhängt [3]. Alle anderen Abhängigkeiten muss der Testentwickler durch Test-Doubles [4] ersetzen [5].

https://www.informatik-aktuell.de/entwicklung/methoden/was-ist-ein-guter-unit-test-und-wie-entwickelt-man-ihn.html

Unit-Tests (=Komponententests) überprüfen, ob die von den Entwicklern geschriebenen Komponenten so arbeiten, wie diese es beabsichtigen. In agilen Methoden wird zur Qualitätssicherung eine sehr häufige Ausführung der Komponententests angestrebt. Das lässt sich nur erreichen, wenn die Tests vollständig automatisiert vorliegen, sie also selbst ein Programm sind, dessen Ausführung nicht mehr Aufwand als einen Knopfdruck erfordert.

https://www.it-agile.de/wissen/agiles-engineering/unit-tests/

Viele Köpfe scheinen also viele unterschiedliche Meinungen zu haben. Jetzt denke man zurück an die Universität, wo die Testmodelle eingeführt wurden. Dort bezeichnete mein Professor den Unit-Test als “Modultest”. Hier wurde von “funktionalen Einzelteilen” geredet. Eine zu testende Einheit ist so groß oder so klein wie notwendig, aber niemals größer als die funktionale Klammer um den selbst geschriebenen Code.

Wie passt das jetzt alles zusammen?

Nun, der gemeinsame Nenner bei allen Aussagen hier ist: Ein Unit-Test testet eine Einheit. Die Variabilität ist die Größe eben jener. Man kann die Größe einer Einheit auslegen wie man es präferiert.

Alle “größeren Tests” sind also dann Integration-Tests, richtig?

Hier wird es interessant. Wenn auf diese Frage mit “Ja” geantwortet wird, so kann es sein, dass ein Test in dem einen Projekt als “Integration-Test” und in einem anderen Projekt als “Unit-Test” erkannt wird. An diese Tests gehen wir ganz anders heran, besonders in der Enterprise-Entwicklung. Unit Tests können den Entwicklungsprozess und die Qualitätssicherung sogar behindern oder gar gegenteilig wirken.

Wo können Unit-Tests kontraproduktiv sein?

Nehmen wir die School of London (meistens in TDD verwendet), oder eine etwas losere Variante eben jener. Wir haben eine Klasse die wir testen wollen, also isolieren wir diese indem wir alle Abhängigkeiten mocken. Von der Idee her soll dieser Test jetzt nur diese eine Klasse testen und verifizieren, dass diese ein Klasse genau so funktioniert wie vorgesehen.

Lasst uns das Ganze etwas konkreter im Kontext von Enterprise-Entwicklung aufziehen. Sagen wir, wir haben die Klasse “UserService” in unserem hypothetischen Spring-Boot-Projekt. Diese sieht in etwa so aus:

@Service class UserService( private val userRepository: UserRepository, private val userFactory: UserFactory, private val emailService: EmailService ) { @Transactional fun createNewUser(userDto: CreateUserDto) { if(userRepository.findByEmail(userDto.email).isPresent) { throw AlreadyRegisteredException() } val user = userFactory.construct(userDto) val persistedUser = userRepository.save(user) emailService.sendRegisteredEmail(persistedUser) } }
Code-Sprache: Kotlin (kotlin)

Dies ist mehr oder weniger ein klassisches Beispiel von so einem UserService. Wenn es den Nutzer bereits gibt, dann werfen wir eine Exception, die von einem RestControllerAdvice entsprechend verarbeitet wird. Ansonsten bauen wir einen User auf Basis des DTOs zusammen, speichern diesen und schicken dem just registrierten Nutzer eine Email, dass er sich registriert hat. Diese Methode is annotiert mit @Transactional, was wiederum bedeutet, dass Daten nur dann in der Datenbank persistiert werden, wenn es keine Fehler bei der Ausführung der annotierten Methode gab. Eigentlich würde dieser Service auch Daten zurück geben, die an den Anfragenden zurück geschickt werden können, aber das lassen wir in diesen Beispielen der Einfachheit halber weg.

Nun, wir wollen einen stark isolierten Test schreiben. Wie sieht das jetzt aus? Zuerst müssen wir Mocks für alle Abhängigkeiten erstellen, an die zu testende Klasse übergeben und verifizieren, dass alles richtig abgelaufen ist. Dies sieht (nach AAA und mit Mockk) in etwa so aus:

class UserServiceTest { @Test fun testCreateNewUser() { // Arrange val userRepository: UserRepository = mockk() val userFactory: UserFactory = mockk() val emailService: EmailService = mockk() val user: User = mockk() val userDto: UserDto = UserDto( email = "e.mail@email.com", password = "password" ) every { emailService.sendRegisteredEmail(user) } just runs every { userRepository.findByEmail(userDto.email) } returns Optional.empty() every { userRepository.save(user) } returns user every { userFactory.construct(userDto) } returns user val userService = UserService(userRepository, userFactory, emailService) // Act userService.createNewUser(userDto) // Assert verifyAll { userRepository.findByEmail(user) userFactory.construct(userDto) userRepository.save(user) emailService.sendRegisteredEmail(user) } } }
Code-Sprache: Kotlin (kotlin)

Unser Arrange-Block ist … lang. Sehr lang. Wir können das Ganze verkürzen, indem wir (wo möglich) die Mocks als “relaxed” markieren, aber trotzdem haben wir einen sehr langes Setup. Nicht nur das, zudem ist der Assert block sehr wenig aussagekräftig. Warum?

Lasst uns darüber einmal nachdenken. Was verifizieren wir hier gerade? Wir stellen sicher, dass einige Methoden aufgerufen werden (in einem extrem Fall sogar simpel mit any()). Was wollen wir aber eigentlich testen? Wir wollen die Funktionalität der Unit sicherstellen. Aber wir stellen das hier doch gar nicht sicher. Wir testen, dass Methoden aufgerufen werden. Ob das jetzt wirklich unsere Funktionalität ist, ist zweifelhaft. Warum?

Was stellen wir hier sicher? Wir stellen sicher, dass die Methoden aufgerufen wurden. Nicht, dass Ergebnisse richtig sind oder dass das delegate Ballette zwischen diesen Aufrufen entsprechend korrekt ist. Denn wir konstruieren ja das Ergebnis und Parameter für die Methodenaufrufe in unserem Test selbst. Um die anderen Fälle und Abzweigungen kümmern sich dann andere Tests. Heißt, wir schreiben eine neue Methode für den “if”-Fall. In diesem Fall ist das einfach. Aber wie sieht das mit komplexerer Logik aus? If-Else in If-Else zum Beispiel? Alle diese Fälle müssten wir jetzt einzeln testen. Und wenn wir an dem Code etwas verändern? Naja, der Usecase an sich mag noch korrekt implementiert sein, aber die Tests schlagen trotzdem fehl, weil nicht “dieses else in dem if getriggert wurde”.

Wir spiegeln unseren Code und die darin verarbeitete Logik quasi in den Unit-Tests, was wiederum für viel zusätzliche Arbeit sorgt. Verändern wir das Zusammenspiel der Klassen, müssen wir nicht nur den Code selbst anpassen, sondern auch noch alle Tests, in denen wir das gespiegelt haben. Stellenweise mehrfach. Dabei ist alles was an der Logik wichtig ist: “Kommt das richtige Ergebnis zurück bei richtigen Eingabewerten”.

Diese Art von Tests ist besonders bei TDD sehr verbreitet. Es ist eine Möglichkeit sich ausschließlich auf “die eine entwickelte Klasse” zu konzentrieren. In dem Scenario wollen wir uns auch nicht auf andere Klassen konzentrieren, schließlich entwickeln wir gerade unsere eine Klasse. Auch im klassischen Wasserfallmodell wird dies häufig verwendet, um sich auf bestimmte Teile zu konzentrieren und keine Ablenkung bei der Entwicklung zu haben. Hier allerdings nicht so stark zentriert wie bei TDD.

Dieses Vorgehen hat allerdings noch einen weiteren großen Nachteil: Wenn wir etwas an der Signatur der Methode ändern, müssen wir auch den Test ändern. Im ersten Moment klingt das richtig. Logisch, wir haben ja die Methode angepasst, also müssen wir auch den Test anpassen. Aber jetzt haben wir den code des Methodenaufrufs zwei mal geschrieben: In der Klasse die die genutzte Klasse testet und in dem Test. Wir spiegeln hier also auch den Code, den wir sowieso schon geschrieben haben und sind nicht sicher gegenüber Refaktorisierung. Wir spielen also auch durch, wie die Klassen miteinander kommunizieren, nicht ob das Gesamtergebnis davon korrekt ist.

Solche “Schnittstellentests” sind häufig von Vorteil, wenn man REST-Controller, Kafka-Consumer oder andere Consumer-Driven Schnittstellen aufbaut. Bei einem REST-Controller ist es zwingend notwendig dass der Entwickler mitbekommt, wenn die Signatur sich nicht Rückwärts kompatibel verändert. Aber dafür, wie unser UserService das UserRepository aufruft ist das eigentlich nicht so relevant.

Wie können wir nun allerdings das delikate Ballet zwischen all den Klassen sicherstellen, ohne, dass wir genau sagen was wann wie passiert? Wir können ganz simpel einen Test schreiben, der alles einfach… naja… benutzt. Und hier scheiden sich die Geister:

Also schreiben wir halt einfach einen Integration-Test?

Bevor wir diese Frage beantworten, lasst uns einen Integration-Test schreiben. Für unsere Spring-Boot-Applikation kann der so aussehen:

@SpringBootTest @TestDatabase @Transactional class UserServiceTest { @Autowired private lateinit var userService: UserService @Autowired private lateinit var userRepository: UserRepository @Sql("testCreateNewUser.sql") @Test fun testCreateNewUser() { // Arrange val userDto: UserDto = UserDto( email = "e.mail@email.com", password = "password" ) // Act userService.createNewUser(userDto) // Assert assertThat(userRepository.findByEmail(userDto.email)).isNotEmpty() } }
Code-Sprache: Kotlin (kotlin)

Unsere Testmethode sieht jetzt sehr viel schlanker aus. Die AAA Sektionen sind viel übersichtlicher und wir brauchen nicht mehr so viel Code zu schreiben. Die @SpringBootTest Annotation sorgt dafür, dass ein Spring Application Context hochgefahren wird. @TestDatabase ist eine Beispiel-Annotation, damit wir eine Datenbank haben und @Transactional sorgt dafür, dass alle Änderungen nach diesem Test zurückgerollt werden. @Sql führt das angegebene SQL-Script vor der Methode aus.

Jetzt haben wir alle diese Systeme im Zusammenspiel und führen die Methode “einfach aus”. Wir sagen nicht mehr, was wann passiert und bestimmen damit auch nicht die Funktion des Tests im Test selbst. Wir geben nur einen Eingabewert hinnein und checken, dass am Ende alles so ist wie es sein soll. Wenn wir jetzt intern etwas verändern, dann wird der Test hier sagen, ob unser Usecase noch genauso umgesetzt ist, oder ob wir wirklich etwas an der Logik kaputt gemacht haben. Also genau das perfekte Verhalten! Dafür müssen wir jetzt etwas anderes in Kauf nehmen: Zeit, Performance und reduzierte QA-Sicherheit.

ZEIT
Einen Spring Boot Application Context zu starten kostet Zeit. Viel Zeit. Und diese Zeit ist notwendig, noch bevor die Methode ausgeführt wird. Quasi vor “BeforAll”. Aber das wird vermutlich mit Abstand das kleinste Problem sein. Wir können die Integration-Tests separieren und nur in der Pipeline ausführen, richtig? Ja, klar, aber warum schreiben wir diese dann? Wenn diese Tests quasi die “single source of truth” oder “best line of defence” sind, warum können wir diese dann nicht regelmäßig und häufig ausführen, ohne dass wir länger auf Ergebnisse von Tests warten als wir tatsächlich programmieren? Wir kommen auf dieses Thema gleich noch einmal zu sprechen, da es viele Reibungspunkte mit den anderen beiden Themen gibt.

PERFORMANCE
Spring ist ein Monster. Es ist riesig und das muss es sein. Es braucht eine ganze Flotte an Klassen und Modulen, nur um alle Components, Beans und Configurations zu finden, aufzubauen, externe frameworks in diesen Cycle zu integrieren, Proxies via dynamischer Metaprogrammierung zu erstellen usw. Puh. Das ist ein ganzer Mund voll. Und all das passiert vor dem Test. All diese Klassen werden in den Heap geladen und gehalten (größtenteils zumindest). Jetzt liegt ein riesiger Eisberg an Klassen vor, nur um unsere vier Klassen im Ballet zu testen.

Hier gibt es mit Sicherheit auch dein eines Framework was das ganz anders und besser macht, aber auch dieses muss immer vollständig geladen werden. Das meiste davon ist aber nicht relevant für den Test. Wir wollen unseren Code testen, nicht das Framework welches wir nutzen.

QA-SICHERHEIT
Das ist wohl das mit Abstand nervtötendste der Probleme. Wer kennt das nicht:

Die Pipeline schlägt fehl, Testfehler. Hmm. Mist. Naja, nochmal starten und siehe da, schon ist es grün. War wohl mal wieder ein Hickup.

Woran das liegt, ist leider nicht sooo trivial. Obwohl, doch. Eigentlich schon. Woran liegt das?
Springs Application Context ist groß, gigantisch groß und beinhaltet den Status aller Klassen. Normalerweise arbeiten wir nach Möglichkeit ohne Zustand, also “Stateless”. Aber trotzdem gibt es immer einen Zustand. Die Instanzen werden (salopp gesagt) von Spring verwaltet. Und das potentiell über Tests hinweg. So zum Beispiel können wir einmal eine Verbindung zur Datenbank aufbauen und müssen das nicht jedes Mal aufs Neue machen. Das erhöht die Performance, reduziert den Stress auf den Heap und den Prozessor und erspart uns auch Zeit, da wir nur einmal starten müssen. Allerdings teilen wir den Zustand dann über mehrere oder gar alle Tests hinweg.

Wir können jetzt Spring sagen “erstelle diesen Kontext nach diesem Test neu” und dasselbe passiert auch, wenn wir Mocks verwenden. Wir können Spring sagen, dass bestimmte Klassen im Context als Mock vorgehalten werden sollen. Wenn wir das machen, dann wird der Kontext nach den Tests neu erstellt. Dagegen können wir fast nichts machen. Verwenden wir Mocks, haben wir also wieder ein riesen Zeitproblem, denn jeder dieser Tests muss erst einen neuen Application-Context hochfahren. In den Zahlen steht am Ende “Test executed successful in 25ms”, aber das Hochfahren, bis die Testmethode ausgeführt werden kann schlägt sich darin nicht wieder.

Auf gut Deutsch gesagt: Wir kämpfen damit, ob unsere Tests isoliert und unabhängig von anderen Tests sind, oder ob unsere Tests schnell starten und durchlaufen. Doch damit nicht genug.
Wir haben jetzt mehrere “Sollbruchstellen” in unseren Tests.
Der Spring Application Context startet auch einen Tomcat. Der lauscht auf einen Port. Natürlich kann man diesen zufällig wählen lassen, aber wenn unser Testsystem es nicht erlaubt, dass die Applikationen Ports belegen dürfen, dann schlägt der Test fehl, ohne dass unser Code einen Fehler hat.
Wir brauchen ggf. eine Datenbank. Die braucht auch wieder einen Port. Die muss aber auch gestartet werden, z.B.: über Docker. Heißt, auf der Maschine die die Pipelines ausführt muss Docker installiert und verfügbar sein. Klar, auch das ist bei sehr vielen Systemen heutzutage der Fall, aber ebenfalls eine notwendige Abhängigkeit. Im Notfall nehmen wir einfach eine In Memory Datenbank, wie H2. Sind damit dann alle Probleme gelöst?
Nicht wirklich, denn wir müssen mit der Datenbank über das Netzwerk kommunizieren, was fehlschlagen kann. Auch bei Wiremock-Servern kann dass der Fall sein. Netzwerkkommunikation ist häufig ein Punkt, der besonders langsam sein kann, abgebrochen werden kann oder ähnliches, da das Betriebssystem hier asynchrone IO-Operationen synchronisiert.
Dasselbe ist übrigens auch der Fall für andere IO-Operationen, wie das Öffnen von Dateien.

Sehr viele Sollbruchstellen und fragile Kommunikationen. Das sind Stellen, die den Test zum Fehlschlagen bringen könnten, ohne dass der Usecase verändert wurde und nicht mehr funktioniert. Eigentlich genau das, was wir nicht wollen. Die Tests sollen dann fehlschlagen, wenn wir den Code kaputt gemacht haben. Wenn die inhärente Logik kaputt gemacht oder verändert wurde. Wenn dass delegate Ballette zwischen den Klassen nicht mehr richtig funktioniert. Auch ein Problem ist die Komplexität die wir damit einführen. Ein neuer Entwickler muss nicht nur verstehen wie das System aufgebaut wurde, sondern auch noch wie das Testsystem funktioniert.
Haben wir die Tests nicht “optimiert”, also so gebaut, dass jeder Test seinen eigenen Context startet und damit auch lange Zeit zum Starten braucht, dann erhöhen wir die Zeit, die ein neuer Entwickler braucht um das System kennen zu lernen. Mal eben kurz ein Feature implementieren/updaten dauert sehr lange, besonders bis alle Tests grün sind. Natürlich ist das auch bei extrem isolierten Unit-Tests der Fall, denn die schiere Menge der Tests ist hier ggf. überwältigend, auch wenn bei diesen etwas klarer ist, was wo wie passiert.
Haben wir die Tests allerdings so aufgebaut, dass wir den Application-Context wiederverwenden, dann können wir als neuer Entwickler potentiell andere Tests kaputt machen, ohne dass wir diese anfassen. Die Testklasse ist nicht mit @Transactional annotiert? Uff, in anderen Tests sind jetzt Testdaten, die wir eigentlich nicht haben wollen. Wir sehen das nicht direkt. Unser Test klappt, der andere klappt, aber gemeinsam schlagen sie fehl. Aber auch nicht immer. Nur wenn der neue vor dem älteren ausgeführt wird. Viel Spaß beim Debugging.

Also ist das alles schlecht?

Was wir in dem vorherigen Abschnitt gesehen haben ist ein full out Integration-Test. Einen Test, der in Teilen das System nachstellt, was wir Entwickler auf dem Rechner nutzen, um die Applikation zu entwickeln. Es emuliert nicht das Produktionssystem, also ist kein klassischer System-Test. Gehen wir aber noch einmal einen Schritt zurück und gehen auf den Begriff der Unit ein. Was ist in diesem oben genannten Scenario eine Unit, die wir testen können?

Nun, gucken wir uns den hypothetischen Abhängigkeitsbaum (dependency tree) an, der aufgebaut wird:

Unsere vier Klassen sind relativ simpel aufgebaut. Das UserRepository benötigt den JPA EntityManager. Selbst wenn wir die Annahme treffen, dass Spring JPA die Implementation selbst aufbaut, ist die Abhängigkeit inhärent. Unser EmailService nutzt irgendeinen Client, der den eigentlich Request gegen den Mailserver aufbaut und absetzt. In der Applikation selbst wird der UserService beispielhaft von einem RestController verwendet. Wir könnten den Test auch an dem Controller ansetzen, um das Beispiel aber homogen zu halten, lassen wir den Test weiter auf dem UserService laufen. Warum das besser sein könnte, wird hoffentlich gleich klar.

In dem Integration-Test haben wir nichts gemockt, bei dem Unit-Test sah dies wie folgt aus:

Jeder Mock bedarf einer Instantiierung, sowie eine Definition “Was passiert wann”. Heißt, was wir versuchen zu testen (den UserService), testen wir wie oben bereits beschrieben nur bedingt. Und klar, das muss auch gemacht werden, damit wir keine Sollbruchstellen in die Tests einbauen. Aber was wollen wir dann eigentlich? Unser Code soll getestet werden, aber alles was Sollbruchstellen einführen würde, soll gemockt werden. Perfekt wäre so etwas:

Wir mocken ausschließlich die externen Systeme. Heißt, wir müssen keine Datenbank und keine Verbindung zum Mail-Server aufbauen. Das könnten wir auch “einfach” schreiben. Nur dann sähe der Test ungefähr so aus:

class UserServiceTest { @Test fun testCreateNewUser() { // Arrange val entityManager = mockk<EntityManager>(relaxed = true) val emailClient = mockk<EmailClient>(relaxed = true) val email = "e.mail@email.com" val user = User( email = email, password = BCrypt.hash("password") ) val userDto: UserDto = UserDto( email = email, password = "password" ) val userRepository = UserRepository(entityManager) val userFactory = UserFactory() val emailService = EmailService(emailClient) every { emailClient.dispatch(any()) } just runs every { entityManager.find(User::class.java, email) } returns user every { entityManager.merge(eq(user)) } returns user val userService = UserService(userRepository, userFactory, emailService) // Act userService.createNewUser(userDto) // Assert verifyAll { entityManager.persist(eq(user)) emailClient.dispatch(any()) } } }
Code-Sprache: Kotlin (kotlin)

Okay, was haben wir hier gerade gemacht? Der Arrange-Block sieht einer Wall Of Text sehr sehr ähnlich. Dort bauen wir den oben im Bild dargestellten Abhängigkeitsbaum auf. Und dieser Block ist … lang. 72% des gesamten Tests sind nur der Aufbau der zu testenden Klasse. Bei dem verifyAll-Block verifizieren wir dann, dass die externen Systeme getriggert wurden. Wie, was dazwischen passiert ist, ist uns egal. Unser Service wird aufgerufen und macht etwas. Am Ende soll ein neuer User erstellt werden. Alles andere ist eigentlich nicht relevant. Im Grunde ist dies, wie unser Integration-Test von vorhin, nur wesentlich länger.

Und jetzt die 1 Millionen-Euro-Frage: Ist das ein Unit- oder Integration-Test?

So viel Pallaber, nur für diese eine Frage. An alle die jetzt rufen “UNIT-TEST DU VOLL IDIOT”: Okay, sorry, man muss nicht direkt aggressiv werden, aber um auf den Kern deiner Antwort zu sprechen zu kommen: nicht zwingend. Dasselbe gilt für die andere Richtung. Es hängt nur davon ab, wo wir die Grenzen ziehen. Sehen wir eine Einheit als Klasse an, dann wäre das schon ein Integration-Test. Ziehen wir die Grenze größer, dann ist das noch ein Unit-Test. Zum Beispiel, wenn wir die Grenze wie folgt setzen (das Gelbe ist unsere Unit):

Eine Einheit ist ein sehr schwammiger Begriff. Sie kann so groß oder so klein sein, wie man möchte. Unsere Unit ist nur eine Klasse? Okay. Unsere Unit ist die gesamte Applikation? Auch okay. Unsere Unit ist ein Modul aus der Applikation? Ebenfalls okay.

Es ist ausschließlich eine Frage der Definition. Das Wichtigste ist, dass man sich einheitlich an diese Definition hält, die man als Team festlegt. Aber das kann zu Problemen führen, wenn man mit anderen Teams zusammenarbeitet. Meinungskonflikte sind quasi vorprogrammiert und am Ende des Tages sind die Tests nicht das Produkt, welches wir abliefern wollen. Also leiden diese meist.

Ich will aber sicherstellen, dass Spring das auch richtig macht.

Vorsicht. Das ist ein sehr tiefes Kaninchenloch. Wenn wir selbst komplexe JPQA Querries schreiben, die komplizierte logische Zusammenhänge beachten, dann ist es absolut verständlich, dass wir sicherstellen wollen, dass dieses auch funktioniert. Und das geht eigentlich nur in Integration-Tests, da wir die von Spring generierten Repositories brauchen, um diese zu verifizieren. (Achtung, auch hier ist eigentlich ein sehr starkes Wort. Auch die können wir isoliert verwenden.)

Aber müssen wir zum Beispiel sicherstellen, dass das Speichern von Entitäten mit von Spring generierten Repositories funktioniert? Und wenn ja, auch dass wir die dann finden können? Und auch ob diese in “findAll” enthalten sind? Wo hört das auf? Was ist das Ziel?

Ich höre auch schon die Stimmen, die vorher gesagt haben:

Aber woher weiß ich denn jetzt, dass die Datenbank nicht befüllt wird, wenn ein Fehler in dem UserService passiert? Woher weiß ich, dass die Annotation dafür sorgt?

Möchtest du wirklich verifizieren, dass alle diese Konzepte so funktionieren wie sie es sollen? Warum nur @Transactional? Woher wissen wir, dass @Async funktioniert? Oder @Retryable? Und wie sicher ist das mit @KafkaListener? Und wenn wir schon dabei sind: Woher wissen wir, dass ein Test, der mit @Transactional annotiert ist, auch wirklich zurück rollt, nachdem der Test abgeschlossen ist?

Auf alles was wir nutzen müssen wir uns zu einem gewissen Maß stützen können. Man stelle sich vor, man würde testen, dass die Controller auch wirklich richtig an das Servlet gebunden und auch wirklich richtig aufgerufen werden. Wo hört das auf? Bei der Erstellung von Proxies? Eigentlich muss man auch sicherstellen, dass diese richtig gebaut werden, oder? Und ehe man sich versieht, testet man, dass Test-Frameworks richtig Tests testen.

Bitte seid vorsichtig mit dieser Argumentation! Die Frameworks, die wir verwenden, werden auch getestet (Jaja, bis auf dieses eine da). Sie selbst stellen die Funktionalität ihrer Business Logik fundamental sicher. Sie sind wie ein anderes, externes Modul. In Integration-Tests testen wir, dass diese dann auch zusammen funktionieren, aber in den meisten Fällen ist nicht dies der Fokus. Wir müssen die Annahme treffen können, dass das Framework funktioniert wie es sich beschreibt, sonst testen wir nur Frameworks. Überlegt einmal, wie viel Code ihr schreibt versus wie viel Code ihr via Frameworks importiert.

Dafür gibt es Ausnahmen, selbstverständlich. Keine Frage, dein einer Test ist sehr wichtig was das angeht. Aber was stellst du sicher und was willst du sicherstellen? Sind die Kosten das wert? Ist dieser Test schon einmal fehlgeschlagen außer aufgrund eines Fehlers, den du in deiner Logik gemacht hast?

Eine alternative Semantik

Selbstverständlich ist es “up to you” was du tust und was du für was einschätzt. Aber lass mich ein paar Vorschläge machen, wie man Dinge aufbauen und konkretisieren könnte.

Vorab: ich möchte keinen Styl der Entwicklung verdammen oder schlecht machen. Bei z.b: TDD nicht mit isolierten Klassen zu arbeiten ist eine bewusste Entscheidung, die jeder für sich selbst treffen muss.
Es kann richtig in dem Kontext von dem Team oder dem Projekt sein, muss es aber nicht.
Ich möchte mit diesen Vorschlägen auch nicht einen Styl hervorheben.
Ich versuche hier lediglich Kategorien zu schaffen, an denen man sich orientieren kann und das besonders für die Enterprise-Entwicklung.

Ohne weitere Umschweife, hier sind meine Vorschläge für die Begriffe und deren Kontext besonders für die Enterprise-Entwicklung:

Unit-Test
Ein Unit-Test ist ein Test, welcher eine beliebig große, aber wohldefinierte und zusammenhängende Einheit testest, die keine externen System aufruft. Spätestens sobald ein Unit-Test über Systemgrenzen gehen muss, reden wir von einem Integration-Test. Der Hauptfokus in diesem Test liegt auf der Funktionalität des selbstgeschriebenden Codes nach EVA-Prinzip.

Diese Tests sollten schnell und robust sein. Sie testen isolierte Einheiten, die wiederum unsere Logik wiederspiegeln. Unsere Usecases, Business-Logik oder wie auch immer man es nennen möchte. Bugs hier sind tatsächliche Fehler in unserer Logik, die wir eingeführt haben.

Wir sollten immer genau dann einen Unit-Test schreiben, wenn wir eine neue Anforderung im Code umsetzen. Unit-Tests sollten nur dann angepasst werden müssen wenn:

  • die Anforderung sich geändert hat
  • sich Parameter geändert haben
  • ein neuer (vielleicht neu erkannter) Bug auftritt
  • sich Anforderungen an eine ausgeklammerte Unit geändert haben

Bei einem Unit-Test sollten wir soweit wie möglich oben in dem Abhängigkeitsbaum ansetzen und so wenige Abhängigkeiten wie möglich mocken, sofern möglich nur “Blätter”. Dann ist es nicht notwendig den Test anzupassen, wenn wir in dem Methodenrumpf Änderungen vornehmen. Sofern unser Test noch die Anforderungen erfüllt, verrät er uns trotzdem eine Menge darüber, ob die Logik an sich richtig ist.


Integration-Test
Ein Intergration-Test ist ein Test, welcher ein vollständiges Entwicklungssystem emuliert und das Zusammenspiel von mehreren, besonders externen, Systemen sicherstellt. Ziel ist es, Sollbruchstellen zu erkennen und für die System-Tests, sowie der Produktionsumgebung abzusichern oder gar aufzulösen und grundlegendere Probleme bei der Verwendung von Frameworks zu erkennen.

Diese Tests müssen nicht schnell sein. Sie dürfen auch sehr sehr lange dauern, weil sie das Zusammenspiel mit komplexen Systemen testen. Da sie allerdings so lange dauern, sollten Unit-Tests für die Validierung von neuen oder angepassten Logiken herangezogen werden und Integration-Tests nur bei neuen oder angepassten, verwendeten Frameworks.

Somit sind Integration-Tests Sicherungsnetze, die die Verwendung des Gesamtsystems validieren, nicht aber, ob die Usecases selbst korrekt implementiert sind. Wir treffen die Annahme, dass diese verwendeten Frameworks korrekt sind und alle Fehler durch eine falsche Verwendung auftreten. Das bedeutet allerdings auch, dass jeder Integration-Test möglichst isoliert von allen anderen ist. Die Datenbank (sofern benötigt) wird immer neu gestartet und der Application Context neu aufgebaut. Keine potentiellen Sollbruchstellen sollten zwischen diese Tests eingeführt werden, da wir hier versuchen diese im Code zu finden. Unsere Unit-Tests sollten nicht dieselbe oder gar eine größere technische Komplexität haben, als unser Code den wir versuchen zu testen.

Um es mit Martin Fowlers Worten zu sagen:

Integration tests determine if independently developed units of software work correctly when they are connected to each other.

Die Frameworks, die wir verwenden, funktionieren für sich gesehen und sind via Unit-Tests abgesichert. Unsere Logik funktioniert für sich gesehen und ist ebenfalls über Unit-Tests abgesichert. Nun lasst uns sicherstellen, dass wir dieses Framework bei uns richtig verwendet und integriert haben. Schlägt ein Integration-Test fehl, nun, dann haben wir einen Fehler bei der Verwendung dieses Frameworks gemacht und können keine Schlussfolgerungen für die Business-Logik ziehen.


System-Test
Ein System-Test ist dann schlussendlich ein Integration-Test der auf einem “Echt-System”, welches der Produktionsumgebung nachempfundenen ist, ausgeführt wird. Das System wird dabei nicht von dem Test selbst emuliert, sondern ist ein “echtes” System, welche genutzt werden kann und soll “as is”, indem die Applikation “einfach gestartet wird”. Ziel ist, dass die Applikation “immer noch” funktioniert, wenn sie auf Produktion released wird.

Dies ist dann der Test, ob alles was wir vorher aufgebaut haben, genauso auch am Ende funktioniert. Zum Beispiel können wir einen Docker hochfahren, indem alle Produktionsumgebungen gesetzt sind und wir die Applikation “tatsächlich starten”.


Warum aber genau so?

In der Enterprise-Entwicklung ist das Ziel einem Kunden ein fertiges Produkt zu servieren, nicht ein neues, perfektes, ausgereiftes System zu entwickeln. Dabei spiele ich nicht auf Architektur oder Verständlichkeit des Codes an, diese stehen hier nicht zur Debatte, sondern auf die Entwicklung von Logik und Code, welcher Anforderungen umsetzt. Alt und bewährt kann besser sein, als neu und glänzend, besonders wenn wir unter Zeitdruck stehen. Besonders wenn wir unser Produkt am Ende an den Kunden oder einen weiteren Dienstleister übergeben. Damit wir uns nicht verlieren, können wir uns in Unit-Tests also auf die Funktion konzentrieren und auch den Entwicklern, denen wir dieses Produkt übergeben, mit Zuverlässigkeit sagen: “Wenn die Unit-Tests grün sind, dann sind alle Anforderungen umgesetzt und Fehler sind nicht betrachtete Randfälle”.

Ist die Verarbeitung von Usern korrekt?
Werden Daten richtig aufbereitet, bevor sie persistiert werden?
Haben wir Fehler beim Zurückgeben von Datensätzen?
Kann ein neues Team problemlos mit diesem Code weiterarbeiten ohne sehr lange Einarbeitungszeit?

All das interessiert uns, den Kunden oder andere Dienstleister am Ende des Tages mehr als:

Haben wir so wenig Code wie möglich geschrieben?
Ist der Code so perfekt und elegant wie möglich?
Klappt diese neue und bessere Variante von Hibernate, die ich selbst geschrieben habe?
Ist das System neu und glänzend?

Am Ende des Tages haben wir ganz viele Probleme mit denen wir uns jeden Tag herumschlagen. Warum startet die Applikation nicht richtig? Wieso brennt die Pipeline grade jetzt? Wenn wir schnell und zuverlässig Tests ausführen können, um zu verifizieren, dass wir keine Bugs in unserer inhärenten Logik haben, dann sparen wir Unmengen an Zeit. Die Unit-Tests sind grün? Okay, dann müssen wir das Framework falsch verwendet haben. Die Integration-Tests sind grün, alles klar, wir haben eine fundamental falsche Annahme getroffen.

Natürlich ist das ganze auch hier mit etwas Salz zu betrachten. Es kann sein, dass wir komplexe neue Systeme entwerfen. Systeme, die komplizierte technische Anforderungen umsetzen. Und selbstverständlich müssen wir auch diese testen. Aber wir können auch die Systeme ansehen wie externe Frameworks. Die Funktionalität von jenen wird in Unit-Tests sichergestellt und ob wir sie richtig verwenden, in Integration-Tests. Sobald wir etwas neues, technisches entwerfen, entwerfen wir ein Framework und können auch hier dieselben Standards anwenden.

Make Unit-Tests Great Again*

*Kein politisches Statement

Lasst uns das ganze System doch einmal konkret anwenden. Nehmen wir unseren Unit-Test von oben, der den Abhängigkeitsbaum komplett getestet hat:

class UserServiceTest { @Test fun testCreateNewUser() { // Arrange val entityManager = mockk<EntityManager>(relaxed = true) val emailClient = mockk<EmailClient>(relaxed = true) val email = "e.mail@email.com" val user = User( email = email, password = BCrypt.hash("password") ) val userDto: UserDto = UserDto( email = email, password = "password" ) val userRepository = UserRepository(entityManager) val userFactory = UserFactory() val emailService = EmailService(emailClient) every { emailClient.dispatch(any()) } just runs every { entityManager.find(User::class.java, email) } returns user every { entityManager.merge(eq(user)) } returns user val userService = UserService(userRepository, userFactory, emailService) // Act userService.createNewUser(userDto) // Assert verifyAll { entityManager.persist(eq(user)) emailClient.dispatch(any()) } } }
Code-Sprache: Kotlin (kotlin)

Dieser Test ist so schwer zu lesen, weil der Arrange-Block so unglaublich viel Code erfordert. Ein neuer Test ist ca noch mal so lang wie dieser, heißt wir sorgen für sehr sehr viel Code. Was wäre, wenn wir diesen Block automatisieren könnten? Also unseren Test auf das Folgende reduzieren:

class UserServiceTest { private lateinit var userService: UserService private lateinit var entityManager: EntityManager private lateinit var emailClient: EmailClient @Test fun testCreateNewUser() { // Arrange val email = "e.mail@email.com" val user = User( email = email, password = BCrypt.hash("password") ) val userDto: UserDto = UserDto( email = email, password = "password" ) every { entityManager.find(User::class.java, email) } returns user every { entityManager.merge(eq(user)) } returns user // Act userService.createNewUser(userDto) // Assert verifyAll { entityManager.persist(eq(user)) emailClient.dispatch(any()) } } }
Code-Sprache: Kotlin (kotlin)

Wir haben jetzt “irgendwie” die Abhängigkeiten der Klassen in unserem Test. Neue Tests sind ebenfalls sehr viel kleiner, da der Arrange-Block reduziert ist auf “Welche Eingabe-Daten gibt es”. Wir “verwenden” die Klassen ganz einfach. Unser Arrange-Block sieht nun sehr viel schlanker aus und wir sagen nur noch wie die (emulierten) externen Systeme sich verhalten sollten. Kein händisches Aufbauen und Gefrickel mit Klassen, die uns in dem Test eigentlich gar nicht interessieren. Zusätzlich ist unser Test robuster. Er muss nur angepasst werden, wenn:

  • die Methoden Signatur von der zu testenden Methode sich ändert (auch Klassen, die hier übergeben werden) oder
  • die “Business-Logik” (so etwas wie Anforderungen an die Software) sich ändert.

Allerdings sind sämtliche transitive Abhängigkeiten im Kontext von Refactoring egal. Sie werden einfach aufgebaut, nicht mehr explizit genannt und sorgen bei Anpassung nicht für Fehler im Code. Wenn sie dies tun, dann wurde die Business-Logik geändert und wir müssen jeden Test anpassen. Das Standardverhalten wird impliziert durch den Code selbst und muss nicht explizit definiert werden. Wie können wir das jetzt konkret erreichen?

Dafür können wir im Prinzip jedes beliebige DI Framework verwenden. Eine Kernidee dafür wäre, sich eine JUnit Jupiter Extension zu schreiben (oder eine JUnit 4 Rule, wenn notwendig), welche die Felder befüllt.

Luck is on your side.

Genau das habe ich an einem regnerischen Freitagabend im 2020 Lockdown getan. Die Monstrosität habe ich “Wire Unit” getauft. Es gibt mit Sicherheit 17 bessere Namen dafür, aber wenn es nicht einen komischen Namen hätte, wäre es nicht von mir.

Von der Idee her sieht das nun exakt so aus wie vorher, plus Annotations. Dafür ist die folgende Dependency notwendig:

<dependency> <groupId>com.github.thorbenkuck</groupId> <artifactId>wire-unit</artifactId> <version>0.1</version> <scope>test</scope> </dependency>
Code-Sprache: HTML, XML (xml)

Dann nehmen wir unseren Test wie zuvor und brauchen damit nur das Folgende zu tun:

@AutoWire class UserServiceTest { @Wire private lateinit var userService: UserService @Inject private lateinit var entityManager: EntityManager @MockWire private lateinit var emailClient: EmailClient @Test fun testCreateNewUser() { // Arrange val email = "e.mail@email.com" val user = User( email = email, password = BCrypt.hash("password") ) val userDto: UserDto = UserDto( email = email, password = "password" ) every { entityManager.find(User::class.java, email) } returns user every { entityManager.merge(eq(user)) } returns user // Act userService.createNewUser(userDto) // Assert verifyAll { entityManager.persist(eq(user)) emailClient.dispatch(any()) } } }
Code-Sprache: Kotlin (kotlin)

Die @AutoWire-Annotation fügt eine Extension hinzu, welche Felder befüllt, die selbst mit Annotationen befüllt sind. Es wird explizit erwartet, dass die Felder annotiert werden, damit nicht andere Probleme auftreten mit Feldern in euren Tests.

Das Framework sucht nun nach den folgenden Annotationen:

  • @com.github.thorbenkuck.wire.annotations.Wire
  • @javax.inject.Inject
  • @com.github.thorbenkuck.wire.annotations.MockWire
  • @io.mockk.impl.annotations.MockK

Die erste Annotation (@Wire) ist die mit Abstand wichtigste. Sie sorgt dafür, dass das Framework einen impliziten Dependency Tree aufbaut und Supplier bereitstellt für alle notwendigen Klassen. Und mit Aufbauen meine ich wirklich Aufbauen. Das Framework analysiert rekursiv alle Konstruktoren aller Abhängigkeiten. So lange bis es Konstruktoren ohne Abhängigkeiten findet, die sie einfach instantiieren kann. In diesem Abhängigkeitsbaum sind Interfaces mit Mockk gemockt. Warum erkläre ich gleich weiter unten.

Das bedeutet allerdings auch, dass Setter und Field Injection nicht unterstützt werden. Wenn eure Spring-Boot-Applikation ausschließlich auf Setter oder Field Injection setzt, dann funktioniert das hier nicht. Warum habe ich das gemacht? Die Idee war, dass das Ganze unabhängig von anderen Frameworks ist (soweit wie irgend möglich). Es soll nicht auf Spring gebrandet sein oder abhängig von anderen, es soll standalone sein. Vielleicht wird mit V.1 die Möglichkeit eingebaut auch diese zu setzen, aber für jetzt ist das nicht unterstützt.

Wenn ihr wollt, dass Abhängigkeiten aus dem aufgebauten Dependency Tree injectet werden, dann könnt ihr mit @Inject diese hier einfügen, ohne andere Konzepte auszulösen.

@MockWire und @MockK sind besonders. Sie sorgen dafür, dass die Typen von markierten Feldern in dem resultierenden Dependency Tree sowie alle ihre Kinder als “Mocks” gesetzt werden. Was bedeutet das? Nehmen wir an, wir haben den folgenden Abhängigkeitsbaum:

Und einen Test, der so aufgebaut wird:

@AutoWire class ExampleTest { @Wire private lateinit var a: A @MockWire // Or @MockK private lateinit var d: D // Tests.. }
Code-Sprache: Kotlin (kotlin)

Wenn das Framework jetzt den Abhängigkeitsbaum aufbaut, so sieht der resultierende Abhängigkeitsbaum folgendermaßen aus:

Alle D-Abhängigkeiten und auch alle transitiven F-Abhängigkeiten werden gemockt. Aber! Die Rekursivität wird durch Mockk erstellt. Das heißt, wir können die transitive Abhängigkeit nicht in unseren Test injecten! Aber, von der Idee her sollten nur externe Abhängigkeiten gemockt werden. Das bedeutet, eigentlich solltet ihr hier keine Probleme haben. Eigentlich. Und das ist eine gute Überleitung in “Warum werden alle Interfaces gemockt”.

Warum alle Interfaces mocken?

Das hat zwei große Gründe. Performance und Architektur Annahmen.

Für Performance ist die Grundidee die folgende: Wir haben die direkten Abhängigkeiten überall vorliegen. Aber nicht die Implementationen von Interfaces. Das heißt, wir müssten Implementationen händisch suchen. Was bedeutet, wir müssten händisch durch den Classpath usw. Dieser Rattenschwanz ist etwas, was ich definitiv nicht hier einführen wollte. Stattdessen können wir “einfach” die Abhängigkeiten finden.

Für Architektur habe ich eine Annahme getroffen. Wann und warum benutzt man in der Enterprise-Entwicklung Interfaces? Bei Frameworks, klar: Wir stellen diese als Schnittstelle für Implementationen bereit, die wir nicht kennen, oder erweiterbar halten wollen, oder… You get the point. Aber in der Enterprise-Entwicklung? Was bringt es uns etwas über ein Interface zu abstrahieren? Meistens sind das Kosten an Komplexität, die wir nicht wirklich wollen. Wir entwerfen hier das Endprodukt, also warum sollten wir eine potentielle technische Erweiterung einbauen? An den Stellen wo wir das machen, machen wir das um an die Architekturen anzupassen. Zum Beispiel in einer klassischen 3-Schichten Architektur, die in etwa so aussieht:

Application ist hier die Schnittstelle nach außen, Business Logic ist…. nun…. Die Business-Logik und Entities sind die Abbildung der Umwelt in Objekte (zum Beispiel mit DDD). Wenn Application die externen Schnittstellen sind und wir in einer Business-Logik z.B. eine E-Mail schicken wollen, wie ist das vereinbar? Meistens, indem in Business Logic ein Interface hinzugefügt wird und die Implementation in Application hinterlegt wird. Dann, via IOC/DI Container, werden diese Abhängigkeiten aufgelöst und gebunden. So kann hier die Logik einen externen Service nutzen, ohne die eindeutige Abwärtsabhängigkeit zu verletzen.

Das heißt also: meistens definieren Interfaces Schnittstellen für externe Systeme in solchen klassischen Architekturen. Aber auch in der “altbewährten” DDD-Architektur, wo die Anbindung an die Datenbank in dem untersten Layer liegt, ist dies war. Denn der EntityManager, welcher in Jpa verwendet wird, ist auch über ein Interface abstrahiert. Sogar in Ports And Adapters, wo Ports interfaces sind zu anderen System braucht man nichts besonderes zu machen und bei Clean Architecture… Sagen wir einfach ihr braucht hier nicht noch mehr zu machen…

Abschließend…

Wir können zeiteffizient Tests schreiben, die unsere Logik, die Mutter aller Q.A.-Notwendigkeiten, testet und das auch noch schnell und ohne dass diese Tests kaputt gehen, wenn wir eine interne Abhängigkeit verändern. Wir können via dem uralten EVA-Prinzip uns darauf konzentrieren, dass unsere Logik macht was sie soll. Das Ganze während wir Sollbruchstellen in Kern-Frameworks wie Spring umgehen.

Integration-Tests sind dann mehr Tests, die sicherstellen dass wir die Technologie richtig verwenden, nicht dass sie macht was sie soll. Damit haben wir eine Testhierarchie, die uns nicht in eine Welt führt, wo jeder Test ein Integration-Test sein muss, damit er als “gut” oder “ausreichend” gilt und nicht jeder Unit-Test nervtötend ist. Automatisierung von Q.A. muss nicht kostspielig und nervig sein.

In den Fällen, in denen es möglich ist, sollten wir dann nicht isolierte Klassen testen, sondern isolierte logische Einheiten. Wir sollten auch keine externen Systeme ansprechen, die unseren Test brechen können, obwohl er nicht kaputt ist. Tests haben dann wieder eine Aussagekraft.

Make Unit-Tests Great Again*
*Kein politisches Statement

1

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.