diff --git a/blog/2026-05-11-unit-testing-vs-integration-testing/images/payment-tests.svg b/blog/2026-05-11-unit-testing-vs-integration-testing/images/payment-tests.svg new file mode 100644 index 0000000..f88f2b1 --- /dev/null +++ b/blog/2026-05-11-unit-testing-vs-integration-testing/images/payment-tests.svg @@ -0,0 +1,2 @@ +Integrationstests (Zahlungskomponententeam)„Normale“ Service-Tests (Serviceteams)ZahlungskomponenteInterne Ende-zu-Ende-TestsUnit-TestsUnit-TestsZahlungs-dienstleisterZahlungs-dienstleister-adapter(bedingt zuverlässig)Unit-TestsUnit-TestsZahlungs-logikZahlungs-APIServicesDatenbankFake-Zahlungsmittel-Implementierung \ No newline at end of file diff --git a/blog/2026-05-11-unit-testing-vs-integration-testing/images/test-scopes-overlapping.svg b/blog/2026-05-11-unit-testing-vs-integration-testing/images/test-scopes-overlapping.svg new file mode 100644 index 0000000..06c9957 --- /dev/null +++ b/blog/2026-05-11-unit-testing-vs-integration-testing/images/test-scopes-overlapping.svg @@ -0,0 +1,2 @@ +FizzBuzzTestDoesTestSimpleSelectorTestAscendingStreamerTestDoesSimpleSelectorAscendingStreamerFizzBuzz \ No newline at end of file diff --git a/blog/2026-05-11-unit-testing-vs-integration-testing/images/test-scopes-separate.svg b/blog/2026-05-11-unit-testing-vs-integration-testing/images/test-scopes-separate.svg new file mode 100644 index 0000000..ca2f94d --- /dev/null +++ b/blog/2026-05-11-unit-testing-vs-integration-testing/images/test-scopes-separate.svg @@ -0,0 +1,2 @@ +FizzBuzzTestDoesTestSimpleSelectorTestAscendingStreamerTestDoesSimpleSelectorAscendingStreamerFizzBuzz \ No newline at end of file diff --git a/blog/2026-05-11-unit-testing-vs-integration-testing/images/unit-testing-vs-integration-testing.png b/blog/2026-05-11-unit-testing-vs-integration-testing/images/unit-testing-vs-integration-testing.png new file mode 100644 index 0000000..829fc09 Binary files /dev/null and b/blog/2026-05-11-unit-testing-vs-integration-testing/images/unit-testing-vs-integration-testing.png differ diff --git a/blog/2026-05-11-unit-testing-vs-integration-testing/index.mdx b/blog/2026-05-11-unit-testing-vs-integration-testing/index.mdx new file mode 100644 index 0000000..f578fb1 --- /dev/null +++ b/blog/2026-05-11-unit-testing-vs-integration-testing/index.mdx @@ -0,0 +1,687 @@ +--- +title: "Unit-Testing vs. Integration-Testing" +description: "Was ist eigentlich eine Unit? Und wie können wir zwischen Unit- und Integration-Testing das Beste herausholen?" +authors: + - sebastianhans +date: 2026-05-11 +tags: [testing] +image: ./images/unit-testing-vs-integration-testing.png +--- +import CenteredImage from '@site/src/components/Image'; +import PaymentTestsSvg from './images/payment-tests.svg'; +import TestScopesSeparateSvg from './images/test-scopes-separate.svg'; +import TestScopesOverlappingSvg from './images/test-scopes-overlapping.svg'; + +# Unit-Testing vs. Integration-Testing + +Unit-Tests und Integrationstests werden oft miteinander verglichen – und dabei meist als Gegensätze +gegenübergestellt. In diesem Artikel werde ich diese beiden Testarten unter einem anderen +Blickwinkel betrachten und zeigen, wie diese Sichtweise uns helfen kann, bessere Tests zu schreiben. + +{/* truncate */} + +## Plädoyer für eine differenzierte Betrachtung + +Unit-Tests werden meist als „klein“, „schnell“ und „zuverlässig“ charakterisiert, während +Integrationstests in der Regel als „groß“, „langsam“ und „unzuverlässig“ angesehen werden. +Vergleicht man einen Test für eine Methode einer Klasse, die nicht viel tut, mit einem Test des +kompletten Systems inklusive externer Datenbank, so stimmt das auch. Bei ersterem wären sich +wahrscheinlich die meisten Entwickler einig, dass es sich um einen Unit-Test handelt, und bei +letzterem, dass es sich um einen Integrationstest handelt. Das ist sicherlich nicht falsch. Es ist +aber auch nicht die ganze Wahrheit. Schauen wir uns dazu mal einen Test für eine Java-Klasse an, die +das allseits beliebte FizzBuzz-Problem löst. + +Der gesamte Code aus diesem Post steht im GitHub-Repo +[fizzbuzz-testing-example](https://github.com/bbvch/fizzbuzz-testing-example) +zum Ausprobieren zur Verfügung. + + +```java +public class FizzBuzz { + public List go(int n) { + return IntStream.rangeClosed(1, n).mapToObj(this::fizzBuzz).toList(); + } + + private String fizzBuzz(int n) { + if (n % 3 == 0 && n % 5 == 0) return "FizzBuzz"; + else if (n % 3 == 0) return "Fizz"; + else if (n % 5 == 0) return "Buzz"; + else return Integer.toString(n); + } +} + +public class Main { + public static void main(String[] args) { + var fizzBuzz = new FizzBuzz(); + fizzBuzz.go(100).forEach(System.out::println); + } +} + +class FizzBuzzTest { + @Test + void oneIs1() { + FizzBuzz fizzBuzz = new FizzBuzz(); + + var result = fizzBuzz.go(1); + + assertEquals("1", result.getFirst()); + } + + // + eine Reihe ähnlicher Testfälle +} +``` + +Vermutlich würde jeder zustimmen, dass das ein Unit-Test ist. Er instanziiert die Klasse, ruft eine +Methode auf, die nicht mit anderen Komponenten interagiert, und prüft das Ergebnis. Mehr nicht. … +Wirklich nicht? Was ist mit `IntStream`? Was ist mit `String` und `Integer`? Was +ist mit dem Java-Compiler? Und mit der Java-Runtime? All das ist nötig, um die Methode +`FizzBuzz.go(int)` wirklich auszuführen. Lassen wir etwas davon weg, ist das Programm nicht mehr +lauffähig. Ein Bug in jeder dieser Komponenten könnte den Test fehlschlagen lassen. Der Test läuft +nur erfolgreich durch, wenn all diese Komponenten fehlerfrei funktionieren. + +Lassen wir das erst mal sacken. + +„Aber die Sachen sind ja alle nicht von mir! Wenn mein Test von 3rd-Party-Zeug abhängt, soll das +heißen, _das_ ist ein _Integrationstest!?_“ + +Ja, genau das soll es heißen. Genauer gesagt: _Jeder_ Test ist ein Integrationstest, denn _jeder_ +Test integriert etwas, die Frage ist nur, was. Somit ist auch jeder Unit-Test ein Integrationstest. +Er integriert alles, was Bestandteil der Unit ist, und lässt alles weg, was nicht Teil der Unit ist. +Im Beispiel oben sind es die genannten externen Abhängigkeiten, aber der Test +integriert auch die Methoden `go(int)` und `fizzBuzz(int)`. Und auch die +Methoden der Standard-Library. + +Die spannende Frage ist also, … + +## Was ist eine Unit? + +Für unsere Zwecke ist eine _Unit_ eine in sich abgeschlossene Funktionseinheit +mit definierten Abhängigkeiten und Ein- und Ausgängen. +Units können als Teil einer größeren Unit zusammenarbeiten. +Wir erweitern das FizzBuzz-Beispiel ein wenig, um einige Möglichkeiten +aufzuzeigen: + +```java +public interface Output { + String render(); +} + +public record NumberOutput(int number) implements Output { + @Override + public String render() { return Integer.toString(number); } +} + +public enum FizzBuzzOutput implements Output { + FIZZ("Fizz"), + BUZZ("Buzz"), + FIZZBUZZ("FizzBuzz"); + + private final String representation; + + FizzBuzzOutput(String representation) { this.representation = representation; } + + @Override + public String render() { return representation; } +} + +public interface Selector { + Output select(int n); +} + +public class Does { + public boolean divide(int divisor, int n) { return (n % divisor == 0); } +} + +public record SimpleSelector(Does does) implements Selector { + @Override + public Output select(int n) { + if (does.divide(3, n) && does.divide(5, n)) return FIZZBUZZ; + else if (does.divide(3, n)) return FIZZ; + else if (does.divide(5, n)) return BUZZ; + else return new NumberOutput(n); + } +} + +public interface Streamer { + Stream go(int n); +} + +public record AscendingStreamer(Selector selector) implements Streamer { + @Override + public Stream go(int n) { + return IntStream.rangeClosed(1, n).mapToObj(selector::select); + } +} + +public record FizzBuzz(Streamer streamer) { + public List go(int n) { + return streamer.go(n).map((output) -> { + if (output == FIZZBUZZ) return output.render().toUpperCase(); + else return output.render(); + }).toList(); + } +} + +public class Main { + public static void main(String[] args) { + var fizzBuzz = new FizzBuzz(new AscendingStreamer(new SimpleSelector(new Does()))); + fizzBuzz.go(100).forEach(System.out::println); + } +} +``` + +Die neue Lösung trennt die Verantwortlichkeiten auf: Die Klasse `FizzBuzz` +verwendet nun einen `Streamer`, der bestimmt, mit welcher Zahl wir beginnen und +in welcher Reihenfolge die Zahlen abgearbeitet werden. `Streamer` wiederum +verwendet einen `Selector`, um zu ermitteln, was für eine Zahl tatsächlich +ausgegeben werden soll. `Selector` wiederum setzt auf der Hilfsklasse +`Does`[^does] +auf, die die Teilbarkeitsprüfung übernimmt. Die Ausgaben werden typisiert +zurückgegeben. `FizzBuzz` selbst muss nun nur noch den `Stream` von `Output`s in +eine Liste von `String`s umwandeln, um die Schnittstelle beizubehalten.[^record] + +[^does]: + Die Namen `Does` und `divide` mögen etwas seltsam anmuten, ermöglichen + aber im Client-Code die Formulierung `if (does.divide(3, n))`. Ob man die Namen + dafür in Kauf nimmt, ist Geschmackssache. + +[^record]: + Die Verwendung von `record` statt `class` für `SimpleSelector`, + `AscendingStreamer` und `FizzBuzz` dient hier nur der syntaktischen Kürzung für + die Zwecke eines Blog-Posts und ist ansonsten nicht sinnvoll. + +Eine Erweiterung haben wir allerdings noch eingebaut: Wir wollen nämlich +„FizzBuzz“ in Großbuchstaben schreien und verwenden dazu ein zusätzliches +Mapping in der Klasse `FizzBuzz`. + +Um die Einzelteile zusammenzustöpseln, verwenden wir constructor-based +Dependency Injection und die Verwendung von Interfaces erlaubt es uns, die +Implementierungen einfach auszutauschen. Die Aufteilung ist allerdings ein rein +internes Implementierungsdetail und hat keine Auswirkung auf die Funktionalität. +Bis auf die Initialisierung sieht die `main`-Methode genauso aus wie vorher. + +Was sind hier nun die relevanten Units? Die reflexartige Antwort wäre: „Jede +Klasse ist eine Unit.“ Für den Unit-Test bedeutet das, dass wir die Klassen +einzeln testen und die Abhängigkeiten dabei weglassen. Wir verwenden dazu +[Mockito](https://site.mockito.org/) und außerdem +[AssertJ](https://assertj.github.io/doc/), um den Umgang mit Listen und Streams +in den Tests zu vereinfachen. Hier ein paar beispielhafte Tests: + +```java +@ExtendWith(MockitoExtension.class) +class SimpleSelectorTest { + @Mock + private Does does; + + private SimpleSelector selector; + + @BeforeEach + void setUp() { + selector = new SimpleSelector(does); + } + + @Test + void modNothingYieldsNumber() { + when(does.divide(3, 1)).thenReturn(false); + when(does.divide(5, 1)).thenReturn(false); + assertThat(selector.select(1)).isEqualTo(new NumberOutput(1)); + } + + // … + + @Test + void mod3AndMod5YieldsFizzBuzz() { + when(does.divide(3, 1)).thenReturn(true); + when(does.divide(5, 1)).thenReturn(true); + assertThat(selector.select(1)).isEqualTo(FIZZBUZZ); + } +} +``` + +Dieser Test prüft die Auswahllogik und er tut dies direkter als der Test in der +vorigen Version, weil er das Ergebnis nicht mehr aus einer Liste herausholen +muss. Die Klasse `Does` ist weggemockt. Für die gibt es eigene Tests. + +```java +@ExtendWith(MockitoExtension.class) +class AscendingStreamerTest { + @Mock + private Selector selector; + + private Streamer streamer; + + @BeforeEach + void setUp() { + streamer = new AscendingStreamer(selector); + } + + @Test + void returnsSelectedOutputsInOrder() { + when(selector.select(1)).thenReturn(new NumberOutput(1)); + when(selector.select(2)).thenReturn(new NumberOutput(2)); + when(selector.select(3)).thenReturn(FIZZ); + + var stream = streamer.go(3); + + assertThat(stream).containsExactly(new NumberOutput(1), new NumberOutput(2), FIZZ); + } +} +``` + +Dieser Test prüft nur das Verhalten unserer Streamer-Implementierung. Der +Selector ist weggemockt. + +Und zu guter Letzt prüft der Test von `FizzBuzz` das Verhalten dieser Klasse, +wobei der Output, der aus dem Streamer stammt, durch Mockito vorgegeben wird: + +```java +@ExtendWith(MockitoExtension.class) +class FizzBuzzTest { + @Mock + private Streamer streamer; + + private FizzBuzz fizzBuzz; + + @BeforeEach + void setUp() { + fizzBuzz = new FizzBuzz(streamer); + } + + @Test + void aggregates() { + when(streamer.go(1)).thenReturn(Stream.of(new NumberOutput(1), new NumberOutput(2), FIZZ)); + assertThat(fizzBuzz.go(1)).containsExactly("1", "2", "Fizz"); + } + + @Test + void shoutsFIZZBUZZ() { + when(streamer.go(1)).thenReturn(Stream.of(FIZZBUZZ)); + assertThat(fizzBuzz.go(1)).containsExactly("FIZZBUZZ"); + } +} +``` + +Die Tests laufen alle erfolgreich durch und wenn wir `Main.main()` ausführen, +sehen wir, dass auch im Zusammenspiel alles funktioniert. + +Der Ansatz „Unit = Klasse“ (oder auch „Unit = Methode“) ist im Java-Umfeld weit +verbreitet. Tests dieser Art (auf Klassenebene mit weggemockten Dependencies) +habe ich schon oft in unterschiedlichen Projekten gesehen. Und Dependency-Ketten +sind durchaus üblich. Ein Beispiel in einem Microservice auf Basis +Ports-and-Adapters-Architektur könnte sein (Pfeile sind Laufzeitabhängigkeiten): +Web-Controller -> Driving Port -> Applikationsservice -> anderer Applikationsservice +-> Domänenobjekt -> anderes Domänenobjekt -> Driven Port -> JDBC-Adapter. + +Natürlich ist FizzBuzz in seiner Gesamtheit auch eine Unit. In der ersten +Implementierung steckt der gesamte Code in einer Klasse, in der zweiten ist er +über mehrere Klassen verteilt, aber dennoch handelt es sich um eine funktionale +Einheit. In diesem Fall ist die Einheit recht klein und lässt sich auch in ihrer +Gesamtheit gut testen, in komplexeren Code-Basen können solche Gesamttests aber +recht langsam sein (looking at you, `@SpringBootTest`). Das ist der Punkt, an +dem die meisten Entwickler das Wort „Integrationstest“ ins Spiel bringen und +ablehnende Vibes zu spüren sind. + +Was passiert aber nun mit dem hier vorgestellten Testing-Stil, wenn wir +Änderungen vornehmen? + +## Eine kleine Änderung +Nehmen wir an, der Auftraggeber für FizzBuzz möchte die Weichen für eine +strahlende Zukunft stellen, in der FizzBuzz nicht nur „fizzen“ und „buzzen“ +kann, sondern auch „zoomen“ und „boomen“ und noch viel mehr – und das nicht nur +bei 3 und 5, sondern bei beliebigen anderen Zahlen. Da dies dazu führen kann, +dass mehrere Zahlen bzw. deren Outputs kombiniert werden müssen, stoßen wir mit +unserem `FizzBuzzOutput`-Enum an die Grenzen. Da müssten wir ja nicht nur `ZOOM` +und `BOOM` als Werte hinzufügen, sondern auch alle möglichen Kombinationen. Um +die kombinatorische Explosion zu vermeiden, führen wir stattdessen einen +`CombinedOutput` ein: + +```java +public record CombinedOutput(List outputs) implements Output { + public CombinedOutput(Output... outputs) { this(List.of(outputs)); } + + @Override + public String render() { return outputs.stream().map(Output::render).collect(Collectors.joining("")); } +} +``` + +Und verwenden diesen in unserem `SimpleSelector`: + +```diff +< if (does.divide(3, n) && does.divide(5, n)) return FIZZBUZZ; +--- +> if (does.divide(3, n) && does.divide(5, n)) return new CombinedOutput(FIZZ, BUZZ); + +``` + +Und in dessen Test: + +```diff +< assertThat(selector.select(1)).isEqualTo(FIZZBUZZ); +--- +> assertThat(selector.select(1)).isEqualTo(new CombinedOutput(FIZZ, BUZZ)); +``` + +Einmal die gesamte Test-Suite ausgeführt, und wir sehen, dass alle Tests +durchlaufen. Fein! Das wäre geschafft. Wir sind nun gut gerüstet für die +Einführung von `ZOOM` bei 7 oder was auch immer sonst daherkommen mag. +Noch schnell in Produktion geschoben und das Sprint-Ziel ist erreicht. Oder der +im Werksvertrag vereinbarte Release-Umfang ist abgenommen. Oder so. Jedenfalls +sind wir fertig. + +----------------------------------------------------- + +**PAUSE** + +Bevor du weiterliest, überleg selbst mal, was gerade kaputtgegangen ist. Denn, +ja, es ist etwas kaputtgegangen. Ich habe aber nicht gelogen. Alle Tests laufen +ohne Fehler durch. + +----------------------------------------------------- + +Überlegt? Gut. Dann schauen wir es uns mal an. Was passiert, wenn wir +`Main.main()` ausführen? + +``` +1 +2 +Fizz +``` + +Bis hierher sieht es gut aus. Fizzen tut es. + +``` +4 +Buzz +``` + +Buzzen auch. + +``` +Fizz +7 +8 +Fizz +Buzz +11 +Fizz +13 +14 +FizzBuzz +``` + +Und FizzBuzzen tut es auch. Aber – moment mal! Sollte es nicht „FIZZBUZZ“ sein? +Da war doch diese SCHREI-Anforderung. Und ich weiß genau, dass es extra dafür +einen Test gibt. Ja, in `FizzBuzzTest`. Den schauen wir uns jetzt nochmal +genauer an. + +```java + @Test + void shoutsFIZZBUZZ() { + when(streamer.go(1)).thenReturn(Stream.of(FIZZBUZZ)); + assertThat(fizzBuzz.go(1)).containsExactly("FIZZBUZZ"); + } +``` + +Wenn der Streamer `FIZZBUZZ` liefert, soll „FIZZBUZZ“ herauskommen. Was ist also +das Problem? Das Problem ist, dass der Streamer in Produktion gar nicht mehr +`FIZZBUZZ` liefert, weil wir den Selector so geändert haben, dass er einen +`CombinedOutput` auswirft. Würde der Selector noch `FIZZBUZZ` auswerfen, würde +die Klasse `FizzBuzz` auch SCHREIEN. Aber das tut er nicht mehr. + +Da wir unsere Tests so auf die jeweilige Unit fokussiert haben – oder das, was +wir als Unit identifiziert haben, nämlich die Klasse –, waren sie nicht in der +Lage, diesen Mismatch aufzudecken. Ein umfassender Integrationstest hätte +geholfen, aber die sind ja teuer und langsam und werden daher erst relativ spät +im Entwicklungszyklus ausgeführt – oder auch ganz weggelassen, wenn die +Testabdeckung so schon gut ist. Und sie ist gut (100%).[^testabdeckung] + +[^testabdeckung]: + Ja, der Fehler wäre aufgefallen, wenn wir das überflüssige Enum + `FIZZBUZZ` gleich gelöscht hätten. Dann hätte `FizzBuzzTest` nicht mehr + kompiliert. Aber in großen Projekten ist es oft so, dass Änderungen nicht + gleichzeitig über die gesamte Code-Basis durchgeführt werden und alte Klassen + oder Werte daher vorerst erhalten bleiben. + +## Ein echtes Beispiel +Natürlich ist das Beispiel konstruiert. Aber genau solche Fehler kommen auch in +der Praxis vor. Ein Fall, der mir persönlich schon begegnet ist, war die +Duplikatsprüfung an einer REST-Schnittstelle. Es gab einen Test für den +Datenbankadapter, der sichergestellt hat, dass eine `DuplicatePaymentException` +geworfen wurde, wenn beim Einfügen in die Datenbank der Unique-Constraint +verletzt war. Und es gab einen Test in der API-Schicht, der sichergestellt hat, +dass im Falle einer `DuplicatePaymentTransactionException` eine entsprechende +Meldung (HTTP-Status 409) an den Client erfolgt. In dem Projekt wurde +stark auf Mocking gesetzt, um die „Units“ in den Tests zu isolieren. Jeder der +Tests sah, für sich betrachtet, plausibel aus und war erfolgreich. Nur haben +Clients in Produktion öfter mal HTTP-Status 500 bekommen statt der erwarteten +Duplikatsfehlermeldung, da die `DuplicatePaymentException` unbehandelt +durchgeflogen ist. Die Ursache ist erst aufgefallen, als ich die Tests +nebeneinander gehalten habe. Da ist mir aufgefallen, dass hier unterschiedliche +Exceptions zum Einsatz kommen. + +## Das Problem bei zu eng gefassten Unit-Tests +Das Grundproblem hier ist dasselbe wie im FizzBuzz-Beispiel: Der Test einer Unit +macht Annahmen über das Verhalten einer anderen Unit und kodiert sie fest in den +Test hinein, anstatt sie mit zu überprüfen. _Wenn_ der Streamer `FIZZBUZZ` +liefert, dann tut `FizzBuzz` das Richtige. Wenn er es aber nicht tut, hat der +Test keine Aussagekraft. _Wenn_ der Adapter eine +`DuplicatePaymentTransactionException` wirft, liefert die API die richtige +Antwort. Wenn er aber eine `DuplicatePaymentException` wirft, haben wir +verloren. + +Wir haben in unseren Tests versucht, Dinge unabhängig voneinander zu machen, die +in Wirklichkeit nicht unabhängig voneinander sind. Wenn sich aber das Verhalten +einer Dependency ändert, auf das sich die Implementierung unserer Unit verlässt, +wäre es schon gut, wenn wir einen Test haben, der fehlschlägt, wenn die Änderung +unsere Annahmen invalidiert. + +Also doch wieder zurück zum Monster-Integrationstest? Nicht unbedingt. Ich +schlage stattdessen überlappende Tests vor. + +## Überlappende Tests +Solche Tests sind auch Unit-Tests. Wir wählen nur die Unit etwas anders. +Bisher haben wir genau eine Klasse als Unit verwendet, mit Grenzen nach oben +(zum Aufrufer hin) und nach unten (zu den Dependencies hin). Wenn wir die Scopes +dieser Tests aufmalen, sind diese vollständig voneinander getrennt. Im folgenden +Bild umrahmt jeder Test die Klassen, die er abdeckt, nämlich genau eine. Die +Pfeile zwischen den Klassen stellen die Laufzeitabhängigkeit dar. + + + +Wie wir gesehen haben, ergibt sich durch diese scharfe Abgrenzung das Problem, +dass das Zusammenspiel ungetestet bleibt. Überlappende Tests lösen dies, indem +sie den Scope erweitern (also die Unit unter Betrachtung vergrößern), sodass +auch das Zusammenspiel der Klassen untereinander getestet wird. Bildlich +gesprochen, ist auch jeder Pfeil in mindestens einem Test-Scope enthalten. +Für unser Projekt „Over-engineered FizzBuzz“ kann das so aussehen: + + + +Und hier ist der Code dazu: + +```java +class SimpleSelectorTest { + private final SimpleSelector selector = new SimpleSelector(new Does()); + + @Test + void oneIs1() { + assertThat(selector.select(1)).isEqualTo(new NumberOutput(1)); + } + + // … + + @Test + void fifteenIsFizzBuzz() { + assertThat(selector.select(15)).isEqualTo(new CombinedOutput(FIZZ, BUZZ)); + } +} +``` + +Hier wird `Does` nicht mehr weggemockt, wodurch der Testcode sogar kürzer +wird.[^does-test] + +[^does-test]: + Der Test von `Does` selbst bleibt gleich. Hier gibt es ohnehin keine Abhängigkeiten. + +```java +class AscendingStreamerTest { + @Test + void returnsSelectedOutputsInOrder() { + var streamer = new AscendingStreamer(new SimpleSelector(new Does())); + + var stream = streamer.go(15); + + assertThat(stream).containsExactly( + n(1), n(2), FIZZ, n(4), BUZZ, FIZZ, n(7), n(8), FIZZ, BUZZ, n(11), FIZZ, n(13), n(14), FIZZBUZZ + ); + } + + private NumberOutput n(int n) { return new NumberOutput(n); } +} +``` + +Hier wird der Selector auch nicht mehr gemockt (und `Does` auch nicht – das +könnten wir zwar tun, hätten aber keinen Vorteil davon). Im Test müssen alle +möglichen Outputs vorkommen, die uns wichtig sind, daher geht der Test bis 15. +Da kommt zum ersten Mal `FIZZBUZZ`. Dies stellt sicher, dass der Streamer für +alle `Output`s funktioniert, die `SimpleSelector` liefert. In der +Testimplementierung mit Mocking ist das sinnlos, da der Test selbst definiert, +was der Streamer liefert. Er enthält sozusagen den Pfeil nicht. Dieser Test tut +das schon. + +```java +@ExtendWith(MockitoExtension.class) +class FizzBuzzTest { + @Mock + private Does does; + + private FizzBuzz fizzBuzz; + + @BeforeEach + void setUp() { + fizzBuzz = new FizzBuzz(new AscendingStreamer(new SimpleSelector(does))); + } + + @Test + void aggregates() { + when(does.divide(3, 1)).thenReturn(false); + when(does.divide(5, 1)).thenReturn(false); + when(does.divide(3, 2)).thenReturn(false); + when(does.divide(5, 2)).thenReturn(false); + when(does.divide(3, 3)).thenReturn(true); + when(does.divide(5, 3)).thenReturn(false); + + assertThat(fizzBuzz.go(3)).containsExactly("1", "2", "Fizz"); + } + + @Test + void shoutsFIZZBUZZ() { + when(does.divide(3, 1)).thenReturn(true); + when(does.divide(5, 1)).thenReturn(true); + + assertThat(fizzBuzz.go(1)).containsExactly("FIZZBUZZ"); + } +} +``` + +In diesem Test wird `Does` gemockt. Er reicht also nicht bis zum Ende der +Dependency-Chain (sonst wäre es ein vollständiger Integrationstest). +Er muss aber weit genug reichen, um die für ihn interessante +Verhaltensunterschiede in den Dependencies abzudecken. In unserem Fall heißt +das, der Selector muss noch mit rein, da der bestimmt, welche `Output`s in +`FizzBuzz` ankommen können. So bekommen wir einen Test, der fehlschlägt, wenn +wir den Selector von `FizzBuzzOutput.FIZZBUZZ` auf `CombinedOutput` umstellen +und dadurch unser SCHREI-Feature kaputtgeht. + +Wo genau man die Scope-Grenzen für die Tests setzt, um einen Mittelweg zwischen +scharf abgegrenzten Tests und allumfassenden Integrationstests zu finden, ist +ein wenig Erfahrungssache. Es gibt jedoch ein paar Heuristiken. + +Eine Dependency mit einer stabilen Schnittstelle kann besser weggemockt werden, +ohne Sicherheit zu verlieren. Die Schnittstelle von `Does` ist sehr stabil, da +sie auf mathematischen Regeln beruht, und kann somit gemockt werden, ohne Gefahr +zu laufen, von Änderungen überrascht zu werden. In diesem Beispiel ist das +tatsächlich nicht ganz so sinnvoll, da sie eigentlich schnell genug ist und wir +durch das Mocking nicht wirklich etwas gewinnen. Wenn wir uns aber vorstellen, +dass es sich um eine Web-Schnittstelle handelt, die langsam und ein wenig +unzuverlässig ist, aber einen stabilen Schnittstellenkontrakt hat, ergibt es +wesentlich mehr Sinn. + +## Ein größeres Beispiel +Das Konzept mit den überlappenden Tests lässt sich nicht nur auf Unit-Tests +innerhalb eines Stücks Software anwenden, sondern auch auf Integrationstests +zwischen mehreren Services. Ein Beispiel, bei dem wir es erfolgreich angewendet +haben, war die Integration mehrerer Services mit Zahlungsdienstleistern +über eine dedizierte Schnittstellenkomponente. + +Jeder Service hatte natürlich Tests für sich selbst und auch die +Zahlungskomponente hatte Tests für sich selbst. Große Integrationstests, bei +denen ein Service mit der Zahlungskomponente _und_ den Zahlungsdienstleistern +integriert war, waren schwierig, da die Anbindung an die Testumgebungen der +Zahlungsdienstleister nicht besonders stabil war und daher solche Tests oft +durch temporäre Probleme gestört waren. Darum haben wir als Entwickler der +Zahlungskomponente mit den Services vereinbart, dass sie für den Großteil ihrer +Tests Fake-Zahlungsmittel verwenden, bei denen die Anbindung an den +Zahlungsdienstleister abgeklemmt war. So konnten sie ungestört testen. Da sich +die Test-Scopes, wie im Bild zu sehen, überlappt haben, war trotzdem +sichergestellt, dass das Gesamtkonstrukt funktioniert. + +Das folgende Bild zeigt eine Übersicht der überlappenden Tests. Orange +dargestellte Tests liegen in der Obhut des Zahlungskomponententeams, blau +dargestellte bei den Serviceteams.[^payment-note] + +[^payment-note]: + Die Zahlungsdienstleister liegen im Bild nur halb im Test-Scope, da + deren Testumgebungen mit diversen Einschränkungen behaftet sind, sodass auch die + Integrationstests nicht alles abdecken. Ja, das hat Probleme verursacht. Ja, wir + haben auch in Produktion getestet. + + + +Zusätzlich zu den im Bild gezeigten Tests gibt es weitere überlappende Tests +innerhalb der Zahlungskomponente. Und auch die einzelnen Bausteine im Bild +bestehen aus mehreren Units, die teils scharf abgegrenzt, teils überlappend +getestet werden. Insgesamt erreicht die Zahlungskomponente so eine sehr gute +Testabdeckung – viel besser, als sie mit vollständig isolierten Unit-Tests zu +erreichen wäre, auch wenn man diese mit „großen“ Integrationstests ergänzen +würde. + +## Fazit +Unit-Tests und Integrationstests sind nicht scharf voneinander abgrenzbar – und +schon gar kein Widerspruch. Units können unterschiedlich groß sein. Sie grenzen +sich einerseits gegen andere Units ab und integrieren andererseits die +Bestandteile innerhalb der Unit. Units nur auf einer Ebene zu betrachten (z. B. +nur Unit = Klasse) und scharf abgegrenzt zu testen, führt zu Testlücken und in +Folge zu Bugs. + +Überlappende Tests schließen diese Testlücken und helfen gleichzeitig dabei, +aufwändige, langsame und teure _allumfassende_ Integrationstests zu vermeiden. + +In diesem Sinne: Happy Testing! diff --git a/blog/authors.json b/blog/authors.json index 4037512..4ce1169 100644 --- a/blog/authors.json +++ b/blog/authors.json @@ -59,5 +59,18 @@ "linkedin": "oliver-with", "github": "quattervals" } + }, + "sebastianhans": { + "name": "Sebastian Hans", + "title": "Senior Software Engineer & Architect", + "description": "Sebastian Hans is a Senior Software Engineer and Architect at bbv Software Services GmbH. Having worked in different roles and with a variety of technologies, he believes that the technology itself is not actually that important. His focus is on quality and the socio-technical aspects of software development.", + "url": "https://www.sebastian-hans.de/", + "imageURL": "/img/authors/SebastianHans.jpg", + "page": true, + "image": "", + "socials": { + "github": "sebastian-hans-bbv", + "mastodon": "https://hachyderm.io/@sebhans" + } } } diff --git a/blog/tags.yml b/blog/tags.yml index 4d192d0..0d2ebae 100644 --- a/blog/tags.yml +++ b/blog/tags.yml @@ -10,6 +10,10 @@ cloud-native: label: Cloud Native permalink: /cloud-native +testing: + label: Testing + permalink: /testing + well-architected: label: Well-Architected permalink: /well-architected diff --git a/docusaurus.config.ts b/docusaurus.config.ts index c6362c5..1c0fee4 100644 --- a/docusaurus.config.ts +++ b/docusaurus.config.ts @@ -120,7 +120,7 @@ const config: Config = { prism: { theme: prismThemes.github, darkTheme: prismThemes.dracula, - additionalLanguages: ['csharp', 'powershell', 'bicep', 'bash', 'json'], + additionalLanguages: ['csharp', 'powershell', 'bicep', 'bash', 'diff', 'java', 'json'], }, } satisfies Preset.ThemeConfig, }; diff --git a/static/img/authors/SebastianHans.jpg b/static/img/authors/SebastianHans.jpg new file mode 100644 index 0000000..50bbb4e Binary files /dev/null and b/static/img/authors/SebastianHans.jpg differ