C#Clean Code

Römische Zahlen mit automatischen Tests

Testgetriebene Softwareentwicklung kann helfen saubereren Code zu erstellen. Hier ein C#-Lösung zur bekannten Kata „Roman Numerals“ (römische Zahlen konvertieren). Natürlich legen wir auch großes Augenmerk auf automatische Tests bei der Softwareentwicklung der digitalen Produkte unserer Kunden.

Code durch automatische Tests absichern
Mauricio Artieda

Neulich habe ich zu mehr Evolvierbarkeit aufgerufen. Ich hatte dabei auch schon erwähnt, dass es wichtig ist, einen gesicherten Raum zu haben, um freudiger auf das Thema Weiterentwicklung und Änderungen zu reagieren. Es ist nichts Neues, dass man hierfür am besten automatisierte Tests verwendet. Ein solches Sicherheitsnetz kann mit Entwicklungsmethodiken wie TDD (je nach Sichtweise auch BDD) errichtet werden.

Ich möchte jetzt nicht im Detail erklären, wie TDD funktioniert, da es dafür bereits genügend Anleitungen im Netz sowie in gedruckter Form gibt. Kurz zusammengefasst kommt man mit TDD durch kleine iterative Schritte (sogenannte Babysteps) zu seiner Lösung. Innerhalb jedes Schritts durchläuft man die drei Phasen: red, green und refactor. Dabei wird zuerst ein Testfall erstellt, der einen bisher noch nicht vom System abgedeckten Teil der Lösung erwartet. Dieser Test muss zu Beginn fehlschlagen (=rot sein). Als Nächstes wird genau so viel Code implementiert, dass der Test bestanden wird (=grün). Dabei sollte man nicht zu viel Code implementieren, denn Aufräumen und Verbessern kommt erst in der letzten Phase (=Refactoring). In dieser Phase sind alle bisherigen Tests grün und der Entwickler kann den Code beliebig umgestalten. Die Tests sichern einen in dieser Phase sozusagen vor zerstörerischen Änderungen. Es folgt dann eine weitere Iteration dieser drei Phasen. Dies geschieht so lange bis der Code vollständig implementiert wurde.

Ziel des Artikels ist es nicht, über das Für und Wider von TDD zu diskutieren, dazu gab es schon genug Diskussionen (z. B. Anfang 2014). Ich selbst habe die Erfahrung gemacht, dass es auf jeden Fall eine Methodik ist, die jeder gute Entwickler beherrschen sollte, muss aber auch gestehen, dass ich es bewusst nicht immer anwende. (Anmerkung: Ich meine damit natürlich nicht, dass ich in solchen Fällen dann auf automatisierte Tests verzichte, sondern nur auf den Test-First Ansatz). Warum das so ist, möchte ich anhand der folgenden, nach TDD gelösten, Kata RomanNumerals (Teil 2) zeigen.

Babystep I

In der Regel sucht man sich bei TDD zu Beginn einfache Fälle heraus, die zuerst entwickelt werden. Im Fall der römischen Zahlen ist es daher naheliegend mit römisch I anzufangen. Ein solcher Test könnte in C# unter Nutzung des Test-Frameworks NUnit so aussehen:

using NUnit.Framework;

[TestFixture]
class FromRomainNumeralsTest
{
  [Test]
  public void Should_Return_1_When_I_Is_Passed()
  {
    Assert.AreEqual(1, FromRomainNumerals.Convert("I"));
  }
}

Die einfachste Implementierung welche den Testfall erfüllt, wäre folgender Code:

static class FromRomainNumerals
{
  public static int Convert(string romainNumber)
  {
    return 1;
  }
}

Dieser Code lässt sich dann auch kaum noch weiter vereinfachen oder umgestalten. Von daher folgt der nächste Test.

Babystep II

Nachdem I getestet wurde, liegt es auf der Hand mit der römischen Zahl II weiterzumachen. Der passende Test sieht so aus:

[Test]
public void Should_Return_2_When_II_Is_Passed()
{
  Assert.AreEqual(2, FromRomainNumerals.Convert("II"));
}

Wir suchen wieder die einfachste Lösung, die uns einfällt. Angenommen wir wissen derzeit noch nicht genau, wie der Algorithmus dahinter funktioniert, dann könnte eine Lösung so aussehen:

public static int Convert(string romainNumber)
{
  if(romainNumber=="II") return 2;
  return 1;
}

Viel aufräumen kann man an dieser Stelle wieder nicht. (Anmerkung: Immer noch unter der Annahme, wir wissen nicht, wie die weiteren Bedingungen aussehen).

Babystep III

Nachdem wir I und II getestet haben, kümmern wir uns um die römische Zahl III, da der bisherige Code dies noch nicht abdeckt:

[Test]
public void Should_Return_3_When_III_Is_Passed()
{
  Assert.AreEqual(3, FromRomainNumerals.Convert("III"));
}

Eine einfache Implementierung würde nach bisherigem Muster dann so aussehen:

public static int Convert(string romainNumber)
{
  if(romainNumber=="II") return 2;
  if(romainNumber=="III") return 3;
  return 1;
}

Spätestens jetzt wird klar, dass das der Code aufgeräumt werden kann, da sich ein Muster abzeichnet. Da alle Tests grün sind, können wir uns dem Refactoring widmen. Schauen wir uns den Code an, kommt man schnell auf die Idee zu zählen wie viele Zeichen hereinkommen. Also z. B. ein return romainNumer.Length könnte den bisherigen Code ersetzen. Da wir bereits Wissen, dass andere Symbole mit anderen Wertigkeiten folgen, habe ich mich für folgende Lösung entschieden:

public static int Convert(string romainNumber)
{
  return romainNumber.Select(x => 1).Sum();
}

Babystep V

Nachdem wir uns um das Symbol I gekümmert haben, können wir das nächste Symbol V betrachten. Der dazugehörige Test sieht so aus:

[Test]
public void Should_Return_5_When_V_Is_Passed()
{
  Assert.AreEqual(5, FromRomainNumerals.Convert("V"));
}

Wir müssen jetzt also das Zeichen I mit 1 übersetzen und das V mit 5 übersetzen. Da ich weiß, dass es weitere Symbole gibt, habe ich mich entschieden, ohne weitere Tests bereits alle durch ein Dictionary abzudecken. Die Implementierung sieht wie folgt aus:

public static int Convert(string romainNumber)
{
  var mapping = new Dictionary<char, int> {
    {'I', 1}, {'V', 5}, {'X', 10}, {'L', 50}, {'C', 100}, {'D', 500}, {'M', 1000}};
  return romainNumber.Select(x => mapping[x]).Sum();
}

Wie man sieht, habe ich nicht nur die getesteten Symbole I und V implementiert, sondern auch gleich alle weiteren Übersetzungen ergänzt. Da die Tests nur das Dictionary testen, habe ich entschieden keine weiteren Tests für X, L, C, D und M zu ergänzen. Wendet man TDD nach Lehrbuch an, so hätte ich diese vermutlich mit je einem Test herleiten sollen. Der aktuelle Code gefällt mir gut, ein Refactoring ist nicht derzeit notwendig.

Babystep VI

Nachdem wir die einzelnen Symbole getestet haben, können wir diese doch auch gleich gemischt Testen. Hierzu habe ich mir VI herausgesucht und komme zu folgendem Test:

[Test]
public void Should_Return_6_When_VI_Is_Passed()
{
  Assert.AreEqual(6, FromRomainNumerals.Convert("VI"));
}

Ohhh... Der Test war gar nicht notwendig, da er bereits grün ist. D.h. ich musste gar nichts dafür implementieren. Er fügt also keine weitere Erwartung hinzu. Ich entscheide mich dennoch den Test beizubehalten, da er einen weiteren wichtigen Aspekt erwartet. Nämlich das Aufsummieren der Wertigkeiten verschiedener Symbole.

Babystep IV

Das Aufsummieren der Wertigkeiten der Symbole ist damit umgesetzt. Es fehlt nun noch die Implementierung der Subtraktionsregel. Hierzu können wir einen Test ergänzen der für die römische Zahl IV die 4 erwartet:

[Test]
public void Should_Return_4_When_IV_Is_Passed()
{
    Assert.AreEqual(4, FromRomainNumerals.Convert("IV"));
}

Eine einfache Lösung des Problems besteht darin, die Werte bei der Aufsummierung zu negieren, wenn die nachfolgende Ziffer in ihrer Wertigkeit größer ist. Die aktuelle Implementierung verhindert diese Umstellung. Also konvertiere ich zuerst das LINQ-Statement Sum() in eine normale for-Schleife. Dabei möchte ich sicherstellen, dass alle bisherigen Tests weiterhin funktionieren. Den neuen Test deaktiviere ich vorerst durch das Attribut [Ignore]:

[Test]
[Ignore]
public void Should_Return_4_When_IV_Is_Passed()
{
    Assert.AreEqual(4, FromRomainNumerals.Convert("IV"));
}

Die geänderte Lösung sieht dann so aus (Tipp: der ReSharper kann einem dabei helfen, da er LINQ-Statements zu Code konvertieren kann):

internal static class FromRomainNumerals
{
  public static int Convert(string romainNumber)
  {
    var mapping = new Dictionary<char, int> {{'I', 1}, {'V', 5}, 
                               {'X', 10}, {'L', 50}, {'C', 100}, 
                               {'D', 500}, {'M', 1000}};
    var numbers = romainNumber.Select(x => mapping[x]).ToList();
    var sum = 0;
    for (var i = 0; i < numbers.Count; i++)
    {
      sum += numbers[i];
    }
    return sum;
  }
}

Jetzt kann ich den vorher ignorierten Test wieder aktivieren und den verbleibenden Teil implementieren:

using System.Collections.Generic;
using System.Linq;

internal static class FromRomanNumerals
{
  public static int Convert(string romanNumber)
  {
    var sum = 0;
    var mapping = new Dictionary<char, int> {{'I', 1}, {'V', 5}, 
                               {'X', 10}, {'L', 50}, {'C', 100}, 
                               {'D', 500}, {'M', 1000}};
    var numbers = romanNumber.Select(x => mapping[x]).ToList();
    for (var i = 0; i < numbers.Count; i++)
    {
      var current = numbers[i];
      var next = i < numbers.Count - 1 ? numbers[i + 1] : 0;
      sum += next > current ? -current : current;
    }
    return sum;
  }
}

Fertig

Ich hoffe, der hier gezeigte Ablauf war nicht zu gekünstelt. Aber meiner Meinung nach läuft eine Implementierung unter Einsatz von TDD meist nach ähnlichem Muster ab. Allerdings implementieren wir bei richtigen Anwendungen nicht nur einen, wenige Methoden betreffenden, Algorithmus, sondern unser Code erstreckt sich über viele Klassen mit noch mehr Methoden. Da läuft es dann nicht immer so effizient wie hier gezeigt. Hinzu kommt, dass wir es dann auch noch mit vielen verschiedenen Bereichen wie Frontends, Datenbanken, etc.pp. zu tun haben. Spätestens dann wird es mit TDD nicht mehr ganz so einfach, da man beispielsweise externe Abhängigkeiten wie dem Datenbankzugriff bei seinen Tests durch Mocks raushalten muss. Ebenso ist nicht jeder Code so komplex wie ein Algorithmus. So wird vielleicht der Code für das UI in einem Designer erstellt. Dabei entsteht zwar viel Code, dieser enthält aber normalerweise nicht eine einzige Kontrollstruktur.

Ist es das, was mich an TDD stört? Nicht wirklich, mein Problem ist eher, dass man doch eher selten auf der empirischen Suche nach einer Lösung ist, meistens hat bereits eine grobe Idee und dann ist es besser, mit einem Plan zu arbeiten. Wenn man sich meinen Code anschaut und diesen mit meiner Version aus dem letzten Post vergleicht, stellt man schnell fest, dass beide Versionen doch sehr ähnlich sind. Klar, die zweite Lösung ist etwas eleganter programmiert. Dies betrifft vor allem die Berechnung der Subtraktionsregel. Dennoch hat sich die Umsetzung hauptsächlich aus meiner vorherigen Lösung ergeben. Warum? Weil ich diese bereits als Plan im Kopf hatte. TDD hat seine Stärken besonders dann, wenn man durch Herumprobieren die Lösung erarbeitet, denn hier reduzieren vor allem die vorhandenen Tests das Risiko vor unbedachten Änderungen. Ich bin aber der Meinung, dass es besser ist, vorher einen groben Plan zu machen, (das Problem etwas zu durchdenken) und diesen dann mithilfe von Methoden wie TDD umzusetzen. Je nach Situation setzt man also eine Mischung unterschiedlicher Methoden zur Lösung eines konkreten Problems ein. Unabhängig davon sind automatisierte Tests unverzichtbar, TDD hilft hier auf intuitive Weise, diese zu bekommen.

Anmerkung: Bei diesem Text handelt es sich um einen überarbeiteten Repost eines alten Blog-Artikels aus 2015 von mir.