MVVM

Schreiben einer testfähigen Darstellungsschicht mit MVVM

Brent Edwards

Bei den herkömmlichen Anwendungen aus der Zeit von Windows Forms wurde standardmäßig beim Testen eine Ansicht entworfen, dann wurde im Codebehind der Ansicht Code geschrieben, und anschließend wurde die App als Test ausgeführt. Glücklicherweise hat sich seitdem einiges geändert.

Durch Windows Presentation Foundation (WPF) wurde das Konzept der Datenbindung auf eine völlig neue Ebene gehoben. Auf dieser Grundlage wurde ein neues Entwurfsmuster mit der Bezeichnung „Model-View-ViewModel“ (MVVM) entwickelt. Mit MVVM können Sie die Präsentationslogik von der eigentlichen Präsentation trennen. Das bedeutet im Grunde genommen, dass Sie es in den meisten Fällen vermeiden können, Code im Codebehind der Ansicht zu schreiben.

Für diejenigen, die am Entwickeln testfähiger Anwendungen interessiert sind, bedeutet dies eine wesentliche Verbesserung. Die Präsentationslogik ist nicht mehr an den Codebehind der Ansicht angehängt. Dies machte das Testen aufgrund des eigenen Lebenszyklus komplizierter. Stattdessen können Sie nun ein Plain Old CLR Object (POCO) verwenden. Ansichtsmodelle besitzen nicht die Lebenszykluseinschränkungen einer Ansicht. Sie können einfach ein Ansichtsmodell in einem Komponententest initiieren und mit dem Testen beginnen.

In diesem Artikel erläutere ich eine Herangehensweise zum Schreiben einer testfähigen Präsentationsschicht für Anwendungen mithilfe von MVVM. Um Ihnen meinen Ansatz näher zu bringen, verwende ich Beispielcode aus einem von mir geschriebenen Open-Source-Framework mit dem Namen „Charmed“ sowie eine begleitende Beispiel-App mit der Bezeichnung „Charmed Reader“. Das Framework und die beispielhaften Apps sind auf GitHub unter github.com/brentedwards/Charmed verfügbar.

Das Charmed-Framework habe ich in meinem Artikel im Juli 2013 (msdn.microsoft.com/magazine/dn296512) als Windows 8-Framework und Beispielanwendung vorgestellt. In der Ausgabe vom September 2013 (msdn.microsoft.com/magazine/dn385706) habe ich besprochen, wie das Framework plattformübergreifend als Windows 8- und Windows Phone 8-Framework und Beispiel-App eingesetzt werden kann. In beiden Artikeln habe ich erläutert, welche Entscheidungen ich gefällt habe, um die App testfähig zu machen. Jetzt möchte ich die Entscheidungen unter einem neuen Blickwinkel betrachten und zeigen, wie ich beim Testen der App vorgehe. In diesem Artikel wird in den Beispielen Windows 8- und Windows Phone 8-Code verwendet. Die vorgestellten Konzepte und Techniken können Sie jedoch auf jeden Anwendungstyp anwenden.

Über die Beispiel-App

Die Beispiel-App, mit der ich meine Vorgehensweise beim Schreiben einer testfähigen Präsentationsschicht verdeutliche, heißt „Charmed Reader“. Charmed Reader ist eine einfache Blog-Reader-App, die mit Windows 8 und Windows Phone 8 funktioniert. Sie besitzt die erforderlichen Mindestfunktionen zum Darstellen der wichtigsten Punkte in diesem Artikel. Außerdem funktioniert sie plattformübergreifend und in der Regel auf beiden Plattformen identisch. Allerdings nutzt die Windows 8-App einige Windows 8-spezifische Funktionen. Sie ist zwar einfach konzipiert, verfügt aber über ausreichende Funktionen für Komponententests.

Was sind Komponententests?

Komponententests beruhen auf dem Konzept, gesonderte Codeblöcke (Komponenten) zu verwenden und Testmethoden zu schreiben, die den Code auf eine erwartete Art und Weise verwenden. Anschließend werden Tests ausgeführt, um zu überprüfen, ob die Ergebnisse den Erwartungen entsprechen. Der Testcode wird mit einer Art Testumgebungsframework ausgeführt. Mit Visual Studio 2012 funktionieren mehrere Testumgebungsframeworks. Im Beispielcode verwende ich MSTest, das in Visual Studio 2012 (und frühere Versionen) integriert ist. Das Ziel besteht darin, eine einzelne Komponententestmethode für ein bestimmtes Szenario zu erhalten. Gelegentlich werden mehrere Komponententestmethoden benötigt, um alle Szenarios zu berücksichtigen, die nach Ihrer Erwartung von Ihrer Methode oder Eigenschaft abgedeckt werden können.

Eine Komponententestmethode sollte ein einheitliches Format aufweisen, damit es von anderen Entwicklern leicht verstanden werden kann. Als bewährte Methode hat sich das folgende Format herausgestellt:

  1. Arrange
  2. Act
  3. Assert

Möglicherweise müssen Sie einigen Setupcode schreiben, um eine Instanz der getesteten Klasse sowie potenzielle Abhängigkeiten zu erstellen. Dies ist der Arrange-Abschnitt des Komponententests.

Nachdem die Voraussetzungen für den eigentlichen Test durch den Komponententest geschaffen wurden, können Sie entweder die betreffende Methode oder Eigenschaft ausführen. Dies ist der Act-Abschnitt des Tests. Sie können die betreffende Methode oder Eigenschaft mit Parametern ausführen (sofern anwendbar), die im Arrange-Abschnitt festgelegt wurden.

Nachdem Sie die betreffende Methode oder Eigenschaft ausgeführt haben, muss schließlich im Test überprüft werden, ob die Methode oder Eigenschaft den Anweisungen genau gefolgt ist. Dies ist der Assert-Abschnitt des Tests. Während der Assert-Phase werden Assert-Methoden aufgerufen, um die tatsächlichen mit den erwarteten Ergebnissen zu vergleichen. Wenn die tatsächlichen den erwarteten Ergebnissen entsprechen, ist der Komponententest erfolgreich. Andernfalls ist er fehlerhaft.

Wenn ich diese bewährte Methode befolge, sehen meine Tests in der Regel folgendermaßen aus:

[TestMethod]
public void SomeTestMethod()
{
  // Arrange
  // *Insert code to set up test
  // Act
  // *Insert code to call the method or property under test
  // Assert
  // *Insert code to verify the test completed as expected
}

Dieses Format wird auch gelegentlich ohne Kommentare zum Aufrufen der verschiedenen Abschnitte des Tests (Arrange/Act/Assert) verwendet. Ich bevorzuge es, zum Trennen der drei Abschnitte voneinander Kommentare einzufügen. Dadurch behalte ich den Überblick, worauf der Test gerade reagiert, oder kann die Einrichtung nachverfolgen.

Ein zusätzlicher Vorteil, eine umfassende Suite ordnungsgemäß geschriebener Komponententests zu besitzen, besteht darin, dass sie gleichzeitig als lebendige Dokumentation der App dient. Neue Entwickler, die Ihren Code anzeigen, erkennen Ihre Erwartungen an die Verwendung des Codes, indem sie die unterschiedlichen Szenarios der Komponententests untersuchen.

Planen der Testfähigkeit

Beim Schreiben einer testfähigen Anwendung ist es wichtig, vorausschauend zu planen. Sie sollten die Architektur der Anwendung so entwerfen, dass sie für Komponententests geeignet ist. Statische Methoden, versiegelte Klassen, Datenbankzugriff und Aufrufe des Webdiensts können zu Schwierigkeiten beim Ausführen von Komponententests führen oder die Ausführung verhindern. Durch Planung können Sie jedoch die Auswirkungen auf Ihre Anwendung minimieren.

Die Charmed Reader-App dient vor allem dem Lesen von Blogbeiträgen. Für das Herunterladen der Blogbeiträge ist Webzugriff auf RSS-Feeds erforderlich, und es kann schwierig sein, für diese Funktion Komponententests auszuführen. Zum einen sollten Sie Komponententests schnell und in getrenntem Verbindungsstatus ausführen können. Wenn Sie sich auf Webzugriff in einem Komponententest verlassen, wird potenziell gegen diese Prinzipien verstoßen.

Zum anderen sollte ein Komponententest wiederholbar sein. Da Blogs normalerweise regelmäßig aktualisiert werden, kann es sich als unmöglich erweisen, über einen Zeitraum hinweg immer dieselben Daten herunterzuladen. Ich wusste vorher, dass das Testen der Funktion zum Laden von Blogbeiträgen ohne vorherige Planung nicht möglich sein würde.

Ich wusste, dass Folgendes stattzufinden hatte:

  1. „MainViewModel“ musste alle Blogbeiträge laden, die der Benutzer gleichzeitig lesen möchte.
  2. Diese Blogbeiträge mussten von verschiedenen RSS-Feeds heruntergeladen werden, die der Benutzer gespeichert hat.
  3. Nach dem Herunterladen war es erforderlich, die Blogbeiträge in Datentransferobjekten (Data Transfer Objects, DTOs) zu analysieren und für die Ansicht verfügbar zu machen.

Würde ich den Code zum Herunterladen des RSS-Feeds in „MainViewModel” platzieren, wäre er plötzlich für Aufgaben zuständig, die über das Laden von Daten und Zulassen der Datenbindung für die Anzeige hinausgingen. „MainViewModel” wäre dann für das Durchführen von Webanforderungen und das Analysieren von XML-Daten verantwortlich. Meine Absicht ist es jedoch, dass „MainViewModel” ein Hilfsprogramm aufruft, das die Webanforderung durchführt und die XML-Daten analysiert. „MainViewModel” sollte in diesem Fall Objektinstanzen erhalten, die die anzuzeigenden Blogbeiträge darstellen. Diese werden DTOs genannt.

Mit diesem Hintergrundwissen kann ich das Laden des RSS-Feeds und das Analysieren in ein Hilfsobjekt abstrahieren, das „MainViewModel” aufrufen kann. Es geht jedoch noch weiter. Wenn ich nur eine Hilfsklasse erstelle, die die RSS-Feeddatenarbeit übernimmt, würde jeder Komponententest, den ich für „MainViewModel” zu dieser Funktion schreiben würde, auch diese Hilfsklasse für den Webzugriff aufrufen. Wie oben erwähnt, entspricht dies nicht dem Ziel der Komponententests. Es sind also weitere Schritte erforderlich.

Wenn ich eine Schnittstelle für die Funktion zum Laden der Daten per RSS-Feed erstelle, kann mein Ansichtsmodell mit der Schnittstelle anstelle einer konkreten Klasse funktionieren. Dann kann ich der Schnittstelle unterschiedliche Implementierungen zur Verfügung stellen, wenn ich Komponententests anstelle der App ausführe. Dieses Konzept steht hinter dem „Mocking”. Wenn ich die App dann in einem realen Szenario ausführe, wird das echte Modell, das die echten RSS-Feeddaten verwendet, herangezogen. Beim Ausführen der Komponententests benötige ich ein Mock-Objekt, das vorgibt, die RSS-Daten zu laden, das jedoch niemals auf das Web zugreift. Das Mock-Objekt kann konsistente Daten erstellen, die wiederholbar sind und sich niemals ändern. Dadurch erhalten meine Komponententests Informationen dazu, was jedes Mal erwartet werden kann.

Vor diesem Hintergrund sieht meine Schnittstelle zum Laden von Blogbeiträgen folgendermaßen aus:

public interface IRssFeedService
{
  Task<List<FeedData>> GetFeedsAsync();
}

Nur eine Methode, und zwar „GetFeedsAsync”, kann von „MainViewModel” zum Laden der Daten für den Blogbeitrag verwendet werden. Für „MainViewModel” ist es unerheblich, wie „IRssFeedService” die Daten lädt oder analysiert. Es ist in diesem Zusammenhang nur relevant, dass durch das Aufrufen von „GetFeedsAsync” die Daten für den Blogbeitrag asynchron zurückgegeben werden. Dies ist besonders wichtig, weil die App plattformübergreifend funktioniert.

Windows 8 und Windows Phone 8 verfügen über unterschiedliche Methoden zum Herunterladen und Analysieren der RSS-Feeddaten. Indem ich die IRssFeedService-Schnittstelle erstelle und eine Interaktion mit „MainViewModel” anstelle eines direkten Downloads von Blogfeeds zulasse, vermeide ich es, „MainViewModel” zu zwingen, dieselbe Funktion mehrmals zu implementieren.

Mithilfe von Abhängigkeitsinjektion kann ich sicherstellen, dass „MainViewModel” die richtige Instanz von „IRssFeedService” zur richtigen Zeit erhält. Wie erwähnt, stelle ich während der Komponententests eine Mock-Instanz von „IRssFeedService” zur Verfügung. Das Interessante an der Verwendung von Windows 8- und Windows Phone 8-Code als Grundlage für eine Erörterung von Komponententests ist es, dass es zurzeit für diese Plattformen keine echten dynamischen Mockframeworks gibt. Da Mocking einen großen Teil der Komponententests meines Codes einnimmt, musste ich einen eigenen Weg finden, Mocks zu erstellen. Das sich ergebende „RssFeedServiceMock” wird in Abbildung 1 dargestellt.

Abbildung 1: „RssFeedServiceMock”

public class RssFeedServiceMock : IRssFeedService
{
  public Func<List<FeedData>> GetFeedsAsyncDelegate { get; set; }
  public Task<List<FeedData>> GetFeedsAsync()
  {
    if (this.GetFeedsAsyncDelegate != null)
    {
      return Task.FromResult<List<FeedData>>(this.GetFeedsAsyncDelegate());
    }
    else
    {
      return Task.FromResult<List<FeedData>>(null);
    }
  }
}

Im Grunde genommen möchte ich einen Delegat bereitstellen, der das Laden der Daten bestimmt. Wenn Sie nicht für Windows 8 oder Windows Phone 8 entwickeln, können Sie höchstwahrscheinlich ein dynamisches Mockframework wie Moq, Rhino Mocks oder NSubstitute verwenden. Es gelten immer die gleichen Prinzipien, unabhängig davon, ob Sie Ihre eigenen Mocks entwickeln oder ein dynamisches Mockframework verwenden.

Jetzt habe ich die IRssFeedService-Schnittstelle erstellt und in „MainViewModel” eingefügt. Darüber hinaus ruft „MainViewModel” „GetFeedsAsync” an der IRssFeedService-Schnittstelle auf, und „RssFeed­ServiceMock” wurde erstellt und für die Verwendung bereitgestellt. Nun ist es an der Zeit, Komponententests für die Interaktion zwischen „MainViewModel” und „IRssFeedService” durchzuführen. In erster Linie soll in dieser Interaktion getestet werden, ob „GetFeedsAsync” durch „MainViewModel” ordnungsgemäß aufgerufen wird und die zurückgegebenen Feeddaten denjenigen entsprechen, die „MainViewModel” über die FeedData-Eigenschaft verfügbar macht. Im Komponententest in Abbildung 2 wird dies überprüft.

Abbildung 2: Testen der Funktion zum Laden von Feeds

[TestMethod]
public void FeedData()
{
  // Arrange
  var viewModel = GetViewModel();
  var expectedFeedData = new List<FeedData>();
  this.RssFeedService.GetFeedsAsyncDelegate = () =>
    {
      return expectedFeedData;
    };
  // Act
  var actualFeedData = viewModel.FeedData;
  // Assert
  Assert.AreSame(expectedFeedData, actualFeedData);
}

Beim Ausführen von Komponententests für ein Ansichtsmodell (oder für ein anderes Objekt) bevorzuge ich es, eine Hilfsmethode zu verwenden, die mir die tatsächliche Instanz des zu testenden Ansichtsmodells zur Verfügung stellt. Es ist sehr wahrscheinlich, dass sich Ansichtsmodelle im Laufe der Zeit ändern. Dabei werden möglicherweise verschiedene Objekte in das Ansichtsmodell eingefügt, was verschiedene Konstruktorparameter bedeutet. Wenn ich eine neue Instanz des Ansichtsmodells in allen Komponententests erstelle und dann die Signatur des Konstruktors ändere, muss ich gleichzeitig einen ganzen Satz an Komponententests ändern. Wenn ich jedoch eine Hilfsmethode zum Erstellen der neuen Instanz des Ansichtsmodells erstelle, muss ich die Änderung nur an einer Stelle vornehmen. In diesem Fall stellt „GetViewModel” die Hilfsmethode dar:

private MainViewModel GetViewModel()
{
  return new MainViewModel(this.RssFeedService, 
    this.Navigator, this.MessageBus);
}

Ich habe auch das TestInitialize-Attribut verwendet, um sicherzustellen, dass die MainViewModel-Abhängigkeiten vor jedem Test neu erstellt werden. Im Folgenden sehen Sie die hierfür verantwortliche TestInitialize-Methode:

[TestInitialize]
public void Init()
{
  this.RssFeedService = new RssFeedServiceMock();
  this.Navigator = new NavigatorMock();
  this.MessageBus = new MessageBusMock();
}

Damit besitzt jeder Komponententest in dieser Testklasse eine brandneue Instanz aller Mocks, wenn sie ausgeführt werden.

Was den Test selbst betrifft, erstellt der folgende Code die erwarteten Feeddaten und richtet den Mock-RSS-Feeddienst für die Rückgabe ein:

var expectedFeedData = new List<FeedData>();
this.RssFeedService.GetFeedsAsyncDelegate = () =>
  {
    return expectedFeedData;
  };

Beachten Sie, dass ich der expectedFeedData-Liste keine tatsächlichen FeedData-Instanzen hinzufüge, weil es nicht erforderlich ist. Ich muss nur sicherstellen, dass „MainViewModel” am Ende diese Liste erhält. In Bezug auf diesen Test ist es unerheblich, was passiert, wenn die Liste tatsächlich FeedData-Instanzen enthält.

Der Act-Abschnitt des Tests enthält die folgende Zeile:

var actualFeedData = viewModel.FeedData;

Ich kann bestätigen, dass „actualFeedData” der Instanz von „expectedFeedData” entspricht. Wenn sie nicht identisch sind, war „MainViewModel” nicht erfolgreich, und der Komponententest ist fehlerhaft.

Assert.AreSame(expectedFeedData, actualFeedData);

Testfähige Navigation

Ein weiterer wichtiger Teil der zu testenden Beispielanwendung betrifft die Navigation. Die Beispiel-App Charmed Reader verwendet modellbasierte Navigation, weil ich die Ansichten von den Ansichtsmodellen trennen möchte. Charmed Reader ist eine plattformübergreifende Anwendung. Die Ansichtsmodelle, die ich erstelle, werden auf beiden Plattformen verwendet, auch wenn die Ansichten für Windows 8 und Windows Phone 8 unterschiedlich sein müssen. Hierfür gibt es mehrere Gründe, die aber alle letztendlich darauf zurückzuführen sind, dass XAML auf jeder Plattform etwas anders ist. Deshalb möchte ich die Ansichtsmodelle von den Ansichten trennen und eine Vermischung vermeiden.

Aus verschiedenen Gründen bestand die Lösung im Abstrahieren der Navigationsfunktion einer Schnittfläche. In erster Linie besitzt jede Plattform unterschiedliche an der Navigation beteiligte Klassen. Ich wollte nicht, dass durch diese Unterschiede in meinem Ansichtsmodell Irritationen entstehen. Außerdem können in beiden Fällen die an der Navigation beteiligten Klassen keine Pseudoklassen sein. Als Resultat habe ich diese Probleme unabhängig vom Ansichtsmodell abstrahiert und die INavigator-Schnittstelle erstellt:

public interface INavigator
{
  bool CanGoBack { get; }
  void GoBack();
  void NavigateToViewModel<TViewModel>(object parameter = null);
#if WINDOWS_PHONE
  void RemoveBackEntry();
#endif // WINDOWS_PHONE
}

Ich füge „MainViewModel” „INavigator” über den Konstruktor hinzu, und „MainViewModel” verwendet „INavigator” in einer Methode mit dem Namen „ViewFeed”:

public void ViewFeed(FeedItem feedItem)
{
  this.navigator.NavigateToViewModel<FeedItemViewModel>(feedItem);
}

Wenn ich die Interaktion zwischen „ViewFeed” und „INavigator” betrachte, fallen mir zwei Dinge auf, die ich beim Schreiben des Komponententests bestätigen möchte:

  1. Das „FeedItem”, das an „ViewFeed” übergeben wird, ist mit dem „FeedItem” identisch, das an „NavigateToViewModel” übergeben wird.
  2. Der an „NavigateToViewModel” übergebene Ansichtsmodelltyp ist „FeedItemViewModel”.

Bevor ich den Test schreibe, muss ich ein anderes Mock erstellen, diesmal für „INavigator”. Abbildung 3 zeigt das Mock für „INavigator”. Ich habe dasselbe Muster wie zuvor mit Delegaten für jede Methode zum Ausführen von Testcode verwendet, wenn die eigentliche Methode aufgerufen wird. Auch hier gilt: Wenn Sie auf einer Plattform mit Support für ein Mockframework arbeiten, müssen Sie Ihr eigenes Mock nicht erstellen.

Abbildung 3: Mock für „INavigator”

public class NavigatorMock : INavigator
{
  public bool CanGoBack { get; set; }
  public Action GoBackDelegate { get; set; }
  public void GoBack()
  {
    if (this.GoBackDelegate != null)
    {
      this.GoBackDelegate();
    }
  }
  public Action<Type, object> NavigateToViewModelDelegate { get; set; }
  public void NavigateToViewModel<TViewModel>(object parameter = null)
  {
    if (this.NavigateToViewModelDelegate != null)
    {
      this.NavigateToViewModelDelegate(typeof(TViewModel), parameter);
    }
  }
#if WINDOWS_PHONE
  public Action RemoveBackEntryDelegate { get; set; }
  public void RemoveBackEntry()
  {
    if (this.RemoveBackEntryDelegate != null)
    {
      this.RemoveBackEntryDelegate();
    }
  }
#endif // WINDOWS_PHONE
}

Meine Mock-Navigator-Klasse kann ich in einem Komponententest einsetzen (siehe Abbildung 4).

Abbildung 4: Testen der Navigation mithilfe von Mock-Navigator

[TestMethod]
public void ViewFeed()
{
  // Arrange
  var viewModel = this.GetViewModel();
  var expectedFeedItem = new FeedItem();
  Type actualViewModelType = null;
  FeedItem actualFeedItem = null;
  this.Navigator.NavigateToViewModelDelegate = (viewModelType, parameter) =>
    {
      actualViewModelType = viewModelType;
      actualFeedItem = parameter as FeedItem;
    };
  // Act
  viewModel.ViewFeed(expectedFeedItem);
  // Assert
  Assert.AreSame(expectedFeedItem, actualFeedItem, "FeedItem");
  Assert.AreEqual(typeof(FeedItemViewModel), 
    actualViewModelType, "ViewModel Type");
}

In diesem Test ist es wirklich wichtig, dass das weitergegebene „FeedItem” und das Ansichtsmodell, zu dem navigiert wird, korrekt sind. Beim Arbeiten mit Mocks sollten Sie im Auge behalten, was für einen bestimmten Text relevant ist und welche Ziele Sie verfolgen. Da in diesem Test „MainViewModel” auf Grundlage der INavigator-Schnittstelle arbeitet, muss ich nicht sicherstellen, ob die Navigation überhaupt stattfindet. Hierfür ist die Funktion zuständig, mit der „INavigator” für die Laufzeitinstanz implementiert wird. Ich muss nur sicherstellen, dass „INavigator” bei der Navigation die richtigen Parameter erhält.

Testfähige sekundäre Kacheln

Der letzte in diesem Artikel zu testende Bereich befasst sich mit sekundären Kacheln. Sekundäre Kacheln stehen in Windows 8 sowie in Windows Phone 8 zur Verfügung und ermöglichen Benutzern, Elemente einer App an ihre Startbildschirme anzuheften und dadurch einen Deep-Link zu einem bestimmten Teil der App zu erstellen. Sekundäre Kacheln werden auf beiden Plattformen jedoch vollkommen unterschiedlich behandelt. Das bedeutet, dass ich plattformspezifische Implementierungen bereitstellen muss. Trotz der Unterschiede kann ich eine einheitliche Schnittstelle für sekundäre Kacheln bereitstellen, die ich auf beiden Plattformen verwenden kann:

public interface ISecondaryPinner
{
  Task<bool> Pin(TileInfo tileInfo);
  Task<bool> Unpin(TileInfo tileInfo);
  bool IsPinned(string tileId);
}

Die TileInfo-Klasse ist ein DTO, das für beide Plattformen kombinierte Eigenschaften enthält, um eine sekundäre Kachel zu erstellen. Da jede Plattform eine andere Kombination aus Eigenschaften von „TileInfo” verwendet, muss jede Plattform anders getestet werden. Ich konzentriere mich auf die Windows 8-Version. Abbildung 5 zeigt, wie „ISecondaryPinner” in meinem Ansichtsmodell verwendet wird.

In der Pin-Methode werden zwei Vorgänge ausgeführt (siehe Abbildung 5): zum einen das eigentliche Anheften der sekundären Kachel und zum anderen das Speichern von „FeedItem” im lokalen Speicher. Folglich muss ich zwei Aspekte testen. Durch diese Methode wird die IsFeedItemPinned-Eigenschaft im Ansichtsmodell geändert. Dies geschieht auf Grundlage der Ergebnisse des Versuchs, „FeedItem” anzuheften. Folglich muss ich auch die beiden möglichen Ergebnisse der Pin-Methode für „ISecondaryPinner” testen: „true” und „false”. Abbildung 6 zeigt den ersten von mir implementierten Test, mit dem das Erfolgsszenario getestet wird.

Abbildung 5: Verwenden von „ISecondaryPinner“

public async Task Pin(Windows.UI.Xaml.FrameworkElement anchorElement)
{
  // Pin the feed item, then save it locally to make sure it's still available
  // when they return.
  var tileInfo = new TileInfo(
    this.FormatSecondaryTileId(),
    this.FeedItem.Title,
    this.FeedItem.Title,
    Windows.UI.StartScreen.TileOptions.ShowNameOnLogo |
      Windows.UI.StartScreen.TileOptions.ShowNameOnWideLogo,
    new Uri("ms-appx:///Assets/Logo.png"),
    new Uri("ms-appx:///Assets/WideLogo.png"),
    anchorElement,
    Windows.UI.Popups.Placement.Above,
    this.FeedItem.Id.ToString());
  this.IsFeedItemPinned = await this.secondaryPinner.Pin(tileInfo);
  if (this.IsFeedItemPinned)
  {
    await SavePinnedFeedItem();
  }
}

Abbildung 6: Testen auf erfolgreiches Anheften

[TestMethod]
public async Task Pin_PinSucceeded()
{
  // Arrange
  var viewModel = GetViewModel();
  var feedItem = new FeedItem
  {
    Title = Guid.NewGuid().ToString(),
    Author = Guid.NewGuid().ToString(),
    Link = new Uri("https://www.bing.com")
  };
  viewModel.LoadState(feedItem, null);
  Placement actualPlacement = Placement.Default;
  TileInfo actualTileInfo = null;
  SecondaryPinner.PinDelegate = (tileInfo) =>
    {
      actualPlacement = tileInfo.RequestPlacement;
      actualTileInfo = tileInfo;
      return true;
    };
  string actualKey = null;
  List<FeedItem> actualPinnedFeedItems = null;
  Storage.SaveAsyncDelegate = (key, value) =>
    {
      actualKey = key;
      actualPinnedFeedItems = (List<FeedItem>)value;
    };
  // Act
  await viewModel.Pin(null);
  // Assert
  Assert.AreEqual(Placement.Above, actualPlacement, "Placement");
  Assert.AreEqual(string.Format(Constants.SecondaryIdFormat,
    viewModel.FeedItem.Id), actualTileInfo.TileId, "Tile Info Tile Id");
  Assert.AreEqual(viewModel.FeedItem.Title,
    actualTileInfo.DisplayName, "Tile Info Display Name");
  Assert.AreEqual(viewModel.FeedItem.Title,
    actualTileInfo.ShortName, "Tile Info Short Name");
  Assert.AreEqual(viewModel.FeedItem.Id.ToString(),
    actualTileInfo.Arguments, "Tile Info Arguments");
  Assert.AreEqual(Constants.PinnedFeedItemsKey, actualKey, "Save Key");
  Assert.IsNotNull(actualPinnedFeedItems, "Pinned Feed Items");
}

Das Einrichten dieses Tests ist etwas aufwendiger als das der vorherigen Tests. Nach dem Controller richte ich als Erstes eine FeedItem-Instanz ein. Beachten Sie, dass ich „ToString” in GUIDS für „Titel” sowie „Author” aufrufe. Das liegt daran, dass die eigentlichen Werte nicht relevant sind. Ich muss nur sicherstellen, dass die Werte dafür geeignet sind, im Assert-Abschnitt verglichen zu werden. Da „Link” ein Uri ist, benötige ich einen gültigen Uri, damit dies funktioniert. Folglich habe ich einen bereitgestellt. Auch hier ist der eigentliche Uri unerheblich, es ist nur wichtig, dass er gültig ist. Während der restlichen Einrichtung wird sichergestellt, dass ich Interaktionen zum Anheften und Speichern für Vergleiche in dem Assert-Abschnitt erfasse. Um sicherzustellen, dass der Code auch tatsächlich das Erfolgsszenario testet, ist es wichtig, dass „PinDelegate” „true” zurückgibt und damit auf den Erfolg hinweist.

In Abbildung 7 wird ein vergleichbarer Test gezeigt, jedoch für das nicht erfolgreiche Szenario. Durch die Rückgabe von „false” wird sichergestellt, dass das nicht erfolgreiche Szenario im Mittelpunkt des Tests steht. Im nicht erfolgreichen Szenario muss ich darüber hinaus im Assert-Abschnitt sicherstellen, dass „SaveAsync” nicht aufgerufen wurde.

Abbildung 7: Testen auf nicht erfolgreiches Anheften

[TestMethod]
public async Task Pin_PinNotSucceeded()s
{
  // Arrange
  var viewModel = GetViewModel();
  var feedItem = new FeedItem
  {
    Title = Guid.NewGuid().ToString(),
    Author = Guid.NewGuid().ToString(),
    Link = new Uri("https://www.bing.com")
  };
  viewModel.LoadState(feedItem, null);
  Placement actualPlacement = Placement.Default;
  TileInfo actualTileInfo = null;
  SecondaryPinner.PinDelegate = (tileInfo) =>
  {
    actualPlacement = tileInfo.RequestPlacement;
    actualTileInfo = tileInfo;
    return false;
  };
  var wasSaveCalled = false;
  Storage.SaveAsyncDelegate = (key, value) =>
  {
    wasSaveCalled = true;
  };
  // Act
  await viewModel.Pin(null);
  // Assert
  Assert.AreEqual(Placement.Above, actualPlacement, "Placement");
  Assert.AreEqual(string.Format(Constants.SecondaryIdFormat,
    viewModel.FeedItem.Id), actualTileInfo.TileId, "Tile Info Tile Id");
  Assert.AreEqual(viewModel.FeedItem.Title, actualTileInfo.DisplayName,
    "Tile Info Display Name");
  Assert.AreEqual(viewModel.FeedItem.Title, actualTileInfo.ShortName,
    "Tile Info Short Name");
  Assert.AreEqual(viewModel.FeedItem.Id.ToString(),
    actualTileInfo.Arguments, "Tile Info Arguments");
  Assert.IsFalse(wasSaveCalled, "Was Save Called");
}

Das Schreiben von testfähigen Anwendungen stellt eine Herausforderung dar. Besonders schwierig ist das Testen der Präsentationsschicht mit Benutzerinteraktion. Wenn Sie im Voraus wissen, dass Sie eine testfähige Anwendung schreiben, können Sie bei jedem Schritt Entscheidungen im Sinne der Testfähigkeit fällen. Sie können auch auf Faktoren achten, die die Testfähigkeit Ihrer App herabsetzen, und Sie können Methoden zum Beheben solcher Probleme entwickeln.

In drei verschiedenen Artikeln habe ich das Schreiben testfähiger Apps mit dem MVVM-Muster besprochen, insbesondere für Windows 8 und Windows Phone 8. Im ersten Artikel stand das Schreiben testfähiger Windows 8-Anwendungen und die gleichzeitige Nutzung Windows 8-spezifischer Features im Mittelpunkt, die alleine nicht besonders einfach getestet werden können. Im zweiten Artikel bin ich der Frage nachgegangen, wie das Entwickeln testfähiger plattformübergreifender Apps in Windows 8 und Windows Phone 8 integriert werden kann. Dabei habe ich Herangehensweisen zum Testen von Apps gezeigt, an deren Testfähigkeit ich hart gearbeitet habe.

MVVM ist ein umfassendes Thema mit vielen unterschiedlichen Interpretationsmöglichkeiten. Ich freue mich, dass ich meine Interpretation dieses interessanten Themas mit Ihnen teilen kann. Ich finde MVVM sehr wertvoll, gerade, weil es einen Bezug zur Testfähigkeit hat. Darüber hinaus finde die Beschäftigung mit Testfähigkeit inspirierend und nützlich, und es freut mich, meine Herangehensweise an das Schreiben einer testfähigen Anwendung hier darlegen zu können.

Brent Edwards ist leitender Berater bei Magenic, einem Unternehmen für benutzerdefinierte Anwendungsentwicklung, das seinen Schwerpunkt im Microsoft-Bereich und der Entwicklung mobiler Anwendungen hat. Er ist außerdem Mitbegründer der Twin Cities Windows 8 User Group in Minneapolis im US-amerikanischen Minnesota. Sie erreichen ihn unter brente@magenic.com.

Unser Dank gilt dem folgenden technischen Experten für die Durchsicht dieses Artikels: Jason Bock (Magenic)
Jason Bock ist Practice Lead bei Magenic (www.magenic.com). Außerdem ist er Mitverfasser von „Metaprogramming in .NET” (www.manning.com/hazzard). Sie erreichen ihn unter jasonbock.net oder über Twitter: @jasonbock.