sebschauers blog
Veröffentlicht am
IT

Tests für alten und erneuerten Code zugleich nutzen

Autor

Mitunter findet man wirklich alten Code vor, der ungetestet und schlecht wartbar ist - und den man gern modernisieren würde. Der Autor dieser Zeilen hatte es z.B. mit VB.NET-Code zu tun, der nach C# umgezogen werden sollte. Es gab keine Unit-Tests, und die Code-Struktur war unübersichtlich und die Intention der ursprünglichen Entwickler oft schlecht erkennbar.

Wie kann man den Umzug nach C# in diesem Falle angehen? Hier wird eine von vielen möglichen Strategien beschrieben. Diese Strategie lässt sich für alle möglichen Arten von Ursprungscode anwenden, der schlecht wartbar ist (es muss nicht VB sein).

Vorüberlegungen

Der alte VB.NET-Code liegt in einem Projekt MyClasses.Old vor; die zu refaktorisierende Klasse heißt MyClass und besitzt eine öffentliche Methode. Hier ist die Klasse:

Class [MyClass]
  Private _factor As Integer

  Public Sub New(factor As Integer)
    _factor = factor
  End Sub

  Public Function Product(value As Integer) As Integer
    Return value * _factor
  End Function

End Class

Diese Klasse soll also nach C# umgezogen werden; wir stellen uns vor, dass die eigentliche Klasse komplex und unübersichtlich ist.

Der neue C#-Code soll im C#-Projekt MyClasses.New liegen.

Da es keine Testabdeckung gibt, wäre es sehr heikel, die Klasse MyClass einfach nach C# zu übersetzen oder gar automatisiert übersetzen zu lassen. Größere Refactorings des alten Codes sind aber aus dem gleichen Grund ebenfalls riskant.

Die naheliegende Strategie wäre: 1. Schreiben von Tests für den VB-Code, soweit möglich; 2. Übersetzen der VB-Logik nach C#; 3. Schreiben von Tests für den neuen Code. Die Gefahr ist aber natürlich, dass durch das doppelte Schreiben der Tests Fehler unterlaufen können (es wird das DRY-Prinzip verletzt).

Sicherer wäre das folgende Vorgehen:

  1. Tests für den alten Code schreiben
  2. In C# einen Dummy erzeugen, der den alten Code aufruft und peu a peu durch neuen Code ersetzt wird
  3. Die Tests für den alten Code währenddessen gleichzeitig auch auf den neuen Code anzuwenden.

Dieses Vorgehen soll hier nun demonstriert werden.

Schritt 1: Vorbereitungen

Zunächst ziehen wir ein Interface IMyClass von MyClass, das mindestens alle die öffentlichen Methoden und Properties enthält, die im neuen Code enthalten sein sollen. Dieses Interface benutzen wir für die Tests, denn auch unsere neue Klasse in C# wird dieses Interface implementieren.

Interface IMyClass
  Function Product(value As Integer) As Integer
End Interface

Nun folgt die einzige Änderung am Originalcode: Implementieren des Interfaces.

Class MyClass
  Implements IMyClass
    ...
End Class

Schritt 2: Anlegen der neuen Projekte

Wir legen zwei neue .NET-Projekte an: Zum einen ein C#-Testprojekt MyClasses.Tests (hier am Beispiel xUnit), und das Projekt, das den neuen C#-Projekt enthält: MyClasses.New. Beide Projekte erhalten einen Projektverweis auf das originale Projekt; das Testprojekt erhält außerdem einen Projektverweis auf MyClasses.New.

In letzterem legen wir eine neue Klasse MyClass an; diese implementiert ebenfalls unser Interface. Zunächst tut sie aber nichts weiter, als ein Objekt des Originalcodes zu erzeugen und sämtliche Berechnungen an den Originalcode weiterzureichen. Ihr Konstruktor hat die gleiche Signatur wie der des Originalcodes.

using MyClasses.Old;

public class MyClass : IMyClass
{
  private IMyClass _vbOriginal;

  public MyClass(int factor)
  {
    _vbOriginal = new MyClasses.Old.MyClass(factor);
  }

  public int Product(int value)
  {
    return _vbOriginal.Product(value);
  }
}

Schritt 3: Teststruktur

Nun geht es an das Erstellen der Tests. Wir wollen erreichen, dass jeder Test, der z.B. unsere Methode Product abdeckt, garantiert mit beiden Varianten ausgeführt wird. Hierzu erstellen wir eine abstrakte Klasse, die unsere Tests enthält und nur das Interface nutzt.

using MyClasses.Old;
using MyClasses.New;
using FluentAssertions;

public abstract class TestClass
{
  public abstract IMyClass GetTestee(int value);

  [Fact]
  public void ProductShouldBeInvariantUnderOne()
  {
    var testee = GetTestee(1);
    var value = 42;
    testee.Product(value).Should().Be(value);
  }
}

Das eigentliche Testobjekt wird von einer Funktion GetTestee(int factor) erzeugt, die nun von mehreren Klassen implementiert werden muss, nämlich:

public class VBTestclass : TestClass
{
  public IMyClass GetTestee(int value) => new MyClasses.Old.MyClass(value);
}

public class CsharpTestclass : TestClass
{
  public IMyClass GetTestee(int value) => new MyClasses.New.MyClass(value);
}

Werden die Tests nun ausgeführt, so findet der Testrunner den Test ProductShouldBeInvariantOnOne zweimal und führt ihn für den alten und den neuen Code aus.

Tests werden auf altem und neuem Code ausgeführt

Schritt 4: Refactoring

Nun endlich können wir mit unserem abgesicherten Refactoring beginnen. Wenn uns die Testabdeckung genügt, können wir in der neuen C#-Klasse beginnen, die Aufrufe der alten Klasse durch eigenen Code zu ersetzen. Sobald sich das Verhalten im Vergleich zur alten Klasse ändert, werden unsere Tests fehlschlagen und uns warnen.

Irgendwann wird auch der letzte Aufruf zur Originalklasse verschwunden sein. Dann haben wir einen Zustand erreicht, in dem wir die neue Klasse vollständig vom alten Code gelöst haben, sie aber trotzdem das gleiche Verhalten zeigt. Wir können dann das Feld _vbOriginal und den Verweis auf IMyClass (und das entsprechende using) entfernen und die Zugriffe auf die originale Klasse durch Zugriffe auf die unsrige ersetzen. Wenn dies geschehen ist, können wir die originale Klasse entfernen.

Um unsere Tests behalten zu können, auch wenn das alte Projekt entfernt werden sollte, müssen wir hier noch die Testklasse VBTestClass entfernen. Unsere abstrakte Klasse können wir nun konkret machen, indem wir den Typen der abstrakten Methode GetTestee() auf unseren neuen C#-Typen ändern und ihr den new()-Aufruf aus der CsharpTestClass als Implementation mitgeben, woraufhin auch CsharpTestClass entfernt werden kann.

using FluentAssertions;

namespace MyClass.Tests
{
    public class TestClass
    {
        public MyClasses.New.MyClass GetTestee(int value) => new MyClasses.New.MyClass(value);

        [Fact]
        public void ProductShouldBeInvariantUnderOne()
        {
            var testee = GetTestee(1);
            var value = 42;
            testee.Product(value).Should().Be(value);
        }
    }
}

Fertig!

Und so könnte unsere refaktorisierte Klasse in C# aussehen:

namespace MyClasses.New
{
  public class MyClass(int factor)
  {
    public int Product(int value) => value * factor;
  }
}

Der gesammelte Code dieses Beitrages findet sich auf Github; man hangele sich durch die Commits...