Herausforderung Brownfield, Teil 3: Das Sicherheitsnetz erweitern

Die Angst vor Regressionsfehlern ist sicherlich die größte Angst bei Brownfield-Projekten. Erst wenn Fehler schnell zu erkennen sind, können Entwickler ohne Angst Veränderungen am Quellcode vornehmen. Um die Sicherheit zu erlangen, benötigen sie automatisierte Tests.

In Pocket speichern vorlesen Druckansicht
Lesezeit: 19 Min.
Von
  • Stefan Lieser
  • Ralf Westphal
Inhaltsverzeichnis

Die Angst vor Regressionsfehlern ist sicherlich die größte Angst bei Brownfield-Projekten. Da Entwickler nicht sicher sein können, dass Änderungen keine Probleme bereiten, lassen sie lieber die Finger weg vom Quellcode. Doch auf die Weise wird aus dem "big ball of mud" keine grüne Wiese. Erst wenn die Programmierer sicher sein können, dass Fehler schnell zu erkennen sind, können sie ohne Angst Veränderungen am Quellcode vornehmen. Um die Sicherheit zu erlangen, benötigen sie automatisierte Tests.

Durch die Einführung von Versionskontrolle und Continuous Integration hat ein Brownfield-Projekt einen Stand erreicht, bei dem sich die Entwickler trauen können, Änderungen vorzunehmen. Schließlich liegen der gesamte Quellcode und alle anderen Artefakte in der Versionsverwaltung, wodurch nichts verloren gehen kann. Ferner lässt sich zu jedem Zeitpunkt ein früherer Versionsstand rekonstruieren. Das ist schon viel Wert, nimmt aber noch nicht die Angst, dass nach einer Änderung tatsächlich noch alles so funktioniert wie vorher. Zwar erkennt man durch den Continuous-Integration-Prozess sofort den ungünstigsten Fall, dass die Anwendung gar nicht mehr zu übersetzen ist. Doch ob das übersetzte Resultat noch funktioniert, weiß niemand.

Mehr Infos

Herausforderung Brownfield

Im Rahmen einer Artikelserie beleuchten die Initiatoren der "Clean Code Developer"-Initiative, Stefan Lieser und Ralf Westphal, die Herausforderungen an Clean Code Developer in Brownfield-Projekten.

Wie eingangs angerissen, ist die Einführung von automatisierten Tests dringend erforderlich, um die Sicherheit zu erlangen, dass Entwickler bedenkenlos Änderungen am Quellcode vornehmen können. Doch wie soll das bei einem Brownfield-Projekt konkret geschehen? Schließlich wurde es ja nicht nach den Regeln der Kunst implementiert. Folglich steht zunächst der Verdacht im Raum, dass automatisierte Tests in einem solchen Projekt nicht möglich sind.

So viel sei gleich verraten: Ein paar automatisierte Tests gehen immer. Bevor zu klären ist, welche Mittel für automatisierte Tests in Brownfield-Projekten existieren, sei differenziert, welche Arten von Tests es gibt. Ein
wichtiges Kriterium für automatisierte Tests ist die Unterscheidung in Integrations- und Unittests. Die Abgrenzung klingt zunächst einfach: Bei einem Unittests testet man isoliert eine einzelne Unit. Dabei meint "isoliert", dass man keine andere Unit des Produktionscodes in den Tests verwendet. Schwieriger gestaltet es sich, sobald man der Frage nachgeht, was sich denn als Unit bezeichnen lässt. Unit sei deswegen zunächst mal mit Funktionseinheit übersetzt. Sie lässt sich in objektorientierten Programmiersprachen durch Methoden, Klassen, Komponenten, Prozesse et cetera bilden. Folglich ist der Begriff Unit vor allem nicht gleichzusetzen mit Klasse.

Betrachtet man Methoden als Funktionseinheiten auf der untersten Ebene, sind auf ihr Unittests so definiert, dass ein Test eine einzelne Methode in Isolation testet. Die Methode darf keine anderen Methoden aus dem Produktionscode verwenden – und auch keine Funktionseinheiten der höheren Ebenen. Auf der nächsten Ebene stehen die Klassen. Auf ihr lassen sich Tests einer einzelnen Klasse isoliert durchführen. Eine Klasse darf keine andere des Produktionscodes verwenden. Gleiches gilt für die weiteren Ebenen. Das Prinzip gilt auch beim isolierten Testen von Komponenten und Prozessen.

Verwendet eine Funktionseinheit im Test andere Einheiten des Produktionscodes, spricht man von Integrationstests. Sie sind auf den unterschiedlichen Abstraktionsebenen umzusetzen. Testet man eine Methode so, dass sie andere Methoden des Produktionscodes verwendet, spricht man auf der Ebene der Methoden von einem Integrationstest. Auf der Klassenebene betrachtet mag es sich jedoch um einen Unittest handeln, nämlich wenn alle im Test verwendeten Methoden aus derselben Klasse stammen.

Es sei erwähnt, dass sich die Begriffe Unit- und Integrationstest üblicherweise nicht so exakt abgrenzen lassen, wie das hier geschieht. Meist ist von einem Unittest die Rede, wenn man eine Klasse isoliert testet. Von Integrationstest spricht man oft, wenn die komplette Anwendung von der Benutzerschnittstelle bis "nach unten" hindurch zur Datenbank oder anderen Ressourcen getestet wird. Es ist erforderlich, das Abstraktionsniveau einzubeziehen, weil es vor allem beim Begriff Integrationstest sonst zu Verwechslungen kommen mag.

Das Isolieren der zu testenden Funktionseinheiten ist ein Ziel, das Entwickler bei Brownfield-Projekten in der Regel nicht von Anfang an erreichen. Warum das Testen in Isolation so wichtig ist, mag die Analogie zum Automobilbau zeigen. Dort ist es selbstverständlich, einzelne Teile isoliert zu testen. Es gibt beispielsweise Motorenprüfstände, auf denen sich der Motor eines Autos gesondert testen lässt. Der Motor muss sich also zum Testen nicht in einem Auto befinden. Das hat unbestreitbar Vorteile: Man stelle sich ein Testteam vor, das bei Tempo 200 auf der Autobahn versucht, den Motor unter den Betriebsbedingungen zu testen. Um den Motor isoliert testen zu können, muss der Prüfstand die erforderlichen Anschlüsse bieten, damit der Motor so zu betreiben ist, als wäre er in ein Auto eingebaut. Neben Spritzufuhr und Steuerungssignalen sind andere Rahmenbedingungen zu schaffen, die ein Motor in eingebautem Zustand erfährt. Insbesondere ist ihm ein Widerstand entgegenzugestellen, damit er nicht leer läuft.

Übertragen auf die Softwareenwicklung benötigen Entwickler Prüfstände für Methoden, Klassen, Komponenten et cetera. Doch zurück zum Sinn des isolierten Testens. Bei der Software ist es wichtig, Funktionseinheiten isoliert testen zu können. Das hat mehrere Gründe:

  • Aufbau der Testumgebung
  • Erzeugen von Testdaten
  • Ablaufgeschwindigkeit der Tests
  • Erkennen der Fehlerursache

Um eine Funktionseinheit automatisiert testen zu können, ist eine Testumgebung aufzubauen. Das fällt umso leichter, desto weniger Abhängigkeiten die zu testende Funktionseinheit hat. Denn alle Abhängigkeiten sind im Test zur Verfügung zu stellen. Das Aufbauen der Testumgebung gestaltet sich also umso schwieriger, desto mehr Korrelationen die zu testende Funktionseinheit hat. Ferner hängt der Aufwand davon ab, wie schwer es fällt, die einzelnen Abhängigkeiten bereitzustellen.

Ist die Funktionseinheit darauf ausgelegt, die Abhängigkeiten von außen zu erhalten, ist das automatisierte Testen vergleichsweise einfach. Schwieriger gestaltet es sich, wenn die Funktionseinheit die Abhängigkeiten selbst beschafft. Dann ist es oft nicht umsetzbar, für den Test spezielle Abhängigkeiten bereitzustellen, nämlich die über Messinstrumente verfügen.

Übertragen sei das noch mal auf den Test eines Motors: der benötigt eine Kraftstoffzufuhr. Wäre sie mit dem Motor verbunden, sodass keine Möglichkeit bestünde, sie auf dem Prüfstand durch eine andere zu ersetzen, würde der Motor samt Spritschlauch und Tank auf den Prüfstand gestellt. Wollte man nun den Spritverbrauch während eines Testlaufs ermitteln, müsste man dazu in den Tank schauen. Klingt kompliziert? Ist es auch. Selbstverständlich verfügt der Motor über einen (möglicherweise sogar genormten) Anschluss für die Spritzufuhr, und selbstverständlich wird dort auf dem Prüfstand eine Spritzufuhr angeschlossen, an der sich der Durchfluss messen lässt.

Nichts anderes benötigt der Entwickler für Softwarefunktionseinheiten: sozusagen Funktionseinheiten mit "Durchflussmesser". An die zu testende Funktionseinheit lassen sich im Idealfall also andere so "anschließen", dass man während des Tests beobachten kann, wie sich die zu testende Funktionseinheit gerade verhält.

Nachdem die zu testende Funktionseinheit bereitsteht, müssen Testdaten her. Je mehr "echte" Funktionseinheiten der Test verwendet, desto aufwendiger ist das Bereitstellen der Testdaten. Nutzt man nämlich für eine Abhängigkeit lediglich eine Attrappe anstelle der echten Funktionseinheit, sind die Testdaten einfacher zur Verfügung zu stellen.

Ein Beispiel mag das verdeutlichen: Man denke sich eine zu testende Funktionseinheit, die eine andere Funktionseinheit für die Überprüfung von Kreditkartendaten benötigt. Lässt sich die Kreditkartenprüfung im Test durch eine Attrappe ersetzen, kann man sie anweisen, die Gültigkeitsprüfung positiv oder negativ zu beantworten. Dafür sind keine Testdaten nötig. Will man im Test jedoch auf die "echte" Kreditkartenprüfung zugreifen, sind Testdaten in Form von Kreditkarteninformationen bereitzustellen. Die Daten müssen dazu führen, dass die Prüfung positiv beziehungsweise negativ ausfällt. Mithin sind also detaillierte Kenntnisse über die Kreditkartenprüfung erforderlich, um die Testdaten zur Verfügung stellen zu können.

Das Erzeugen von Testdaten sollte so einfach wie möglich sein. Nicht nur, weil sich sonst die automatisierten Tests nicht effizient erstellen lassen, sondern auch, weil sich in den Testdaten subtile Verletzungen des Prinzips "Don't Repeat Yourself" verbergen können. Das oben geschilderte Beispiel der Kreditkartenprüfung würde dazu führen, dass die Details zum Prüfungsverfahren über mehrere Stellen verteilt in Tests auftreten.

Die Geschwindigkeit, mit der automatisierte Tests ablaufen, hat einen hohen Einfluss darauf, wie oft sich die Tests ausführen lassen. Nur Tests, die schnell ablaufen, führt oft der Entwickler aus. Sobald ein Test länger läuft als ein paar Millisekunden, summieren sich die Laufzeiten so auf, dass ein Entwickler nur noch einzelne Tests ausführt (wenn überhaupt). Damit lassen sich die automatisierten Tests allerdings ad absurdum führen. Schließlich sollen sie eine schnelle Rückmeldung an den Entwickler geben, damit er merkt, dass etwas schief läuft. Je schneller die Rückmeldung eintrifft, desto einfacher ist es, den Fehler mit den Änderungen am Code in Beziehung zu setzen. Erfolgt die Rückmeldung erst nach mehreren Minuten oder sogar Stunden, muss der Entwickler erst nachdenken, welche Änderungen er überhaupt vorgenommen hat. Erfolgt die Rückmeldung unmittelbar nach einer Änderung, ist kein langes Nachdenken notwendig.

Damit Tests schnell ablaufen, müssen sie möglichst von der Infrastruktur unabhängig sein. Sobald ein Test Infrastruktur wie das Dateisystem oder eine Datenbank benötigt, ist die Ablaufgeschwindigkeit deutlich reduziert. Natürlich lässt sich das nicht in allen Fällen vermeiden. Aber Tests, die von Infrastruktur abhängen, sollten die Ausnahme sein.

Ein weiterer Grund für die Forderung, Funktionseinheiten isoliert testen zu können, liegt im Erkennen der Fehlerursache. Je weniger isoliert der Entwickler eine Funktionseinheit testet, desto größer ist der Einfluss anderer Einheiten auf das Testergebnis. Denn nicht selten lassen sich beim Testen einer Funktionseinheit Fehler in anderen Funktionseinheiten feststellen. Je mehr der Einheiten also den Test durchlaufen, desto mehr Fehlerquellen sind in Betracht zu ziehen.

Die Forderung, Funktionseinheiten isoliert testen zu können, soll nicht zum Ausdruck bringen, dass Tests über mehrere integrierte Funktionseinheiten keinen Wert hätten. Solche Tests sind selbstverständlich ebenfalls notwendig. Allerdings liegt dabei der Schwerpunkt auf Problemen, die bei der Integration auftreten können, nicht auf Problemen innerhalb der Funktionseinheiten. Erst durch die Trennung in Unit- und Integrationstests erreicht der Entwickler überhaupt eine vernünftige Testabdeckung.

Beispielsweise ziehe man dazu zwei Funktionseinheiten A und B heran, die eine dritte namens C verwendet. Wenn in A und B nur jeweils drei unterschiedliche Eingaben möglich sind, sind für A und B je drei Unittests zu schreiben. Sie prüfen, ob sich A beziehungsweise B bei den drei Eingaben korrekt verhalten. Ergänzt sei das Szenario um Tests für C, die zeigen, dass C die beiden Funktionseinheiten A und B korrekt verwendet. Ein vollständiger Test aller Kombinationsmöglichkeiten erscheint zwar noch realisierbar, ist in der Praxis aber unmöglich zu bewerkstelligen. Folglich muss der Entwickler sich bei den Testfällen so einschränken, dass einige typische Fälle abgedeckt sind. Das ist umso einfacher, je isolierter sich einzelne Funktionseinheiten betrachten lassen. Damit steht mfest: Unittests sind anzustreben, auch bei Brownfield-Projekten.

Ist das Isolieren von Funktionseinheiten auf einer bestimmten Abstraktionsebene nicht möglich, ist die betreffende Funktionseinheit übergangsweise in einem Integrationstest zu testen. Das trifft dann zu, wenn zum Beispiel die Abhängigkeiten einer zu testenden Klasse zu anderen Klassen dergestalt sind, dass sich die zu testende Klasse nicht aus den Abhängigkeiten herauslösen lässt. Man kann für den Test alle benötigten Klassen instanziieren. Verglichen mit dem Testen eines Motors könnte das etwa bedeuten, den Motorraum samt Halterungen zu "instanziieren", um darin den Motor zu betreiben. Das ist immer noch besser, als zum Testen des Motors mit dem Auto über die Autobahn fahren zu müssen.

In der Regel lassen sich in Brownfield-Projekten, zu denen bislang keine automatisierten Tests existieren, keine Unittests entwickeln, ohne vorher die Struktur zu verändern. Doch bevor der Programmierer sie verändert, benötigt er einen Mechanismus, der sicherstellt, dass die Anwendung hinterher noch so funktioniert wie vor der Änderung. Der Ausweg liegt in Integrationstests. Im ersten Schritt betrifft das meist die gesamte Anwendung. Sie ist mit allen Abhängigkeiten zu starten, um automatisiert Tests durchführen zu können. Dazu gehört, dass sich die Anwendung immer in einem definierten Zustand starten lässt. Das betrifft zum Beispiel durch die Anwendung benötigte Ressourcen wie eine Datenbank. Anschließend sollte sich die Anwendung möglichst automatisiert bedienen lassen, um ein gewünschtes Ergebnis zu erzielen, welches der Test überprüft.

Je nach eingesetzter Technik sind die einzelnen Schritte unterschiedlich anspruchsvoll. Immerhin gibt es für alle Unterstützung durch Tools, und der Entwickler muss die automatisierte Bedienung der Anwendung nicht erst entwickeln. Das gilt sowohl für Desktop- als auch für Webanwendungen. Eine Übersicht mit einigen Tools gibt es unter anderem hier.

Das Bereitstellen der für die Anwendung benötigten Ressourcen mag für eine Datenbank noch relativ einfach sein. Greift die Applikation jedoch auf externe Ressourcen wie Webservices zu, gestaltet es sich schwieriger, sie für den Test bereitzustellen. Die Anwendung sollte dann zumindest soweit konfigurierbar sein, dass man ihr im Test spezielle Testressourcen zur Verfügung stellen kann. Ist das nicht möglich, stellt das einen ersten erforderlichen Schritt der Veränderung dar. Die Änderung der Anwendung ist zwar nicht durch einen automatisierten Test abgedeckt, aber irgendwo muss der Entwickler eben beginnen. Auch sollte er die Vorgehensweise mit dem Ausgangszustand der Anwendung vergleichen. Jetzt haben Entwickler Versionskontrolle und Continuous Integration als Sicherheitsnetz. Im Zweifelsfall lassen sich die Änderungen ohne Aufwand rückgängig machen, denn es ist jederzeit nachzuvollziehen, welche Änderungen überhaupt vorgenommen wurden.

Ein erstes Ziel stellt somit die Isolation der benötigten Ressourcen dar. Erst wenn es zwischen der Anwendung und den von ihr benötigten Ressourcen eine klare Trennlinie gibt, ist man in der Lage, die Ressourcen im Test durch Attrappen zu ersetzen. Das Ziel ist in der Praxis nicht selten nur mit viel Aufwand zu erreichen. Oft sind nämlich die Zugriffe auf Ressourcen "wild" über die gesamte Anwendung verteilt. Das geht los bei Datenbankzugriffen direkt in einer Form und endet bei Zugriffen auf den HttpContext in Webanwendungen. Erst wenn die Ressourcen klar von der Anwendungslogik getrennt sind, spielen sie in automatisierten Tests keine Rolle mehr. Solange die Trennung nicht erreicht ist, werden sie sich immer wieder in die Tests "drängeln" und verhindern, dass man eine Funktionseinheit isoliert testen kann.

Stehen die von der Anwendung benötigten Ressourcen in einem definierten Zustand bereit, lässt sich die Anwendung starten. Anschließend ist sie automatisiert zu bedienen. Das bedeutet meist, dass man die Applikation durch die Benutzeroberfläche hindurch steuert. Nur wenn der Entwickler die Benutzeroberfläche vom Kern des Programms getrennt hat, kann er die Steuerung unmittelbar unter der Oberfläche ansetzen. In aller Regel ist das jedoch nicht möglich, weil in der Benutzeroberfläche wichtige Anwendungsteile stecken, sie also ohne die Oberfläche gar nicht funktionsfähig ist.

Fortgeschrittene Werkzeuge zur Automatisierung der Anwendung können die Bedienung der Anwendung im Hintergrund aufzeichnen. Man startet die Applikation unter Aufsicht des Tools, bedient sie ganz normal und kann die einzelnen Bedienungsschritte später beliebig oft wieder abspielen. Bei der Fernsteuerung der Anwendung ist es notwendig, die Steuerelemente zu identifizieren, auf die sich eine Eingabe beziehen soll. Beispielsweise ist ein bestimmtes Eingabefeld erst auszuwählen, bevor sich dort ein Wert eingeben lässt. Ein erster Ansatz dazu wäre, die Mausbewegungen zu verfolgen und Steuerelemente über ihre Position zu identifizieren. Der Ansatz hat den großen Nachteil, dass schon kleine Veränderungen am Layout des Formulars dazu führen, dass die automatisierte Bedienung der Anwendung nicht mehr funktioniert. Ein anderer Ansatz ist die Identifikation der Steuerelemente über einen eindeutigen Identifizierer. Sowohl in Web- als auch in Desktopanwendungen weist man den Steuerelementen üblicherweise Namen zu. Verweisen die Aufzeichnungen auf die Namen, sind die Tests stabiler gegenüber Layoutänderungen.

Am Ende soll die automatisierte Bedienung der Anwendung zu einem überprüfbaren Ergebnis führen. Lässt sich das Ergebnis innerhalb der Benutzeroberfläche überprüfen, benötigt man eine Option, aus dem Tests auf Steuerelemente der Oberfläche zugreifen zu können. Dazu ist es von Vorteil, wenn die Aufzeichnung der Anwendungssteuerung als Ergebnis im Quellcode vorliegt. Es ist nämlich relativ einfach, die Aufzeichnung um Prüfungen zu ergänzen, die feststellen, ob in den jeweiligen Steuerelementen der erwartete Inhalt steht.

Ein anderes Mittel besteht darin, den Zustand der verwendeten Ressourcen zu überprüfen. Kommt beispielsweise eine Datenbank zum Einsatz, lässt sich im Test prüfen, ob dort erwartete Datensätze vorhanden sind. Die Prüfungen erfolgen programmatisch in den Tests, stellen also in der Regel keine große technische Herausforderung dar. Problematisch an solchen Tests ist eher, dass sie das konkrete Datenbankschema verwenden. Daraus folgt, dass bei Änderungen am Schema meist auch Tests zu ändern sind. Im Idealfall stellt die Persistenz aber ein Implementierungsdetail dar, das keinen Einfluss auf die Anwendungslogik hat. Hier zeigt sich wieder, wie wichtig es ist, den Ressourcenzugriff von der Anwendungslogik zu trennen.

Das automatisierte Testen von Brownfield-Anwendungen ist machbar, wenn auch aufwendig. Es hilft aber nicht, darüber zu lamentieren – ohne Automatisierung gestaltet sich das Testen nur noch aufwendiger, denn dann sind die Tests von Hand auszuführen. Wenn das mit der gleichen Konsequenz und Sorgfalt erfolgt wie bei automatisierten Tests, ist der Aufwand in jedem Fall höher. Es lohnt sich also, die Automatisierung der Integrationstests in Angriff zu nehmen.

Die mangelnde Trennung von Anwendungslogik und Ressourcenzugriff im Test bereitet große Probleme. Sie ist demnach in späteren Schritten anzugehen. Auch die Trennung von Benutzeroberfläche und Anwendungslogik ist wichtig, bereitet aber in der Phase des Brownfield-Projekts weniger Probleme als die Ressourcenzugriffe, da man die Steuerung der Anwendung durch die Benutzeroberfläche recht gut automatisieren kann.

Nachdem die Artikelserie mit Versionskontrolle, Continuous Integration und automatisierten Tests das Sicherheitsnetz aufgespannt hat, wird es im nächsten Teil darum gehen, die Anwendung so zu zerlegen, dass handhabbare Einheiten entstehen, die sich ersten Veränderungen unterziehen lassen.

Stefan Lieser
ist freiberuflicher Berater/Trainer/Autor aus Leidenschaft. Seine Schwerpunkte liegen im Bereich Clean Code Developer sowie Domain Driven Design.

Ralf Westphal
ist Microsoft MVP für Softwarearchitektur und freiberuflicher Berater, Projektbegleiter und Trainer für Themen rund um .NET-Softwarearchitekturen
(ane)