Unit Fairy Magic Fairy: DSL in C #

Wie oft ist es passiert, dass Sie beim Schreiben eines Arbeitseinheitentests dessen Code überprüfen und ist es ... schlecht? Und du denkst so: "Dies ist ein Test, lass es so ...". Nein,% Benutzername%, also lass es nicht. Tests sind ein wichtiger Teil des Systems, der Codeunterstützung bietet, und es ist sehr wichtig, dass dieser Teil auch unterstützt wird. Leider haben wir nicht viele Möglichkeiten, dies sicherzustellen (wir werden keine Tests für Tests schreiben), aber es gibt immer noch ein paar.


In unserer Dodo DevSchool-Entwicklerschule heben wir unter anderem die folgenden Kriterien für einen guten Test hervor:

  • Reproduzierbarkeit: Das Ausführen von Tests mit demselben Code und derselben Eingabe führt immer zum gleichen Ergebnis.
  • Fokus: Es sollte nur einen Grund geben, warum der Test fällt.
  • Verständlichkeit: Nun, hier ist es klar. :) :)

Wie gefällt Ihnen ein solcher Test in Bezug auf diese Kriterien?

[Fact] public void AcceptOrder_Successful() { var ingredient1 = new Ingredient("Ingredient1"); var ingredient2 = new Ingredient("Ingredient2"); var ingredient3 = new Ingredient("Ingredient3"); var order = new Order(DateTime.Now); var product1 = new Product("Pizza1"); product1.AddIngredient(ingredient1); product1.AddIngredient(ingredient2); var orderLine1 = new OrderLine(product1, 1, 500); order.AddLine(orderLine1); var product2 = new Product("Pizza2"); product2.AddIngredient(ingredient1); product2.AddIngredient(ingredient3); var orderLine2 = new OrderLine(product2, 1, 650); order.AddLine(orderLine2); var orderRepositoryMock = new Mock<IOrderRepository>(); var ingredientsRepositoryMock = new Mock<IIngredientRepository>(); var service = new PizzeriaService(orderRepositoryMock.Object, ingredientsRepositoryMock.Object); service.AcceptOrder(order); orderRepositoryMock.Verify(r => r.Add(order), Times.Once); ingredientsRepositoryMock.Verify(r => r.ReserveIngredients(order), Times.Once); } 

Für mich - sehr schlecht.

Es ist unverständlich: Ich kann beispielsweise nicht einmal Arrangier-, Act- und Assert-Blöcke zuweisen.

Nicht spielbar: Die DateTime.Now-Eigenschaft wird verwendet. Und schließlich ist es nicht fokussiert, weil hat zwei Gründe für den Rückgang: Aufrufe von Methoden von zwei Repositorys werden überprüft.

Obwohl die Benennung von Tests den Rahmen dieses Artikels sprengt, achte ich dennoch auf den Namen: Bei solch einer Reihe negativer Eigenschaften ist es schwierig, ihn so zu formulieren, dass eine externe Person beim Betrachten des Testnamens sofort versteht, warum dieser Test im Allgemeinen im Projekt ist.
Wenn Sie den Test nicht präzise benennen können, stimmt etwas mit dem Test nicht.
Da der Test unverständlich ist, sagen wir Ihnen, was darin passiert:

  1. Die Zutaten werden erstellt.
  2. Aus den Zutaten werden Produkte (Pizzen) hergestellt.
  3. Aus den Produkten wird eine Bestellung erstellt.
  4. Es wird ein Dienst erstellt, für den die Repositorys nass sind.
  5. Die Bestellung wird an die AcceptOrder-Methode des Dienstes übergeben.
  6. Es wird überprüft, ob die Methoden Add und ReserveIngredients der jeweiligen Repositorys aufgerufen wurden.

Wie können wir diesen Test verbessern? Sie müssen versuchen, nur das zu belassen, was wirklich wichtig ist. Und dafür haben kluge Leute wie Martin Fowler und Rebecca Parsons DSL (Domain Specific Language) entwickelt . Hier werde ich über die DSL-Muster sprechen, die wir bei Dodo verwenden, um sicherzustellen, dass unsere Unit-Tests weich und seidig sind und die Entwickler sich jeden Tag sicher fühlen.

Der Plan lautet wie folgt: Zuerst werden wir diesen Test verständlich machen, dann werden wir an der Reproduzierbarkeit arbeiten und ihn schließlich fokussieren. Wir fuhren ...

Entsorgung von Inhaltsstoffen (vordefinierte Domänenobjekte)


Beginnen wir mit dem Auftragserstellungsblock. Die Bestellung ist eine der zentralen Domänenentitäten. Es wäre cool, wenn wir die Reihenfolge so beschreiben könnten, dass selbst Leute, die nicht wissen, wie man Code schreibt, aber die Domänenlogik verstehen, verstehen können, welche Art von Reihenfolge wir erstellen. Dazu müssen wir zunächst auf die Verwendung der abstrakten "Ingredient1" und "Pizza1" verzichten und diese durch echte Zutaten, Pizzen und andere Domänenobjekte ersetzen.

Der erste Kandidat für die Optimierung sind die Zutaten. Bei ihnen ist alles einfach: Sie benötigen keine Anpassung, nur einen Aufruf des Konstruktors. Es reicht aus, sie in einen separaten Container zu packen und zu benennen, damit Domain-Experten klar sind:

 public static class Ingredients { public static readonly Ingredient Dough = new Ingredient("Dough"); public static readonly Ingredient Pepperoni = new Ingredient("Pepperoni"); public static readonly Ingredient Mozzarella = new Ingredient("Mozzarella"); } 

Anstelle der völlig verrückten Zutaten1, Zutaten2 und Zutaten3 haben wir Teig, Pepperoni und Mozzarella bekommen.
Verwenden Sie vordefinierte Domänenobjekte für häufig verwendete Domänenentitäten.

Builder für Produkte


Die nächste Domänenentität sind Produkte. Bei ihnen ist alles etwas komplizierter: Jedes Produkt besteht aus mehreren Zutaten, die wir vor der Verwendung dem Produkt hinzufügen müssen.

Hier ist das gute alte Builder-Muster nützlich. Hier ist meine Build-Version für das Produkt:

 public class ProductBuilder { private Product _product; public ProductBuilder(string name) { _product = new Product(name); } public ProductBuilder Containing(Ingredient ingredient) { _product.AddIngredient(ingredient); return this; } public Product Please() { return _product; } } 

Es besteht aus einem parametrisierten Konstruktor, einer benutzerdefinierten Containing Methode und einer Terminal- Please Methode. Wenn Sie mit dem Code nicht freundlich sein möchten, können Sie Please durch Now ersetzen. Der Builder verbirgt komplexe Konstruktoren und Methodenaufrufe, die das Objekt konfigurieren. Der Code wird sauberer und verständlicher. In guter Weise sollte der Builder die Erstellung des Objekts vereinfachen, damit der Code für den Domänenexperten klar ist. Es lohnt sich insbesondere, einen Builder für Objekte zu verwenden, die vor Arbeitsbeginn konfiguriert werden müssen.

Mit dem Product Builder können Sie Designs erstellen wie:

 var pepperoni = new ProductBuilder("Pepperoni") .Containing(Ingredients.Dough) .Containing(Ingredients.Pepperoni) .Please(); 

Mit Builds können Sie Objekte erstellen, die angepasst werden müssen. Ziehen Sie in Betracht, einen Builder zu erstellen, auch wenn die Konfiguration aus einer Zeile besteht.

ObjectMother


Trotz der Tatsache, dass die Entwicklung des Produkts viel anständiger geworden ist, sieht der Designer des new ProductBuilder immer noch ziemlich hässlich aus. Korrigieren Sie es mit dem ObjectMother (Vater) -Muster.

Das Muster ist einfach wie 5 Kopeken: Wir erstellen eine statische Klasse und sammeln alle Builder darin.

 public static class Create { public static ProductBuilder Product(string name) => new ProductBuilder(name); } 

Jetzt können Sie so schreiben:

 var pepperoni = Create.Product("Pepperoni") .Containing(Ingredients.Dough) .Containing(Ingredients.Pepperoni) .Please(); 

ObjectMother wurde für die deklarative Erstellung von Objekten erfunden. Darüber hinaus hilft es, neue Entwickler in die Domain einzuführen, als Wenn Sie das Wort "IDE Create selbst schreiben, Create Sie, was Sie in dieser Domäne erstellen können.

In unserem Code wird ObjectMother manchmal als Nicht Create , sondern angegeben bezeichnet. Ich mag beide Optionen. Wenn Sie weitere Ideen haben, teilen Sie diese in den Kommentaren mit.
Verwenden Sie ObjectMother, um Objekte deklarativ zu erstellen. Der Code wird sauberer und es wird für neue Entwickler einfacher, in die Domäne einzutauchen.

Produktentfernung


Es ist viel besser geworden, aber die Produkte haben noch Raum zum Wachsen. Wir haben eine begrenzte Anzahl von Produkten und diese können wie Zutaten in einer separaten Klasse gesammelt und nicht für jeden Test initialisiert werden:

 public static class Pizza { public static Product Pepperoni => Create.Product("Pepperoni") .Containing(Ingredients.Dough) .Containing(Ingredients.Pepperoni) .Please(); public static Product Margarita => Create.Product("Margarita") .Containing(Ingredients.Dough) .Containing(Ingredients.Mozzarella) .Please(); } 

Hier nannte ich den Container nicht Products , sondern Pizza . Dieser Name hilft beim Lesen des Tests. Zum Beispiel hilft es, Fragen wie „Ist Pepperoni eine Pizza oder eine Wurst?“ Zu entfernen.
Versuchen Sie, echte Domänenobjekte zu verwenden, keine Ersatzobjekte wie Product1.

Der Builder für die Bestellung (Beispiel von hinten)


Jetzt wenden wir die beschriebenen Muster an, um einen Order Builder zu erstellen, aber jetzt gehen wir nicht vom Builder aus, sondern von dem, was wir erhalten möchten. So möchte ich eine Bestellung erstellen:

 var order = Create.Order .Dated(DateTime.Now) .With(Pizza.Pepperoni.CountOf(1).For(500)) .With(Pizza.Margarita.CountOf(1).For(650)) .Please(); 

Wie können wir das erreichen? Wir brauchen natürlich Bauherren für die Bestellung und die Bestellposition. Mit dem Bauherrn für die Bestellung ist alles kristallklar. Da ist er:

 public class OrderBuilder { private DateTime _date; private readonly List<OrderLine> _lines = new List<OrderLine>(); public OrderBuilder Dated(DateTime date) { _date = date; return this; } public OrderBuilder With(OrderLine orderLine) { _lines.Add(orderLine); return this; } public Order Please() { var order = new Order(_date); foreach (var line in _lines) { order.AddLine(line); } return order; } } 

Bei OrderLine Situation jedoch interessanter: Erstens wird die Terminal Please-Methode hier nicht aufgerufen, und zweitens wird der Zugriff auf den Builder nicht durch das statische Create und nicht durch den Konstruktor des Builders selbst bereitgestellt. Wir werden das erste Problem mit dem implicit operator lösen und unser Builder sieht folgendermaßen aus:

 public class OrderLineBuilder { private Product _product; private decimal _count; private decimal _price; public OrderLineBuilder Of(decimal count, Product product) { _product = product; _count = count; return this; } public OrderLineBuilder For(decimal price) { _price = price; return this; } public static implicit operator OrderLine(OrderLineBuilder b) { return new OrderLine(b._product, b._count, b._price); } } 

Die zweite Methode hilft uns, die Erweiterungsmethode für die Product verstehen:

 public static class ProductExtensions { public static OrderLineBuilder CountOf(this Product product, decimal count) { return Create.OrderLine.Of(count, product) } } 

Im Allgemeinen sind Erweiterungsmethoden gute Freunde von DSL. Sie können aus einer völlig höllischen Logik eine deklarative, verständliche Beschreibung machen.
Verwenden Sie Erweiterungsmethoden. Benutze sie einfach. :) :)
Nachdem wir alle diese Aktionen ausgeführt haben, haben wir den folgenden Testcode erhalten:

 [Fact] public void AcceptOrder_Successful() { var order = Create.Order .Dated(DateTime.Now) .With(Pizza.Pepperoni.CountOf(1).For(500)) .With(Pizza.Margarita.CountOf(1).For(650)) .Please(); var orderRepositoryMock = new Mock<IOrderRepository>(); var ingredientsRepositoryMock = new Mock<IIngredientRepository>(); var service = new PizzeriaService(orderRepositoryMock.Object, ingredientsRepositoryMock.Object); service.AcceptOrder(order); orderRepositoryMock.Verify(r => r.Add(order), Times.Once); ingredientsRepositoryMock.Verify(r => r.ReserveIngredients(order), Times.Once); } 

Hier haben wir den Ansatz gewählt, den wir "Fairy Fairy" nennen. In diesem Fall schreiben Sie zuerst Leerlaufcode, wie Sie ihn sehen möchten, und versuchen dann, das, was Sie in DSL geschrieben haben, zu verpacken. Dies ist sehr nützlich, um zu handeln - manchmal können Sie sich selbst nicht vorstellen, wozu C # in der Lage ist.
Stellen Sie sich vor, eine magische Fee ist eingetroffen und hat Ihnen erlaubt, Code nach Ihren Wünschen zu schreiben, und dann zu versuchen, alles in DSL zu schreiben.

Erstellen eines Dienstes (Testbares Muster)


Mit der Bestellung ist jetzt alles mehr oder weniger nicht schlecht. Es ist an der Zeit, sich mit den Mokas der Repositories zu befassen. Es ist erwähnenswert, dass der Test selbst, den wir betrachten, ein Test für das Verhalten ist. Verhaltenstests sind stark mit der Implementierung von Methoden verbunden. Wenn es möglich ist, solche Tests nicht zu schreiben, ist es besser, dies nicht zu tun. Manchmal sind sie jedoch nützlich, und manchmal können Sie überhaupt nicht auf sie verzichten. Die folgende Technik hilft dabei, Verhaltenstests genau zu schreiben. Wenn Sie plötzlich feststellen, dass Sie sie verwenden möchten, überlegen Sie zunächst, ob Sie die Tests so umschreiben können, dass sie den Status und nicht das Verhalten überprüfen.

Ich möchte also sicherstellen, dass es in meiner Testmethode keinen einzigen Mok gibt. Dazu erstelle ich einen Wrapper für PizzeriaService in dem ich die gesamte Logik kapsle, die Methodenaufrufe überprüft:

 public class PizzeriaServiceTestable : PizzeriaService { private readonly Mock<IOrderRepository> _orderRepositoryMock; private readonly Mock<IIngredientRepository> _ingredientRepositoryMock; public PizzeriaServiceTestable(Mock<IOrderRepository> orderRepositoryMock, Mock<IIngredientRepository> ingredientRepositoryMock) : base(orderRepositoryMock.Object, ingredientRepositoryMock.Object) { _orderRepositoryMock = orderRepositoryMock; _ingredientRepositoryMock = ingredientRepositoryMock; } public void VerifyAddWasCalledWith(Order order) { _orderRepositoryMock.Verify(r => r.Add(order), Times.Once); } public void VerifyReserveIngredientsWasCalledWith(Order order) { _ingredientRepositoryMock.Verify(r => r.ReserveIngredients(order), Times.Once); } } 

Diese Klasse ermöglicht es uns, Methodenaufrufe zu überprüfen, aber wir müssen sie trotzdem irgendwie erstellen. Dazu verwenden wir den bereits bekannten Builder:

 public class PizzeriaServiceBuilder { public PizzeriaServiceTestable Please() { var orderRepositoryMock = new Mock<IOrderRepository>(); var ingredientsRepositoryMock = new Mock<IIngredientRepository>(); return new PizzeriaServiceTestable(orderRepositoryMock, ingredientsRepositoryMock); } } 

Im Moment sieht unsere Testmethode folgendermaßen aus:

 [Fact] public void AcceptOrder_Successful() { var order = Create.Order .Dated(DateTime.Now) .With(Pizza.Pepperoni.CountOf(1).For(500)) .With(Pizza.Margarita.CountOf(1).For(650)) .Please(); var service = Create.PizzeriaService.Please(); service.AcceptOrder(order); service.VerifyAddWasCalledWith(order); service.VerifyReserveIngredientsWasCalledWith(order); } 

Das Aufrufen von Testmethoden ist nicht der einzige Grund, warum die Testable-Klasse verwendet werden kann. Hier zum Beispiel verwendet unser Dima Pavlov es für das komplexe Refactoring eines Legacy-Codes.
Testable kann die Situation in den schwierigsten Fällen retten. Für Verhaltenstests hilft es, hässliche Anrufprüfungen in schöne Methoden zu packen.
In diesem bedeutsamen Moment haben wir die Verständlichkeit des Tests verstanden. Es bleibt, um es reproduzierbar und fokussiert zu machen.

Reproduzierbarkeit (wörtliche Erweiterung)


Das wörtliche Erweiterungsmuster steht nicht in direktem Zusammenhang mit der Reproduzierbarkeit, aber es wird uns dabei helfen. Unser aktuelles Problem ist, dass wir das DateTime.Now Datum als DateTime.Now verwenden. Wenn sich die Logik der Auftragsannahme plötzlich ab einem bestimmten Datum ändert, müssen wir in unserer Geschäftslogik zumindest für einige Zeit 2 Logik der Auftragsannahme unterstützen und sie trennen, indem wir prüfen, if (order.Date > edgeDate) . In diesem Fall kann unser Test fallen, wenn das Systemdatum die Grenze überschreitet. Ja, wir werden dies schnell beheben und sogar zwei von einem Test durchführen: Einer überprüft die Logik vor dem Grenzdatum und der andere danach. Trotzdem ist es besser, solche Situationen zu vermeiden und alle Eingabedaten sofort konstant zu machen.

"Und wo ist das DSL?" - Du fragst. Tatsache ist, dass es bequem ist, Daten in Tests über Erweiterungsmethoden 3.May(2019) , z. B. 3.May(2019) . Diese Form der Aufzeichnung ist nicht nur für Entwickler, sondern auch für Unternehmen verständlich. Erstellen Sie dazu einfach eine solche statische Klasse

 public static class DateConstructionExtensions { public static DateTime May(this int day, int year) => new DateTime(year, 5, day); } 

Natürlich sind Daten nicht die einzigen Dinge, für die dieses Muster verwendet wird. Wenn wir zum Beispiel die Menge der Zutaten in die Zusammensetzung der Produkte einführen, könnten wir ungefähr 42.Grams("flour") schreiben.
Quantitative Objekte und Daten werden bequem mit den bekannten Erweiterungsmethoden erstellt.

Fokus


Warum ist es wichtig, die Tests fokussiert zu halten? Tatsache ist, dass fokussierte Tests einfacher zu warten sind, aber dennoch unterstützt werden müssen. Zum Beispiel müssen sie beim Ändern des Codes geändert und beim Heraussägen alter Funktionen gelöscht werden. Wenn die Tests nicht fokussiert sind, müssen Sie beim Ändern der Logik die großen Tests verstehen und Teile der getesteten Funktionalität aus ihnen herausschneiden. Wenn die Tests fokussiert sind und ihre Namen klar sind, müssen Sie nur die veralteten Tests entfernen und neue schreiben. Wenn die Tests ein gutes DSL haben, ist dies überhaupt kein Problem.

Nachdem wir mit dem Schreiben von DSL fertig waren, hatten wir die Möglichkeit, diesen Test fokussiert zu machen, indem wir ihn in zwei Tests aufteilten:

 [Fact] public void WhenAcceptOrder_AddIsCalled() { var order = Create.Order .Dated(3.May(2019)) .With(Pizza.Pepperoni.CountOf(1).For(500)) .With(Pizza.Margarita.CountOf(1).For(650)) .Please(); var service = Create.PizzeriaService.Please(); service.AcceptOrder(order); service.VerifyAddWasCalledWith(order); } [Fact] public void WhenAcceptOrder_ReserveIngredientsIsCalled() { var order = Create.Order .Dated(3.May(2019)) .With(Pizza.Pepperoni.CountOf(1).For(500)) .With(Pizza.Margarita.CountOf(1).For(650)) .Please(); var service = Create.PizzeriaService.Please(); service.AcceptOrder(order); service.VerifyReserveIngredientsWasCalledWith(order); } 

Beide Tests erwiesen sich als kurz, klar, reproduzierbar und konzentriert.

Bitte beachten Sie, dass jetzt die Namen der Tests den Zweck widerspiegeln, für den sie geschrieben wurden, und jetzt jeder Entwickler, der in mein Projekt kam, versteht, warum jeder der Tests geschrieben wurde und was in diesem Test passiert.
Der Fokus der Tests macht sie unterstützt. Ein guter Test muss fokussiert sein.
Und jetzt kann ich dich schon zu mir rufen hören: „Yura, was zum Teufel bist du? Wir haben eine Million Code geschrieben, um ein paar Tests hübsch zu machen? “ Ja, das ist so. Obwohl wir nur ein paar Tests haben, ist es sinnvoll, in DSL zu investieren und diese Tests verständlich zu machen. Sobald Sie DSL schreiben, erhalten Sie eine Reihe von Extras:

  • Es wird einfach, neue Tests zu schreiben. Sie müssen sich nicht 2 Stunden lang für Unit-Tests einrichten. Nehmen Sie einfach und schreiben Sie.
  • Tests werden verständlich und lesbar. Jeder Entwickler, der sich den Test ansieht, versteht, warum er geschrieben wurde und was er überprüft.
  • Der Schwellenwert für die Teilnahme an Tests (und möglicherweise in der Domäne) für neue Entwickler wurde verringert. Mit ObjectMother können Sie beispielsweise leicht herausfinden, welche Objekte in der Domäne erstellt werden können.
  • Und schließlich ist es einfach schön, mit Tests zu arbeiten, und dadurch wird der Code besser unterstützt.

Beispielquellcode und Tests finden Sie hier .

Source: https://habr.com/ru/post/de451598/


All Articles