Database
 sql >> Datenbank >  >> RDS >> Database

Regeln für die Implementierung von TDD in einem alten Projekt

Der Artikel „Sliding Responsibility of the Repository Pattern“ hat mehrere Fragen aufgeworfen, die sehr schwer zu beantworten sind. Brauchen wir ein Endlager, wenn die völlige Vernachlässigung technischer Details nicht möglich ist? Wie komplex muss das Repositorium sein, damit seine Ergänzung als sinnvoll angesehen werden kann? Die Antwort auf diese Fragen variiert je nachdem, welche Schwerpunkte bei der Entwicklung von Systemen gesetzt werden. Die wohl schwierigste Frage ist die folgende:Brauchen Sie überhaupt ein Repository? Das Problem der „fließenden Abstraktion“ und die wachsende Komplexität der Codierung mit steigendem Abstraktionsgrad lassen keine Lösung zu, die beide Seiten des Zauns zufriedenstellt. Bei der Berichterstellung beispielsweise führt das Absichtsdesign zur Erstellung einer großen Anzahl von Methoden für jeden Filter und jede Sortierung, und eine generische Lösung erzeugt einen großen Codierungsaufwand.

Um ein vollständiges Bild zu erhalten, habe ich mir das Problem der Abstraktionen im Hinblick auf ihre Anwendung in einem Legacy-Code angesehen. Ein Repository ist in diesem Fall für uns nur als Werkzeug interessant, um qualitativ hochwertigen und fehlerfreien Code zu erhalten. Natürlich ist dieses Muster nicht das Einzige, was für die Anwendung der TDD-Praktiken notwendig ist. Nachdem ich während der Entwicklung mehrerer großer Projekte einen Scheffel Salz gegessen und beobachtet habe, was funktioniert und was nicht, habe ich für mich ein paar Regeln entwickelt, die mir helfen, die TDD-Praktiken zu befolgen. Ich bin offen für konstruktive Kritik und andere Methoden zur Implementierung von TDD.

Vorwort

Einige werden vielleicht bemerken, dass es nicht möglich ist, TDD in einem alten Projekt anzuwenden. Es gibt die Meinung, dass verschiedene Arten von Integrationstests (UI-Tests, End-to-End) für sie besser geeignet sind, weil es zu schwierig ist, den alten Code zu verstehen. Außerdem hört man, dass das Schreiben von Tests vor dem eigentlichen Codieren nur zu einem Zeitverlust führt, weil wir möglicherweise nicht wissen, wie der Code funktionieren wird. Ich musste an mehreren Projekten arbeiten, in denen ich mich nur auf Integrationstests beschränkte, weil ich glaubte, dass Unit-Tests nicht aussagekräftig sind. Gleichzeitig wurden viele Tests geschrieben, viele Dienste ausgeführt usw. Infolgedessen konnte nur eine Person sie verstehen, die sie tatsächlich geschrieben hat.

Während meiner Praxis habe ich es geschafft, an mehreren sehr großen Projekten zu arbeiten, in denen es viel Legacy-Code gab. Einige von ihnen enthielten Tests, andere nicht (es gab nur die Absicht, sie zu implementieren). Ich habe an zwei großen Projekten teilgenommen, in denen ich irgendwie versucht habe, den TDD-Ansatz anzuwenden. In der Anfangsphase wurde TDD als Test-First-Entwicklung wahrgenommen. Schließlich wurden die Unterschiede zwischen diesem vereinfachten Verständnis und der gegenwärtigen Wahrnehmung, kurz BDD genannt, deutlicher. Welche Sprache auch immer verwendet wird, die Hauptpunkte, ich nenne sie Regeln, bleiben ähnlich. Jemand kann Parallelen zwischen den Regeln und anderen Prinzipien des Schreibens von gutem Code finden.

Regel 1:Verwendung von Bottom-Up (Inside-Out)

Diese Regel bezieht sich eher auf die Methode der Analyse und des Softwaredesigns beim Einbetten neuer Codeteile in ein funktionierendes Projekt.

Wenn Sie ein neues Projekt entwerfen, ist es ganz natürlich, sich ein ganzes System vorzustellen. In dieser Phase steuern Sie sowohl den Komponentensatz als auch die zukünftige Flexibilität der Architektur. Daher können Sie Module schreiben, die einfach und intuitiv miteinander integriert werden können. Ein solcher Top-Down-Ansatz ermöglicht es Ihnen, die zukünftige Architektur im Voraus gut zu entwerfen, die notwendigen Leitlinien zu beschreiben und sich ein vollständiges Bild davon zu machen, was Sie letztendlich wollen. Nach einer Weile verwandelt sich das Projekt in den sogenannten Legacy-Code. Und dann beginnt der Spaß.

In der Phase, in der es notwendig ist, eine neue Funktionalität in ein bestehendes Projekt mit einer Reihe von Modulen und Abhängigkeiten zwischen ihnen einzubetten, kann es sehr schwierig sein, sie alle im Kopf zu haben, um das richtige Design zu erstellen. Die andere Seite dieses Problems ist der Arbeitsaufwand, der erforderlich ist, um diese Aufgabe zu erfüllen. Daher ist der Bottom-up-Ansatz in diesem Fall effektiver. Mit anderen Worten, Sie erstellen zuerst ein vollständiges Modul, das die erforderliche Aufgabe löst, und bauen es dann in das vorhandene System ein, wobei Sie nur die erforderlichen Änderungen vornehmen. In diesem Fall können Sie die Qualität dieses Moduls garantieren, da es sich um eine vollständige Funktionseinheit handelt.

Anzumerken ist, dass es mit den Ansätzen nicht ganz so einfach ist. Wenn Sie beispielsweise eine neue Funktionalität in einem alten System entwerfen, werden Sie, ob Sie wollen oder nicht, beide Ansätze verwenden. Bei der Erstanalyse gilt es noch, das System zu evaluieren, dann auf die Modulebene abzusenken, zu implementieren und dann wieder auf die Ebene des Gesamtsystems zu gehen. Meiner Meinung nach sollte man hier vor allem nicht vergessen, dass das neue Modul eine vollständige Funktionalität und eigenständig sein sollte, als separates Tool. Je strenger Sie sich an diese Vorgehensweise halten, desto weniger Änderungen werden am alten Code vorgenommen.

Regel 2:Teste nur den modifizierten Code

Wenn Sie mit einem alten Projekt arbeiten, müssen Sie keine Tests für alle möglichen Szenarien der Methode/Klasse schreiben. Darüber hinaus sind Ihnen einige Szenarien möglicherweise überhaupt nicht bewusst, da es möglicherweise viele davon gibt. Das Projekt ist bereits in Produktion, der Kunde ist zufrieden, Sie können also beruhigt sein. Im Allgemeinen verursachen nur Ihre Änderungen Probleme in diesem System. Daher sollten nur sie getestet werden.

Beispiel

Es gibt ein Online-Shop-Modul, das einen Warenkorb mit ausgewählten Artikeln erstellt und in einer Datenbank speichert. Die konkrete Umsetzung interessiert uns nicht. Done as done – das ist der Legacy-Code. Jetzt müssen wir hier ein neues Verhalten einführen:Senden Sie eine Benachrichtigung an die Buchhaltung, falls die Warenkorbkosten 1000 $ überschreiten. Hier ist der Code, den wir sehen. Wie führe ich die Änderung ein?

public class EuropeShop : Shop
{
    public override void CreateSale()
    {
        var items = LoadSelectedItemsFromDb();
        var taxes = new EuropeTaxes();
        var saleItems = items.Select(item => taxes.ApplyTaxes(item)).ToList();
        var cart = new Cart();
        cart.Add(saleItems);
        taxes.ApplyTaxes(cart);
        SaveToDb(cart);
    }
}

Gemäß der ersten Regel müssen die Änderungen minimal und atomar sein. Wir sind nicht am Datenladen interessiert, wir kümmern uns nicht um die Steuerberechnung und das Speichern in der Datenbank. Uns interessiert aber der berechnete Warenkorb. Wenn es ein Modul gäbe, das das Erforderliche tut, dann würde es die notwendige Aufgabe erfüllen. Deshalb machen wir das.

public class EuropeShop : Shop
{
    public override void CreateSale()
    {
        var items = LoadSelectedItemsFromDb();
        var taxes = new EuropeTaxes();
        var saleItems = items.Select(item => taxes.ApplyTaxes(item)).ToList();
        var cart = new Cart();
        cart.Add(saleItems);
        taxes.ApplyTaxes(cart);

        // NEW FEATURE
        new EuropeShopNotifier().Send(cart);

        SaveToDb(cart);
    }
}

Ein solcher Melder arbeitet eigenständig, kann getestet werden und die Änderungen am alten Code sind minimal. Genau das besagt die zweite Regel.

Regel 3:Wir testen nur Anforderungen

Um sich von der Anzahl der Szenarien zu befreien, die mit Unit-Tests getestet werden müssen, überlegen Sie, was Sie tatsächlich von einem Modul erwarten. Schreiben Sie zuerst die minimalen Bedingungen auf, die Sie sich als Anforderungen für das Modul vorstellen können. Der minimale Satz ist der Satz, der, wenn er durch einen neuen ergänzt wird, das Verhalten des Moduls nicht wesentlich ändert, und wenn er entfernt wird, funktioniert das Modul nicht. Der BDD-Ansatz hilft in diesem Fall sehr.

Stellen Sie sich auch vor, wie andere Klassen, die Clients Ihres Moduls sind, damit interagieren. Müssen Sie 10 Zeilen Code schreiben, um Ihr Modul zu konfigurieren? Je einfacher die Kommunikation zwischen den Teilen des Systems ist, desto besser. Daher ist es besser, Module, die für etwas Bestimmtes zuständig sind, aus dem alten Code auszuwählen. SOLID wird Ihnen in diesem Fall helfen.

Beispiel

Lassen Sie uns nun sehen, wie uns alles oben Beschriebene mit dem Code helfen wird. Wählen Sie zunächst alle Module aus, die nur indirekt mit der Erstellung des Warenkorbs in Verbindung stehen. So verteilt sich die Verantwortung für die Module.

public class EuropeShop : Shop
{
    public override void CreateSale()
    {
        // 1) load from DB
        var items = LoadSelectedItemsFromDb();

        // 2) Tax-object creates SaleItem and
        // 4) goes through items and apply taxes
        var taxes = new EuropeTaxes();
        var saleItems = items.Select(item => taxes.ApplyTaxes(item)).ToList();

        // 3) creates a cart and 4) applies taxes
        var cart = new Cart();
        cart.Add(saleItems);
        taxes.ApplyTaxes(cart);

        new EuropeShopNotifier().Send(cart);

        // 4) store to DB
        SaveToDb(cart);
    }
}

So können sie unterschieden werden. Natürlich können solche Änderungen in einem großen System nicht sofort vorgenommen werden, aber sie können schrittweise vorgenommen werden. Wenn sich Änderungen beispielsweise auf ein Steuermodul beziehen, können Sie die Abhängigkeit anderer Teile des Systems vereinfachen. Dies kann helfen, hohe Abhängigkeiten loszuwerden und es in Zukunft als eigenständiges Tool zu verwenden.

public class EuropeShop : Shop
{
    public override void CreateSale()
    {
        // 1) extracted to a repository
        var itemsRepository = new ItemsRepository();
        var items = itemsRepository.LoadSelectedItems();
			
        // 2) extracted to a mapper
        var saleItems = items.ConvertToSaleItems();
			
        // 3) still creates a cart
        var cart = new Cart();
        cart.Add(saleItems);
			
        // 4) all routines to apply taxes are extracted to the Tax-object
        new EuropeTaxes().ApplyTaxes(cart);
			
        new EuropeShopNotifier().Send(cart);
			
        // 5) extracted to a repository
        itemsRepository.Save(cart);
    }
}

Für die Tests werden diese Szenarien ausreichen. Ihre Umsetzung interessiert uns bisher nicht.

public class EuropeTaxesTests
{
    public void Should_not_fail_for_null() { }

    public void Should_apply_taxes_to_items() { }

    public void Should_apply_taxes_to_whole_cart() { }

    public void Should_apply_taxes_to_whole_cart_and_change_items() { }
}

public class EuropeShopNotifierTests
{
    public void Should_not_send_when_less_or_equals_to_1000() { }

    public void Should_send_when_greater_than_1000() { }

    public void Should_raise_exception_when_cannot_send() { }
}

Regel 4:Nur getesteten Code hinzufügen

Wie ich bereits geschrieben habe, sollten Sie Änderungen am alten Code minimieren. Dazu kann der alte und neue/geänderte Code getrennt werden. Der neue Code kann in Methoden platziert werden, die mit Unit-Tests überprüft werden können. Dieser Ansatz wird dazu beitragen, die damit verbundenen Risiken zu reduzieren. Es gibt zwei Techniken, die im Buch „Working Effectively with Legacy Code“ (Link zum Buch unten) beschrieben wurden.

Sprout-Methode/-Klasse – Diese Technik ermöglicht es Ihnen, einen sehr sicheren neuen Code in einen alten einzubetten. Die Art und Weise, wie ich den Melder hinzugefügt habe, ist ein Beispiel für diesen Ansatz.

Wrap-Methode – etwas komplizierter, aber die Essenz ist die gleiche. Es funktioniert nicht immer, aber nur in Fällen, in denen ein neuer Code vor/nach einem alten aufgerufen wird. Bei der Zuweisung von Verantwortlichkeiten wurden zwei Aufrufe der Methode ApplyTaxes durch einen Aufruf ersetzt. Dazu war es notwendig, die zweite Methode so zu ändern, dass die Logik nicht stark bricht und überprüft werden kann. So sah die Klasse vor den Änderungen aus.

public class EuropeTaxes : Taxes
{
    internal override SaleItem ApplyTaxes(Item item)
    {
        var saleItem = new SaleItem(item)
        {
            SalePrice = item.Price*1.2m
        };
        return saleItem;
    }

    internal override void ApplyTaxes(Cart cart)
    {
        if (cart.TotalSalePrice <= 300m) return;
        var exclusion = 30m/cart.SaleItems.Count;
        foreach (var item in cart.SaleItems)
            if (item.SalePrice - exclusion > 100m)
                item.SalePrice -= exclusion;
    }
}

Und hier, wie es danach aussieht. Die Logik, mit den Elementen des Wagens zu arbeiten, hat sich ein wenig geändert, aber im Allgemeinen ist alles gleich geblieben. In diesem Fall ruft die alte Methode zuerst ein neues ApplyToItems und dann seine vorherige Version auf. Das ist die Essenz dieser Technik.

public class EuropeTaxes : Taxes
{
    internal override void ApplyTaxes(Cart cart)
    {
        ApplyToItems(cart);
        ApplyToCart(cart);
    }

    private void ApplyToItems(Cart cart)
    {
        foreach (var item in cart.SaleItems)
            item.SalePrice = item.Price*1.2m;
    }

    private void ApplyToCart(Cart cart)
    {
        if (cart.TotalSalePrice <= 300m) return;
        var exclusion = 30m / cart.SaleItems.Count;
        foreach (var item in cart.SaleItems)
            if (item.SalePrice - exclusion > 100m)
                item.SalePrice -= exclusion;
    }
}

Regel 5:„Brechen“ Sie versteckte Abhängigkeiten

Das ist die Regel über das größte Übel in einem alten Code:die Verwendung des Neuen Operator innerhalb der Methode eines Objekts, um andere Objekte, Repositories oder andere komplexe Objekte zu erstellen. Warum ist das schlimm? Die einfachste Erklärung ist, dass dies die Teile des Systems stark miteinander verbindet und dazu beiträgt, ihre Kohärenz zu verringern. Noch kürzer:führt zur Verletzung des „Low Coupling, High Cohesion“-Prinzips. Wenn Sie sich die andere Seite ansehen, dann ist es zu schwierig, diesen Code in ein separates, unabhängiges Tool zu extrahieren. Solche versteckten Abhängigkeiten auf einmal loszuwerden, ist sehr mühsam. Dies kann jedoch schrittweise erfolgen.

Zunächst müssen Sie die Initialisierung aller Abhängigkeiten an den Konstruktor übergeben. Dies gilt insbesondere für die Neuen Operatoren und die Erstellung von Klassen. Wenn Sie ServiceLocator haben, um Instanzen von Klassen zu erhalten, sollten Sie es auch zum Konstruktor entfernen, wo Sie alle notwendigen Schnittstellen daraus ziehen können.

Zweitens müssen Variablen, die die Instanz eines externen Objekts/Repositorys speichern, einen abstrakten Typ und besser eine Schnittstelle haben. Die Schnittstelle ist besser, weil sie einem Entwickler mehr Möglichkeiten bietet. Dadurch wird es möglich, aus einem Modul ein atomares Werkzeug zu machen.

Drittens:Hinterlassen Sie keine großen Methodenblätter. Dies zeigt deutlich, dass die Methode mehr leistet, als ihr Name verspricht. Es weist auch auf einen möglichen Verstoß gegen SOLID, das Demeter-Gesetz, hin.

Beispiel

Sehen wir uns nun an, wie der Code, der den Warenkorb erstellt, geändert wurde. Nur der Codeblock, der den Warenkorb erstellt, blieb unverändert. Der Rest wurde in externe Klassen platziert und kann durch eine beliebige Implementierung ersetzt werden. Jetzt nimmt die EuropeShop-Klasse die Form eines atomaren Werkzeugs an, das bestimmte Dinge benötigt, die explizit im Konstruktor dargestellt werden. Der Code wird leichter erkennbar.

public class EuropeShop : Shop
{
    private readonly IItemsRepository _itemsRepository;
    private readonly Taxes.Taxes _europeTaxes;
    private readonly INotifier _europeShopNotifier;

    public EuropeShop()
    {
        _itemsRepository = new ItemsRepository();
        _europeTaxes = new EuropeTaxes();
        _europeShopNotifier = new EuropeShopNotifier();
    }

    public override void CreateSale()
    {
        var items = _itemsRepository.LoadSelectedItems();
        var saleItems = items.ConvertToSaleItems();

        var cart = new Cart();
        cart.Add(saleItems);

        _europeTaxes.ApplyTaxes(cart);
        _europeShopNotifier.Send(cart);
        _itemsRepository.Save(cart);
    }
}SCRIPT

Regel 6:Je weniger große Tests, desto besser

Große Tests sind verschiedene Integrationstests, die versuchen, Benutzerskripte zu testen. Zweifellos sind sie wichtig, aber die Logik einiger IFs in der Tiefe des Codes zu überprüfen, ist sehr teuer. Das Schreiben dieses Tests dauert genauso lange, wenn nicht sogar länger, wie das Schreiben der Funktionalität selbst. Sie zu unterstützen ist wie ein weiterer Legacy-Code, der schwer zu ändern ist. Aber das sind nur Tests!

Es ist notwendig zu verstehen, welche Tests erforderlich sind, und sich klar an dieses Verständnis zu halten. Wenn Sie eine Integrationsprüfung benötigen, schreiben Sie eine Mindestmenge an Tests, einschließlich positiver und negativer Interaktionsszenarien. Wenn Sie den Algorithmus testen müssen, schreiben Sie einen minimalen Satz von Komponententests.

Regel 7:Teste keine privaten Methoden

Eine private Methode kann zu komplex sein oder Code enthalten, der nicht von öffentlichen Methoden aufgerufen wird. Ich bin mir sicher, dass sich jeder andere Grund, der Ihnen einfällt, als Merkmal eines „schlechten“ Codes oder Designs erweisen wird. Höchstwahrscheinlich sollte ein Teil des Codes aus der privaten Methode zu einer separaten Methode/Klasse gemacht werden. Prüfen Sie, ob das erste Prinzip von SOLID verletzt wird. Das ist der erste Grund, warum es sich nicht lohnt, dies zu tun. Zweitens überprüfen Sie auf diese Weise nicht das Verhalten des gesamten Moduls, sondern wie das Modul es implementiert. Die interne Implementierung kann sich unabhängig vom Verhalten des Moduls ändern. Daher erhalten Sie in diesem Fall anfällige Tests, und es dauert länger als nötig, sie zu unterstützen.

Um das Testen privater Methoden zu vermeiden, stellen Sie Ihre Klassen als eine Reihe atomarer Tools dar, von denen Sie nicht wissen, wie sie implementiert sind. Sie erwarten ein Verhalten, das Sie testen. Diese Haltung gilt auch für den Unterricht im Rahmen der Versammlung. Klassen, die Clients (von anderen Assemblys) zur Verfügung stehen, sind öffentlich, und diejenigen, die interne Aufgaben ausführen, privat. Es gibt jedoch einen Unterschied zu Methoden. Interne Klassen können komplex sein, also können sie in interne umgewandelt und auch getestet werden.

Beispiel

Um beispielsweise eine Bedingung in der privaten Methode der EuropeTaxes-Klasse zu testen, werde ich keinen Test für diese Methode schreiben. Ich gehe davon aus, dass Steuern auf eine bestimmte Weise angewendet werden, sodass der Test genau dieses Verhalten widerspiegelt. Im Test habe ich manuell gezählt, was das Ergebnis sein sollte, es als Standard genommen und das gleiche Ergebnis von der Klasse erwartet.

public class EuropeTaxes : Taxes
{
    // code skipped

    private void ApplyToCart(Cart cart)
    {
        if (cart.TotalSalePrice <= 300m) return; // <<< I WANT TO TEST THIS CONDIFTION
        var exclusion = 30m / cart.SaleItems.Count;
        foreach (var item in cart.SaleItems)
            if (item.SalePrice - exclusion > 100m)
                item.SalePrice -= exclusion;
    }
}

// test suite
public class EuropeTaxesTests
{
    // code skipped

    [Fact]
    public void Should_apply_taxes_to_cart_greater_300()
    {
        #region arrange
        // list of items which will create a cart greater 300
        var saleItems = new List<Item>(new[]{new Item {Price = 83.34m},
            new Item {Price = 83.34m},new Item {Price = 83.34m}})
            .ConvertToSaleItems();
        var cart = new Cart();
        cart.Add(saleItems);

        const decimal expected = 83.34m*3*1.2m;
        #endregion

        // act
        new EuropeTaxes().ApplyTaxes(cart);

        // assert
        Assert.Equal(expected, cart.TotalSalePrice);
    }
}

Regel 8:Testen Sie nicht den Algorithmus von Methoden

Manche Leute überprüfen die Anzahl der Aufrufe bestimmter Methoden, verifizieren den Aufruf selbst usw., mit anderen Worten, sie überprüfen die interne Arbeit von Methoden. Es ist genauso schlimm wie das Testen der Privaten. Der Unterschied liegt nur in der Anwendungsschicht einer solchen Prüfung. Dieser Ansatz führt wiederum zu vielen unsicheren Tests, daher nehmen einige Leute TDD nicht richtig ein.

Weiterlesen…

Regel 9:Ändere Legacy-Code nicht ohne Tests

Dies ist die wichtigste Regel, da sie den Wunsch des Teams widerspiegelt, diesem Weg zu folgen. Ohne den Wunsch, sich in diese Richtung zu bewegen, hat alles oben Gesagte keine besondere Bedeutung. Denn wenn ein Entwickler TDD nicht verwenden möchte (seine Bedeutung nicht versteht, die Vorteile nicht sieht usw.), dann wird sein wirklicher Nutzen durch die ständige Diskussion, wie schwierig und ineffizient es ist, verwischt.

Wenn Sie TDD verwenden möchten, besprechen Sie dies mit Ihrem Team, fügen Sie es der Definition of Done hinzu und wenden Sie es an. Am Anfang wird es schwierig sein, wie bei allem Neuen. Wie jede Kunst erfordert TDD ständige Übung, und das Vergnügen kommt, wenn Sie lernen. Nach und nach wird es mehr schriftliche Unit-Tests geben, Sie werden beginnen, die „Gesundheit“ Ihres Systems zu spüren und die Einfachheit des Schreibens von Code zu schätzen lernen, indem Sie die Anforderungen in der ersten Phase beschreiben. Es gibt TDD-Studien, die an wirklich großen Projekten bei Microsoft und IBM durchgeführt wurden und eine Reduzierung der Fehler in Produktionssystemen von 40 % auf 80 % zeigen (siehe die Links unten).

Weiterführende Literatur

  1. Buch „Effektives Arbeiten mit Legacy-Code“ von Michael Feathers
  2. TDD, wenn Ihnen der Legacy-Code bis zum Hals steht
  3. Brechen versteckter Abhängigkeiten
  4. Der Legacy-Code-Lebenszyklus
  5. Sollten Sie private Methoden für eine Klasse testen?
  6. Unit-Testing-Interna
  7. 5 häufige Missverständnisse über TDD und Unit-Tests
  8. Demeter-Gesetz