Take this layered design:

and compare it to these layers:

Do you see the difference?
Even though Scott Hanselman thinks he’s giving a „little reminder about layers of abstraction“, he’s not. Doing some UI stuff is not on a higher level of abstraction than doing some business stuff, which is not on a higher level of abstraction than doing data access stuff. UI and business and data access are just different stuff on the same level of abstraction.
Asking your mom for some bacon and toast and butter and salad is not on a higher level of abstraction than putting these ingredients together in a sandwich.
But a recipe is on a different level of abstraction! And that’s the point about recipes. They provide overview of what needs to be done: get ingredients, put them together in a certain way. Whether you ask mom for the ingredients or buy them at a grocery store yourself, that’s a detail. Whether you put them together yourself, ask your sister or let a machine do it, that’s a detail, too. Getting and putting together are just two tasks which combined form a whole.
The recipe represents the whole „Make sandwich“ on a high level of abstraction, and asking your mom and doing it yourself is the whole on a low level of abstraction.
Such differentiation between levels of abstraction is what the OSI layers are about. Each layer is describing the same: data transfer. But the application layer does that on a high level of abstraction and the network layer does it on a low level of abstraction.
It’s extraordinarily deplorable that the term „layer“ got used in both models. This has caused much confusion. And it has added value to the layered software design pattern which it does not deserve.
What to do about the term „layer“ then? I suggest to keep it but only use it in one kind of model. Let’s keep it for the layered design pattern. It’s more widely known today than the OSI layer model.
But what to call the OSI layers? I suggest to call them strata (singular: stratum).
I take this term from Abelson/Sussman who use it in their paper „Lisp: A Language for Stratified Design“.
Where layers are levels of equal abstraction in a software, strata are levels of different abstraction. „The 7 Layers of OSI“ then become „The 7 Strata of OSI“.
„Layer“ of course is not a bad term. It’s useful – as long as it has a very specific meaning. Since that’s the case now we can use both „layer“ and „stratum“ to talk meaningfully about software design. Both even go very well together in the same picture:

And not only Abelson/Sussman are talking about the power of abstraction. Alan Kay, the father of the term „object orientation“, does too. However he does so in a slightly different way. Instead of „stratum“ he uses the term „language“ or „domain specific language“ or „problem oriented language“.
But in fact it’s all the same: The basic structure of software should consist of code on distinct levels of abstraction. That’s not the case if you follow just the layered design pattern. You have to start thinking in a different dimension. You have to explicitly design strata orthogonally to layers.
Since different layers do different things they all need to work together to accomplish the whole task. Tasks are represented as cross-cuts across several layers.
The way to do that with layers is to make them know each other, to even functionally depend on each other. A higher layer calls a lower layer, a lower layer calls an even lower layer.
Sounds normal. But also sounds hard to evolve and hard to test. That’s why principles like Inversion of Control (IoC) and Dependency Inversion (DI) and tools like dependency injection containers and mock frameworks were invented.
However, even though a higher layer now does only depend on an abstraction instead of an implementation at design time, it needs an implementation at runtime. The dependency might be defused somewhat but it’s essentially still there. Testing now might be easier – but not really easy. And reasoning about the code is still difficult because nowhere the whole is visible. Looking at one layer only reveals some connection to another layer. What has been done before within the scope of a task, what will be done beyond the next layer…? To answer those questions you have to jump around in the code or even debug it. The whole which several layers form together has no clear representation.
And then: why should a layer depend on another anyway? Why should some code responsible for doing UI stuff be concerned about business stuff? Why should the UI know the business and control it through request/response calls? Or why should the business know how to fetch/store data? Why should it be concerned with data management at all? Its purpose is to work with data and produce results, that’s all. Any knowledge of how and when to access some data store it none of the business of the business functionality.
Functional dependencies – doing one thing and then waiting for some other party to deliver something so you can continue – are simply a poor form of organising work. That’s true for industrial production, work in an office, and in software.
To make the distinction between layers and strata more tangible for you, let me show you two solutions to the same small problem:
A user enters a one line text through the console and the program determines the number of words in the text. But not all words count! Some stop words defined in the file „stopwords.txt“ should be ignored.
That’s a problem which can easily be solved with a layered software design:

In code this looks like follows (for the whole code at once see here). Please forgive me to not have applied IoC. But I wanted to keep the implementation to the point of layers. IoC does not change the structure fundamentally. The dependencies remain, albeit somewhat mitigated.
public static void Main(string[] args) {
var data = new DataLayer();
var business = new BusinessLayer(data);
var presentation = new PresentationLayer(business);
presentation.Show();
}
The dependencies are clearly visible as objects being injected from bottom to top layer. That’s cute in this case – but it gets ugly once we start looking into the layers and/or dependency hierarchies grow.
Here’s the UI doing its work:
class PresentationLayer {
readonly BusinessLayer business;
public PresentationLayer(BusinessLayer business) {
this.business = business;
}
public void Show() {
Console.Write("Text: ");
var text = Console.ReadLine();
var n = this.business.Count_words(text);
Console.WriteLine($"Number of words: {n} ");
}
}
Although Show() might look normal to you, please try to see the fundamental problem: It’s difficult to test just the presentation logic.
Sure, the presentation logic in this case is trivial. But if you imagine a bigger problem with a more complicated solution the design stays the same and the logic is difficult to test.
How do you check if
Console.Write("Text: ");
var text = Console.ReadLine();
is doing its job correctly?
How do you check if
Console.WriteLine($"Number of words: {n} ");
is doing its job correctly?
And on top of that: Why should the presentation know about this business stuff in the first place? The purpose of the presentation layer, the UI is to interact with the user, i.e. ask the user for data and her wishes and present data to the user. That’s all.
The same goes for the business layer:
class BusinessLayer {
readonly DataLayer data;
public BusinessLayer(DataLayer data) {
this.data = data;
}
public int Count_words(string text) {
var words = Extract_words(text);
return words.Length;
}
private string[] Extract_words(string text) {
var words = text.Split(new[] { ' ', '\t', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
return Remove_stopwords(words);
}
private string[] Remove_stopwords(string[] words) {
var stopwords = this.data.Load_stopwords();
words = words.Except(stopwords).ToArray();
return words;
}
}
How do you test word extraction logic in isolation? You cannot. Layering has even been refined here. The business layer itself consists of yet more sub-layers depending on each other. What a nightmare!
This all is hard to test (even with IoC & dependency injection). And it’s hard to understand in the first place. The need for a dependency diagram is very high!
The class dependencies might be simple:

But look closer! There are so many functions depending on each other, each containing logic calling logic in yet other functions.

Again: That’s a very common structure for software. Nothing out of the ordinary here. Look at your project’s code. It’s the same – only worse, because it contains 10,000 times more lines of code to solve a massively more complicated problem.
But that means, the problems of the layered design are also much, much bigger for you.
A stratified design for the same problem of course will consist of the same basic functional aspects – e.g. loading stop words from a file, presenting the result to the user, removing stop words from the list of words in the text. But a stratified design will arrange those aspects differently. This will become obvious already at the entry point of the application:
public static void Main(string[] args) {
var data = new Data();
var business = new Business();
var presentation = new Presentation();
var app = new App(presentation, business, data);
app.Run();
}
Firstly, the classes of the layers don’t know each other anymore. No dependency injection between them. Business logic no longer cares about loading stop words.
Secondly, there is a new class App{}. It represents the top stratum of the application, it stands for the whole of what needs to be done. The sole purpose of App{} is to integrate the layers into this whole.
App.Run() thus represents everything that happens – on the highest possible level of abstraction. The overall behaviour is expressed as one verb.
Now let’s drill down! And a drill down it is, because we’re talking about strata not layers. There is no real drill down into layers, only maybe a drill through.
If Run() is about everything, then what’s inside of the function, too, is about everything – just on a little lower level of abstraction:
public void Run() {
var text = presentation.Ask_for_text();
var n = Count_words(text);
presentation.Display_word_count(n);
}
Ah, now you see: „everything“ means asking for a text, then counting words in the text, and finally presenting the number of words to the user.
In the layered design there’s nowhere you have such an overview of the whole process.
But, wait, there’s more! What does „counting words“ mean? How is it done? You can see all of it on a high level of abstraction with another drill down:
private int Count_words(string text) {
var stopwords = data.Load_stopwords();
return Business.Count_words(text, stopwords);
}
Ah, now you see: the „everything“ of „counting words“ means first loading stop words and then doing the actual word count by taking the stop words into account.
Since Ask_for_text() and Display_word_count() contain only logic
public string Ask_for_text() {
Console.Write("Text: ");
return Console.ReadLine();
}
public void Display_word_count(int n) {
Console.WriteLine($"Number of words: {n} ");
}
they get carried over into this lower level of abstraction. On stratum #3 the whole of accomplishing the task consist of asking for a text, loading stop words, counting the words by applying the stop words, and in the end displaying the result.
And what about the core domain „counting words“ in Business{}? Let’s drill down again:
class Business {
public static int Count_words(string text, string[] stopwords) {
var words = Extract_words(text);
words = Remove_stopwords(words, stopwords);
return words.Count();
}
private static string[] Extract_words(string text) {
return text.Split(new[] { ' ', '\t', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
}
private static string[] Remove_stopwords(string[] words, string[] stopwords) {
return words.Except(stopwords).ToArray();
}
}
See how Count_words{} does not contain any logic? There’s nothing that could go wrong here. Nothing needs to be tested. It’s just integration again. No functional dependencies.
You immediately get an overview of what „counting words“ means. The whole is visible at a glance: first, actually get the words from the text, then remove from them the stop words, finally count the remaining words.
What needs to be done is not deeply nested but laid out sequentially.
Check out the class design:

It makes even more clear how independent the functional aspects of the solutions are. Only App{} knows the workhorse classes – but App{} does not contain any logic. It’s only there to integrate the parts into a whole. But that’s a very important task! It’s a responsibility of its own to be separated from actually doing stuff like processing data or loading data.
But please note: The relationship between App{} and Presentation{} etc. is not functional! There is no logic in App{} which uses logic in Presentation{} etc. The relationship thus is purely „integrational“.
And then there is the hierarchy of functions:

This diagram shows the nesting in a away so you can see the strata. Each function is sitting on the level where it’s used. That way lower strata are just sparsely populated. Some functions simply represent vocabulary relevant on several levels of abstraction.
Maybe this becomes more clear when I show you the strata explicitly:

But do you see how logic only resides in the leafs of the call tree? Let me move around the functions a little bit:

I call those functions with just logic in them and without any dependencies operations. The other ones without logic but with integrational dependencies are integrations.
The overarching principle here is the Integration Operation Segregation Principle (IOSP). A function either integrates or operates, it either only calls other functions or it does not call other functions but contains only logic.
Run(), Count_words() are integrations, Ask_for_text() or Extract_words() etc. are operations.
As you can imagine: Operations are easy to test. No functional dependencies! Integrations on the other hand, you might think, are not easy to test due to their dependencies – but they don’t need to be tested. There simply is nothing to test, no logic. If the functions an integration is integrating are correct, then the integration also is correct.
Of course I’m assuming it’s easy to check by review whether an integration actually is calling the right functions in the right sequence. But experience tells me, that’s most often the case.
That said, you’re free to test integrations too, if you like. And of course integrations at the root or close to the root should be tested to see if the overall idea of how the whole is assembled from the parts is correct. I call that acceptance tests.
Check out the complete stratified source code. It’s only slightly longer than the layered code, but I find its readability is much higher. Especially the integration functions adhere very well to the Single Level of Abstraction (SLA) principle.
Also the functions are more to the point. They are following the Single Responsibility Principle (SRP) much more closely because they focus on either integration or operation.
Stratified design avoids the pitfalls of layered design:
Functional aspects are focused on their purpose and don’t need to be concerned with other functional aspects. This increases decoupling.
Stratified design is what the IODA Architecture is about. But stratified design is more general. It’s relevant in the small and in the large. You can apply it to the next kata you’re doing in a coding dojo.
Take the CSV Table-izer for example. The function in demand is a whole, it represents the top stratum on the highest level of abstraction. Now drill down and stay true to the IOSP. That’s all there is to stratified design. Design your own small domain languages. No DSL tools needed. Just come up with many verbs and some nouns on different levels of abstraction which you arrange in strata. You’ll help greatly the readability and testability of your code – and finally escape the dependency hell of layered design.
]]>Aber eins nach dem anderen:
Nach einem Urlaub im Hotel Rössle in Au Ende September 2016 hatte ich die Idee, das Lernen von Clean Code Development einmal anders zu gestalten.
Üblich ist es, 8 Entwickler in einem mehr oder weniger engen und tristen Seminarraum für 1 oder 2 Tage zusammenzupferchen, um ihnen die Prinzipien und Praktiken zukunftsfähiger Softwareentwicklung nahezubringen. So läuft das halt mit Seminaren, oder? Es soll ja ordentlich was gelernt werden, oder? Dafür ist Kon-zen-tra-tion nötig, den ganzen Tag. Damit auch möglichst viel Lehrstoff in den Kopf reingeht pro Tag, sollte man auf dem Hosenboden sitzen. Leistung ist auch beim Lernen angesagt. Das muss keinen Spaß machen, das soll viel bringen.
Gut finde ich diesen Ansatz weder in der Schule bei meiner Tochter noch im Seminar für erwachsene Softwareentwickler. Doch so werden Seminare eben an das Unternehmen gebracht. Etwas anderes ist Personalabteilungen und Vorgesetzten selten geheuer.
Also boten wir mit der CCD School unsere Seminare bisher im allseits gewünschten und akzeptierten Format an.
Das tun wir auch immer noch – doch ich wollte mal etwas anders ausprobieren und habe das nun getan. Das neue Format heißt Clean Code Developer Retreat (CCD Retreat) und verbindet das Thema zukunftsfähige und nachhaltige Softwareentwicklung mit einer anderen Aktivität. Lernen im Doppelpack sozusagen.
Jeder Tag eines Retreat besteht aus 2-3 Lernblöcken. Insgesamt stehen ca. 6 Stunden technisches Training und ca. 4 Stunden praktisches Tun auf dem Programm. Dazu kommen gemeinsame Mahlzeiten mit allen Teilnehmern.
Dahinter stehen kritische Gedanken, die sich mir in den letzten Jahren aufgedrängt haben während Hunderter üblicher Trainingstage:
Daraus habe ich die Hypothese abgeleitet, dass Lernen besser funktioniert, wenn es diese Kritikpunkte vermeidet. Vom 15. bis 20. Mai 2017 habe ich nun erstmals ein Experiment abgehalten, um meine Hypothese zu bestätigen (oder zu falsifizieren).
Insgesamt 6 Teilnehmer hatten sich zum ersten Clean Code Development Retreat angemeldet. Damit war der Retreat ausgebucht. Und wie sich herausstellte, war damit nicht nur der Seminarraum für den „Programmierunterricht“ gut gefüllt, sondern auch eine Gruppengröße für schöne Gruppendynamik erreicht.

Ganz bewusst war der Seminarraum nicht mit dem üblichen Mobiliar versehen. So konnten wir uns leicht umgruppieren und „zusammenrotten“ für Übungen.
Durch besondere Ästhetik zeichnete sich der Raum zwar auch nicht aus – doch im Verlauf des Unterrichts haben wir ihn ohnehin mit unseren Arbeitsergebnissen tapeziert :-)

Außerdem war der Aufenthalt darin an einem Stück stets begrenzt. Ein typischer Retreat-Tag hatte diese Struktur:
Max. 4 Stunden geistige Arbeit in einem Raum hat sich als bekömmlich herausgestellt. Die Konzentration hat während der Zeit nicht gelitten. Wir waren meistens sogar so im Flow, dass wir Pausen vergessen haben ;-)
Denn die „Denkpause“ war ja garantiert nach einem so überschaubaren Block. Die „Kontrastaktivität“ am Nachmittag – in Au war es das Mountainbiking – hat alle blitzschnell aus Tunnelblick, Denkfallen und Konzentrationslöchern herausgeholt.

Wer kann beim Anfahren am Berg über SRP, IOSP, PoMO, TDD grübeln? Niemand. Und das ist gut so. Der komplette Ausstieg aus dem „Theoriemodus“ des Vormittags war ganz bewusst. So wurde der Kopf frei, das Gelernte konnte sich unbewusst setzen und die Motivationsbatterie wieder aufgeladen werden.

Der Kontakt mit der Natur, den Elementen hat dazu ein Übriges getan. Das Wetter hatten wir optimal getroffen.
Nach 4 Stunden „Ausfahrt“ war es aber auch genug. Die Kondition der Teilnehmer war unterschiedlich ausgeprägt. Nicht jedem fielen die teilweise länglichen Auffahrten gar Aufstiege leicht, nicht jeder fühlte sich gleich sicher bei der Abfahrt über einen Bike Trail im Wald.


Keiner hat jedoch seine Laue verloren. Der Spaß am Mountainbiking war die ganze Woche bei allen ungebrochen. Eine gute Anleitung durch die Bike Trainer von Die Bike Schule hat das natürlich unterstützt. Alle Teilnehmer wurden bei ihrem Leistungsstand abgeholt, jeder konnte mitmachen. Die Gruppe wurde dadurch zusammengeschweißt.
Erholung bot anschließend ein üppiges Abendessen. Die Halbpension des Hotels Rössle hat allen gut getan.

Und zum Ausklang des Tages noch eine zweite Runde Clean Code Development. Das war für alle nicht nur ok, sondern sehr willkommen. Nochmal in die Programmierung eintauchen am Abend, war ganz leicht. Der Kopf war frei und wir konnten dort weitermachen, wo wir mittags aufgehört hatten.

Eine wissenschaftliche Begleitung gab es nicht für den Retreat, um meine Hypothese zu bestätigen. Ich bin auf mein subjektives Urteil und das der Teilnehmer angewiesen. Doch da es sich um ein Erlebnis handeln sollte, warum sollte das nicht ausreichen?
Hat also das Lernen besser funktioniert im Retreat verglichen mit einem üblichen Seminar von gleicher Dauer?
Hier sind einige Teilnehmerstimmen:
„Der Kontextwechsel aus Clean Code und Mountainbiking hat für mich den Reiz ausgemacht. Durch die Bewegung zwischen den Sessions konnte ich gut abschalten und Kraft für die nächste Session sammeln.“, Jannis von Albedyll
„Die Kombination aus Theorie, Praxis und Ausgleich durch körperliche Betätigung sollte in viel mehr Trainings Anwendung finden.“, Görge Albrecht
„Der Balance zwischen Sport und Unterricht war genau richtig dosiert. Kein Platz zum Langweilen.“, Thomas Wolf
„Die Kombination von Programmieren und Mountain Biken ist sehr spannend und hilft neue Konzepte/Ideen besser zu verarbeiten.“, Florian Böhmak
Der Background der Teilnehmer war ganz unterschiedlich: Manche sind Freelancer, manche Angestellte in großen Unternehmen. Manche arbeiten mit C#, manche mit Java, andere mit Go oder ABAP.
Das Urteil fiel dennoch sehr einheitlich aus. Einem Net Promotor Score von 9,66 (von 10) ist kaum etwas hinzuzufügen :-)
Zufriedenheit ist aber nicht gleich Lernerfolg, oder? Hat das Lernen von Clean Code Development besser funktioniert als beim üblichen Seminarformat?
Ich behaupte, das hat es. Das mache ich nicht an irgendwelchen Testresultaten fest, die üblicherweise so ausfallen und nun besser. Solche Tests gibt es nicht. Für mich besteht Lernerfolg in Lebendigkeit, in Motivation, im Tun. Und diese Aspekte waren deutlich stärker ausgeprägt während und auch noch nach dem Retreat.
Natürlich stehen alle Teilnehmer auch nach dieser Erfahrung vor der Herausforderung, das Gehörte und Geübte nicht aus den Augen zu verlieren und in den Arbeitsalltag zu übertragen. Dafür ist die Wahrscheinlichkeit jedoch höher mit der „getankten Energie“.
Das Thema ist nun aufgeladen mit den Emotionen, die der engen Gemeinschaft während der Tage und den körperlichen Leistungen entspringen. Die Chance, Gewohnheiten zu brechen und neue aufzubauen, ist größer als üblich.
Für mich ist der Retreat also ein klarer Erfolg. Nicht nur subjektiv, sondern auch objektiv Dank des wunderbaren Feedbacks.

Schön, dass der nächste Retreat im Juni 2017 schon ins Haus steht. Weitere werden garantiert folgen. Ich sehe in dem Format eine Zukunft des Softwaretrainings der CCD School.
Wenn Sie dabei sein sollen, folgen Sie uns einfach auf Twitter.
]]>Der Review gehört zur „Definition of Done“ und wird geleitet durch die Architekturrolle.
Um die Zukunftsfähigkeit verlässlich zu überprüfen, sollte der Review systematisch vorgehen. Allen Anwesenden sollte klar sein, welche Aspekte zu überprüfen sind. Als Leitfaden kann eine Checkliste dienen.
Hier unser Vorschlag für eine solche Checkliste. Ausführliche Erklärungen zu jeder Facette finden Sie in den verlinkten Beiträgen.
Korrektheit
Merksatz: „Erwachsener“ Code demonstriert jederzeit automatisiert die Erfüllung aller Verhaltensanforderungen und wurde sorgfältig gestützt hergestellt.
Merksatz: Regressionssicherer Code ist erwachsen und erfüllt jederzeit automatisiert nachweisbar die Erfüllung aller Modulkontrakte.
Wandelbarkeit
Merksatz: Verständlicher Code ist sprechend, geradlinig, flach, überschaubar, nivelliert, begründet.
Merksatz: Testbarer Code ist eindeutig, „operational“, sichtbar, „leicht zu beschaffen“ (bzw. zu erreichen) und unabhängig von Konstanten, Zustand, Ressourcen.
]]>Code ist also ständiger Änderung unterworfen. Eingriffe in Logik stellen ein ständiges Risiko dar, „dass etwas verrutscht“ oder nicht zum wunschgerechten Verhalten führt. Zukunftsfähigkeit ist per definitionem also nicht einmal hergestellt, sondern muss sich ständig bewehren.
Nicht nur ist daher unsicher, wo Code morgen angefasst werden muss. Es ist daraus folgend auch unsicher, wo morgen Code automatisiert getestet werden muss. Denn Veränderungen am Code ziehen selbstverständlich neue Tests nach sich. Entweder hat sich ja herausgestellt, dass bisherige Tests einen Bug haben durchschlüpfen lassen. Oder es existieren noch keine Tests für neues Verhalten.
Verständlichkeit hilft beim Auffinden der Stelle, die morgen angefasst werden müssen zum Bug Fixing oder für Neuerungen.
Darüber hinaus braucht zukunftsfähiger Code jedoch noch eine weitere Eigenschaft…
Um Veränderungen punktgenau einbringen und anschließend automatisiert überprüfen zu können, muss Code auch noch testbar sein.
Wandelbarkeit ist also die Kombination aus Verständlichkeit und Testbarkeit.
Testbarkeit drückt aus: Ein Verhaltensaspekt existiert als klar abgegrenzte Einheit und wurde automatisiert auf Korrektheit überprüft.
Vielleicht ist der Verhaltensaspekt sogar mit bleibenden Tests versehen, wie sie Maturität und Regressionsfreiheit fordern. Aber wenn nicht, dann können solche Tests jederzeit leicht nachgerüstet werden, falls Bedarf für eine detailliertere Korrektheitsüberprüfung entsteht.
Schwer zu testen ist Code, wenn dasselbe oder nahezu dasselbe an mehreren Orten steht. Um „dasselbe“ zu testen, müssten dann ja mehrere Tests geschrieben werden.
Codeduplikate stehen der Verständlichkeit nicht unbedingt im Wege, gelegentlich verbessern sie sie sogar. Die Testbarkeit wird durch Duplikate jedoch verringert. Für einen Aspekt kann dann ja nicht nur an einem Ort eine „Testsonde angelegt werden“.
Aufgabe des Reviews ist es daher zu erkennen, ob derselbe oder sehr ähnlicher Code an mehreren Stellen der Codebasis auftaucht. Ist das der Fall – widerspricht der Code als dem DRY-Prinzip -, sollte er sehr wahrscheinlich an einem Ort in Form einer geeigneten Abstraktion zusammengeführt werden. Dadurch wird zwar eine Abhängigkeit an den bisherigen Nutzungsorten aufgebaut – doch das ist ein anderes Thema und wird im Review getrennt behandelt.
Duplikate können im Produktionscode oder im Testcode vorkommen. Im Testcode sind sie zwar verzeihlicher – Tests können für größere Unabhängigkeit etwas „feucht“ sein -, doch letztlich sollte auch dort auf Einhaltung von DRY geachtet werden. Schließlich unterliegen auch Tests Veränderungen, die umso schwerer fallen, je weniger DRY der Code ist.
Hier ein Beispiel für „feuchten“ Testcode:
[Test]
public void Load_with_empty_lines() {
var sut = new FileProvider();
var result = sut.Load("numbers_with_empty_lines.txt");
Assert.AreEqual(new[] { 0, 1, 0, 2, 0, 0, 3 }, result);
}
[Test]
public void Load_with_whitespace() {
var sut = new FileProvider();
var result = sut.Load("numbers_with_whitespace.txt");
Assert.AreEqual(new[] { 1 }, result);
}
Das Muster, die Wiederholung liegt auf der Hand. Beide Tests unterscheiden sich nur in Input und Output. Das lässt sich „trocken“ so knapper formulieren:
[TestCase("numbers_with_empty_lines.txt", new[] { 0, 1, 0, 2, 0, 0, 3 })]
[TestCase("numbers_with_whitespace.txt", new[] { 1 })]
public void Load(string filename, int[] expected) {
var sut = new FileProvider();
var result = sut.Load(filename);
Assert.AreEqual(expected, result);
}
Ein solchermaßen datengetriebener Test ist aber natürlich nur möglich, wenn der Testframework ihn bietet und die Testdaten sich im Fall von C# im Attribut auch notieren lassen. Das ist nur für Konstanten der Fall.
Außerdem ist zu beachten, dass die Bedeutung des Tests nicht verlorengeht. Es gibt nur noch eine Testmethode, deren Name etwas über die Testfälle aussagen kann. Im Verein mit den Dateinamen mag das hier ausreichen, in anderen Szenarien müssten zusätzliche Informationen hineingereicht werden.
Ansonsten probat und auch anwendbar außerhalb von Testcode ist die Extraktion von Duplikaten in eigene Module:
[Test]
public void Load_with_empty_lines() {
Assert.AreEqual(new[] { 0, 1, 0, 2, 0, 0, 3 }, Try_Load("numbers_with_empty_lines.txt"));
}
[Test]
public void Load_with_whitespace() {
Assert.AreEqual(new[] { 1 }, Try_Load("numbers_with_whitespace.txt"));
}
private int[] Try_Load(string filename) {
var sut = new FileProvider();
return sut.Load(filename);
}
Die Tests sind nun auf die Differenz fokussiert, in Try_Load() ist das Gemeinsame zusammengefasst.
Während Tools für die Beurteilung der Verständlichkeit von Code mit Vorsicht zu genießen sind, stellen sie beim Auffinden von Duplikaten jedoch eine unschätzbare Hilfe dar. Versuchen Sie es ruhig einmal mit einem statischen Analysewerkzeug ihrer Wahl.
Durch Duplikate entsteht höherer Testaufwand über eine größere Menge an Tests. Funktionale Abhängigkeiten hingegen erhöhen den Testaufwand durch die Notwendigkeit zusätzlicher Infrastruktur.
Funktionale Abhängigkeiten sind ein Grundübel schlecht wandelbaren Codes. Nicht nur verschlechtern sie die Verständlichkeit durch Wechsel des Abstraktionsniveaus, sie machen es auch noch schwer, Logik zu testen.
Überall dort, wo Logik einer Domäne weitere Logik über einen Funktionsaufruf anspricht, um Verhalten herzustellen, liegt eine funktionale Abhängigkeit vor. Hier ein Beispiel dafür:
public int SumUpFileContent(string filename) {
var sum = 0;
var lines = File.ReadAllLines(filename);
foreach (var line in lines)
sum += Parse_line_to_number(line);
return sum;
}
private int Parse_line_to_number(string line) {
if (!string.IsNullOrWhiteSpace(line))
return int.Parse(line);
else
return 0;
}
Methode SumUpFileContent() enthält Logik und (!) ruft darin eine andere Methode der Domäne auf: Parse_line_to_number(), die wiederum Logik enthält. Die aufrufende ist von der aufgerufenen abhängig.
Das bedeutet, die Logik in SumUpFileContent() kann nicht ohne die Logik von Parse_line_to_number() getestet werden. Wenn beim Test ein Fehler auftritt, ist deshalb nicht klar, ob den die aufrufende oder aufgerufene Methode verursacht.
Sie können sich vorstellen, dass diese Unklarheit mit der Tiefe des Baumes funktionaler Abhängigkeit wächst. Funktionale Abhängigkeiten erschweren die Testbarkeit also erheblich. Und die Verständlichkeit, denn die Abstraktionsniveaus sind unterschiedlich. Logik auf Level 1 oder 2 trifft auf Funktionsaufrufe auf Level 3, 4 oder 5.
Dennoch finden Sie funktionale Abhängigkeiten allerorten im Code. Sie sind ganz normal. Sie scheinen ja auch unvermeidbar. Wie sonst sollte Logik strukturiert sein, wenn Methoden nur begrenzt Logik enthalten dürfen für die Verständlichkeit? Ganz davon zu schweigen, dass auch unverständliche Funktionen mit tausenden Zeilen starke funktionale Abhängigkeiten besitzen.
Funktionale Abhängigkeiten sind also die Norm. Deshalb gibt es auch schon lange eine Empfehlung, wie mit ihnen umzugehen ist, um die Testbarkeit nicht zu kompromittieren. Die Lösung lautet: Dependency Inversion (DI) oder Inversion of Control (IoC).
Wenn der aufrufende Code zumindest zur Entwicklungszeit nicht direkt von einer Implementation abhängig ist, sondern von einer Abstraktion, dann kann die für Testzwecke ersetzt werden durch eine Attrappe. Das könnte z.B. so aussehen:
public class Aggregator {
readonly Func<string, int> parse;
public Aggregator() => this.parse = this.Parse_line_to_number;
internal Aggregator(Func<string,int> parse) => this.parse = parse;
public int SumUpFileContent(string filename) {
var sum = 0;
var lines = File.ReadAllLines(filename);
foreach (var line in lines)
sum += parse(line);
return sum;
}
...
Die Abstraktion hier ist allerdings kein Interface, wie üblich, sondern lediglich eine Funktion. Aber das tut der Anwendung des Prinzips keinen Abbruch. Die Attrappe kann über den Konstruktor injiziert werden (ctor injection). Geschieht das nicht, wird die klasseneigene Implementation benutzt.
Ein Test, der alles außer der Logik testen möchte, von der die Methode funktional abhängig ist, sähe dann so aus:
[Test()]
public void Reading_and_adding() {
var sut = new Aggregator(_ => 1);
var result = sut.SumUpFileContent("numbers.txt");
Assert.AreEqual(4, result);
}
Die injizierte Attrappe liefert konstant 1 zurück, so dass effektiv lediglich die Zahl der Zeilen in der Datei gezählt wird. Eine Differenzierung zwischen Leerzeilen und mit Zahlen gefüllten findet natürlich nicht statt; das ist Aufgabe der Funktion, die nun ausgeblendet ist.
Mit Austausch der eigentlichen Implementation durch eine Attrappe wird die funktional abhängige Logik also testbar. Irgendwie. Denn schön ist das nicht. Erstens macht es zusätzlichen Aufwand: die Injektion muss überhaupt möglich gemacht werden, eine Abstraktion muss her und dann auch noch die Attrappe. Zweitens ist bei einem Fehler immer noch nicht gleich klar, ob der durch die Logik vor dem Aufruf oder nach dem Aufruf der Attrappe verursacht wird.
Wenn Sie das Szenario weiterspinnen, können Sie sich vorstellen – wenn Sie es nicht schon selbst erlebt haben -, dass DI/IoC die Testbarkeit ganz praktisch nicht wirklich verbessern. Die Prinzipien samt zugehöriger Mock-Frameworks sind reparieren nur an einem Symptom herum, nicht an der Ursache.
Das Symptom sind die Aufrufe von Funktionen, die Ursache ist die funktionale Abhängigkeit bzw. die Vermischung von Abstraktionsniveau 1/2 mit 3 oder höher.
Einen richtigen Sprung nach vorn macht die Testbarkeit hingegen, wenn auf funktionale Abhängigkeiten verzichtet wird. Das bedeutet: Domänenfunktionen enthalten entweder Logik (Abstraktionslevel 1 und 2) oder sie rufen ausschließlich andere Domänenfunktionen.
Code, der so strukturiert ist, folgt dem Integration Operation Segregation Principle (IOSP):
Parse_line_to_number() oben ist eine Operation, SumUpFileContent() hingegen ist keine Integration, weil die Funktion Logik enthält und den Aufruf einer Operation.
Wie könnte die Summierung denn aber nach IOSP aussehen? Zum Beispiel so:
public class Aggregator {
public int SumUpFileContent(string filename) {
var lines = Load_text(filename);
var numbers = Extract_numbers(lines);
return Sum(numbers);
}
private IEnumerable<string> Load_text(string filename) {
return File.ReadAllLines(filename);
}
private IEnumerable<int> Extract_numbers(IEnumerable<string> lines) {
foreach(var line in lines)
if (!string.IsNullOrWhiteSpace(line))
yield return int.Parse(line);
}
private int Sum(IEnumerable<int> numbers) {
var sum = 0;
foreach (var n in numbers)
sum += n;
return sum;
}
}
SumUpFileContent() ist jetzt eine Integration. Die Funktion ist nicht mehr funktional abhängig von anderen. Sie enthält keine Logik. In ihr kann nichts mehr schief gehen. Bugs sitzen vor allem in Logik.
Reine Integration muss daher nicht getestet werden – es sei denn als öffentliche Funktion, also an der Oberfläche eines Modules im Rahmen eines Akzeptanztests oder als Pfeilertest zur Absicherung der Regressionsfreiheit.
Und wenn eine Integration getestet werden sollte, dann müssen die aufgerufenen Funktionen nicht sofort durch Attrappen ausgetauscht werden. Dann handelt es sich bewusst um einen Integrationstest, der das Zusammenspiel der Teile überprüfen soll.
Operationen hingegen müssen getestet werden, insbesondere im Rahmen von Gerüsttests. Dort „spielt die Musik“. Dort wird Verhalten hergestellt. Dort kann etwas schief gehen.
Zum Glück ist das jedoch sehr einfach. Operationen sind per definitionem funktional unabhängig. Also müssen keine Attrappen aufgebaut werden. Um zum Beispiel zu prüfen, ob die Summierung korrekt funktioniert, kann Sum() allein getestet werden:
[Test, Description("Gerüttest")]
public void Sum() {
var sut = new Aggregator();
var result = sut.Sum(new[] { 1, 2, 3 });
Assert.AreEqual(6, result);
}
Dafür muss Sum() zwar sichtbar gemacht werden, doch das ist ein kleiner Preis, der zu bezahlen ist, um „mal eben“ die Logik zu überprüfen. Viel schwieriger wäre es, wenn auch noch Attrappen gebaut werden müssten.
Funktionale Abhängigkeiten reduzieren die Testbarkeit drastisch. Funktionale Abhängigkeiten machen Software schwer verständlich. Unsere klare Empfehlung lautet daher: Verzichten Sie auf funktionale Abhängigkeiten! Folgen Sie dem IOSP.
Testbarkeit ist nicht binär vorhanden oder nicht. Logik lässt sich graduell schlechter bzw. besser testen.
Erste Voraussetzung ist ihre Einmaligkeit. Zweite Voraussetzung ist ihre Unabhängigkeit.
Aber auch wenn Logik nur in einer Operation existiert, ist die Testbarkeit noch nicht gleich maximal. Es mögen weitere Schritte nötig sein, um sie zu verbessern.
Logik ist nur testbar, wenn sie freigestellt ist. Sie muss für sich allein adressierbar sein als Funktion. Sonst kann kein Test als Sonde unmittelbar angelegt werden.
Operationen machen zwar funktional unabhängige Logik testbar, doch genügt diese Logik auch dem SRP? Wenn nicht, dann sind die Tests sehr pauschal. Sie überprüfen nicht nur einen Aspekt, sondern mehrere. Schlagen sie fehl, stellt sich die Frage, welcher Aspekt daran Schuld ist.
Die folgende Funktion ist zwar eine Logik, doch hat sie auch nur eine Verantwortlichkeit?
internal class FileProvider {
public int[] Load(string filename) {
var lines = System.IO.File.ReadAllLines(filename);
var numbers = new List<int>();
for (var i = 0; i < lines.Length; i++)
if (!string.IsNullOrWhiteSpace(lines[i]))
numbers.Add(int.Parse(lines[i]));
return numbers.ToArray();
}
}
Aus einer gewissen Flughöhe betrachtet, entspricht Load() dem SRP: Die Funktion beschafft die Zahlen aus einer Textdatei. Doch wenn Sie genau hinschauen, hat auch diese Funktionalität noch zwei Aspekte.
Der eine ist „die Bedienung“ eines API, hier der Umgang mit Textdateien mittels technischer Funktionen aus dem Namensraum System.IO. Das umfasst zwar derzeit nur eine Zeile Code, weil der .NET Framework viele Details kapselt. Doch das könnte auch ganz anders aussehen. Was zum Beispiel, wenn die Datei nicht existiert und dieser Fall soll gezielt behandelt werden? Oder was, wenn der Text nicht schon zeilenweise geladen würde?
Wie an die einzelnen Zeilen des Textes in einer Datei kommen, ist die eine Sache. Eine ganz andere ist es, aus den Zeilen des Textes die Zahlen zu extrahieren. Wie müssen die Zeilen aufgebaut sein, damit das klappt? Dürfen sie leer sein, wie sind sie mit Zahlen gefüllt? Neben der Textbeschaffung ist die Textanalyse ein zweiter Aspekt.
Ein separater Test des Aspekte ist nicht möglich. Es lässt sich nicht nur prüfen, ob mit dem API korrekt umgegangen wird; es lässt sich nicht isoliert prüfen, ob ein Text korrekt analysiert wird. Das drückt sich in Tests z.B. dadurch aus, dass immer gegen eine Datei getestet werden muss. Wie umständlich, eine Datei anlegen zu müssen, um Varianten der Textstruktur durchzuspielen.
Wie anders sieht dagegen diese Klasse aus:
internal class FileProvider {
public int[] Load(string filename) {
var lines = Load_text(filename);
return Parse(lines).ToArray();
}
private IEnumerable<string> Load_text(string filename) {
return System.IO.File.ReadAllLines(filename);
}
private IEnumerable<int> Parse(IEnumerable<string> lines) {
foreach (var line in lines)
yield return Parse(line);
}
private int Parse(string line) {
if (!string.IsNullOrWhiteSpace(line))
return int.Parse(line);
else
return 0;
}
}
Hier sind alle Aspekte freigestellt in eigene Funktionen. Die Beschaffung des Textes ist getrennt testbar von der Analyse und sogar die Gesamtanalyse unterschieden von der Analyse einer Zeile. Aus der ursprünglichen Operation ist dabei eine Integration geworden.
Hier ist deutlich erkennbar, wie die Testbarkeit die Wandelbarkeit befördert. Sollte sich etwas bei der Benutzung des API verschieben, ist klar, in welcher Funktion Änderungen ganz gezielt vorgenommen werden müssen. Oder sollte sich der Textaufbau ändern, wird das sehr wahrscheinlich Auswirkungen auf auch nur eine Funktion haben. Tests der Veränderungen können dann punktgenau angebracht werden.
Logik freigestellt ohne Abhängigkeiten in der Tiefe, in der Vertikalen in Form von Operationen und auch noch freigestellt in der Horizontalen, d.h. getrennt von anderen Aspekten ist grundsätzlich gezielt testbar, d.h. unabhängig von anderer Logik.
Aber ist die Logik auch leicht erreichbar? Das ist sie nur, wenn sie erstens öffentlich ist. Tests von privaten Methoden sind nicht ohne weiteres möglich. Manchmal helfen Tools, manchmal muss die Sichtbarkeit jedoch manuell erweitert werden.
Geringe Sichtbarkeit verringert zwar die Testbarkeit, andererseits erhöht sie die Entkopplung. Beide Ziele sind auszubalancieren. Nur wegen der Testbarkeit Funktionen öffentlich zu machen, ist sicher nicht die Empfehlung. Gelegentlich mag das angezeigt sein, im Allgemeinen ist es aber angemessen, sie lediglich während der konkreten Arbeit an ihnen temporär öffentlich zu machen und mit Gerüstests zu versehen. Nach getaner Arbeit werden diese Tests dann gelöscht und die Sichtbarkeit zurückgenommen. So wie es sich für Details gehört.
Nur weil Operationen eine fokussierte Verantwortlichkeit haben und öffentlich sind, ist die Testbarkeit allerdings nicht automatisch hoch. Im Test sind sind die Funktionen ja auch noch zu beschaffen. Was muss dafür getan werden?
Stehen sie als statische Funktion einfach so zur Verfügung oder muss zuerst eine Objektinstanz erzeugt werden?
Statische Funktionen sind in den letzten Jahren in Ungnade gefallen, weil sie die Testbarkeit zu behindern schienen. Sie lassen sich nicht in Interfaces abstrahieren, um sie während eines Tests zu injizieren. Statische Methoden stehen DI/IoC im Wege.
Das mag sein, doch DI/IoC sind ja nur Symptombehandlungen. Wenn das darunter liegende Problem der funktionalen Abhängigkeit gelöst ist, verlieren die Prinzipien an Bedeutung. Aus unserer Sicht machen statische Methoden keine prinzipiellen Problem und erhöhen die Testbarkeit sogar.
Statische Methoden sind sogar vorzuziehen. Wir weichen von diesem Default nur ab, wenn andere Kräfte das nahelegen. Sehen Sie, wie die obige Klasse mit statischen Methoden an Testbarkeit gewinnt:
internal class FileProvider {
public int[] Load(string filename) { ... }
private IEnumerable<string> Load_text(string filename) { ... }
internal static IEnumerable<int> Parse(IEnumerable<string> lines) {
foreach (var line in lines)
yield return Parse(line);
}
private static int Parse(string line) {
if (!string.IsNullOrWhiteSpace(line))
return int.Parse(line);
else
return 0;
}
}
Um die Analyse zu testen, sind jetzt keine Objektinstanzen mehr nötig:
[Test]
public void Parse_with_empty_lines() {
var result = FileProvider.Parse(new[] { "", "1", "", "2", "" });
Assert.AreEqual(new[]{0,1,0,2,0} , result.ToArray());
}
Die FUT (Function Under Test) steht sofort zur Verfügung. Der Test kann sehr knapp ausfallen.
So wird auch deutlich, dass hier noch Verbesserungspotenzial schlummert. Denn warum sollte die Analyse Nullen für leere Zeilen zurückliefern? Das macht in Bezug auf das Problem keinen Sinn, sondern ist, wenn Sie genau hinschauen, ein Ergebnis übereifriger Aspekttrennung. Besser ist es so:
internal class FileProvider {
public int[] Load(string filename) {
var lines = Load_text(filename);
return Parse(lines).ToArray();
}
private IEnumerable<string> Load_text(string filename) {
return System.IO.File.ReadAllLines(filename);
}
internal static IEnumerable<int> Parse(IEnumerable<string> lines) {
foreach (var line in lines)
if (!string.IsNullOrWhiteSpace(line))
yield return int.Parse(line);
}
}
Die Operation Parse(string) ist in Parse(IEnumerable<string>) aufgegangen. Nun muss kein unnötiger Wert mehr für den Fall einer leeren Zeile zurückgegeben werden.
Auf Akzeptanztests der Gesamtfunktionalität zur Summierung von Zahlen in einer Datei hat diese Veränderung im Detail keinen Einfluss. Doch der Test der verbleibenden Parse()-Methode fällt leichter aus wie auch der Akzeptanztest von Load(). Statische Methoden machen es möglich.
Öffentliche statische Methoden sind leicht testbar. Eigentlich. Denn die schöne Testbarkeit kann durch Abhängigkeiten verhagelt werden. Operationen sind zwar nicht mehr funktional abhängig, doch Abhängigkeit gibt es nicht nur von Funktionen.
Abhängig kann Logik auch von Konstanten sein. Das ist besonders sichtbar, wenn Daten, die eigentlich unveränderlich sind, einem einfachen Test im Wege stehen.
Angenommen, die zu summierenden Daten werden immer in der Datei numbers.txt angeliefert. Dann wäre es legitim, diesen Namen in der Logik zu hinterlegen:
public class Aggregator {
public int SumUpFileContent() {
var lines = Load_text("numbers.txt");
var numbers = Extract_numbers(lines);
return Sum(numbers);
}
...
Für den Produktivbetrieb ist das kein Problem, aber für den Test. Denn jeder Test von SumUpFileContent() müsste Testdaten in einer Datei dieses Namens zur Verfügung stellen. Das ist umständlich.
Testrelevante Konstanten sollten daher zumindest für Tests variabel gemacht werden. In diesem Fall könnte das durch einen Methodenparameter geschehen:
public int SumUpFileContent() => SumUpFileContent("numbers.txt");
internal int SumUpFileContent(string filename) {
var lines = Load_text(filename);
var numbers = Extract_numbers(lines);
return Sum(numbers);
}
Das eigentliche Arbeitspferd wird eine Methode mit einem Parameter und die öffentliche Methode leitet dahin unter Angabe des konstanten Dateinamens. Im Test wird das Arbeitspferd bemüht, um leichter unterschiedliche Szenarien zu durchlaufen, z.B.
[Test()]
public void Akzeptanztest_mit_Leerzeilen() {
var sut = new Aggregator();
var result = sut.SumUpFileContent("numbers_with_empty_lines.txt");
Assert.AreEqual(6, result);
}
Alternativ kann eine Konstante auch über den Konstruktor injiziert werden:
public class Aggregator {
private string filename;
public Aggregator() : this("numbers.txt") {}
internal Aggregator(string filename) => this.filename = filename;
public int SumUpFileContent() {
var lines = Load_text(this.filename);
var numbers = Extract_numbers(lines);
return Sum(numbers);
}
...
Der öffentliche Konstruktor ist unverändert. Aber ein neuer für Tests nimmt den Dateinamen als Parameter und stellt ihn als Feld allen Methoden zur Verfügung.
Da es sich bei SumUpFileContent() schon um eine Instanzmethode handelt, wäre das eine legitime Möglichkeit. Bei einer statischen Methode wäre abzuwägen, ob für diesen Zweck nicht die Injektion via Parameter besser wäre oder ob gerade die Abhängigkeit ein Signal ist, zu einer Instanzmethode zu wechseln. Außerdem in Anschlag zu bringen ist der Zweck von Methode und Klasse. Passt es zur Aufgabe von Aggregator{} bzw. SumUpFileContent(), einen ctor-Parameter zu haben oder fühlt sich ein Funktionsparameter natürlicher an? In jedem Fall sollte die Abhängigkeit von testrelevanten Konstanten einen so begrenzten Einfluss wie möglich haben.
Häufiger als die Abhängigkeit von Konstanten verhagelt die Abhängigkeit von Zustand eine gute Testbarkeit.
Funktionen einer Klasse können Daten gemeinsam benutzen oder dieselbe Funktion kann mit sich selbst Daten teilen über Aufrufe hinweg.
Wenn dann die Logik in einer Funktion getestet werden soll, müsste eigentlich zuerst Logik in einer anderen Funktion aufgerufen werden, um den passenden Zustand aufzubauen.
Als Beispiel mag ein FileProvider{} dienen, der die zu summierenden Zahlen beschafft:
internal class FileProvider {
public int[] Load(string filename) {
var lines = Load_text(filename);
return Parse(lines).ToArray();
}
private IEnumerable<string> Load_text(string filename) {
return System.IO.File.ReadAllLines(filename);
}
internal static IEnumerable<int> Parse(IEnumerable<string> lines) {
foreach (var line in lines)
if (!string.IsNullOrWhiteSpace(line))
yield return int.Parse(line);
}
}
Der hat noch keinen Zustand. Doch was, wenn die Datei nicht immer wieder geladen soll? Die Zahlen könnten gecachet werden. Nur, wenn der letzte Aufruf lange genug zurückliegt, würde die Datei erneut gelesen. Die Verfallszeit könnte 1 Minute betragen.
Ein FileProvider{} mit Cache müsste sich merken, mit welcher Datei er aufgerufen wurde, wann dieser Aufruf stattgefunden hat und was das Ergebnis war. Das wäre einiger Zustand, von dem er abhängig wäre. Zusätzlich gäbe es eine Abhängigkeit von einer Konstanten, der Cache-Verfallszeit.
Zustand und Konstante sind Details der Klasse. Doch für Testzwecke wäre es vorteilhaft, sie gezielt setzen zu können. Die Implementation könnte so aussehen:
internal class FileProvider {
string previousFilename;
DateTime loadTime;
int[] numbers;
TimeSpan expirationSpan;
public FileProvider() : this(null,
DateTime.MinValue,
null,
new TimeSpan(0,1,0)) { }
internal FileProvider(string previousFilename,
DateTime loadTime,
int[] numbers,
TimeSpan expirationSpan) {
this.previousFilename = previousFilename;
this.loadTime = loadTime;
this.numbers = numbers;
this.expirationSpan = expirationSpan;
}
public int[] Load(string filename) {
return Retrieve_from_cache(filename,
Acquire_numbers);
int[] Acquire_numbers() {
var lines = Load_text(filename);
return Parse(lines).ToArray();
}
}
internal int[] Retrieve_from_cache(string filename, Func<int[]> get_data) {
if (Cache_hit())
return this.numbers;
else
return Reset_cache();
bool Cache_hit() {
return this.previousFilename == filename &&
DateTime.Now.Subtract(this.expirationSpan) < this.loadTime;
}
int[] Reset_cache() {
this.previousFilename = filename;
this.loadTime = DateTime.Now;
this.numbers = get_data();
return this.numbers;
}
}
…
Sie sehen am Anfang der Klasse den Zustand ihrer Instanzen, der über einen Konstruktor gesetzt werden kann, aber nicht muss.
Für das neue Verhalten ist ein Aufruf der Funktion Retrieve_from_cache() in Load() dazugekommen. Der umfängt die bisherige Datenbeschaffung, die jetzt nur noch bei Bedarf aufgerufen wird.
In Retrieve_from_cache() sehen Sie, dass zwischen einem cache hit und einem cache miss unterschieden wird. Ein hit ist, wenn der Cache noch gültig ist, weil die gerade angefragte Datei dieselbe wie die vorherige ist und der letzte Aufruf noch nicht zu weit in der Vergangenheit liegt. Dann werden die schon geladenen Zahlen zurückgeliefert. Nur bei einem miss fragt das Caching nach neuen Inhalten und setzt den Zustand zurück.
Die Testbarkeit ist durch die Möglichkeit der Injektion des Zustands hoch. Hier drei Gerüsttests, die sich den Zustand vor Aufruf der zu testenden Funktion gezielt einrichten:
[Test]
public void Retrieve_from_cache() {
var expected = new[] { 1, 2, 3 };
var sut = new FileProvider("a.txt", DateTime.Now, expected, new TimeSpan(1,0,0));
var result = sut.Retrieve_from_cache("a.txt", null);
Assert.AreEqual(expected, result);
}
[Test]
public void Cache_miss_due_to_different_filename() {
var expected = new[] { 1, 2, 3 };
var sut = new FileProvider("a.txt", DateTime.Now, null, new TimeSpan(1, 0, 0));
var result = sut.Retrieve_from_cache("x.txt", () => new[]{1,2,3} );
Assert.AreEqual(expected, result);
}
[Test]
public void Cache_miss_due_to_expiration() {
var expected = new[] { 1, 2, 3 };
var sut = new FileProvider(
"a.txt",
DateTime.Now.Subtract(new TimeSpan(2,0,0)),
null,
new TimeSpan(1, 0, 0));
var result = sut.Retrieve_from_cache("a.txt", () => new[] { 1, 2, 3 });
Assert.AreEqual(expected, result);
}
Ein Akzeptanztest kann die Konfigurierbarkeit des Zustands natürlich auch nutzen:
[Test]
public void Load_from_cache() {
var expected = new[] { 1, 2, 3 };
var sut = new FileProvider("numbers.txt", DateTime.Now, expected, new TimeSpan(1, 0, 0));
var result = sut.Load("numbers.txt");
Assert.AreEqual(expected, result);
}
Dieses Vorgehen funktioniert gut, solange der Zustand nicht zu umfangreich ist oder nur gesetzt werden soll. Zustandsänderungen hingegen lassen sich nicht überprüfen, zumindest nicht, wenn es sich um primitive Datentypen wie int oder string handelt.
Noch besser ist die Testbarkeit daher, wenn der Zustand in einem eigenen Objekt zusammengefasst wird, z.B.
internal class FileProvider {
internal class FileProviderState {
public string previousFilename;
public DateTime loadTime = DateTime.MinValue;
public int[] numbers;
public TimeSpan expirationSpan = new TimeSpan(0,1,0);
}
private FileProviderState state;
public FileProvider() : this(new FileProviderState()) { }
internal FileProvider(FileProviderState state) {
this.state = state;
}
...
Ein Zustandsobjekt kann im Test nicht nur vorbelegt und injiziert, sondern auch nach Aufruf der FUT überprüft werden. Das ist zwar ein Blick hinter die Kulissen auf ein Detail der Implementation. Doch allemal in Gerüsttests ist das ein legitimes Vorgehen, um zielstrebig Korrektheit für eine in dem Moment ohnehin im Detail bekannte Struktur herzustellen.
[Test, Description("Gerüttest")]
public void Load_into_cache() {
var state = new FileProvider.FileProviderState();
var sut = new FileProvider(state);
sut.Load("numbers.txt");
Assert.AreEqual(state.previousFilename, "numbers.txt");
Assert.AreEqual(state.numbers, new[]{1, 22, 333} );
Assert.IsTrue(DateTime.Now.Subtract(new TimeSpan(0,0,1)) < state.loadTime);
}
Abhängigkeiten reduzieren die Testbarkeit von Logik. Injektion hilft, das zu kompensieren. Davon können Operationen schon für testrelevante Daten wie Konstanten und Zustand profitieren.
Aber auch auch wenn Operationen nicht mehr funktional abhängig sind, enthalten Sie durchaus noch eine letzte Abhängigkeit, die die Testbarkeit erschwert.
Funktionale Abhängigkeit wurde bisher in Bezug auf andere Domänenfunktionalität gesehen. Oder denken Sie stattdessen „Logik, die ich selbst geschrieben habe.“ Eine Funktion, die eine andere Funktion aufruft, die ebenfalls Sie bzw. Ihr Team mit Logik gefüllt haben, ist funktional abhängig. Das sollte nicht sein. Folgen Sie dem IOSP.
Operationen können jedoch nicht operieren, wenn sie nicht irgendwelche Funktionen aufrufen. Jedes +, jedes System.Console.WriteLine(), jedes string.Join() ist ein Funktionsaufruf, der Logik in Bewegung setzt. Allerdings haben nicht Sie diese Logik entwickelt. Sie denken darüber nicht nach; die Logik liegt als Black Box vor. Sie benutzen existierende, für Sie unveränderliche Bibliotheken, Frameworks, Services.
Weil diese Funktionen für Sie Black Boxes sind, werden sie auch der Logik zugeschlagen und nicht als funktionale Abhängigkeiten gewertet. Sie sind unvermeidbar. Aber sie sollten eben in Operationen konzentriert werden.
Die meisten solcher Funktionsaufrufe sind für die Testbarkeit unkritisch. Solange eine Funktion vorliegt, die sie zusammenfasst, ist der Testbarkeit Genüge getan.
Einige dieser Black Boxes machen den Test von Operationen jedoch schwer. Das sind Funktionen, die auf Ressourcen zugreifen. Sie nutzen über in-memory Zustand hinaus in irgendeiner Weise Hardware, das kann eine Festplatte, eine Internetverbindung, der Bildschirm, die Maus, ein Scanner, ein anderer Thread usw. sein.
Solche Ressourcen sind in Tests oft schwer oder gar nicht zu kontrollieren. Das verringert die Testbarkeit von Operationen erheblich, die von Ressourcen abhängen.
Eine Operation, deren ausgewiesene Aufgabe es ist, eine Ressource zu benutzen, kann in dieser Hinsicht nicht testbarer gemacht werden. Aber wie sieht es mit folgender Funktion aus:
public class Aggregator {
public int SumUpFileContent(string filename) {
var sum = 0;
var lines = File.ReadAllLines(filename);
foreach (var line in lines)
if (!string.IsNullOrWhiteSpace(line))
sum += int.Parse(line);
return sum;
}
}
Das ist eine Operation. Diese Operation ist von einer Ressource abhängig: der Festplatte oder dem Dateisystem bzw. einer Datei. Auf diese Ressource greift sie mittels eines I/O-API zu, hier: System.IO.File.ReadAllLines().
Ist es aber die „ausgewiesene Aufgabe“ der Operation, auf eine Ressource zuzugreifen? Nein. Sie tut es nur im Rahmen eines größeren Zwecks. Der Ressourcenzugriff ist lediglich ein Aspekt ihrer Verantwortlichkeit.
Ressourenzugriffe sind Verantwortlichkeitssignale. Wo sie stattfinden, sollten Sie genau hinschauen, ob eine Operation auch wirklich nur darauf konzentriert ist. Andere Aspekte sollten mit Ressourcenzugriffen nicht in einer Operation zusammengefasst werden. Zugriffe auf verschiedene Ressourcen in einer Operation sind ebenfalls zu vermeiden.
Um die Testbarkeit der obigen Funktion zu erhöhen, sollte also der Ressourcenzugriff herausgelöst und injiziert werden. Beispiel:
public class Aggregator {
readonly Func<string, string[]> load_text;
public Aggregator() : this(Load_text) {}
internal Aggregator(Func<string, string[]> load_text)
=> this.load_text = load_text;
public int SumUpFileContent(string filename) {
var sum = 0;
var lines = this.load_text(filename);
foreach (var line in lines)
if (!string.IsNullOrWhiteSpace(line))
sum += int.Parse(line);
return sum;
}
internal static string[] Load_text(string filename)
=> File.ReadAllLines(filename);
}
Jetzt ist die Funktion nicht mehr direkt von der Ressource abhängig, sondern von einer „Abstraktion“. Die wird injiziert, allerdings nur als Funktion. Wenn Sie wollen, können Sie natürlich auch eine weitere Klasse für den Ressourcenzugriff definieren und mit einem Interface versehen, um dem üblichen Muster von DIP/IoC zu entsprechen. Doch das ist nur ein Unterschied in der Form, nicht prinzipiell.
Die Entscheidung, ob der Ressourcenzugriff nur in eine Methode der bisherigen Klasse oder gar in eine eigene Klasse ausgelagert werden sollte, ist nicht unwichtig, beeinflusst die Testbarkeit jedoch weniger als überhaupt die Einführung einer Injektion.
In jedem Fall kann ein Test jetzt leicht ohne Ressourcenaufwand durchgeführt werden:
[Test()]
public void Akzeptanztest_mit_Ressourcenattrappe() {
var sut = new Aggregator( _ => new[]{"1", "2", "3"} );
var result = sut.SumUpFileContent("unwichtig.txt");
Assert.AreEqual(6, result);
}
Der Preis für eine Attrappe ist gering im Vergleich zum Gewinn an Testbarkeit.
Aber Vorsicht! Attrappen sind kein Selbstzweck. Strukturieren Sie zuerst Ihren Code nach IOSP. Attrappen sind dann lediglich noch nötig, um hier und da Logik im Test von sehr fokussierten Ressourcenzugriffen zu entkoppeln. Attrappen sind kein Mittel, um dem Grundübel funktionaler Abhängigkeiten zu entgehen.
Ein anderes Beispiel für eine Ressourcenabhängigkeit, die oft übersehen wird, finden Sie oben im Beispiel zur Zustandsinjektion:
[Test, Description("Gerüttest")]
public void Load_into_cache() {
var state = new FileProvider.FileProviderState();
var sut = new FileProvider(state);
sut.Load("numbers.txt");
Assert.AreEqual(state.previousFilename, "numbers.txt");
Assert.AreEqual(state.numbers, new[]{1, 22, 333} );
Assert.IsTrue(DateTime.Now.Subtract(new TimeSpan(0,0,1)) < state.loadTime);
}
Sehen Sie die Abhängigkeit, die den Test erschwert? Es ist die von der Zeit. Die letzte Überprüfung ist umständlich: Eigentlich sollte es reichen zu schreiben Assert.AreEqual(DateTime.Now, state.loadTime), denn der Ladezeitpunkt ist ja der Zeitpunkt, zu dem der Test läuft. Eigentlich. Das stimmt nämlich nur im Groben. Wenn Sie genau hinschauen, ist die Zeit ja weitergelaufen seitdem state.loadTime gesetzt wurde. DateTime.Now im Test würde einen anderen Zeitpunkt liefern und der Test fehlschlagen, auch wenn der Zeitunterschied nur eine Millisekunde beträgt.
Hier kann die Lösung wieder in einer Injektion bestehen. Der „Zeitgeber“ könnte Teil des Zustands sein oder separat injiziert werden:
internal class FileProvider {
...
private FileProviderState state;
readonly Func<DateTime> currentTime;
public FileProvider() : this(new FileProviderState(), () => DateTime.Now) { }
internal FileProvider(FileProviderState state, Func<DateTime> currentTime) {
this.currentTime = currentTime;
this.state = state;
}
...
internal int[] Retrieve_from_cache(string filename, Func<int[]> get_data) {
if (Cache_hit())
return this.state.numbers;
else
return Reset_cache();
bool Cache_hit() {
return this.state.previousFilename == filename &&
this.currentTime().Subtract(this.state.expirationSpan) < this.state.loadTime;
}
int[] Reset_cache() {
this.state.previousFilename = filename;
this.state.loadTime = this.currentTime();
this.state.numbers = get_data();
return this.state.numbers;
}
}
...
Im Test lässt sich dann die Zeit festgelegen:
[Test, Description("Gerüttest")]
public void Load_into_cache() {
...
var expectedTime = new DateTime(2017, 5, 5, 11, 28, 30);
var sut = new FileProvider(state, () => expectedTime);
sut.Load("numbers.txt");
...
Assert.AreEqual(expectedTime, state.loadTime);
}
Hohe Testbarkeit ist gegeben, wenn Logik
ist.
Das sieht wie ein Gewinn für die Korrektheit aus und ist es auch. Aber es ist auch ein enormer Gewinn für die Wandelbarkeit. Der ist die Testbarkeit zugeschlagen, weil sie nicht unmittelbar Korrektheit nachweist, sondern nur potenziell, d.h. in der Zukunft bei Bedarf, wenn sich Logik verändert. Dann soll Logik so strukturiert sein, dass „Testsonden“ ganz gezielt angelegt werden können.
Das ist nur möglich, wenn der Code modular ist. Was zusammengehört, ist in Modulen von Funktion bis Service zusammengefasst (hohe Kohäsion). Was sich nicht beeinflussen und unbekannt sein soll, ist hinter Modulkontrakten verborgen (lose Kopplung). Module, insbesondere Funktionen, mit klar erkennbaren, abgegrenzten und erreichbaren Verantwortlichkeiten sind die Voraussetzung für gezielte Eingriffe zur Verbesserung der Korrektheit (Bug Fix) bzw. Erweiterung des Verhaltens.
Ein Review für die Zukunftsfähigkeit im Sinne der Wandelbarkeit muss auf Modularität achten. Statt jedoch bei Abstraktionen zu beginnen, sollte der Ausgangspunkt ganz konkret die Logik sein.
Wir empfehlen also einen bottom-up Review, der sich fragt, ob Logik so in Funktionen strukturiert ist, dass sie leicht testbar ist. Indem darauf das Augenmerk gelegt wird, entstehen kleine und kleinste Funktionen, die eine Masse bilden, in der Muster erkannt werden können. Das sind dann naheliegende Abstraktionen, die ihren Ausdruck in Modulen höherer Ordnung finden können.
Durch einen Fokus auf die Testbarkeit entstehen Klassen und Bibliotheken quasi nebenbei. Die Grobstruktur einer Codebasis bekommt die Chance zu emergieren.
Weitere Artikel in dieser Serie:
Doch auch wenn der Review nur 100% korrekte Software zum Release freigäbe, wäre ihre Zukunftsfähigkeit noch nicht zwangsläufig optimal. Denn auch korrekte Software will verändert werden. Die nächste Anforderung nach Erweiterung ihrer Funktionalität oder Erhöhung der Effizienz kommt bestimmt. Und dann soll es möglichst einfach sein, diesen Wunsch im Code umzusetzen. Logik muss verändert und ergänzt werden. Wie einfach ist das möglich? Das ist die Frage nach der Wandelbarkeit von Software.
Wie die Korrektheit hat auch die Wandelbarkeit zwei Seiten, die es zu betrachten gilt. Deren erste ist die…
Bevor Code verändert werden kann, muss er verstanden werden. Wie wird das existierende Verhalten überhaupt im Zusammenspiel der vielen Module hergestellt? Wer das nicht versteht, kann nicht beurteilen, was wo verändert und/oder hinzugefügt werden muss, um zu neuem Verhalten zu kommen. Der Lösungsansatz für das Neue ist abhängig vom Vorhandenen.
Code wird viel häufiger gelesen, als geschrieben. Denn bevor eine Zeile Code geschrieben wird, müssen 5, 10, 50, gar Hunderte Zeilen Code gelesen werden. Durch das Lesen wir ein mentales Modell aufgebaut (oder aktualisiert), wie Verhalten aktuell entsteht. In diesem mentalen Modell werden Veränderungen als Lösungsalternativen simuliert. Und erst am Ende wird das veränderte Modell wieder in Code gegossen.
Aus diesem Grunde sollte Code für das Lesen optimiert sein. Wer das KISS-Prinzip aufruft (Keep It Simple, Stupid), der meint (oder sollte meinen) Simplizität, die die Ressource „Lesezeit“ des Entwicklers möglichst wenig belastet. (Simplizität wird hier verstanden als Funktion der knappsten Ressource.)
Für die Wandelbarkeit ist es also kontraproduktiv, die Zeit zu optimieren, die gebraucht wird, um Code zu schreiben. Der schnell geschriebene Einzeiler, der besonders knappe, elegante Code, die Kenntnis von IDE-Shortcuts… das alles ist unwichtig oder gar kontraproduktiv, falls es die Verständlichkeit des Codes negativ beeinflusst.
Verständlichkeit ist insofern auch relativ bzw. subjektiv. Sie entsteht im Auge des Lesers. Der Schreiber von Code kann sie oft nur schwer abschätzen und überschätzt sie schnell, weil er von sich im Moment des Schreibens ausgeht. Das mentale Modell, das er in dem Moment jedoch hat, kann nicht bei einem Leser vorausgesetzt werden – der der Schreiben selbst schon in 1 Stunde, 1 Woche oder 1 Monat sein kann.
Verständlich ist Code, für den zunächst die Frage „Wie entsteht die Funktionalität/Effizienz durch die existierende Logik?“ leicht zu beantworten ist. Und ist darauf eine Antwort gefunden, sollte die Frage „Welchen Effekt auf Funktionalität und Effizienz hat eine Veränderung von Logik?“ leicht zu beantworten sein.
Beide Fragen beziehen sich auf Logik an einem Ort und ihre Beziehung zu Logik an anderen Orten. Es geht also um die Struktur von Logik zur Herstellung von Verhalten. Klar erkennbare Bedeutungen und Zusammenhänge sind nötig. Dann ist Code „easy to reason about“.
Logik selbst fehlt Bedeutung. Sehen Sie selbst: Welche Funktionalität stellt die folgende Logik her?
var b = 0;
foreach(var c in System.IO.File.ReadAllLines(a))
if (!string.IsNullOrWhiteSpace(c)) b += int.Parse(c);
Wie lange brauchen Sie, um die Funktionalität in einem Satz zu beschreiben?
Wird die Verständlichkeit größer durch Verwendung moderner Sprachfeatures wie Linq in C# (oder Streams ab Java 8)?
var b = File.ReadAllLines(x)
.Where(y => !string.IsNullOrWhiteSpace(y))
.Select(int.Parse)
.Aggregate(0, (z, w) => z + w);
Logik – das sind Transformationen, Kontrollstrukturen und I/O – hat selbst keine Bedeutung. Die entsteht erst im Verlauf einer Interpretation durch einen Betrachter. Indem er Logik studiert, findet er heraus, wie durch sie Input in Output verwandelt wird. Dem gibt er abschließend eine Bedeutung.
Im Beispiel würde der Input in Form einer Datei (Dateiname in a bzw. x) mit dem Inhalt
1
2
3
z.B. in den Output (Variable b) 6 transformiert.
Was bedeutet das aber? Was ist der Zweck?
Rein aus den einzelnen Funktionsaufrufen wie ReadAllLines(), +=, foreach(), Parse(), IsNullOrWhiteSpace() ergibt sich die Antwort nicht automatisch. Ihr Arrangement in einer bestimmten Reihenfolge und Schachtelung muss immer aktiv gedeutet werden durch den Codekonsumenten.
Es sei denn… Ja, es sei denn, der Codeproduzent gibt Hilfestellung durch „sprechende Namen“.
Vorgegeben sind die Namen von verwendeten Funktionen wie oben gelistet. Die sind allerdings nur sprechend in Bezug auf ihre technische Domäne, z.B. den Umgang mit Dateien oder Zeichenketten.
Die Bedeutung, auf deren Suche der Codekonsument ist, ist jedoch eine in der Domäne, für die Verhalten hergestellt werden soll. Die ist orthogonal zur technischen Domäne von Frameworks, die die Logik benutzt.
Es ist ja gerade die Aufgabe des algorithmischen Entwurfs, technische gegebene Funktionalität so zu arrangieren, dass ein neuer Effekt für die Problemdomäne entsteht. Das ist das ureigene Metier des Softwareentwicklers. Hier ist seine Kreativität und Expertise gefordert.
Für das obige Logikbeispiel hat die (bewusst formal formulierte) Aufgabe gelautet „Finde die passenden technischen Funktionen und arrangiere sie so, dass sie zusammen den Zweck erfüllen, ganze Zahlen notiert auf separaten Zeilen einer Textdatei zu summieren.“
Wie die Logik für diese Aufgabe am Ende ausfällt, hängt von der Kenntnis der technischen Funktionen und der Kreativität des Entwicklers ab. Wer Linq nicht kennt, kann Linq nicht benutzen. Wer ReadAllLines() nicht kennt, kann die Funktion nicht nutzen und muss auf anderem Weg an die Zeilen der Textdatei kommen. Auch hier wird wieder die Relativität der Verständlichkeit von Code deutlich.
Soll Logik nun mit Bedeutung aufgeladen werden, ist das erste Mittel die Verwendung von Variablen (und Konstanten). Die sollten auf den Zweck hin benannt werden. Ihre Namen sollten bedeutungsvoll sein.
Das erste Beispiel könnte z.B. so mit Bedeutung aufgeladen werden:
var sum = 0;
foreach(var line in File.ReadAllLines(filename))
if (!string.IsNullOrWhiteSpace(line)) sum += int.Parse(line);
filename, line, sum sind Bezeichnungen aus der Domäne. Es geht ja um Dateien (filename) in deren Zeilen (line) Zahlen stehen, die summiert (sum) werden sollen.
Wer die Domäne und das Problem kennt, wird sich nun in der Logik leichter zurechtfinden. Wer das Problem nicht kennt, wird schneller darauf kommen.
Optimal ist die Lesbarkeit aber immer noch nicht. Es fehlt z.B. ein Name für einen zentralen Begriff der Domäne: Zahl. Die Zahlen sind nur repräsentiert durch int.Parse(). Das zu erkennen, erfordert jedoch wieder Deutungsarbeit. Besser wäre es, die Bedeutung im Code explizit zu machen.
Ähnlich ist es für die Zeilen der Datei. Eine einzelne Zeile ist zwar benannt (line), doch der Gesamtinhalt der Datei findet sich nicht repräsentiert. ReadAllLines() beschafft die Zeilen und ist ein recht sprechender Name, doch der Aufruf ist eingeschachtelt im Schleifenkopf. Dort fällt er nicht so auf. Besser also, den Dateiinhalt in eine eigene Variable herausziehen:
var sum = 0;
var linesFromFile = File.ReadAllLines(filename);
foreach (var line in linesFromFile)
if (!string.IsNullOrWhiteSpace(line)) {
var number = int.Parse(line);
sum += number;
}
return sum;
Daten der Domäne überhaupt explizit zu machen über Variablen und diese dann auch noch „sprechend“ oder „selbsterklärend“ zu benennen, ist ein erster wesentlicher Schritt zu guter Verständlichkeit von Code.
Leider wird der immer wieder übergangen. Vermeintlicher Zeitmangel, der Unwille bzw. die Unfähigkeit, sich in den zukünftigen Codekonsumenten hineinzuversetzen, ungenügendes Domänenverständnis, unklarer Lösungsansatz, Optimierungswille… es gibt viele Gründe, warum Namen fehlen oder schwer verständlich sind.
Spätestens im Review darf es dafür jedoch kein Pardon geben!
Variablennamen können einzelne Codezeilen mit Bedeutung aufladen, z.B.
var number = int.Parse(line);
Der Name number ist noch recht allgemein, doch er passt zur Domäne, die auch allgemein ist. Es sollen nur „irgendwelche“ Zahlen aufsummiert werden. Wozu? Was das für Zahlen sind? Das ist nicht bekannt.
In anderen Domänen mag das klarer sein. Dort sollten dann spezifischere Namen benutzt werden, z.B.
var height = int.Parse(line);
oder
var age = int.Parse(line);
Aus den Bedeutungen einzelner Zeilen ergibt sich allerdings nicht automatisch eine Bedeutung für alle zusammen. Die muss auch bei schönster Benennung von Variablen und Konstanten noch durch Logikstudium erarbeitet werden. Das kostet Zeit.
Um diese Zeit zu sparen oder zumindest zu reduzieren, sollte Logik, die inhaltlich zusammengehört, mit einem eigenen Namen versehen werden. Das Mittel dazu sind die kleinsten Module: Funktionen.
Für das Beispiel könnte eine zusammenfassende Funktion z.B. so lauten:
public int SumUpFileContent(string filename) {
var sum = 0;
var lines = File.ReadAllLines(filename);
foreach (var line in lines)
if (!string.IsNullOrWhiteSpace(line)) {
var number = int.Parse(line);
sum += number;
}
return sum;
}
Wer die Logik sieht, bekommt durch den Funktionsnamen und den Parameter und den Resultatstyp sofort mitgeteilt, worum es geht. Keine Deutung nötig. Der Codekonsument kann dann entscheiden, ob er dennoch die Logik genauer studieren möchte, um zu verstehen wie der Zweck en detail erreicht wird. Um den Zweck zu erkennen, ist das aber nicht mehr wie vorher zwingend nötig.
Mit guten Funktions- und Datennamen ist der Codekonsument in der Lage, durch die Logik zu „springen“. Er springt beim Funktionsnamen ab und benutzt die Namen in der Logik als Trittsteine. Statt jede Zeile ausführlich zu studieren, kann der Blick größere Einheiten erfassen, die durch die Namen repräsentiert werden.
Voraussetzung für hilfreiche Funktionsnamen ist natürlich wieder ein gewisses Einfühlungsvermögen des Codeproduzenten. Der muss vorhersehen, welcher Name einem späteren Codekonsumenten schnellstmöglich Klarheit verschafft. Wie informativ ist der Name bei Blick auf die Funktion, wie informativ ist der Name bei Blick auf einen Aufruf der Funktion?
var totalNumberOfSales = aggregator.SumUpFileContent("numbers.txt");
Die Namensgebung fällt umso leichter, je geringer der Umfang der unter einem Namen zusammengefassten Logik. Für die obigen 8 Zeilen, liegt der Name auf der Hand. Für 50, 100, 500, 5000 Zeilen jedoch… ist es viel schwieriger, „sprechende“, domänenrelevante Namen zu finden. Je mehr Logik in einer Funktion steht, desto wahrscheinlicher, dass die mehr als eine Verantwortlichkeit hat. Die Einhaltung des SRP (Single Responsibility Principle) ist also Voraussetzung für gute Namen.
Auch die Zusammenfassung von Logik zu Funktionen ist eine zentrale Leistung des Softwareentwicklers. Das ist Abstraktion par excellence: Aus der Vielheit (mehrere Zeilen Logik) wird eine Gemeinsamkeit herausdestilliert (hier: Zweck). Das Viele wird zu einem neuen Ganzen auf höherer Ebene verbunden. Der Name für das Ganze steht für die Kohäsion der Teile.
Gleichzeitig wird durch die Zusammenfassung von Logik in einer Funktion eine Klammerung vorgenommen. Die Logik hinter dem Namen hängt enger untereinander zusammen als mit anderer Logik. Der Name steht also auch für Trennung, er entkoppelt.
Dieses Vorgehen funktioniert auf mehreren Ebenen in der Softwareentwicklung. Immer, wenn „viel auf einem Haufen liegt“, kann Ordnung hergestellt werden durch Zusammenfassung. Was eben noch bedeutungsfrei aufgrund von Unübersichtlichkeit war, bekommt Bedeutung durch eine benannte Klammer.
Der Review prüft, ob diese Mittel genutzt wurden, um Logik mit Bedeutung aufzuladen. Manchmal ist es einfach zu erkennen, wo Bedeutungsgrenzen verlaufen, manchmal schwieriger. Es gibt harte, formale Kriterien für die Grenzziehung. Doch letztlich ist es eine Sache Ihrer Erfahrung und des ständigen Bemühens, Ihren „Bedeutungssinn“ zu schärfen, mit dem Sie Verantwortlichkeiten in Logik erkennen.
Wie steht es z.B. mit der Logik in der obigen Methode? Ist die Bedeutung schon klar genug durch den Methodennamen und die Variablennamen? Hat die Logik nur eine Verantwortlichkeit oder sind mehrere darin vermischt?
Namen vermitteln auf einen Blick Bedeutung. Was auf einen Blick erfassbar ist, kann leicht mit einem mentalen Modell abgeglichen werden bzw. es aufbauen helfen.
Deshalb gehört zur Beurteilung der Verständlichkeit im Review auch die Beurteilung des Umfangs von Modulen. Wie viele Zeilen Logik enthält eine Methode? Wie viele Methoden enthält eine Klasse? Wie viele Klassen enthält eine Bibliothek? Passt der Modulinhalt „in den Kopf“? Ist also erstens die Zahl der Elemente überschaubar und sind zweitens die Beziehungen zwischen diesen Elemente klar?
Als Begrenzung für den Umfang liegt eine durchschnittliche Bildschirmhöhe nahe. Solange die komplette Logik noch auf den Bildschirm passt, kann sie im wahrsten Sinn des Wortes mit einem Blick erfasst werden. Je nach Orientierung des Bildschirms und Fontgröße begrenzt das die Länge von Methoden auf vielleicht maximal 50-70 Zeilen. Das ist immer noch viel – doch es ist viel weniger als „in der freien Wildbahn“ zuweilen zu finden ist. Methoden mit 500, gar 5000 Zeilen durchsetzen viele Codebasen.
Sobald das, was Sie versuchen zu verstehen, über den Bildschirm hinaus reicht, wenn Sie also immer wieder genötigt sind zu scrollen, um das Ganze zu sehen, müssen Sie deutlich mehr kognitiven Aufwand treiben, um Probleme zu lösen. Sie können nicht mehr alle Bestandteile „im Kopf jonglieren“, sondern müssen Teile „nachladen“, „auffrischen“. Das gilt es zu vermeiden.
Ohne weitere Kriterien ist eine Begrenzung auf die Bildschirmhöhe natürlich nur ein erster, pauschaler Schritt zu verständlicherem Code. Das gilt für Methoden, Klassen und Bibliotheken. Passt die Liste ihrer Elemente auf einen Bildschirm? Das wären vielleicht 50-70 Methoden bzw. 50-70 Klassen in einem Class- bzw. Project-Browser.
Kleinere Zahlen sind wünschenswert. Doch als erste Näherung und Maximalwerte mögen sie taugen. Für manche Codebasen stellen sicherlich schon sie eine Herausforderung dar.
Letztlich sind solche Zahlen aber natürlich willkürlich. Es kommt auch nicht wirklich auf die Zahl an, sondern darauf, dass das, was verstanden werden soll mit wenig Aufwand „in den Kopf passt“. Für Mengen, die mit einem Blick zu überschauen sind, ist das wahrscheinlicher als für größere. Es gibt allerdings auch gelegentlich größere Mengen, die leicht zu verstehen sind, wenn darin ein klares Muster erkennbar ist. Viel häufiger sind jedoch Mengen, die schon bei 50 Elemente schwer verständlich sind. Dann hilft nur Zerlegung in Untermengen mit jeweils höherer Kohäsion und guter Entkopplung.
Zerlegung des wenig Zusammengehörigen und Zusammenfassung des hoch Kohäsiven: das sind die wesentlichen strukturierenden Handlungen des Softwareentwicklers.
Verständlichkeit ist nicht nur eine Sache der Benennung von Bedeutungseinheiten und ihrer Größenbegrenzung. Damit werden ja nur die Strukturelemente besser erkennbar. Wie steht es aber um die Zusammenhänge?
Softwareverhalten entsteht durch einen Kontrollfluss. Logik wird sequenziell abgearbeitet. (Von der Möglichkeit der Parallelverarbeitung sei hier zunächst abgesehen. Ihr Review stellt weitere Anforderungen.) Wer verstehen will, wie Logik Verhalten herstellt, muss also diesen Fluss deutlich erkennen können.
Am leichtesten ist ein Verarbeitungsfluss verstanden, wenn er so angeordnet ist, dass er der gewohnten Leserichtung entspricht. Code ist Text. Text wird in der westlichen Welt von oben nach unten und von links nach rechts gelesen. Logik sollte dieser Gewohnheit entsprechen.
Leider jedoch widerspricht der Aufbau von Logik dieser simplen Regel oft. Selbst eine klare Sequenz wie Lesen, Verarbeiten, Ausgeben wird im Code „verklausuliert“. Dort finden sich Konstrukte wie:
Console.WriteLine(b.Append(Console.ReadLine()));
Das können Sie als Entwickler natürlich irgendwie verstehen – nur ist das mühsamer als eine Sequenz, die dem „natürlichen“ Lesefluss entspricht. Hier muss die Leserichtung „mit Gewalt“ auf „von rechts nach links“ gedreht werden. Das Verständnis gerät dabei für einen Moment ins Stocken.
Wie viel flüssiger ist dagegen dieser Code zu verstehen:
var a = Console.ReadLine();
b.Append(a);
Console.WriteLine(b);
Von oben nach unten steht dort die Sequenz der Verarbeitung. Selbst mit schlechten Namen für die Daten ist zumindest klar, „was läuft“.
Geschachtelte Funktionsaufrufe sind gut gemeint. Sie sollen einerseits Zeit bei der Codeproduktion sparen, andererseits helfen sie, die Zeilenzahl einer Methode zu reduzieren. Beides geht jedoch auf Kosten der Lesezeit des Codekonsumenten. Der wird mehr belastet.
Anordnungen von Logik, die den Lesefluss behindern, sind daher zu vermeiden. Die Gewohnheit „von oben nach unten und von links nach rechts“ sollte so häufig wie möglich bedient werden.
In dieser Hinsicht ist auch Funktionale Programmierung hilfreich. Wo üblicherweise und ohne sie geschrieben wird
f(g(h(x));
erlaubt sie die Schreibweise:
h(x,
y => g(y,
z => f(z)));
Mit funktionaler Programmierung können Sie Funktionen geschachtelt in der Reihenfolge notieren, in der sie aufgerufen werden, selbst wenn das nur unter bestimmten Bedingungen geschieht oder nur optional.
In F# geht es sogar noch einfacher:
x |> h |> g |> f
Wie gesagt: Bei der Verständlichkeit geht es nicht darum, für den Codeproduzenten zu optimieren, sondern für den Codekonsumenten. Wenn Code in Leserichtung ein paar Tastendrücke mehr brauchen sollte, dann ist das kein Bug, sondern womöglich ein Feature.
Das ist für den objektorientierten Programmierer ungewohnt; er ist auf die Umkehrung des Blickes trainiert. Doch diese Gewohnheit kann man ablegen. Wer einige Male mit funktionalen Features wie Lambda Ausdrücke und Closures in Sprachen wie C#, Java 8 oder auch Ruby, Python usw. gearbeitet hat, wird kaum zurück wollen. Lesefluss in gewohnter Richtung ist ein großer Gewinn für die Verständlichkeit.
Schon das obige zweite Beispiel für schwer verständlichen Code
var b = File.ReadAllLines(x)
.Where(y => !string.IsNullOrWhiteSpace(y))
.Select(int.Parse)
.Aggregate(0, (z, w) => z + w);
war besser zu verstehen als das erste. Denn hier ist der Lesefluss auch ungebrochen von oben nach unten: Zeilen werden gelesen, Leerzeilen werden herausgefiltert, Zeileninhalte werden in Zahlen gewandelt, Zahlen werden summiert. Vier klare und überschaubare Teilverantwortlichkeiten sind durch Linq zu einer natürlichen Sequenz verknüpft.
Aber auch ohne funktionale Features wie Linq oder Lambda Ausdrücke lässt sich die Lesbarkeit steigern, in dem schlicht auf geschachtelte Aufrufe verzichtet wird. Führen Sie stattdessen lieber hier und da temporäre Daten ein. Das hat oben schon den Code lesbarer gemacht:
var sum = 0;
var lines = File.ReadAllLines(filename);
foreach (var line in lines)
if (!string.IsNullOrWhiteSpace(line)) {
var number = int.Parse(line);
sum += number;
}
Die Variablennamen sind nicht nur sprechend, es gibt sie vor allem überhaupt. Durch lines und number wurde die Schachtelung reduziert. Der Lesefluss ist stärker von oben nach unten als in der initialen Version des Codes.
Wenn der Produktionsfluss für das Verhalten dem Lesefluss entspricht, kann Logik leichter interpretiert werden. Das mentale Modell wird beim Lesen Schritt für Schritt flüssig aufgebaut.
Wohin soll der Blick jedoch wandern bei einer Fallunterscheidung mit if-then-else oder switch-case oder try-catch? Letztlich müssen 2 oder mehr Lesepfade verfolgt werden. Bei einer Schleife wie for schlägt das Lesen sogar einen Purzelbaum.
Kontrollstrukturen stellen Verzweigungen im Produktionsfluss dar. Sie sind nötig – erschweren jedoch das Verständnis. Alternativen im Kopf zu behalten und zu überlegen, die das Verhalten in dem einen oder anderen Fall aussieht, ja, überhaupt zu erkennen, wann welcher Fall vorliegt… das erfordert einigen mentalen Aufwand.
Die Zahl der Verzweigungen, die eine Funktion – also ein überschaubarer Block Logik – enthält, sollte daher begrenzt werden. Jede Kontrollstruktur in einer Funktion senkt ihre Verständlichkeit deutlich. Das spiegelt auch die Metrik Zyklomatische Komplexität wider.
Versuchen Sie daher, es bei nur einer Kontrollstruktur je Funktion zu belassen. Daraus folgt auch: Schachteln Sie Kontrollstrukturen nicht.
Aber was, wenn mehr Kontrollstrukturen nötig sind oder sogar Schachtelung? Dann lagern Sie Kontrollstrukturen in eigene Funktionen aus. Das führt auch zu einer ganz natürlichen Reduktion des Umfangs von Funktionen. Für Funktionen, deren Logik auf einen Bildschirm passt, müssen Sie nicht Zeilen zählen, sondern vor allem die Zahl der Kontrollstrukturen konsequent begrenzen.
Wenn Sie die Verästelung des Verzweigungsbaumes bewusst kontrollieren und begrenzen, fällt das mentale Modell für die Logik pro Funktion deutlich einfacher aus.
Die bisher schon recht verständliche Funktion für die Summierung von Zahlen in einer Datei
public int SumUpFileContent(string filename) {
var sum = 0;
var lines = File.ReadAllLines(filename);
foreach (var line in lines)
if (!string.IsNullOrWhiteSpace(line)) {
var number = int.Parse(line);
sum += number;
}
return sum;
}
kann also noch eine Überarbeitung vertragen.
Zwei Kontrollstrukturen sind eine zu viel. Das if wandert in eine eigene Funktion:
public int SumUpFileContent(string filename) {
var sum = 0;
var lines = File.ReadAllLines(filename);
foreach (var line in lines)
sum += Parse_line_to_number(line);
return sum;
}
private int Parse_line_to_number(string line) {
if (!string.IsNullOrWhiteSpace(line))
return int.Parse(line);
else
return 0;
}
Die neue Funktion hat eine ganz enge Verantwortlichkeit: Sie übersetzt einen Text in eine Zahl. Enthält der Text keine Zahl, wird 0 als Ergebnis geliefert. Wie geprüft wird, dass der Text wohlgeformt ist, verbirgt sich nun hinter einer Signatur. Das Verfahren kann sich damit jederzeit ändern, ohne dass die konsumierende Logik angefasst werden müsste.
Jede Kontrollstruktur stellt im Grunde eine Sinneinheit dar. Der eine explizite Bedeutung zu geben, in dem Sie sie in eine Funktion extrahieren, liegt also nahe. Seien Sie sensibel für diese „Hinweise“ der Logik. Sie hilft Ihnen aus sich heraus bei der Modularisierung.
Das ernst genommen, kann der Beispielcode noch ein weiteres Mal refaktorisiert werden. Auch das foreach hat ja eine benennbare Bedeutung:
public int SumUpFileContent(string filename) {
var lines = File.ReadAllLines(filename);
return SumUpLines(lines);
}
private int SumUpLines(IEnumerable<string> lines) {
var sum = 0;
foreach (var line in lines)
sum += Parse_line_to_number(line);
return sum;
}
private int Parse_line_to_number(string line) {
if (!string.IsNullOrWhiteSpace(line))
return int.Parse(line);
else
return 0;
}
Das ist nochmal eine Steigerung der Verständlichkeit. Jede Methode ist nun sehr klein von der Zeilenzahl her und gleichzeitig sehr fokussiert in der Aufgabe.
Kontrollstrukturen lassen sich nicht vermeiden, ja, sie sind geradezu ein Herzstück von Logik; ohne sie bliebe Software trivial. Doch der Mehraufwand, den Sie beim „reasoning about code“ verursachen, lässt sich kompensieren durch Begrenzung ihrer Nutzung pro Funktion und Kapselung in eigenen Methoden. So kann das Lesen und Verstehen besser fließen.
Logik ist das Mittel, mit dem Verhalten hergestellt wird. Wenn Sie Logik lesen, dann sehen Sie, wie das funktioniert. Logik-Anweisungen sind technische Bausteine, aus denen ein Gebäude mit Zweck für die Problemdomäne errichtet wird.
Variablennamen und Modulnamen geben diesem Wie der Logik dann eine Bedeutung. Sie stellen Abstraktionen dar, weil es mit ihnen möglich ist, beim Lesen von den technischen Details abzusehen.
Die folgende Transformation ist pure Technik, sie hat keine Bedeutung. Die Frage „Was passiert in der Codezeile?“ (oder auch: „Wozu gibt es diese Codezeile?“, „Was ist der Beitrag dieser Codezeile zum Ganzen?“) bedarf einigen Interpretationsaufwandes:
int.Parse(line)
Wenn dem Ergebnis jedoch mittels eines Namens eine Bedeutung in einer Domäne zugeordnet wird, wird aus dem Wie ein Was, z.B.
var number = int.Parse(line);
oder
var quantity = int.Parse(line);
Das Wie ist weiterhin sichtbar, doch der Name davor erlaubt dem Betrachter, es zu ignorieren. So kann er schneller ein Verständnis für Logik aufbauen, ohne sofort alle Details zu studieren. Im Grunde reicht es, die Variablennamen von oben nach unten zu scannen, um den Produktionsfluss der Logik zu erkennen. Das könnte für die Summierung des Dateiinhaltes so aussehen:
var lines = …
var numbers = …
var sum = …
return sum;
Auf einem höherem Abstraktionsniveau als dem der Logik ließe sich damit die Frage beantworten, was da eigentlich passiert in all den Zeilen. „Aha, es werden Zeilen von irgendwoher beschafft, dann werden Zahlen beschafft (wahrscheinlich aus den Zeilen), und schließlich wird eine Summe beschafft (wahrscheinlich aus den Zahlen).“
Das funktioniert – doch vielleicht haben Sie es gespürt, irgendwie ist das noch mühsam. Die Bedeutungen bezeichnen nur Daten, das Verhalten müssen Sie sich denken. Deshalb auch die Spekulationen wie „wahrscheinlich aus den Zeilen“ usw. Wenn Sie es genauer wissen wollen, müssen Sie doch wieder rechts von den Variablennamen auf die Logik schauen.
Das ist ok und unvermeidlich, allerdings stellt es einen Wechsel im Abstraktionsniveau dar. Eben war es noch hoch bei Betrachtung des Namens, dann ist es niedrig beim Studium der Logik.
Solcher Wechsel strengt an. Das ist, als würden Sie einen Text in zwei Sprachen lesen. Some sentences might be in German, some in English. Das können Sie grundsätzlich. Since you’ve learned English in school or on the job. Doch erfordert es more mental effort, zwischen the two languages zu wechseln.
Code, der unterschiedliche Abstraktionsniveaus enthält, ist wie eine Straße mit Schlaglöchern. Wo die Straßendecke glatt ist, können Sie mit hoher Geschwindigkeit fahren. Doch dann ein Schlagloch und Sie bremsen vorher ab oder werden unschön überrascht und es kracht.
Besser eine Straße ohne Schlaglöcher, ein Text nur in einer Sprache, Code auf einheitlichem Abstraktionsniveau.
Das lässt sich erreichen durch Kapselung der Logik in eine Funktion, z.B.
var lines = Read_text_from(filename);
var numbers = Convert_to_numbers(lines);
return Calculate_sum(numbers);
Jetzt ist das Abstraktionsniveau nochmal gestiegen. Es ist keine Logik mehr sichtbar. Alles ist mit sprechenden Namen der Domäne benannt.
Es gibt also (mindestens) drei Abstraktionsniveaus im Code:
Selbstredend ist Code umso verständlicher, je höher das Abstraktionsniveau. Auf Level 3 können Sie viel schneller erkennen, was da passiert als auf Level 2 oder 1. Im Review wollen Sie daher möglichst viel Code auf hohem Niveau sehen.
Aber es hilft nichts: irgendwo muss Logik zu sehen sein, denn sonst wird ja kein Verhalten erzeugt. Code nur auf Level 3 ist nicht möglich.
Hier ein Beispiel für eine Methode, wie sie auch im Zusammenhang mit dem Summieren von Zahlen in einer Datei entstanden sein könnte:
internal class FileProvider {
public int[] Load(string filename) {
var lines = System.IO.File.ReadAllLines(filename);
var numbers = new List<int>();
for (var i = 0; i < lines.Length; i++)
if (!string.IsNullOrWhiteSpace(lines[i]))
numbers.Add(int.Parse(lines[i]));
return numbers.ToArray();
}
}
Es wurde also schon ein Teil der Logik herausgezogen in eine Funktion. Die beschafft sogar gleich die Zahlen aus einer Datei, nicht nur die Textzeilen wie oben mit Read_text_from() angedeutet.
Der Zweck der Funktion ist recht fokussiert – dennoch stolpert das Verständnis beim Lesen des Codes. Bemerken Sie auch Schlaglöcher?
Die Funktionsdefinition dient der Level 3 Abstraktion. Welche Abstraktionslevel finden sich jedoch in der Funktion?
Hier die Logikzeilen ergänzt um das Abstraktionsniveau. Sie sehen, es ist eine Mischung.
var lines = System.IO.File.ReadAllLines(filename); // 2
var numbers = new List<int>(); // 2
for (var i = 0; i < lines.Length; i++) // 1
if (!string.IsNullOrWhiteSpace(lines[i])) // 1 + 1
numbers.Add(int.Parse(lines[i])); // 2
return numbers.ToArray(); // 2
Level 2 und 1 wechseln sich ab. Diese Logik folgt nicht dem SLA (Single Level of Abstraktion Principle).
Über die ersten beiden Zeilen fließt der Blick in gleicher Geschwindigkeit. Doch dann ein Schlagloch: die for-Schleife. Was ist ihr Zweck? Das ist nicht anhand eines Namens erkennbar, also muss der Interpretationsaufwand erhöht werden. Danach wieder ein Schlagloch: das if. Was ist dessen Zweck? Wieder kein Name, also nochmals mehr Interpretationsaufwand, während die Interpretation des for noch nicht abgeschlossen ist. Die Bedingung des if ist eine weitere Transformation auf Level 1. Die Verständnisfahrt ist sehr holprig.
Solche häufigen Wechsel des Abstraktionsniveaus sollten vermieden werden. Ohne Schachtelung von Kontrollstrukturen – wie oben empfohlen – wäre das Problem hier natürlich schon entschärft:
public int[] Load(string filename) {
var lines = System.IO.File.ReadAllLines(filename); // 2
var numbers = new List<int>(); // 2
for (var i = 0; i < lines.Length; i++) // 1
numbers.Add(Parse(lines[i])); // 3
return numbers.ToArray(); // 2
}
private int Parse(string line) {
if (!string.IsNullOrWhiteSpace(line)) // 1
return int.Parse(line); // 1
else // 1
return 0; // 1
}
Dennoch enthält Load() anschließend immer noch zwei Abstraktionsniveaus, nein, sogar drei. Denn durch Einsetzen von Parse() beim Sammeln der Zahlen in einer Liste wurde die Zeile im Niveau angehoben. Dort kommen nun Variablenname und Domänenfunktionsname direkt zusammen.
Zur Beantwortung der Frage„Was tut die Schleife?“ muss sich der Codekonsument allerdings immer noch mühsam durch den Kontext arbeiten.
Leichter wäre es, würde das for auch einen Namen bekommen. Dazu reicht aber nicht eine Variable, denn die Schleife selbst erzeugt kein Ergebnis, das zugewiesen werden könnte. Also hilft nur eine Kapselung in eine Funktion:
public int[] Load(string filename) {
var lines = System.IO.File.ReadAllLines(filename); // 2
return Parse(lines).ToArray(); // 3
}
private IEnumerable<int> Parse(IEnumerable<string> lines) {
foreach (var line in lines) // 1
yield return Parse(line); // 3
}
private int Parse(string line) {
if (!string.IsNullOrWhiteSpace(line)) // 1
return int.Parse(line); // 1
else // 1
return 0; // 1
}
Statt einer großen Funktion mit Logik auf unterschiedlichen Abstraktionsniveaus gibt es nun mehrere kleine Funktionen, die entweder nur ein Abstraktionsniveau enthalten (Parse(string)) oder bei unterschiedlichen Abstraktionsniveaus sehr überschaubar sind (Parse(IEnumerable<string>)) oder durch die Differenzierung der Funktionen im Abstraktionsniveau abgehoben wurden (Load()).
Bei den Parse()-Methoden lässt sich nichts mehr tun. Auch wenn der Unterschied zwischen Level 1 und Level 3 bei Parse(IEnumerable<string>) zwar groß ist, die Schleife ist nun auf eine Zeile Kopf und eine Zeile Inhalt zusammengedampft. Weniger geht nicht.
Aber wie steht es mit Load()? Der Unterschied zwischen Level 2 und Level 3 könnte noch ausgeglichen werden.
Das ist einer Ermessensfrage. Wichtig ist es, zunächst den Unterschied überhaupt wahrzunehmen. Stört er jedoch den Lesefluss und das Verständnis?
In diesem konkreten Fall eher nicht. ReadAllLines() ist schon ein sprechender Name. Diese Logik weiter zu kapseln z.B. in Load_text_from_file() brächte kaum Verständnisvorteil.
Aber in anderen Situationen kann der Abstraktionsniveauunterschied eine Motivation darstellen, noch weitere Funktionen herauszuziehen, um ein einheitliche(re)s Level zu bekommen.
Drei verschiedene Abstraktionsniveaus lassen sich also leicht unterscheiden. Gibt es weitere? Ja, auch Domänenfunktionen selbst können auf unterschiedlichem Abstraktionsniveau liegen.
Funktionen, die nur Logik enthalten (z.B. Parse(string)) (sog. Operationen), liegen tiefer als Funktionen, die Logik enthalten, aber auch andere Domänenfunktionen aufrufen (z.B. Load()) (sog. Hybride). Und solche Funktionen liegen wiederum auf einem niedrigeren Abstraktionsniveau als Domänenfunktionen, die nur andere Domänenfunktionen aufrufen (sog. Integrationen). Es sind also mindestens fünf Level zu unterscheiden:
Kommentare stehen einerseits im Ruf, die Lesbarkeit von Code zu erhöhen. Andererseits wird empfohlen, auf Kommentare zu verzichten, weil sie Code verrauschen und mit ihm aus dem Tritt geraten können.
Wie Sie es also machen, es scheint verkehrt.
Wir empfehlen jedoch eine dritte Position: angemessene Kommentare sind hilfreich. Sie müssen also nicht auf Kommentare verzichten – sich aber wahrscheinlich etwas umorientieren.
Die erste Regel im Hinblick auf Kommentare lautet für uns: so wenig wie möglich und so viel wie nötig.
Wenn Sie die Lesbarkeit steigern wollen, greifen Sie zuerst zu einem der anderen vorgestellten Mittel. Kommentare sollen nicht fehlende oder schlechte Namen oder undurchsichtige Kontrollstrukturen kompensieren. Ist die Logik, also das Wie, jedoch mit sprechenden Namen versehen und bedeutungsvoll gekapselt, ist das Was deutlich zu erkennen.
Allerdings kann es immer noch eine Verständlichkeitslücke geben. Überprüfen Sie dann, ob die mit automatisierten Tests geschlossen werden kann. Sie zeigen den Code in Anwendung. Das verstärkt ein Verständnis für das Was.
Erst wenn jetzt immer noch eine Lücke besteht, setzen Sie Kommentare ein. Die sollen sich dann jedoch auf das beziehen, was nicht mit Logik, Namen und Modulen ausgedrückt werden kann. Das ist das Warum.
Kommentare, die Beweggründe, Konzepte, Voraussetzungen beschreiben oder einen Überblick über den Lösungsansatz geben oder Begriffe erklären (Glossar), sind willkommen, ja sogar geboten.
Verständlicher Code ist
Das zu überprüfen, ist für den Review wahrscheinlich die anspruchsvollste Aufgabe. Hier ist Augenmaß und Fingerspitzengefühl nötig. Erfahrung spielt eine große Rolle. Mit ein wenig gutem Willen, lassen sich jedoch große Fortschritte auch in legacy code machen.
Wenn sich gerade zu Anfang dabei heftige Diskussionen ergeben, bleiben Sie am Ball. Wo lange keine Reviews gemacht wurden, muss erst auf diesem Wege ein gemeinsames Verständnis erarbeitet werden. Das ist wie ein neuerliches Storming und Norming in der Teambildung.
Mit der Zeit werden dann auch die vielfach zu sehenden umfangreichen Coding Guidelines überflüssig. Wo Teams sich regelmäßig zum Review treffen, findet eine Angleichung automatisch statt.
Weitere Artikel in dieser Serie:
Beide Facetten der Zukunftsfähigkeit haben Aspekte, die es getrennt zu betrachten gilt. Bei der Korrektheit ist das zum einen die Maturität. Die ist vorhanden, wenn die Software schon bereit für die Abnahme/Auslieferung ist.
Zum anderen ist es die…
Software muss nicht nur schon korrekt sein in Bezug auf die aktuell umgesetzten Verhaltensanforderungen, sie muss auch noch korrekt sein in Bezug auf alle früheren.
Eine Regression liegt immer dann vor, wenn Software durch Veränderungen eine Qualität verliert, die sie bereits hatte. Je größer die Komplexität einer Codebasis, desto größer das Risiko für einen Rückfall auf ein vormaliges Entwicklungsniveau. Wenn die Codesituation unübersichtlich ist, dann ist „Verschlimmbessern“ eine ständige Gefahr. Gut gemeinte Verbesserungen an einer Stelle führen zu unbeabsichtigten Verschlechterungen an anderer. Wer hätte das als Anwender oder Entwickler nicht schon erlebt?
Regressionen sind schlimmer als Software, der die Maturität fehlt. Wenn neu hergestelltes Verhalten einen Fehler aufweist, dann ist das für Kunden nervig, aber halbwegs erwartet. So ist das eben mit Software… Das haben sie in vielen Jahren leidvoll gelernt.
Wenn jedoch Verhalten, das über Monate oder gar Jahre fehlerfrei war, auf einmal fehlerhaft ist, dann ist das ein Desaster. Hier wird dem Anwender der Boden unter den Füßen weggezogen. Er kann sich nicht mehr auf das verlassen, wozu er (entgegen allen Befürchtungen) Vertrauen gefasst hatte.
Regressionen führen zu einem schnellen tiefen Vertrauensverlust beim Kunden und werden regelmäßig eskaliert. Sie stören damit den ruhigen Fluss der Herstellung von Neuerungen erheblich. Nicht nur sind sie Nachbesserungen und daher Verschwendung, sie erfordern oft auch mehr Aufwand beim Aufbau eines mentalen Modells für die Korrektur, weil an den fehlerhaft gewordenen Stellen nicht aktuell gearbeitet wurde. Die Beziehung zum eigentlichen Arbeitsort war ja gerade nicht klar, sonst wäre keine Regression eingetreten.
Also: Regressionen sind zu vermeiden! Egal, was es kosten mag. Naja… fast ;-)
Das Mittel zur Herstellung von Regressionsfreiheit sind automatisierte Tests.
Während sich Maturität vielleicht noch mit manuellen Tests darstellen lässt, ist das für Regressionsfreiheit nicht der Fall. Manuelle Tests skalieren einfach nicht. Denn um zuzusichern, dass kein bisheriges Verhalten kompromittiert wurde durch aktuelle Veränderungen, müsste bisheriges Verhalten ja nochmals getestet werden.
Wenn die erste Iteration 3 Anforderungen umsetzt, sind Maturitätstests für 3 Anforderungen durchzuführen und 0 für die Regressionsfreiheit. Wenn dann jedoch die zweite Iteration 4 Anforderungen umsetzt, sind insgesamt 7 Tests durchzuführen: 4 für die Maturität und 3 für die Regressionsfreiheit. Bei der dritten Iteration mit 5 Anforderungen sind es 5+(4+3)=12 Tests, bei der vierten Iteration mit 2 Anforderungen 2+(5+4+3)=14 Tests usw. usf.
Wer konsequent Regressionsfreiheit überprüfen will, ist schon bald fast nur mit Regressionstests beschäftigt. Neue Anforderungen sind dann lediglich die Schaumkrone auf auf einer Welle in einem Ozean von Anforderungen, die alle, alle immer auch noch korrekt erfüllt sein wollen.
Ohne so konsequente Überprüfung von Regressionsfreiheit wird das Netz zum Fangen von Bugs mit jeder neuen Anforderungen weitmaschiger. Die Zahl der Tests im Verhältnis zur Anzahl der insgesamt umgesetzten Anforderungen nimmt ja stetig ab.
Ohne Automatisierung der Tests geht es also bei der Regressionsfreiheit nicht. Wer noch bei der Maturität meinte, ohne Automatisierung auszukommen, muss spätestens jetzt einsehen, dass das keine Option ist, wenn man die Zukunftsfähigkeit nicht leichtfertig aufs Spiel setzen will.
Wer bei der Maturität hingegen schon Ja zur Automatisierung gesagt hat, der hat ein Fundament für die Regressionsfreiheit gelegt. Die bleibenden Akzeptanztests spannen schon ein Netz auf, in dem sich Regressionen verfangen können.
Allerdings ist dieses Netz relativ weitmaschig. Je komplizierter die Domäne, desto weniger kann angenommen werden, dass Akzeptanztests genügend Pfade durch die verhaltenerzeugende Logik für das angestrebte Niveau von Regressionsfreiheit abdecken.
Der Review will für die Regressionsfreiheit also mehr bleibende Tests sehen als die Akzeptanztests. Die bilden allerdings das Fundament.
Aber was ist mit den Gerüsttests? Wäre es nicht doch gut, die zu behalten? Nein! Gerüsttests sind Gerüsttests und per definitionem zu löschen, nachdem sie geholfen haben, Maturität herzustellen. Sie testen ja Funktionen, die eigentlich privat sind und nicht unbedingt existieren. Sie gehören nicht zur Anforderungsdefinition des Kunden. Der ist lediglich an Eintrittspunkten interessiert, über die er Logik mit Input von außen triggern kann.
Anders liegt der Fall jedoch bei öffentlichen Methoden, die nicht vom Kunden gewünscht sind. Sie stellen Schnittstellen dar, über die Klassen von anderen benutzt werden können und sollen. Jede öffentliche Methode ist Teil eines Versprechens, nicht nur die an der Oberfläche einer Software.
Der Review überprüft daher im Hinblick auf Regressionssicherheit, ob alle öffentlichen Methoden mit angemessenen automatisierten Tests versehen und somit nachweisbar korrekt sind. Diese Tests lassen auch nachvollziehen, was sich die Entwickler dabei gedacht haben, Klassen zu definieren, die über das unmittelbar vom Kunden Gewünschte hinausgehen. Diese Klassen stellen ja eigenständige Verantwortungsbereiche dar. Insofern gibt es für sie ebenfalls Anforderungen und daher auch Akzeptanztests.
Software ist ein selbstähnliches Gebilde. So wie es sich nach außen darstellt – als ein Werkzeug, das Anforderungen erfüllt -, so ist sie innen selbst strukturiert: als eine Versammlung von Werkzeugen mit spezialisierteren Aufgaben.
Die äußere Form dieser Werkzeuge sind Module. Das sind Container für Logik, die einen Kontrakt definieren, der nach außen ein syntaktisches und ein semantisches Versprechen formuliert. „So kannst du mich benutzen. Das ist es, was ich tue.“
Der syntaktische Kontrakt besteht aus Funktionssignaturen. Der semantische Kontrakt ist unzweideutig beschrieben durch automatisierte Tests.
Funktionen sind die kleinsten Module. Klassen fassen Funktionen zu Modulen einer höheren Ebene zusammen. Bibliotheken wiederum fassen Klassen zusammen. Komponenten fassen Bibliotheken zusammen und Services Komponenten.
Auf die unterschiede der einzelnen Modulebenen soll an dieser Stelle nicht eingegangen werden. Für den Review der Regressionsfreiheit ist lediglich wichtig, dass jedes Modul auf jeder Ebene einen Kontrakt definiert, auf dessen korrekte Erfüllung Konsumenten der Module vertrauen.
Ultimativ ist das der Kontrakt gegenüber dem Anwender in Form einer Benutzerschnittstelle. Der ist allerdings vergleichsweise schlecht automatisiert zu testen. Es braucht dafür spezielle Werkzeuge und die Tests müssen besonders robust sein, da die Benutzerschnittstelle vielen Änderungen unterliegt, die nicht zwangsläufig zu Anpassungen von Tests führen sollen.
Doch schon kurz unter dieser Oberfläche lässt sich Logik in Modulen kapseln, die leicht zu testen sind, weil sie unabhängig von einer Benutzerschnittstellentechnologie sind. Dort erwartet der Review automatisierte Tests aller Modulkontrakte.
Wo also Methoden öffentlich sind, weil sie zu einem Kontrakt gehören, müssen ihnen Tests gegenüberstehen. Das ist eine konsequente Fortführung der Regel „Keine Anforderung ohne Akzeptanztest“.
Ob die Methoden geplant von vornherein öffentlich waren oder bei einer Refaktorisierung entstanden sind, ist unerheblich. Ein Akzeptanztest muss sein. Ja, sogar bei Refaktorisierungsergebnissen. Denn öffentliche Methoden sind, nun, öffentlich. Sie stellen ein Versprechen dar, das erstens explizit formuliert werden sollte (semantischer Kontrakt in Form eines Tests als Dokumentation) und zweitens immer wieder auf Einhaltung überprüft werden sollte (Regressionsfreiheit). Denn wenn alle Teile ihre Versprechen erfüllen, dann wird wohl auch das Ganze seine Versprechen erfüllen. Ein plausibler Gedanke, oder?
Aus dieser Richtung betrachtet werden auch nochmal Gerüsttests interessant. Sollte nämlich ein Gerüsttest einen Aspekt überprüfen, der nicht auch irgendwie in Akzeptanztests abgedeckt ist, dann gibt es nicht nur die Möglichkeit, die Akzeptanztests zu erweitern, um auch nach Löschen des Gerüsttests weiterhin den Aspekt im Blick zu behalten.
Die Alternative ist, diesen Aspekt, der bisher eigentlich ein unsichtbares Detail war, zu einem eigenständigen sichtbaren Teil aufzuwerten. Eine bisher eigentlich private Funktion wird dann öffentlich – und wandert in eine eigene Klasse. Sie ist damit Teil eines Kontraktes – und muss bleibend getestet werden. Zugehörige bisherige Gerüsttests werden damit quasi offiziell. Aus Gerüsten werden Pfeiler, d.h. tragende Strukturelemente.
Der Wunsch, „spannende“ Tests zu erhalten, ist mithin ein Treiber der Modularisierung von Software. Wenn Sie sich bisher gefragt haben, wie Sie Klassen „schneiden“ sollen, dann haben Sie jetzt ein weiteres Kriterium.
Es gehört daher nicht nur zum Review zu prüfen, ob Kontrakte unter Test stehen, sondern auch, wo noch Kontrakte definiert werden sollten. Durch Extraktion von Modulen werden dann Grenzen gezogen, die einerseits informieren, andererseits stabilisieren.
Sie informieren, weil sie Semantik beschreiben, die sonst undokumentiert wäre.
Sie stabilisieren, weil hinter einem Kontrakt das Innere eines Moduls sich unabhängig von der Umgebung entwickeln kann, solange der Kontrakt eingehalten wird.
Bezogen auf das Beispiel zur Maturität würde die Regressionsfreiheit noch eine weitere Option eröffnen. Sie erinnern sich? Es ging um Aggregationsfunktionalität: die Zahlen in einer Textdatei waren zu summieren. Nach dem Review der Maturität sah der Code so aus:
public class Aggregator {
public int Sum(string filename) {
var numbers = Load(filename);
return Calculate_sum(numbers);
}
private int[] Load(string filename) {
var lines = System.IO.File.ReadAllLines(filename);
var numbers = new List<int>();
for (var i = 0; i < lines.Length; i++)
if (!string.IsNullOrWhiteSpace(lines[i]))
numbers.Add(int.Parse(lines[i]));
return numbers.ToArray();
}
private int Calculate_sum(IEnumerable<int> numbers) {
var sum = 0;
foreach (var n in numbers)
sum += n;
return sum;
}
}
Aus einem Akzeptanztest und drei Gerüsttests für Load() und Calculate_sum() wurde ein erweiterter Akzeptanztest, der auch noch die Fälle der Gerüsttests von Load() abdeckt.
Das war eine legitime Entscheidung innerhalb des Horizonts Maturität. Doch warum nicht die Erkenntnis, dass zwei Gerüsttests erhalten bleiben sollen, dazu nutzen, die Modularität zu erhöhen und die Regressionsfreiheit zu verbessern? Mehr Module bedeutet mehr Oberfläche zur Überprüfung, ob Code nach Veränderung weiterhin korrekt ist.
Mit der Regressionsfreiheit im Blick könnte das Review-Ergebnis z.B. so aussehen:
public class Aggregator {
public int Sum(string filename) {
var file = new FileProvider();
var numbers = file.Load(filename);
return Calculate_sum(numbers);
}
private int Calculate_sum(IEnumerable<int> numbers) {
var sum = 0;
foreach (var n in numbers)
sum += n;
return sum;
}
}
internal class FileProvider {
public int[] Load(string filename) {
var lines = System.IO.File.ReadAllLines(filename);
var numbers = new List<int>();
for (var i = 0; i < lines.Length; i++)
if (!string.IsNullOrWhiteSpace(lines[i]))
numbers.Add(int.Parse(lines[i]));
return numbers.ToArray();
}
}
Aggregator{} wäre als Geschäftslogikklasse auf die Summation konzentriert. FileProvider{} würde den Datenzugriff kapseln. Eine saubere Trennung von zwei sehr unterschiedlichen Verantwortlichkeiten, die mittels Tests dokumentiert würde:
[Test()]
public void Akzeptanztest() {
var sut = new Aggregator();
var result = sut.Sum("numbers.txt");
Assert.AreEqual(356, result);
}
[Test]
public void Load_with_empty_lines() {
var sut = new FileProvider();
var result = sut.Load("numbers_with_empty_lines.txt");
Assert.AreEqual(new[] { 1, 2, 3 }, result);
}
[Test]
public void Load_with_whitespace() {
var sut = new FileProvider();
var result = sut.Load("numbers_with_whitespace.txt");
Assert.AreEqual(new[] { 1 }, result);
}
Der Akzeptanztest könnte wieder so einfach sein, wie zunächst mit dem Kunden verabredet, der lediglich diesen Inhalt für numbers.txt vorgesehen hatte:
1
22
333
Der Akzeptanztest wäre fokussierter auf den happy day, auf das Wesentliche, auf die Integration von Teilfunktionalitäten. Sonderfälle würden an der Schnittstelle des Teiles geprüft. Die ist nicht öffentlich dem Kunden bzw. Konsumenten der beauftragten Funktionalität gegenüber; das ist nur die Klasse Aggregate{} mit der Methode Sum(). Aber es wäre immer noch eine Schnittstelle gegenüber Aggregate{} im Inneren des beide umfassenden Modules, z.B. der Bibliothek reporting.dll.
Ausgehend von der Überprüfung der Korrektheit von einzelnen Funktionen trägt die Regressionsfreiheit also dazu bei, Module höherer Ebenen herauszukristallisieren: zunächst Klassen, dann Bibliotheken.
Die Römer haben gesagt „Pacta sunt servanda“ – Verträge sind einzuhalten. Das gilt auch für Software. Wo Verträge mittels öffentlicher Module formuliert werden, müssen sie eingehalten werden. Ob das geschieht, überprüfen automatisierte Tests. Und das nicht nur einmal, wenn Veränderungen erstmalig umgesetzt und ausgeliefert werden, sondern immerdar.
Konsequentes Testen aller Verträge sorgt für (weitgehende) Regressionsfreiheit. Wo Verträge ohne Tests sind, sollten die daher nachgerüstet werden. Wo Verträge neu ausgehandelt werden, sollten sie testgetrieben (test-first) implementiert werden.
Wer diese Regel versteht, kommt um einen diesbezüglichen Review jedoch nicht herum. Review muss weiterhin sein, auch wenn alle Entwickler besten Willens sind, so bei der Umsetzung von Anforderungen vorzugehen. In den Wirren des Tagesgeschäftes kommt selbst der beste Vorsatz einfach zu schnell mal unter die Räder. Außerdem wird bei der Codierung, wenn das Verhalten im Vordergrund steht, nicht jede Chance für einen Kontrakt, d.h. für Entkopplung erkannt. Im Review ist der Modus jedoch grundsätzlich anders, so dass solche Versäumnisse ausgebügelt werden können.
Maturität + Regressionsfreiheit: Werden beide Aspekte systematisch im Review betrachtet, kann die Korrektheit nicht anders, als zu steigen. Die Zahl der Nachbesserungen sinkt. Die Kapazität für Neuerungen wächst. Von der Zufriedenheit beim Kunden ganz zu schweigen.
PS: Der Review bleibt natürlich eine Überprüfung. Wenn Tests erst geschrieben werden, weil der Review ihr Fehlen entdeckt hat, wenn Modularität erst nach dem Review in den Code hineinrefaktorisiert wird, dann läuft die Softwareentwicklung immer noch falsch.
Der Review soll eigentlich nur feststellen, dass während der Codierung alle Tests schon test-first realisiert wurden:
Eigentlich sollte der Review in dieser Weise durch den Quellcode fließen. In der Realität wird er jedoch Lücken finden. Das ist normal, das ist ok – solange es im Rahmen bleibt. Wird der überschritten und der Review artet aus in eine Nachbesserungsorgie, dann sind vorgelagert Maßnahmen zu ergreifen. Dann muss die Herstellung der gewünschten Eigenschaften geübt werden, um im Tagesgeschäft mühelos von jedem Entwickler durchgeführt werden zu können.
Weitere Artikel in dieser Serie:
Clean Code erfüllt dieselben Anforderungen – und noch mehr. Clean Code steht dirty code in Sachen Verhalten in nichts nach. Darüber hinaus jedoch erfüllt er auch noch die Anforderung Zukunftsfähigkeit. Die ist dem Kunden (und auch der Softwareentwicklung) allerdings oft nicht bewusst. Außerdem ist sie nicht so leicht zu überprüfen wie das Verhalten.
Verhalten ist eine Laufzeitanforderung. Zukunftsfähigkeit hingegen ist eine Lebenszeitanforderung. Es geht um mehr als das Hier und Heute; es geht um Morgen und Übermorgen.
Clean Code Development bedeutet daher, Software so zu entwickeln, dass sie nicht nur aktuelle Verhaltensanforderungen erfüllt, sondern auch derzeit noch unbekannte in der Zukunft erfüllen können wird.
Dafür muss Software zwei Eigenschaften jenseits von Funktionalität und Effizienz haben und behalten:
Korrektheit scheint auf der Hand zu liegen. Ist sie nicht sogar eine Verhaltensanforderung? Grundsätzlich ist das richtig, doch die Erfahrung zeigt, dass fehlende Korrektheit sich nicht unbedingt im Moment der Abnahme von Software durch den Kunden zeigt. Inkorrekte Software geht allzu oft in Produktion; Fehler zeigen sich dann in einem Moment, wenn es gar nicht gut passt, weder für den Kunden/Anwender, noch für die Softwareentwicklung. Mangelhafte Korrektheit hat mithin Auswirkung auf die Zukunft.
Wandelbarkeit hingegen liegt nicht auf der Hand. Ist Software ihrer immateriellen Natur nach nicht ohnehin wandelbar? Oder ergibt sich Wandelbarkeit nicht mehr oder weniger, wenn man recht fleißig objektorientiert arbeitet? Kunden, Management und selbst Entwickler stellen diese Fragen. Wie die Praxis früher oder später jedoch zeigt, ist die Antwort darauf ein klares Nein. Nein, Wandelbarkeit, also Offenheit für Veränderungen, ergibt sich nicht von allein, auch nicht durch gut gemeinte Objektorientierung. Wandelbarkeit muss vielmehr ganz bewusst und systematisch hergestellt werden. Solange das nicht geschieht, verdunkelt jede Änderung am Code die Zukunft einer Software etwas mehr. Der Aufwand, Veränderungen vorzunehmen, seien das Bug Fixes oder Erweiterungen, steigt dann unverhältnismäßig.
Korrektheit mag als Anforderung auf der Hand liegen und jeder gewissenhafte Entwickler wird sich auch um sie bemühen. Trotzdem ist es so eine Sache mit ihr. Backlogs voll mit gemeldeten Fehlern sind keine Seltenheit, Teams, die sich auf Wochen ausschließlich mit Bug Fixes beschäftigen könnten, finden sich überall.
Korrektheit sollte daher nicht als selbstverständlicher Aspekt der Umsetzung von Verhaltensanforderungen erachtet werden. Ihre Herstellung erfordert mehr Aufmerksamkeit. Deshalb schlagen wir sie der Zukunftsfähigkeit zu. Ihre Überprüfung ist Sache des Reviews; die Architektenrolle will Korrektheit haben und zieht an ihr.
Dabei beginnt alles mit der Maturität. Die Frage lautet: Ist die Software schon korrekt?
Bedingt durch die Komplexität von Software kann diese Frage allerdings weder in einem Review des Codes, noch bei der Abnahme durch den Kunden abschließend beantwortet werden. Deshalb muss die Frage umformuliert werden: Wurde alles getan, um die Korrektheit für eine Auslieferung sicherzustellen?
Bugs lassen sich nicht komplett vermeiden. Doch es kann zumindest ein enges Netz geknüpft werden, um möglichst viele vor Release zu fangen. Wurde das getan?
Jeder Bug, der nicht gefangen wird und zum Kunden gelangt, wird früher oder später als Beschwerde zurückkommen. Die stellt dann eine Unterbrechung dar und wird den Fluss der Entwicklung von Neuerungen stören. Außerdem reduziert der spätere Bug Fix die Kapazität für Neuerungen. Bug Fixing ist Plaque im Entwicklungsprozess. Bugs führen zu Verschwendung. Geld wird verbrannt in Nachbesserungen. Das reduziert die Zukunftsfähigkeit, die umso größer ist, je mehr Neuerungen umgesetzt werden. Dafür wird Software ja gekauft. Dafür bekommen Softwareentwickler ihr Geld: dass sie Neuerungen einbauen. Der Fluss deren korrekter Herstellung ist zu maximieren.
„Die Zukunft beginnt jetzt!“ ist also der Gedanke hinter der Überprüfung der Maturität.
Was sind die Kriterien dafür, dass alles getan wurde, um die Korrektheit für eine Auslieferung sicherzustellen?
Es reicht nicht, wenn in der Reviewrunde gefragt wird, „Habt ihr denn auch alle eure Veränderungen am Code getestet?“ Auch unter der Annahme, dass alle Entwickler gutwillig und bemüht um Qualität sind, wäre eine allseits positive Antwort wenig aussagekräftig. Die Meinung darüber, was ausreichende Tests sind, gehen einfach weit auseinander und verändern sich auch noch unter Druck. Mündliche Zusicherungen sind schlicht nicht nachvollziehbar.
Deshalb gilt es als erstes zu überprüfen, ob für jede Anforderung die zugehörigen Akzeptanztests codiert wurden und automatisiert ausführbar sind und keinen Fehler melden.
Dass es überhaupt Akzeptanztests gibt, ist natürlich Ergebnis einer systematischen Analyse der Anforderungen vor ihrer Umsetzung. Die Umsetzung beginnt nicht, bevor sich nicht Kunde bzw. Stellvertreter und Entwickler auf klare Beispiele korrekten Softwareverhaltens geeinigt haben, die codiert als Tests ausdrücken, dass die wünschten Neuerungen umgesetzt wurden.
Akzeptanztests überprüfen Funktionalität und Effizienz an der Oberfläche einer Software. Sie setzen knapp unter dem UI an, um leicht automatisierbar zu sein. Zur Not „reizen“ sie die Software aber auch durch das UI.
Akzeptanztests sind auf einem Niveau, das der Kunde nachvollziehen kann. Sie stellen für ihn „Relevanzeinheiten“ dar. Auf ihrer Ebene kann der Kunde Feedback geben. Ob durch das Verhalten allerdings auch schon Wert für eine Anwendung in der Praxis entsteht, sei dahingestellt. Feedbackinkremente sind durchaus kleiner als Wertinkremente. Ihr Wert liegt nicht im Auge des Anwenders, sonder im Auge desjenigen, der sich für kontinuierlichen Fortschritt interessiert.
Akzeptanztest sind codiertes Verständnis. Ohne Akzeptanztests ist nur schwer nachvollziehbar, was überhaupt umgesetzt werden soll. Eine auf den Review folgende Abnahme bleibt ohne automatisierte Akzeptanztests schwach und lückenhaft.
Akzeptanztests sind jedoch nur die erste Bastion gegen ungenügende Maturität. Es braucht weitere Maßnahmen, die belegen, dass alles getan wurde, um die Korrektheit für eine Auslieferung sicherzustellen.
Als zweites überprüft der Review daher, ob jede Methode, die Logik enthält und nicht durch eine mechanische Refaktorisierung entstanden ist, mindestens durch einen aussagekräftigen Test überprüft wird.
Das sind Gerüsttests, die das entstehende Softwaregebäude während des Aufbaus umschließen. Mit „jede Methode“ ist daher tatsächlich jede gemeint, die (nicht triviale) Logik enthält, auch und insbesondere solche, die (eigentlich) privat sind. Denn da, wo Logik (Teil-)Verhalten herstellt, besteht Bug-Risiko.
Akzeptanztests leisten ihren Beitrag, um Bugs in allen Methoden aufzuspüren, doch am Ende können in ihnen nicht genügend (Sonder-)Fälle codiert sein, um in alle Winkel der Logik zu leuchten. Es braucht weitere Tests für die vielfältigen Details der ganzen Logik, die hinter dem gewünschten Verhalten einer Neuerung steht.
Der Review überprüft, ob Akzeptanztests und Gerüsttests vorhanden sind. Hat das Softwaregebäude ein Fundament an Tests und wurde es in einem schützenden und stützenden Rahmen hochgezogen?
Doch wie entstehen all diese Tests? Damit der Review sie vorfindet, ist zu empfehlen, sie vor dem Produktionscode zu schreiben. Ein test-first-Vorgehen ist die beste Versicherung dafür, im Review nicht kalt erwischt zu werden. Denn jeder Tests, der im Review fehlt, erfordert eine Nachbesserung, die wertvolle Zeit raubt für Neuerungen und eine Abnahme verzögert.
Sobald jedoch alle Akzeptanztests vorhanden sind und die Gerüsttests nachweisen, dass auch im Detail sorgfältig gearbeitet wurde, kann die Maturität bescheinigt werden. Die Software ist, was diesen Aspekt der Korrektheit angeht, aus Sicht der Softwareentwicklung schon bereit für ein Release.
Die Gerüsttests können anschließend abgebaut oder deutlicher: gelöscht werden. Sie dienten nur dem Aufbau korrekten Codes. Da der nun als korrekt eingestuft wurde und von allein nicht mehr kaputt geht, sind die Gerüsttests überflüssig. Alle privaten Methoden, die zum Zwecke des Tests eine größere Sichtbarkeit hatten, werden nun wirklich auf privat gesetzt. Nur so werden die Grenzen zwischen Modulen deutlich gezogen.
Würden alle Gerüsttests stehenbleiben, wäre der Code bald in einem engen Korsett von Tests eingeschnürt, das weitere Veränderungen erschwert. Gerüsttests sind Whitebox-Tests. Sie testen Details, die eigentlich nicht sichtbar sein sollen. Sie laufen dem Grundprinzip der Kapselung/losen Kopplung entgegen.
Also werden Gerüsttests am Ende eines Reviews gelöscht. Im Repository sind sie ja aber noch vorhanden. Wer also später einmal darauf zurückgreifen will… der kann sie dort suchen.
Das mag sich für Sie rigoros anhören, doch Sie werden es als Erleichterung empfinden, nicht alle Tests behalten zu müssen, wie es sonst in der Literatur empfohlen wird.
Und es ist auch nicht so, dass alle Tests, die nicht Akzeptanztests sind, Gerüsttests darstellen. Sie sind allerdings nicht Thema der Überprüfung der Maturität.
Zumindest mit einem kleinen Beispiel soll die Facette Maturität konkretisiert werden. Nehmen wir an, Sie sollen eine Funktion umsetzen, die ganze Zahlen in einer Textdatei addiert.
Der Dateiaufbau ist simpel: in jeder Zeile steht eine Zahl.
Als Akzeptanztest wünscht sich der Kunde diesen Dateiinhalt für numbers.txt:
1
22
333
in Verbindung mit dieser Summe: 356.
Außerdem war nach den „Verhandlungen“ mit dem Kunden klar, wie die „Oberfläche“ der Software aussehen soll: eine Funktion mit der Signatur int Sum(string filename) in einer Klasse Aggregator.
Der Review schaut zuerst auf den Akzeptanztest. Ist der vorhanden? Ja, das ist er:
[Test()]
public void Akzeptanztest() {
var sut = new Aggregator();
var result = sut.Sum("numbers.txt");
Assert.AreEqual(356, result);
}
Die referenzierte Datei hat den vereinbarten Inhalt.
Der Test ist auch erfolgreich. Ihm steht eine soweit fehlerfreie Umsetzung gegenüber:
public class Aggregator {
public int Sum(string filename) {
var numbers = Load(filename);
return Calculate_sum(numbers);
}
internal int[] Load(string filename) {
var lines = System.IO.File.ReadAllLines(filename);
var numbers = new List<int>();
for (var i = 0; i < lines.Length; i++)
if (!string.IsNullOrWhiteSpace(lines[i]))
numbers.Add(int.Parse(lines[i]));
return numbers.ToArray();
}
internal int Calculate_sum(IEnumerable<int> numbers) {
var sum = 0;
foreach (var n in numbers)
sum += n;
return sum;
}
}
(Übersehen Sie für den Moment, dass es mit C# auch viel einfacher gegangen wäre. Um für das simple Szenario etwas Code-Fleisch auf die Knochen zu bekommen, ist der Code umständlicher als nötig.)
Als nächstes fragt der Review nach den Gerüsttests.
Gibt es weitere Funktionen über den vereinbarten Einstiegspunkt Sum() hinaus? Das ist der Fall. Und diese Funktionen sind sogar nicht durch Refaktorisierungen entstanden, sondern schon während des Entwurfs als Aspekte (eigenständige Verantwortlichkeiten) erkannt worden.
Sind diese Funktionen mit Tests versehen? Ja, das ist der Fall.
[Test]
public void Gerüttest_Load_with_empty_lines() {
var sut = new Aggregator();
var result = sut.Load("numbers_with_empty_lines.txt");
Assert.AreEqual(new[]{1,2,3} , result);
}
[Test]
public void Gerüttest_Load_with_whitespace() {
var sut = new Aggregator();
var result = sut.Load("numbers_with_whitespace.txt");
Assert.AreEqual(new[] { 1 }, result);
}
[Test]
public void Gerüttest_Calculate_sum() {
var sut = new Aggregator();
var result = sut.Calculate_sum(new[] { 1, 2, 3 });
Assert.AreEqual(6, result);
}
Auch diese Tests sind alle grün.
Für das Laden der zu verarbeitenden Zahlen gibt es sogar zwei Tests. Der Entwickler hat sich nicht darauf verlassen, dass die Dateien wohlgeformt sind. Er hat Load() robuster gemacht, als der Kunde es sich zumindest mit dem Akzeptanztest gewünscht hat.
Ist das ein Widerspruch zum YAGNI-Prinzip? Das kommt darauf an, ob die Fälle „Datei enthält Leerzeilen“ und „Zahlen sind von Whitespace umschlossen“ mit dem Kunden diskutiert wurden.
War das nicht der Fall, hat der Entwickler zwar qualitätsbewusst gedacht – doch potenziell vorzeitig und unnötig optimiert. Das hat Zeit gekostet und somit Verschwendung erzeugt.
Hat es jedoch eine Absprache mit dem Kunden gegeben, dann liegt keine Verschwendung vor. Allerdings ist zu fragen, warum diese Aspekte nicht Eingang in Akzeptanztests gefunden haben. Auch die Sonderfälle hätten mit nur einem Akzeptanztest abgedeckt werden können, indem numbers.txt etwas „vielfältiger“ gestaltet worden wäre.
Wie nun mit diesen Testfällen verfahren? Es wird entschieden, den Akzeptanztest zu erweitern. numbers.txt bekommt folgenden Inhalt (Punkte stehen für Whitespace der einen oder anderen Art):
...
1..
..22
333
.
Der Review ist erfolgreich. Die Software ist korrekt, sie hat die gewünschte Maturität.
Damit haben die Gerüsttests ihre Schuldigkeit getan. Sie können gelöscht werden und die Detailfunktionen verschwinden aus der Öffentlichkeit. Ihre Sichtbarkeit wird auf private gesetzt.
Der resultierende Code im Überblick:
[TestFixture()]
public class Test {
[Test()]
public void Akzeptanztest() {
var sut = new Aggregator();
var result = sut.Sum("numbers.txt");
Assert.AreEqual(356, result);
}
}
public class Aggregator {
public int Sum(string filename) {
var numbers = Load(filename);
return Calculate_sum(numbers);
}
private int[] Load(string filename) {
var lines = System.IO.File.ReadAllLines(filename);
var numbers = new List<int>();
for (var i = 0; i < lines.Length; i++)
if (!string.IsNullOrWhiteSpace(lines[i]))
numbers.Add(int.Parse(lines[i]));
return numbers.ToArray();
}
private int Calculate_sum(IEnumerable<int> numbers) {
var sum = 0;
foreach (var n in numbers)
sum += n;
return sum;
}
}
Review ist „die erste Entwicklerpflicht“. Ohne Review besteht kein Verlass, dass Code nicht nur Verhaltensanforderungen erfüllt, sondern auch zukunftsfähig ist.
Während des Reviews durch die Architektenrolle ist zuerst Augenmerk auf die Korrektheit zu legen. Ist die Software schon korrekt? Hat sie die erforderliche Maturität zur Auslieferung?
Nachvollziehbar ist die Maturität nur anhand von codierten Tests. Das sind einerseits die Akzeptanztests, die ausdrücklich mit dem Kunden vereinbart sind. Sie setzen an der Oberfläche der Software an und sind tendenziell grob. Ihre Aufgabe ist es, Grundvertrauen herzustellen und die Umsetzung für den Kunden fühlbar zu machen.
Darüber hinaus braucht es aber weitere Tests für Detailaspekte der Logik. Die sind zumindest nötig während der Umsetzung, um dem Entwickler schrittweise Sicherheit zu geben, korrekt zu arbeiten. Sie ergänzen u.U. auch Akzeptanztests zumindest temporär. Denn am Ende des Reviews werden solche Gerüsttests auf eigentlich privaten Funktionen gelöscht. So stehen sie zukünftigen Refaktorisierungen, die unter der Oberfläche stattfinden, nicht im Wege.
Zukunftsfähigkeit ergibt sich nicht nebenbei. Sie will systematisch geplant, umgesetzt und überprüft werden. Der Ort für die Überprüfung ist der Review. Ihn regelmäßig und umfassend durchzuführen, ist die erste Maßnahme auf dem Weg zu Clean Code.
Weitere Artikel in dieser Serie:
Und zwar in der Reihenfolge, d.h. zuerst muss die Software grundsätzlich die gewünschte Funktionalität haben, dann kann ggf. an ihrer Effizienz geschraubt werden.
Schon diese beiden Anforderungen jedoch können nicht immer vollständig erfüllt werden. Es gibt Situationen, in denen muss eine Balance her. Da kann entweder nicht die volle Funktionalität geliefert werden, wenn die Effizienz für den Rest hoch genug sein soll. Oder es kann nicht die gewünschte Effizienz hergestellt werden, wenn auch die komplette Funktionalität vorhanden sein soll. Noch deutlicher wird das, wenn Effizienz aufgefächert wird in einzelne Aspekte wie Performance, Skalierbarkeit, Sicherheit, Portabilität, Robustheit, Benutzerfreundlichkeit usw.
Der trade-off lauert überall. Da ist dann die Architekturrolle gefragt, wenn z.B. zwischen Performance und Sicherheit abgewogen werden muss, oder es ist ein Gespräch angezeigt zwischen Architekt und Kunde, wenn Portabilität und Funktionalität ausbalanciert werden müssen.
Als wäre die Situation nicht schon kompliziert genug, sind das jedoch nicht alle Anforderungen. Es sind, wie gesagt, nur die irgendwie ausgesprochenen. Zu ihnen finden sich in einem Pflichtenheft oder Konzept mehr oder weniger klare Wünsche des Kunden. Dazu hat er eine Meinung und kann auch prüfen, ob eine Softwareversion ihm taugt.
Funktionalität und Effizienz bilden zusammen die Laufzeit- oder Verhaltensanforderungen.
Ohne, dass es dem Kunden (oder oft auch Management, gar Entwicklern) bewusst wäre, gibt es darüber hinaus allerdings weitere Anforderungen. Die beziehen sich auf die Lebenszeit der Software. Der Kunde kann nicht durch „Herumspielen“ mit einer Softwareversion feststellen ob und inwiefern sie erfüllt sind:
Die Liste der Anforderungen, die während der Softwareentwicklung immer wieder ausbalanciert werden müssen, ist also länger als gemeinhin angenommen:
Eigentlich sind alle diese Anforderungen wichtig. Der Kunde ist an der Erfüllung aller interessiert. Dennoch gibt es eine de facto Priorität. Wenn im Zweifel, dann wird eher eine höher liegende Anforderung erfüllt.
In der Praxis bedeutet das, dass Maßnahmen, die der Erfüllung einer weiter unten liegende Anforderung gelten, gestrichen werden, wenn eine darüber liegende Anforderung noch nicht ausreichend erfüllt zu sein scheint.
Die Entstehung von legacy code, die Anhäufung eines big ball of mud ist daher kein Wunder, oder? Denn die Wandelbarkeit steht ganz am Ende der Liste und gehört zu den Anforderungen, deren Erfüllung der Kunde nicht einfach überprüfen kann. So viele Anforderungen können bedürftig sein, da bleiben kaum Ressourcen, um auch noch der Wandelbarkeit zu dienen.
Solange eine Software überschaubar und keine lange Lebensdauer zu erwarten ist, mag das nicht weiter tragisch sein. Aber wer weiß denn wirklich, ob die klein geplante Codebasis wirklich klein bleibt? Ist eine Software erstmal erfolgreich, wollen Kunden mehr. Dann ist die Lebenszeit nicht absehbar. Das bedeutet, die Software muss regressionsfrei sein und bleiben und auch wandelbar sein und bleiben. Sonst besteht schlicht keine Zukunftsfähigkeit.
Zukunftsfähige Softwareentwicklung muss die Priorität der Anforderungen umkehren. Die Anforderungen müssen auf die Füße gestellt werden. Welche Funktionalität und Effizienz morgen oder übermorgen von einer Software gewünscht werden, ist unsicher. Dass jedoch anderes und mehr gewünscht wird, ist sicher.
Die erste zu erfüllende Anforderung ist mithin die Wandelbarkeit, nicht die Funktionalität!
Auch hier gilt wieder: Wenn im Zweifel, dann lieber eine höher liegende Anforderung erfüllen.
Oder: Wenn auf einer unteren Ebene zwei Optionen keinen Unterschied machen, dann die wählen, die auf höherer Ebene mehr Qualität bietet.
Aber wie kann Wandelbarkeit über Funktionalität stehen? Ohne Funktionalität gibt es keinen Code, der wandelbar sein könnte.
Es geht bei der Positionierung in der Reihenfolge nicht sofort und immer um Code, sondern zunächst um eine Grundhaltung. So wie bei der Fliegerei safety first gilt, sollte bei der Softwareentwicklung evolvability first gelten.
Lesen Sie die Liste gern auch im Stile des agilen Manifests: Wandelbarkeit vor Regressionsfreiheit usw.
Wandelbarkeit an erster Stelle bedeutet, nach Prinzipien zu codieren, die es erleichtern, in der Zukunft Veränderungen an Funktionalität und Effizienz vorzunehmen, sei das für Erweiterungen oder Bug Fixes.
Regressionsfreiheit an zweiter Stelle bedeutet, so zu codieren, dass automatisierte Tests erstens leicht möglich sind und zweitens auch mit einer guten Abdeckung existieren, um Regressionen zügig und mit hoher Wahrscheinlichkeit (und vor Auslieferung) festzustellen.
Und schließlich bedeuten Funktionalität an dritter und Effizienz an vierter Stelle, dass deren Realisierung nur in dem Rahmen stattfinden soll, der von Wandelbarkeit und Regressionsfreiheit aufgespannt wird. Es besteht quasi eine freiwillige Selbstbeschränkung: Funktionalität und Effizienz werden nicht mehr „irgendwie“ oder „auf Teufel komm’ raus“ hergestellt, sondern stets mit Rücksicht auf Wandelbarkeit und Regressionsfreiheit.
Wer an Funktionalität und Effizienz arbeitet, muss sich die Frage gefallen lassen, ob er die darüber liegenden Anforderungen im Blick hat.
Das bedeutet nicht, Wandelbarkeit oder Regressionsfreiheit über alles andere zu stellen. Sie sind nicht wichtiger als Funktionalität oder Effizienz. Aber sie sind eben auch nicht unwichtiger. Damit diese gleiche Gewichtung jedoch ihren Niederschlag im Code findet, ist eine neue Priorisierung nötig.
Clean Code Development wie wir es unterrichten, versteht sich mithin als Anwalt der oft unsichtbaren oder impliziten Anforderungen Wandelbarkeit und Regressionsfreiheit. Wir wollen ihnen zu ihrem gebührenden Gewicht verhelfen. Wir wollen die Anforderungsliste vom Kopf auf die Füße stellen.
Unser Ziel ist es, Softwareentwicklern und Managern (und gern auch Kunden) das Bewusstsein zu vermitteln, dass sich Zukunftsfähigkeit nicht von allein ergibt, sondern ausdrücklicher und systematischer Anstrengung bedarf.
Auf die Füße gestellte Anforderungsprioritäten sind dafür ein Bild. Daran kann sich die Diskussion entzünden. Das kann mit der Realität in einem Projekt verglichen werden. Inwieweit Softwareentwicklung sich um Clean Code bemüht, kann abgelesen werden an der Position von Wandelbarkeit und Regressionssicherheit in der Liste der Anforderungen.
Auf welchem Platz stehen diese Anforderungen denn bei Ihnen? Woran machen Sie das fest?
]]>Testbarkeit (durch automatisierte Tests) bezieht sich auf Verhalten. Verhalten ist entweder Funktionalität (z.B. die Software rechnet) oder Effizienz (z.B. die Software rechnet schnell oder sie ist trotz Berechnung responsive). Verhalten ist, wie sich die Software zur Laufzeit „darstellt“, wie sie auf Reize reagiert.
Hergestellt wird Verhalten durch Logik und ihre Verteilung auf Container, die wir bei der CCD School „Hosts“ nennen. Hosts sind z.B. Threads (oder Actors), Prozess oder Maschinen (auch virtuelle).
Die Testbarkeit ist also hoch, wenn man einen Verhaltensaspekt, z.B. eine (Teil-)Funktionalität oder eine Effizienz leicht überprüfen kann. Dazu muss die zugehörige Logik in den relevanten Hosts möglichst gezielt angesprochen werden.
Das Gegenteil von hoher Testbarkeit ist, wenn Sie eine Anwendung von Hand aufrufen müssen, sich anmelden müssen, sich durch mehrere Dialoge klicken müssen, einige Eingaben machen müssen und dann auf einen Button klicken müssen, nur um zu sehen, ob die Reaktion korrekt im Sinne des in Frage stehenden Anforderungsaspektes ist.
Leider sehe ich immer wieder und immer noch Anwendungen, bei denen genau das nötig ist. Mit solchen aufwändigen Tests vertun Entwickler ihre kostbare Zeit und/oder es werden spezielle Tester eingesetzt („QA Mitarbeiter“), die gewissenhaft Testprotokolle immer wieder durchlaufen.
Das kann man natürlich so machen – nur ist das teuer und kostet viel Zeit und ist fehlerträchtig. Die Reaktionszeit solcher Softwareentwicklung misst sich für die Umsetzung der meisten Anforderungen in Wochen und Monaten. Das klingt nicht reaktionsschnell in Bezug auf den Markt, oder?
Hier ein Beispiel für Verhalten herstellende Logik. Es ist die Implementation einer Lösung für das erste Inkrement der Kata „Word Count“:
Console.Write("Enter text: ");
var text = Console.ReadLine();
var words = text.Split(' ');
var n = words.Length;
Console.WriteLine($"Number of words: {n} ");
Das ist die manifestierte Lösung für den bescheidener Wunsch eines Kunden. Aber diese Logik ist funktional und ausreichend effizient.
Genau um solche Logik geht es.
Ohne weiteres Zutun steckt die Beispiellogik in einer Main()-Methode, die als Entry Point für die Anwendung dient. Darüber kann die Logik durch Programmstart angestoßen werden:
public static void Main(string[] args)
{
Console.Write("Enter text: ");
...
}
Wie steht es mit der Testbarkeit dieser Logik? Schlecht. Als Ganzes kann sie nur getestet werden, indem man das Programm aufruft und von Hand bedient.
Ok, wenn man es genau nimmt, dann könnten die Standard Input/Output Streams umgebogen werden und ein Test wäre automatisiert möglich; der könnte die statische Methode Main() aus einem NUnit-Test aufrufen.
[Test()]
public void Test_Main() {
var output = new StringWriter();
Console.SetOut(output);
Console.SetIn(new StringReader("Mary had a little lamb"));
wordcount.MainClass.Main(null);
Assert.AreEqual("Enter text: Number of words: 5\ n", output.ToString());
}
Doch spätestens mit einem GUI fiele auch diese Möglichkeit weg. Und in jedem Fall ist es umständlich. Das macht keinen Spaß.
[the_ad_group id=“15″]
Es leitet sich ein erster Grad für die Testbarkeit ab: Automatisiert testbar ist, was keine Abhängigkeiten zum UI hat.
In diesem Fall besteht die Abhängigkeit zum UI-API in den ersten beiden Zeilen und in der letzten. Um zumindest die leichter zu testenden Logik-Teile von den schwerer zu testenden zu trennen, ist es angezeigt, sie in separate Funktionen zu verpacken:
public static void Main(string[] args) {
var text = Ask_for_text();
var words = text.Split(' ');
var n = words.Length;
Display_wordcount(n);
}
public static string Ask_for_text() {
Console.Write("Enter text: ");
var text = Console.ReadLine();
return text;
}
public static void Display_wordcount(int n) {
Console.WriteLine($"Number of words: {n} ");
}
Jetzt könne ich das UI allein testen. Von Hand (oder automatisiert). Oder ich lasse es sein, weil das so trivial ist. Ob es korrekt läuft, sehe ich bei der Programmausführung.
Die Testbarkeit des Gesamtverhaltens ist aber noch nicht besser geworden. Ebenfalls kann ich noch nicht die Domänenlogik gezielt testen. Sie steckt in Main() zwischen den UI-Methodenaufrufen. Das ist ein Widerspruch zum Single Level of Abstraction (SLA) Prinzip – und ist schlecht testbar.
Es leitet sich ein nächster Grad der Testbarkeit ab: Logik ist überhaupt nur für sich testbar, wenn sie freigestellt ist in einer eigenen Funktion.
Zum Glück lässt sich die Domäne ebenfalls einfach herausziehen:
public static void Main(string[] args) {
var text = Ask_for_text();
var n = Count_words(text);
Display_wordcount(n);
}
public static int Count_words(string text) {
var words = text.Split(' ');
var n = words.Length;
return n;
}
Das Wichtigste an der Anwendung ist jetzt sehr gut testbar:
[Test]
public void Test_Domain() {
var result = wordcount.MainClass.Count_words("a bc def");
Assert.AreEqual(3, result);
}
Die Domäne ist sicherlich ein ganz eigener Aspekt. Ihre Aufgabe bei der Herstellung des Gesamtverhaltens ist etwas anderes als der der UI-Methoden.
Beide UI-Methoden gehören zum selben Aspekt „Benutzerschnittstelle“. Der ist gekennzeichnet durch die Nutzung eines API und lässt sich daher recht leicht abgrenzen. Überall, wo Methoden desselben API zum Einsatz kommen, geht es um denselben Aspekt. Kennzeichnend für das UI ist in diesem Beispiel der API System.Console.
Die Wortzählung hat mit dem API nichts zu tun. Also gehört sie zu einem anderen Aspekt. Ich nenne ihn mal „Domäne“. Mit der Funktion Count_words() ist der nun sauber getrennt vom UI-Aspekt.
Aber ist die Domäne jetzt schon gut testbar? Insgesamt ja – doch die Domäne besteht aus Sub-Aspekten. Die sind weniger gut zu erkennen, aber sie sind da. Das wird klar, wenn ich diesen Test laufen lasse, in dem die Worte durch mehrere Leerzeichen getrennt sind:
[Test]
public void Test_Domain_with_multiple_whitespaces() {
var result = wordcount.MainClass.Count_words(" a bc def ");
Assert.AreEqual(3, result);
}
Der schlägt fehl. Es werden 11 Worte gezählt, wo ich nur 3 sehe. Wie kommt das? Wo liegt der Fehler?
Jetzt stellt sich die Frage: Werden die Worte falsch gebildet und richtig gezählt oder werden richtig gebildete Worte falsch gezählt?
Kann ich gezielt nur die Wortbildung bzw. die Zählung testen? Nein. Sie stellen sich mir als getrennte Aspekte innerhalb der Domäne dar; deshalb konnte ich die Frage oben so differenzierend formulieren. Doch die verschiedenen Aspekte sind nicht in eigenen Modulen beheimatet, die unabhängig getestet werden könnten.
Also trenne ich die bisher monolithische Logik der Domäne auf:
public static int Count_words(string text) {
var words = Split_into_words(text);
return Count_words(words);
}
public static string[] Split_into_words(string text) {
var words = text.Split(' ');
return words;
}
public static int Count_words(string[] words) {
var n = words.Length;
return n;
}
Nach dieser Refaktorisierung kann ich die „Testsonde“ gezielt bei den Sub-Aspekten anlegen:
[Test]
public void Test_text_splitting() {
var result = wordcount.MainClass.Split_into_words(" a bc def ");
Assert.AreEqual(new[] { "a", "bc", "def"} , result);
}
[Test]
public void Test_wordcount() {
var result = wordcount.MainClass.Count_words(new[] { "a", "bc", "def" });
Assert.AreEqual(3, result);
}
Nun stellt sich heraus, dass die Wortbildung nicht so funktioniert, wie sie sollte.

Mit einem gezielten Eingriff, quasi minimalinvasiv, ist das Problem zum Glück zu beheben. Die Funktion Split_into_words() dient als Schlüsselloch zur kleinstmöglichen Menge Logik, die relevant ist.
public static string[] Split_into_words(string text) {
var words = text.Split(new[] { ' '} , StringSplitOptions.RemoveEmptyEntries);
return words;
}
Jetzt ist wieder alles gut. Geholfen hat, dass die Aspekte in eigenen Funktionen freigestellt waren.
Jetzt weiter zum nächsten Inkrement bei „Word Count“. Es werden nicht mehr alle Worte gezählt, sondern nur noch manche. Die Zählung wird also verändert.
Ich weiß genau, wo die Zählung stattfindet: in Count_words(string[]). Also kommt die neue Logik dort hinein. Zuerst jedoch ein fehlschlagender Test:
[Test]
public void Test_wordcount_without_stopwords() {
var result = wordcount.MainClass.Count_words(new[] { "hello", "the", "world", "off" });
Assert.AreEqual(2, result);
}
Mit ein bisschen Linq-Power ist die Berücksichtigung der Stopwords schnell gemacht:
public static int Count_words(string[] words) {
var stopwords = System.IO.File.ReadAllLines("stopwords.txt");
words = words.Except(stopwords).ToArray();
var n = words.Length;
return n;
}
Der neue Test wird damit grün. Yey! :-)
Aber leider fliegen mir nun andere Tests um die Ohren. Mist! :-(
Ich habe der Einfachheit halber eine stopwords.txt-Datei wie in den Anforderungen gelistet im wordcount-Projekt angelegt, die nicht nur in das Output-Verzeichnis der Anwendung kopiert wird, sondern auch bei den Tests aufschlägt. Der neue Test nutzt sie und wird deshalb grün – aber alle anderen Tests nutzen sie und die erweiterte Wortzählungsfunktionalität auch. Deren Testfälle sind aber nicht darauf ausgelegt, Stopwords zu berücksichtigen.
Das Fehlschlagen der anderen Tests ist ein Zeichen für eine Abhängigkeit. Diese Abhängigkeit ist derzeit verborgen in den Tiefen der Domäne im Aspekt „Wortzählung“. Das macht auf einen Schlag die bisherige schöne Testbarkeit zunichte.
Nicht nur ist also Abhängigkeit von einem UI-API eine Behinderung beim Testen. Jede Abhängigkeit von einer Ressourcen verringert die Testbarkeit. Wo immer irgendein API im Spiel ist – hier: Dateizugriff -, wird das Testen knifflig.
Was tun?
Ich möchte in jedem Test ganz einfach bestimmen können, welche Stopwords zur Anwendung kommen. Dafür muss ich dann zwar auch Tests ändern, aber nicht die bisher erfolgreich getesteten Input-Output-Kombinationen.
Für die gezielte Einstellung der Stopwords ist es nötig, dass die Ressourcenabhängigkeit explizit ist. Stopwords dürfen nicht „einfach so“ nebenbei und im Verborgenen geladen werden.
Das Laden von Stopwords ist ein eigener Aspekt. Das sollte klar sein. Und wer das inhaltlich nicht erkennt, dem sollte das die Abhängigkeit von einem API verraten. Insofern war die obige Implementation natürlich naiv und hat schon dem identifizierten Grad der Testbarkeit „Freistehende Aspekte“ widersprochen. Ich habe nicht gezielt testen können, ob Stopwords überhaupt korrekt geladen werden.
Zuerst stelle ich daher die Stopwords-Beschaffung frei:
public static string[] Load_stopwords() {
return System.IO.File.ReadAllLines("stopwords.txt");
}
Für den Stopwords-Dateizugriff einen Test zu schreiben, ist nun einfach. Der basiert auch auf der Abhängigkeit von der einen Stopwords-Datei, die automatisch im Testverzeichnis landet:
[Test]
public void Test_Load_stopwords() {
var stopwords = wordcount.MainClass.Load_stopwords();
Assert.AreEqual(new[] { "the", "a", "on", "off" }, stopwords);
}
Einerseits ist einfach. Andererseits: Auch das Laden der Stopwords hat verschiedene Aspekte. Wie kann ich das Verhalten der Funktion testen, wenn die Stopwords-Datei fehlt? Wie kann ich testen, wie die Funktion mit Leerzeilen in der Stopwords-Datei umgeht? Für jeden dieser Tests muss derzeit eine andere Datei mit Namen „stopwords.txt“ angelegt (oder gelöscht) werden.
Diese einzeilige Funktion hat nicht nur eine Abhängigkeit zu einem API (das ist sogar ihr Zweck), sondern auch zu einer Konstanten – dem Dateinamen -, deren Wert ich zumindest für Testzwecke gern austauschen möchte.
Der Grad der Testbarkeit sinkt also nicht nur mit Abhängigkeit von Methoden, sondern auch mit Abhängigkeit von Daten, seien das Konstanten oder auch Variablen.
Um den Dateinamen austauschbar zu machen, muss ich ihn von außen setzen können. Das kann per Methodenparameter geschehen. In diesem Fall entscheide ich mich jedoch für einen Konstruktorparameter, um zu unterstreichen, dass der Dateiname eigentlich fix ist. Aus der bisher statischen Methode wird deshalb eine Instanzmethode:
class StopwordsProvider {
readonly string filename;
public StopwordsProvider() : this("stopwords.txt") { }
internal StopwordsProvider(string filename) { this.filename = filename; }
public string[] Load() {
var stopwords = System.IO.File.ReadAllLines(this.filename);
return stopwords;
}
}
Im Produktiveinsatz braucht der Konstruktor keinen Parameter; der Dateiname ist weiterhin eine Konstante. Doch im Testfall übergebe ich einfach einen Dateinamen, wenn ich vom Standard abweichen will, z.B.
[Test]
public void Test_Load_stopwords_from_missing_file() {
var sut = new StopwordsProvider("missing.txt");
var stopwords = sut.Load();
Assert.AreEqual(0, stopwords.Length);
}
Der Test schlägt natürlich fehl. Bisher nimmt die Funktion an, die Stopwords-Datei würde existieren. Aber gut, dass ich das so gezielt prüfen kann. Keine andere Logik muss dafür von Hand (oder auch automatisiert) ausgeführt werden.
Die Nachbesserung ist einfach:
public string[] Load() {
if (!File.Exists(this.filename)) return new string[0];
var stopwords = File.ReadAllLines(this.filename);
return stopwords;
}
Doch wie kommt nun die Wortzählung an die Stopwords? Ich könnte die bisherige direkte Beschaffung in der Domänenlogik ersetzen durch Aufruf der neuen Funktion:
public static int Count_words(string[] words) {
var swp = new StopwordsProvider();
var stopwords = swp.Load();
words = words.Except(stopwords).ToArray();
var n = words.Length;
return n;
}
Damit wären aber immer noch die Aspekte vermischt. Die separate Testbarkeit der Wortzählung wäre immer noch nicht hergestellt.
Das Problem wäre auch nur wenig geringer, würde ich die Stopwords-Beschaffung ersetzbar machen durch eine Attrappe:
class CountingWords {
readonly IStopwordsProvider swp;
public CountingWords(IStopwordsProvider swp) { this.swp = swp; }
public int Count(string[] words) {
var stopwords = this.swp.Load();
words = words.Except(stopwords).ToArray();
var n = words.Length;
return n;
}
}
[Test]
public void Test_wordcount_with_stopwords()
{
var mockswp = new MockStopwordsProvider(new[] { "bc" });
var sut = new CountingWords(mockswp);
var result = sut.Count(new[] { "a", "bc", "def" });
Assert.AreEqual(2, result);
}
class MockStopwordsProvider : IStopwordsProvider
{
readonly string[] stopwords;
public MockStopwordsProvider(string[] stopwords) {
this.stopwords = stopwords;
}
public string[] Load() {
return this.stopwords;
}
}
Wieder bewegt mich die auszutauschende Abhängigkeit dazu, eine bisher statische Funktion zu einer Instanzfunktion zu machen. Aber das ist nicht das Problem. Wo Austauschbarkeit gefragt ist, ist das das Muster.
Nein, problematisch finde ich, dass die Domänenlogik in CountingWords.Count() sich überhaupt über die Beschaffung von Daten, die sie braucht, Gedanken zu machen. Was soll die funktionale Abhängigkeit von einer Methode Load()? Es ist egal, ob die nun direkt ist wie zuerst oder indirekt durch eine Dependency Inversion (DI). Die Wortzählungslogik steht immer noch nicht allein. Durch die Injektion des Providers entsteht eine Last. Ja, Attrappen bauen, ist eine Last. Die wird auch nur marginal geringer mit Mock-Frameworks.
Das fundamentale Problem ist durch DI einfach nicht gelöst. Da mag DI auch noch so geadelt sein durch seinen Platz in den SOLID-Prinzipien. Fundamental problematisch für die Testbarkeit sind nämlich funktionale Abhängigkeiten.
Die Funktion Count() enthält selbst Logik (für die Wortzählung) und ist gleichzeitig noch damit beschäftigt, weitere Logik zu integrieren (Stopwords-Beschaffung). Das ist ein Widerspruch zum Single Responsibility Principle (SRP).
Grundsätzlich gelöst wird das Problem erst durch eine Auflösung der funktionalen Abhängigkeit. Die Wortzählung darf nichts mehr von jeglicher Beschaffung von Stopwords wissen. Ihre einzige und natürlich Abhängigkeit besteht in der von den Stopwords selbst, also von Daten.
Die können statt einer Provider-Attrappe über den Konstruktor der Domänenklasse zur Verfügung gestellt werden:
class CountingWords {
readonly string[] stopwords;
public CountingWords(string[] stopwords) { this.stopwords = stopwords; }
public int Count(string[] words) {
words = words.Except(this.stopwords).ToArray();
var n = words.Length;
return n;
}
}
Diesen Weg wähle ich, weil Stopwords für mich irgendwie orthogonal zur Wortzählung sind. Sie sind statischer als der zu analysierende Text. Der mag wechseln, die Stopwords werden eher dieselben bleiben.
Die Tests für die Domänenlogik fallen nun sehr einfach aus:
[Test]
public void Test_wordcount_with_stopword_not_found() {
var sut = new CountingWords(new[] { "bc" });
var result = sut.Count(new[] { "a", "bc", "def" });
Assert.AreEqual(2, result);
}
[Test]
public void Test_wordcount_with_stopword_found() {
var sut = new CountingWords(new[] { "xy" });
var result = sut.Count(new[] { "a", "bc", "def" });
Assert.AreEqual(3, result);
}
[Test]
public void Test_wordcount_without_stopwords() {
var sut = new CountingWords(new string[0]);
var result = sut.Count(new[] { "hello", "world" });
Assert.AreEqual(2, result);
}
Jetzt sind die ursprünglich fehlschlagenden Tests, weil ich Stopwords eingeführt habe, wieder grün – bis auf einen. Weitere sind hinzugekommen. An den Testszenarien habe ich nichts geändert, allerdings musste doch Testcode angepasst werden, weil meine Lösung nun eine andere Struktur hat.
Lediglich der Akzeptanztest auf Main() ist noch rot. Für ihn bekomme ich keine Austauschbarkeit hin, weil der Kontrakt vorgegeben und unveränderlich ist: eine statische Methode mit einem Parameter.
Aber das macht nichts. In dem Fall passe ich eben das Testszenario auf die Existenz der Beispiel-Stopwords an. Das finde ich für diesen Akzeptanztest nicht tragisch.
Fertig! Oder?
Ich störe mich noch an einigen Methoden von MainClass. Deren Vorhandensein ist gut, denn so sind Logik-Aspekte testbar. Doch es passt nicht, dass diese Methoden öffentlich auf MainClass sind. Sie gehören nicht zu dem, was von außen sichtbar sein sollte.
Um die Situation zu entspannend, sehe ich mehrere Möglichkeiten:
private und teste sie nicht.internal und teste sie weiterhin. Das wären Whitebox-Tests.Möglichkeit 3. schmeckt mir gar nicht. Interna sollen nicht permanent sichtbar sein. Das schafft unnötige Abhängigkeiten.
Möglichkeit 2. scheint mir für die Zerlegung des Textes in Worte angemessen. Das ist ein so eigener Aspekt wie die Stopwords-Beschaffung oder die Wortzählung. Die Methode kann auch statisch bleiben. Hier ist nichts auszutauschen.
class Parser {
public static string[] Split_into_words(string text) {
var words = text.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
return words;
}
}
Dasselbe gilt für die I/O-Methoden des UI:
class UI {
public static string Ask_for_text() {
Console.Write("Enter text: ");
var text = Console.ReadLine();
return text;
}
public static void Display_wordcount(int n) {
Console.WriteLine($"Number of words: {n} ");
}
}
Hier besteht zwar auch eine API-Abhängigkeit, die eine Austauschbarkeit für Tests nahelegt und also Instanzmethoden… doch im Test habe ich damit bisher kein Problem, weil ich Standard-Input/Output auf der Ebene darunter ersetze. Also lasse ich die Methoden statisch.
Jetzt ist nur noch Count_words() übrig:
public static int Count_words(string text, string[] stopwords) {
var words = Parser.Split_into_words(text);
return new CountingWords(stopwords).Count(words);
}
Dafür gibt es sogar einen Test, den ich auf die Stopwords angepasst habe.
Diese Methode enthält keine Logik, sie ist reine Integration. Ihr Zweck ist im Rahmen von MainClass lediglich die Abstraktion der Details, wie nach der Texteingabe die Wortzählung funktioniert.
Aus diesem Grund muss ich sie gar nicht testen. Ich kann sie problemlos auf private setzen. Dadurch fliegen mir natürlich ihre Tests um die Ohren:

Aber das macht nichts. Ich werfe sie einfach weg. Sie testen nichts, was nicht schon durch andere Tests überprüft würde. Die Integration selbst ist trivial und muss nicht getestet werden. Ich kann sie durch Augenscheinnahme überprüfen. Dass ich zwei Methoden falsch in Count_words() „zusammengestöpselt“ habe, ist kaum zu erwarten.
Die Tests, die durch die angemessene Sichtbarkeit fehlschlagen, sind für mich Gerüsttests (scaffolding tests). Sie mögen für eine gewisse Zeit nützlich sein, während ich an der Software arbeite. Doch am Ende baue ich sie ab. Sie verstellen mir den Blick auf das Wesentliche, wenn ich dafür Methoden mit einer unangemessenen Sichtbarkeit versehen muss. Selbst internal wäre für Count_words() nicht passend.
Entscheidend ist, dass Count_words grundsätzlich testbar ist. Ich muss dafür nur die Sichtbarkeit ändern. Das kann ich jederzeit tun, wenn ich meine, auf der Ebene etwas überprüfen zu müssen, d.h. unterhalb von Main(). Ich scheue mich nicht, für die Veränderung von Produktionscode temporär Änderungen vorzunehmen und am Ende wieder zurück zu bauen.
Das ist der finale Code von MainClass:
public class MainClass {
public static void Main(string[] args) {
var stopwords = new StopwordsProvider().Load();
var text = UIPortal.Ask_for_text();
var n = Count_words(text,stopwords);
UIPortal.Display_wordcount(n);
}
private static int Count_words(string text, string[] stopwords) {
var words = Parser.Split_into_words(text);
return new CountingWords(stopwords).Count(words);
}
}
Aufgeräumt, oder? ;-)
[the_ad_group id=“15″]
Logik muss korrekt sein. Sie muss das gewünschte Verhalten herstellen. Wenn Sie das vor Auslieferung prüfen wollen, müssen Sie Logik testen. Tests von Hand skalieren nicht. Damit lässt sich keine Regressionssicherheit herstellen. Also braucht es automatisierte Tests. Um automatisierte Tests gezielt auf Logik ansetzen zu können, muss die testbar sein. Testbarkeit existiert in unterschiedlichen Graden:
Ich hoffe, diese Differenzierung hilft Ihnen, von vornherein testbarere Logik zu schreiben – und dadurch gleichzeitig höhere Wandelbarkeit herzustellen.
]]>Clean Code Development braucht hands-on experience, als Trainingsteilnehmer muss man „es“ getan haben. Und zwar nicht zu knapp, in vielen Wiederholungen. Immerhin gilt es, alte Gewohnheiten abzulegen, die fast schon automatisiert in den Fingerspitzen stecken.
Aber auch wenn es um Clean Code geht, dreht sich das Training nicht nur ums Codieren. Damit ließe sich der Code gar nicht genügend sauber gestalten. Wer erst beim Codieren an Clean Code denkt, denkt zu spät daran.
Deshalb müssen wir viel Reden. Clean Code ist das Ergebnis von Diskussion im Team. Auch das will geübt werden. Nein, insbesondere das will geübt werden. Denn „im Reden“ sind Softwareentwickler gewöhnlich nicht so gut. Oder allgemeiner: Im Nachdenken über Lösungen vor dem Codieren sind sie nicht so geübt, vor allem im kollektiven Nachdenken.
Das tut aber Not. Denn wenn am Ende Collective Code Ownership stehen soll, dann reicht es nicht, den Code im Pair Programming zu schreiben. Dazu müssen Reviews kommen (Diskussion!), dazu müssen aber vor allem gemeinsame (!) Analyse der Anforderungen und gemeinsames (!) Nachdenken über den Lösungsansatz kommen (Diskussion!).
Und da solches diskursives Nachdenken nicht so schnell geht und für die meisten Teilnehmer sehr ungewohnt ist, verwenden wir den größten Teil von Clean Code Development Trainings darauf.
Anders als im bisher üblichen Tagesgeschäft der Teilnehmer sind wir allerdings sehr darauf bedacht, dass dieses gemeinsame Nachdenken nicht spurlos bleibt. Dafür hier ein Beispiel aus einem aktuellen Training:
Das ist der Papertrail von zwei Tagen. In denen haben wir uns auf eine Übungsaufgabe konzentriert: eine Anwendung zur Vorhersage von Aufwänden mittels Monte Carlo Simulation.
Was genau all diese Diagramme und Notizen bedeuten, ist hier nicht so wichtig. An dieser Stelle möchte ich Ihnen nur einen visuellen Eindruck davon vermitteln, wie es halt in den Trainings der CCD School zugeht.
Acht Teilnehmer und ich haben im Verlauf von zwei Tagen, also 16 Stunden Training, diese Spur in intensiven Diskussionen produziert – und auch noch implementiert.
Am ersten Tag haben wir ca. 60 Minuten codiert; wir hatten uns nur ein kleines Inkrement zum Einstieg vorgenommen. Am zweiten Tagen waren es 190 Minuten in mehreren Blöcken und sogar arbeitsteilig: ein Teil der Teilnehmer hat sich auf die Simulation konzentriert, ein anderer auf die Vorhersage. Beide Aspekte der Domäne wurden dann als Bibliotheken den anderen zur Integration zur Verfügung gestellt.
So geht es zu in unseren Clean Code Development Trainings. Selbst Männer kommen ins Reden – und fühlen sich gut dabei :-) Und damit nicht nur „daher geredet wird“, zeichnen wir die Gedanken auf. Es ist uns wichtig, dass die Teilnehmer lernen, das, was sie in der Analyse verstehen, anderen visuell vermitteln zu können. Nur so lässt sich ein gemeinsames (!) Verständnis verlässlich erreichen. Dasselbe gilt für ihre Vorstellungen von der Lösung des Problems. Visualisierung, Gedanken auf Papier ausdrücken können, ist das A und O der Softwareentwicklung im Team. Dazu kommen natürlich noch Prinzipien und Konzepte sauberer und agiler Strukturen, doch das Codieren ist am Ende im Grunde der einfachste Teil. Clean Code schreibt sich fast schon von allein ;-)
Wenn Sie jetzt Lust bekommen haben, das auch einmal zu erleben, dann schreiben Sie uns eine Email an info@ccd-school.de und wir schauen, wann wir für Ihr Team das „Trainingslager“ aufschlagen können. Oder Sie machen bei einem Clean Code Retreat mit oder belegen einen Kurs der Clean Code Development Akademie.
[the_ad_group id=“15″]
]]>