Warum testen wir mit echten Fakes statt mit Mocking-Frameworks? (Teil 2)
Beim Programmieren ist es wichtig sicherzustellen, dass die eigene Logik funktioniert — und zwar unabhängig von einer echten Datenbank oder externen Systemen wie Spotify. Genau hier helfen Unit-Tests. EchoPlay verwendet dabei einen Ansatz, der sich von vielen anderen Projekten unterscheidet: statt Mocking-Frameworks wie Moq kommen echte Fake-Klassen zum Einsatz. Warum das so ist und wie das in der Praxis aussieht, zeigt dieser Artikel.
Was ist der Unterschied zwischen Fakes und Mocks?
Ein Mock ist eine automatisch erzeugte Attrappe, bei der man zur Laufzeit konfiguriert, wie sie sich verhalten soll. Das geht schnell, kann aber dazu führen, dass Tests sehr eng an die interne Implementierung gekoppelt sind. Ändert sich die Implementierung minimal — etwa wird eine Methode intern zweimal statt einmal aufgerufen — schlägt der Test plötzlich fehl, obwohl das Verhalten nach außen identisch geblieben ist.
Ein Fake ist eine eigene, vollständig ausgeschriebene Klasse, die ein Interface implementiert und dabei echte Logik enthält, nur eben vereinfacht. Ein FakeSeriesDataService hat eine echte In-Memory-Liste, in die man Serien speichern kann — genau wie ein echter DataService, nur ohne Datenbankverbindung. Tests, die diesen Fake nutzen, prüfen das Verhalten, nicht die Implementierungsdetails.
EchoPlay verbietet Mock-Frameworks explizit. Der Grund: Fakes machen Testabsichten klarer, sind leichter zu lesen und zwingen dazu, über das Interface-Design nachzudenken. Wenn ein Fake schwer zu schreiben ist, ist oft das Interface das Problem — und das ist eine wertvolle Erkenntnis.
Ein konkretes Beispiel: FakeSeriesDataService
Der FakeSeriesDataService implementiert das Interface ISeriesDataService vollständig und speichert die Daten in einer einfachen Liste im Arbeitsspeicher:
internal sealed class FakeSeriesDataService : ISeriesDataService
{
// Die Liste dient als In-Memory-Datenbank — nur für die Dauer des Tests.
private readonly List<Series> _series = [];
// Tests können über diese Property prüfen, welche Serien gespeichert wurden.
public IReadOnlyList<Series> All => _series;
public Task<IReadOnlyList<Series>> GetAllAsync()
{
// Echte Sortierung nach Titel, wie sie auch der DataService liefern würde.
IReadOnlyList<Series> result = _series.OrderBy(s => s.Title).ToList();
return Task.FromResult(result);
}
public Task<Series?> GetByIdAsync(Guid id)
{
Series? result = _series.FirstOrDefault(s => s.Id == id);
return Task.FromResult(result);
}
public Task AddAsync(Series series)
{
// Entity Framework Core setzt die Id normalerweise beim Speichern in die Datenbank.
// Im Fake wird das über Reflection simuliert, weil Id einen protected Setter hat.
PropertyInfo idProp = typeof(BaseEntity)
.GetProperty(nameof(BaseEntity.Id))!;
idProp.SetValue(series, Guid.NewGuid());
_series.Add(series);
return Task.CompletedTask;
}
public Task UpdateAsync(Series series) => Task.CompletedTask;
public Task DeleteAsync(Guid id)
{
_series.RemoveAll(s => s.Id == id);
return Task.CompletedTask;
}
}
Diese Klasse ist absichtlich einfach gehalten. UpdateAsync tut nichts, weil kein Test derzeit dieses Verhalten braucht — das Fake soll nur so komplex sein wie nötig, nicht mehr. Das Ziel ist immer: so wenig Code wie möglich, so klar wie möglich.
Wie sieht ein Test damit aus?
Hier ein Beispiel aus SeriesListViewModelTests. Der Test prüft, ob das ViewModel nach dem Laden alle Serien korrekt in ViewModel-Kacheln umwandelt:
[Fact]
public async Task LoadAsync_WithTwoSeries_ReturnsTwoCards()
{
// Arrange: Zwei Testserien mit festen, bekannten Werten anlegen.
FakeSeriesDataService fakeService = new();
await fakeService.AddAsync(new Series { Title = "Die drei ???" });
await fakeService.AddAsync(new Series { Title = "TKKG" });
FakeServiceScopeFactory scopeFactory =
new FakeServiceScopeFactory(fakeService);
SeriesListViewModel viewModel = new(scopeFactory);
// Act: Das ViewModel lädt seine Daten.
await viewModel.LoadAsync();
// Assert: Zwei Kacheln wurden erstellt, mit den richtigen Titeln.
Assert.Equal(2, viewModel.Series.Count);
Assert.Contains(viewModel.Series, c => c.Title == "Die drei ???");
Assert.Contains(viewModel.Series, c => c.Title == "TKKG");
}
Der Test besteht aus drei klar abgegrenzten Abschnitten: Arrange (alles vorbereiten), Act (die Methode aufrufen, die getestet werden soll) und Assert (das Ergebnis prüfen). Diese Struktur — auch bekannt als AAA-Muster — macht Tests leicht lesbar und wartbar.
Deterministisch heißt: immer dasselbe Ergebnis
EchoPlay-Tests verwenden keine Zufallsdaten. Jeder Test definiert exakt, welche Eingaben er verwendet und welches Ergebnis er erwartet. Das ist kein Zufall — es ist Absicht. Tests sollen deterministisch sein, also bei jedem Aufruf dasselbe Ergebnis liefern, unabhängig von der Uhrzeit, dem Betriebssystem oder anderen externen Faktoren.
Warum ist das so wichtig? Ein Test, der manchmal grün und manchmal rot ist, ist wertlos. Schlimmer noch: Er erzeugt ein falsches Sicherheitsgefühl. Echte Stabilität entsteht nur durch Tests, die vorhersagbar sind.
Der FakeSpotifyApiClient: externes System ersetzen
Für Tests, die das Suchverhalten über Spotify prüfen, gibt es einen FakeSpotifyApiClient. Er implementiert ISpotifyApiClient vollständig und liefert eine fest definierte Liste von Künstlern zurück — kein Netzwerkzugriff, kein Token, keine Abhängigkeit von Spotify:
internal sealed class FakeSpotifyApiClient : ISpotifyApiClient
{
private readonly IReadOnlyList<SpotifyArtistDto> _artists;
public FakeSpotifyApiClient(IReadOnlyList<SpotifyArtistDto> artists)
{
_artists = artists;
}
public Task<IReadOnlyList<SpotifyArtistDto>> SearchArtistsAsync(
string query,
int limit)
{
// Einfache Filterung: enthält der Künstlername den Suchbegriff?
IReadOnlyList<SpotifyArtistDto> result = [.._artists
.Where(a => a.Name.Contains(query, StringComparison.OrdinalIgnoreCase))
.Take(limit)];
return Task.FromResult(result);
}
// Diese Methoden sind für aktuelle Tests nicht relevant,
// müssen aber vorhanden sein, damit das Interface vollständig erfüllt wird.
public Task<IReadOnlyList<SpotifyAlbumDto>> GetArtistAlbumsAsync(
string artistId, int limit)
=> Task.FromResult<IReadOnlyList<SpotifyAlbumDto>>([]);
public Task<IReadOnlyList<SpotifyTrackDto>> GetAlbumTracksAsync(string albumId)
=> Task.FromResult<IReadOnlyList<SpotifyTrackDto>>([]);
}
Durch diesen Fake kann ein Test prüfen: „Wenn ich nach ‚Die drei‘ suche, bekomme ich die richtigen Ergebnisse zurück“ — ohne Spotify auch nur anzusprechen. Das macht solche Tests schnell (Millisekunden statt Sekunden), stabil (kein Netzwerkausfall möglich) und kostenfrei.
Was sind Smoke-Tests?
Neben Unit-Tests gibt es in EchoPlay noch eine weitere Kategorie: Smoke-Tests. Sie testen gegen echte externe APIs — also wirklich gegen Spotify oder Apple Music. Sie prüfen damit, ob die Verbindung grundsätzlich funktioniert. Diese Tests sind bewusst als manuell gekennzeichnet und laufen nicht automatisch im Build-Prozess. Der Grund: Sie wären von Netzwerkverbindung und gültigen Anmeldedaten abhängig — zwei Dinge, die auf einem Build-Server nicht immer verfügbar sind.
Warum xUnit?
EchoPlay verwendet xUnit als Test-Framework. Es ist eines der meistgenutzten Test-Frameworks in der .NET-Welt und hat eine einfache, klare API. Ein Test wird mit dem Attribut [Fact] markiert — das bedeutet: diese Methode ist ein eigenständiger Testfall mit festen Eingaben. Für Tests, die mit verschiedenen Parametern laufen sollen, gibt es [Theory] in Kombination mit [InlineData], aber in EchoPlay werden Testdaten grundsätzlich im Test selbst definiert, nicht über Parameter-Listen.
WinUI-3-ViewModels in Unit-Tests: COM-Fallen umgehen
WinUI 3 bringt eine besondere Herausforderung für Unit-Tests mit sich: Viele WinUI-Typen sind COM-Objekte, die nur in einer MSIX-Laufzeitumgebung funktionieren. In einem normalen xUnit-Testprozess sind diese COM-Klassen nicht registriert — der Aufruf wirft eine COMException mit dem Code 0x80040154 (REGDB_E_CLASSNOTREG).
Zwei häufige Stolperstellen verdienen besondere Beachtung. DispatcherQueue.GetForCurrentThread() wird in ViewModels verwendet, um Callbacks vom Hintergrundthread auf den UI-Thread zu leiten. In Tests gibt es keinen UI-Thread. Die Lösung: den Aufruf in einen try/catch wickeln und bei COMException null setzen. An den Aufrufstellen prüft das ViewModel dann, ob der DispatcherQueue verfügbar ist:
try
{
_dispatcherQueue = DispatcherQueue.GetForCurrentThread();
}
catch (COMException)
{
_dispatcherQueue = null; // Unit-Test-Umgebung
}
// Später beim Callback:
if (_dispatcherQueue is not null)
{
_dispatcherQueue.TryEnqueue(() => ...);
}
else
{
// In Tests: direkt aufrufen
_ = DoWorkAsync();
}
Die zweite Stolperstelle sind BitmapImage und InMemoryRandomAccessStream — ebenfalls COM-basiert. Tests, die Cover-Bilder erzeugen müssten, können das nicht. Die Lösung: die UI-Ebene (BitmapImage-Erzeugung) nicht im Unit-Test testen. Stattdessen die darunterliegende Logik direkt testen — zum Beispiel ob der CoverService (App-Schicht, Singleton) mit den richtigen Bytes aufgerufen wurde, ohne den BitmapImage-Umweg. Cover werden über die CoverImages-Tabelle verwaltet; der CoverService kapselt diesen Zugriff und ist in Tests durch einen Fake ersetzbar.
Diese Einschränkung betrifft nur WinUI-3-Projekte. Klassische .NET-Bibliotheken wie EchoPlay.Core, EchoPlay.Data und EchoPlay.LocalLibrary haben dieses Problem nicht, weil sie keine UI-Abhängigkeiten haben.
Die gezeigten Code-Beispiele dienen zur Veranschaulichung. Nutzung auf eigene Verantwortung. Mehr dazu