Unit-Testing mit DI + Moq
Wie bereits im Beitrag zu Dependency Injection erwähnt, möchte ich mich dem Thema „Unit Testing“ widmen, welches durch die Verwendung von DI wesentlich vereinfacht wird.
Banal ausgedrückt geht es bei Unit Testing darum, dass eine Klasse und/oder Funktion (= Unit; Einheit) ein- oder mehrfach geprüft wird. Ziel ist die Qualitätssteigerung und dass bei Änderungen am Code (= Refactoring), Fehler frühzeitig ausfindig gemacht werden können. Mittlerweile werden Unit Tests auch oft als Dokumentation genutzt (z.B. im Open-Source-Umfeld). Kurzfristig gesehen erzeugt das Erstellen von Unit Tests einen Mehraufwand, der sich allerdings im Nachhinein mehrfach bezahlt machen dürfte.
Eine Problematik beim Testen von Klassen und Funktionen sind die direkten Abhängigkeiten die bestehen. Durch diese wird das Erstellen eines Tests extrem verkompliziert bzw. unmöglich gemacht. Und genau hier kommt wieder DI ins Spiel.
In vielen Beispielen zu Unit Tests werden simple Funktionen wie die Addition zweier Zahlen getestet. Dies mag für ein erstes Verständnis ausreichen, aber entspricht kaum der echten Welt. Deshalb ist mein Beispiel ein kleines bisschen umfangreicher.
Beispiel
Als Beispiel dient der „CartController“ eines Webshops. Dieser enthält die Methode „CheckOut“, die als erstes den Zahlvorgang macht und dann die Bestellung an das Logistiksystem übermittelt.
Ich werde hier nur den wesentlichen Code anzeigen, da es sonst ziemlich unübersichtlich wird. Der komplette Beispiel-Code zum Ausführen ist unter https://github.com/stenet/stef-testing.
Schauen wir uns als erstes den CartController an:
public class CartController
{
private readonly IPaymentService _PaymentService;
private readonly IShippingService _ShippingService;
public CartController(
IPaymentService paymentService,
IShippingService shipmentService)
{
_PaymentService = paymentService;
_ShippingService = shipmentService;
}
public void CheckOut(Cart cart)
{
if (cart == null)
throw new ArgumentException("no cart");
if (cart.Customer == null)
throw new InvalidOperationException("cart has no customer");
if (!cart.Items.Any())
throw new InvalidOperationException("cart has no items");
_PaymentService.Charge(cart.Customer, cart.GetTotal());
_ShippingService.Ship(cart.Customer, cart.Items);
}
}
Im Konstruktor werden zwei Services übergeben (normalerweise vom DI-Container): der Payment-Service und der Shipping-Service. Die Methode „CheckOut“ prüft erst ein paar Sachen, bucht dann den Betrag von der Kreditkarte ab und übergibt dann die zu liefernden Artikel an das Logistik-System. Soweit sollte dies alles nachvollziehbar sein.
Nun zum Test-Code:
[TestClass]
public class CartControllerTesting
{
private Mock<IPaymentService> _PaymentMock;
private Mock<IShippingService> _ShippingMock;
[TestInitialize]
public void Initialize()
{
_PaymentMock = new Mock<IPaymentService>();
_ShippingMock = new Mock<IShippingService>();
}
[TestMethod]
public void TestCheckOutWithNoCart()
{
var cartController = new CartController(
_PaymentMock.Object,
_ShippingMock.Object);
Assert.ThrowsException<ArgumentException>(() => cartController.CheckOut(null));
}
[TestMethod]
public void TestCheckOutWithNoCustomer()
{
var cartController = new CartController(
_PaymentMock.Object,
_ShippingMock.Object);
var cart = new Cart
{
Items = new List<CartItem>
{
new CartItem { IdProduct = 1, Quantity = 1, Price = 1 }
}
};
Assert.ThrowsException<InvalidOperationException>(() => cartController.CheckOut(cart));
}
[TestMethod]
public void TestCheckOutWithNoItems()
{
var cartController = new CartController(
_PaymentMock.Object,
_ShippingMock.Object);
var cart = new Cart
{
Customer = new Customer
{
Name = "Mister T",
CreditCardNo = "00000"
}
};
Assert.ThrowsException<InvalidOperationException>(() => cartController.CheckOut(cart));
}
[TestMethod]
public void TestCheckOut()
{
var cartController = new CartController(
_PaymentMock.Object,
_ShippingMock.Object);
var cart = new Cart
{
Customer = new Customer
{
Name = "Mister T",
CreditCardNo = "00000"
},
Items = new List<CartItem>
{
new CartItem { IdProduct = 1, Quantity = 1, Price = 1 }
}
};
cartController.CheckOut(cart);
_PaymentMock
.Verify(c => c.Charge(cart.Customer, cart.GetTotal()), Times.Once);
_ShippingMock
.Verify(c => c.Ship(cart.Customer, cart.Items), Times.Once);
}
}
In der Initialize-Methode kommt das erste Mal Moq ins Spiel. Dabei handelt es sich um ein sehr beliebtes Framework zum Erstellen von Mock-Objekten. Übersetzt aus dem Englischen bedeutet Mock Attrappe.
WICHTIG! In diesem Code geht es einzig und alleine um das Testen des CartControllers, nicht um den PaymentService und auch nicht um den ShippingService. Daher setzen wir für den PaymentService und den ShippingService Mock-Objekte ein, die uns erlauben, Eigenschaften und Funktionen zu „manipulieren“ und zu prüfen, was im Testcode ausgeführt wurde.
Zurück zur Initialize-Methode. Hier werden sowohl für IPaymentService als auch IShippingService Standard-Mock-Objekte erzeugt. Die Initialize-Methode wird immer vor dem Aufruf einer Test-Methode aufgerufen. Dadurch hat auch jede Methode wieder neue Mock-Objekte – was in unserem Fall von Relevanz ist.
In den einzelnen Test-Methoden wird jeweils der CartController manuell erstellt und unsere Mock-Objekte übergeben.
Die ersten drei Methoden validieren, ob das Error-Handling der CheckOut-Methode klappt und bei fehlerhaften Werten eine Exception geworfen wird.
In der Methode „TestCheckOut“ sollte der CheckOut dann ohne Fehler funktionieren. Dort wird am Ende geprüft, ob die Methoden PaymentService.Charge und ShippmentService.Ship auch korrekt 1x aufgerufen wurden. Ist dies nicht der Fall, dann schlägt der Test fehl und wir wissen, dass hier etwas nicht mehr passt.
Neben dem Prüfen, was im Test-Code ausgeführt worden ist, könnten wir mit Moq auch, wie bereits zuvor erwähnt, Methoden manipulieren. Beispielsweise könnten wir damit steuern, dass wenn die Methode PaymentService.Charge aufgerufen wird, eine Exception geworfen wird. Dafür müssten wir einfach vor dem Aufruf von „CheckOut“ folgenden Code einfügen:
_PaymentMock
.Setup(c => c.Charge(It.IsAny<Customer>(), It.IsAny<decimal>()))
.Throws<InvalidOperationException>();
Soll die Exception nur geworfen werden, wenn „Total“ den Wert 100 übersteigt, dann sähe der Code so aus:
_PaymentMock
.Setup(c => c.Charge(It.IsAny<Customer>(), It.Is<decimal>(v => v >= 100)))
.Throws<InvalidOperationException>();
Das Ganze sieht auf den ersten Blick etwas verwirrend aus, ist aber, wenn man sich etwas damit beschäftigt hat, ziemlich logisch aufgebaut.
Was sonst noch alles mit Moq möglich ist (Properties, Events, Callbacks, …) wird unter https://github.com/Moq/moq4/wiki/Quickstart gezeigt.
Happy Testing 🙂