BRICKMAKERS Blog

Kovarianz und Kontravarianz von Unit-Tests

Geschrieben von Pascal Martin | Mar 6, 2020 3:18:00 PM

Unit Tests! Seit Jahrzehnten ein großes und immer noch kontrovers diskutiertes Thema in der Softwareentwicklung. Mittlerweile findet man zum Glück kaum noch Entwickler, die freiwillig darauf verzichten. Trotzdem kommt es immer wieder vor, dass sich das Schreiben und vor allem die Wartung der Tests trotz TDD, BDD und unzähligen Tools und Frameworks nicht richtig anfühlt. Es kommt zu Bugs trotz grüner Tests. Es ist schwierig, größere Refactorings durchzuführen, weil man ständig auch Tests editieren muss, obwohl sich gar kein Verhalten geändert hat. Einer der möglichen Gründe dafür ist eine sehr starke Test-Kovarianz und eine mögliche Lösung hierfür ist die Test-Kontravarianz. Beides findet man sehr anschaulich erklärt im Blog von Uncle Bob.

 

Abbildung 1: Kovariante Klassenstruktur


Auf den ersten Blick ist hierbei kein Problem erkennbar. Der Code, den ich geschrieben habe, war in der Regel genauso strukturiert. Jede Klasse stellte für mich eine “Unit” dar und hatte seine Test-Klasse mit den entsprechenden Unit-Tests. Worin besteht also das Problem?

Das Problem liegt in der Kopplung zwischen dem Produktiv- und dem Test-Code. Wir versuchen seit Jahren unseren Code in Komponenten, Module oder sonstige kleine Einheiten zu zerlegen und so dafür zu sorgen, dass sie bloß nicht zu viel voneinander wissen. Wir implementieren Interfaces, benutzen Dependency Injection und IoC-Container und schreiben dann Unit-Tests, in denen jede einzelne Klasse in unserer Lösung direkt referenziert wird. Noch deutlicher wird dieses Problem, wenn man sich den Inhalt der Klassen anschaut, die das öffentliche Interface eines Moduls darstellen. Bei Webanwendungen sind das zum Beispiel die Controller.

 

Abbildung 2: Stark gekoppelte Test-Klasse


In diesem Test referenzieren wir nicht nur den Controller selbst, sondern binden den Test-Code auch noch an seine interne Struktur. Ist der Aufruf von ListCourses tatsächlich das Verhalten, welches wir hier testen wollen? Zu 99% lautet die Antwort hier: Nein! Eigentlich wollen wir testen, ob der Controller alle Kurse eines Studenten auflistet. Möglicherweise haben wir das ursprünglich sogar auch getan. Meistens entstehen solche Aufruf-Tests dadurch, dass wir die Business Logik aus dem Controller in einen Service verschieben und danach den Test-Code umbauen, damit er so aussieht wie oben beschrieben. Dadurch testet der Test-Code des Controllers jetzt nicht mehr das Verhalten, sondern die interne Struktur. Zusätzlich ist er mit der Signatur des Services gekoppelt. Dazu kommen dann noch neue Interfaces, die wir für das Erzeugen der Mock-Objekte brauchen. Ruck Zuck haben wir so jede Menge Code geschrieben, der eine starke Kopplung hat, dadurch schwer wartbar wird und im schlimmsten Fall noch nicht einmal das tut, was er eigentlich tun soll.

Die Lösung für dieses Problem heißt Test-Kontravarianz. Das bedeutet, dass Test- und Produktiv-Code eben nicht die gleiche Klassen- und Methodenstruktur haben. Bei diesem Vorgehen gibt es sicherlich viele Möglichkeiten, wie man den Test-Code strukturiert. Am einfachsten finde ich es, den Test-Code nach Features oder User-Stories zu strukturieren, indem zum Beispiel eine Test-Klasse pro User-Story mit wenigstens einer Test-Methode pro Akzeptanzkriterium geschrieben wird.

Test-Code in dieser Form zu schreiben erfordert, wenn man wie ich an kovariante Tests gewöhnt ist, ein großes Umdenken und einiges an Einarbeitung. Man merkt jedoch relativ schnell, dass die Menge an geschriebenem Code deutlich kleiner wird. Das gilt sowohl für Produktiv- als auch für Test-Code. Am Wichtigsten sind dabei zwei Punkte:

  1. Die Definition von “Unit” muss sich ändern und darf nicht länger gleichbedeutend sein mit einer Klasse

  2. Man muss deutlich weniger Interfaces schreiben und Mock-Klassen ausprägen

Beides hat zur Folge, dass die Tests integrierter sind. Und auch wenn das auf den ersten Blick so aussieht, als würde man die Kopplung der Software erhöhen, geschieht tatsächlich genau das Gegenteil. Wie eine solche integrierte Unit-Test Suite im Detail aussieht und warum in diesem Fall gerade beim Thema Mocks und Interfaces weniger mehr ist, darauf würde ich gerne in einem anderen Beitrag eingehen.

 

An diese Stelle noch einmal vielen Dank an Max, Johanna und Lena für ihr Feedback und das Korrekturlesen ;-)