Arbeit mit bUnit Blog

Effizientes Testen von Blazor-Komponenten

Steven Giesel

Jul 29, 2024

bUnit macht das Unit-Testen für Blazor-Komponenten einfach und effizient. Warum das so ist und wo die Reise hingeht, zeigt der folgende Artikel.

bUnit ist eine Bibliothek, die das Testen von Blazor-Komponenten vereinfacht. Dabei stellt bUnit Klassen und Methoden bereit, die das Interagieren von Blazor-Komponenten (oft „component under test“, kurz „cut“ genannt) ermöglicht und vereinfacht. Dabei ist bUnit, im Gegensatz zu xUnit, nUnit oder MSTest kein eigener Test-Runner, der Tests ausführt, sondern sitzt auf diesen auf und erweitert sie um die Möglichkeit, Blazor-Komponenten zu testen.

 

„Hallo Welt“

Wer schon einmal mit Blazor gearbeitet hat, kennt das klassische „Hallo Welt“-Beispiel, in dem ein Counter hochgezählt wird. Wir erweitern dieses kleine Beispiel (Listing 1) um zwei weitere Sachen:

 

  1. Unsere Komponente soll den initialen Wert als Parameter übergeben bekommen.
  2. Das Inkrementieren erfolgt über einen Service, der per Dependency Injection in die Komponente injiziert wird.

 

Wie sieht nun ein Test für diese Komponente aus (Listing 2)?

Listing 1

@page "/counter"
@inject IIncrementService IncrementService

<h1>Counter</h1>
<p>Current count: @InitialCount</p>

<button class="btn btn-primary" @onclick="IncrementCount">Click me</button>

@code {
[Parameter]
public int InitialCount { get; set; }

private void IncrementCount()
{
InitialCount = IncrementService.Increment(InitialCount);
}
}

 

ZUM NEWSLETTER

Regelmäßig News zur Konferenz und der .NET-Community

 

Listing 2

@using Bunit
@inherits TestContext

@code {
[Fact]
public void IncrementCounterWhenButtonClicked()
{
// Wir haben, wie im produktiven System, einen "DI" Container, den wir befüllen können
// Dies ermöglich uns, den Service zu mocken und zu injizieren
// Services.AddScoped<IIncrementService, FakeIncrementService>();
// Dank des Razor-Formats können wir die Komponente direkt erzeugen und Parameter einfach zuweisen
var cut = Render<Counter>(@<Counter InitialCount="3">)

// Wir nutzen CSS-Selektoren und "Klicken" auf den Button
cut.Find("button").Click();

// Wieder benutzen wir CSS-Selektoren und prüfen ob der Text stimmt
cut.Find("p").MarkupMatches("<p>Current count: 4</p>");
}

private class FakeIncrementService : IIncrementService
{
public int Increment(int count) => count + 1;
}
}

 

Was besonders auffällt, ist, dass der Testcode sehr nah am eigentlichen Blazor-Code liegt. Das liegt daran, dass bUnit-Tests im Razor-Format unterstützt. Die Dokumentation zeigt auf, wie das möglich ist [1]. Natürlich ist es möglich, auch klassische Tests zu schreiben, jedoch ist der Razor-Ansatz sehr angenehm und macht das Testen von Blazor-Komponenten sehr eingängig. Die Unterschiede werden ebenfalls in [2] aufgezeigt.

Entwickler:innen, die sich schon mit Ende-zu-Ende-Tests auseinandergesetzt haben, wird auffallen, dass die Selektion von Elementen sehr ähnlich ist (via CSS-Selektoren). Das ist kein Zufall, denn bUnit verwendet AngleSharp [3], um die Komponenten zu analysieren und zu selektieren.

Im gegebenen Test wird ein FakeIncrementService erstellt und in den DI-Container injiziert. Das ermöglicht es, den Service zu mocken und somit die Businesslogik der Komponente zu testen. Das ist ein sehr einfaches Beispiel, zeigt aber sehr gut, wie einfach bUnit das Testen von Blazor-Komponenten macht. Durch den Razor-Ansatz sind das Erstellen und Zeichnen der Counter-Komponente wie im produktiven Code möglich. Das Übergeben der InitialCount-Parameter geschieht direkt im HTML-Code im Test.

Danach wird via Find ein Button gesucht. Im gegebenen Beispiel gibt es nur einen, welcher zurückgegeben wird – anschließend wird dank der Click Methode das onclick Event ausgelöst.

 

Der Unterschied zu Ende-zu-Ende-Tests, oder: Was bUnit nicht ist

Ende-zu-Ende-Tests sind Tests, welche die gesamte Applikation testen. Dabei wird die Applikation gestartet und die Tests interagieren mit der Applikation, wie Benutzer:innen es tun würde. bUnit hingegen zielt darauf ab, eine einzelne Komponente (oder auch mehrere) zu testen. Dabei wird die Komponente isoliert getestet und nicht die gesamte Applikation. Das bringt einige Vorteile mit sich:

 

  • Schneller: Da nur eine Komponente getestet wird, sind die Tests schneller als Ende-zu-Ende-Tests. Es muss auch viel weniger aufgebaut werden, da nur die Komponente und nicht die gesamte Applikation gestartet wird.
  • Stabiler: Da die Komponente isoliert getestet wird, hat sie weniger Abhängigkeiten zur Umwelt und ist somit stabiler.

 

Wichtig zu verstehen ist, dass bUnit keinen Ersatz für Ende-zu-Ende-Tests darstellt, sondern eine Ergänzung. Zum Beispiel laufen Tests im Gegensatz zu Ende-zu-Ende-Tests nicht im Browser. Das birgt auch einige Nachteile:

 

  • JavaScript-Interaktion: JavaScript wird nicht ausgeführt. Das bedeutet, dass JavaScript-Interaktionen nicht getestet werden können (aber bUnit bietet die Möglichkeit, JavaScript zu emulieren, später dazu mehr).
  • Styling: Da die Komponenten nicht im Browser gerendert werden und CSS-Klassen nicht „angewendet“ werden, können zum Beispiel keine Browser-spezifischen Styles getestet werden.
  • Navigation: Da die Komponenten nicht im Browser gerendert werden (und damit kein Router involviert ist), können nicht einfach Links angeklickt und zu anderen Seiten navigiert werden.

 

Wenn Ihnen diese Punkte wichtig sind, sind Testing-Frameworks wie Playwright oder Cypress die bessere Wahl.

 

Was sollte mit bUnit getestet werden?

Es ist wichtig, zu verstehen, dass bUnit kein Browser oder komplettes End-to-End-Test-Framework ist. Es ist darauf ausgelegt, einzelne Komponenten und deren Businesslogik zu testen. Hier eine sehr vereinfachte Komponente: Sie hat ein Anchor-Element, das auf eine andere interne Seite verweist: <a href=”/bestellung/1″>Zur Bestellung</a>. Ein Test könnte dann so aussehen wie in Listing 3.

 

Listing 3

@using Bunit
@inherits TestContext

@code {
[Fact]
public void ShouldNavigateToUrl()
{
var cut = Render(@<MyComponent />);
cut.Find("a").Click();

// Den NavigationManager aus dem Container holen
var navigationManager = Services.GetRequiredService<NavigationManager>();
navigationManager.Uri.Should().Be("/bestellung/1");
}
}

 

Wir klicken auf den Link und prüfen, ob der URL stimmt. Obwohl der Test zuerst sinnvoll erscheint, ist er es nicht, aus zwei Gründen. Zuerst ist es wesentlich, zu verstehen, dass Click nicht wirklich auf ein HTML-Element klickt, sondern das @onclick Event ausführt. Im gegebenen Beispiel gibt es aber gar kein @onclick Event. Das bedeutet, dass der Test immer fehlschlagen wird, da der Link nicht auf das @onclick Event reagiert. Der zweite Grund ist, dass der Test nur 3rd-Party-Code testet. Unit-Tests, die das Hauptanwendungsgebiet für bUnit sind, sollten immer nur die eigene Businesslogik testen. Das Klicken auf einen Link ist aber nicht Bestandteil der eigenen Businesslogik, sondern das Resultat aus der Kombination von Blazor und dem Browser.

 

Stubbing und Faking von Komponenten

Genau deshalb bringt bUnit auch die Fähigkeit mit, Komponenten komplett zu ersetzen. Das bedeutet, dass wir beliebige Komponenten durch eigene Komponenten ersetzen können oder einfach durch leeres Markup (auch Shallow Rendering genannt). Das macht immer dann Sinn, wenn eine Komponente viele Abhängigkeiten hat, die nicht relevant für den Test sind. Doch was ist Faking beziehungsweise Stubbing? Faking ist das Ersetzen von Komponenten durch eigene Komponenten, die wir kontrollieren können. Stubbing ist das Ersetzen von Komponenten durch leeres Markup. Ein Beispiel, in dem ein Button einer 3rd-Party-Bibliothek verwendet wird, finden Sie in Listing 4.

 

Listing 4

@page "/counter"
<h1>Counter</h1>
<p>Current count: @InitialCount</p>

<ThirdPartyButton Click="Increment">Click me</ThirdPartyButton>

@code {
[Parameter]
public int InitialCount { get; set; }

private void Increment()
{
InitialCount++;
}
}

 

Wie sieht nun ein Test dafür aus? Wie bewegen wir den ThirdPartyButton dazu, dass er das Increment Event auslöst? Das ist gar nicht so einfach, da wir nicht wissen, wie der ThirdPartyButton implementiert ist. Ein Weg besteht darin, herauszufinden, dass der ThirdPartyButton intern auch nur ein button-Element ist und wir dieses Element direkt ansprechen können (Listing 5).

 

Listing 5

@inherits TestContext

@code {
[Fact]
public void IncrementCounterWhenButtonClicked()
{
var cut = Render(@<Counter InitialCount="3" />);

cut.Find("button").Click();

cut.Find("p").MarkupMatches("<p>Current count: 4</p>");
}
}

 

Schön, der Test wird grün, es verstreicht ein wenig Zeit und nun nutzt der ThirdPartyButton ein div-Element. Resultat: Der Test wird rot. Das ist ein Problem, weil wir nicht die Implementation der Komponente testen wollen, sondern nur die Businesslogik. Hier kommt das Faking ins Spiel (Listing 6). Dafür bietet bUnit die Möglichkeit, Komponenten zu ersetzen. Das bedeutet, dass wir den ThirdPartyButton durch eine eigene Komponente ersetzen, die wir kontrollieren und mittels der wir auch einfach das Click Event auslösen können.

 

Listing 6

@using Bunit
@inherits TestContext

@code {
[Fact]
public void IncrementCounterWhenButtonClicked()
{
// Dies ersetzt alle Vorkommen von "ThirdPartyButton" durch "FakeThirdPartyButton"
ComponentFactories.Add<FakeThirdPartyButton, ThirdPartyButton>();
var cut = Render(@<Counter InitialCount="3" />);

// Wir lösen das Click Event aus
cut.FindComponent<FakeThirdPartyButton>().Instance.ClickButton();

cut.Find("p").MarkupMatches("<p>Current count: 4</p>");
}

private class FakeThirdPartyButton : ComponentBase
{
[Parameter]
public EventCallback Click { get; set; }

public Task ClickButton() => Click.InvokeAsync();
}
}

 

Das ist ein sehr einfaches Beispiel, zeigt aber sehr gut, wie mächtig das Ersetzen von Komponenten sein kann. In der offiziellen Dokumentation finden sich mehr Details über Stubbing und Faking von Komponenten [4]. bUnit geht sogar ein Schritt weiter und bietet extra Pakete an, die automatisch Stubs generieren können. Mehr dazu kann auf der offiziellen Website gefunden werden [5].

Natürlich kann das Stubbing auch dafür genutzt werden, um das Set-up von Komponenten zu vereinfachen, zum Beispiel eine Komponente, die gewisse Dienste injiziert bekommt, um zu funktionieren. Wenn diese Komponente für den Test nicht wichtig ist, ergibt es Sinn, sie zu ersetzen, um die Stabilität des Tests zu erhöhen und Abhängigkeiten zu reduzieren.

 

JavaScript-Interaktion

Auch wenn Stubbing oder Faking die Möglichkeit bietet, gewisse Sachen zu vereinfachen, kann es sein, dass die eigene Integration mit JavaScript getestet werden muss. bUnit führt zwar JavaScript nicht direkt aus, bringt aber eine eigene Laufzeit mit, welche es einfach macht, zu prüfen, ob JavaScript aufgerufen wurde (oder nicht). Dieses Verhalten ist automatisch angeschaltet. Dabei wird zwischen strict und loose unterschieden. strict bedeutet, dass, wenn bUnit einen JavaScript-Aufruf entdeckt, welcher nicht erwartet wurde, der Test fehlschlägt. loose hingegen ignoriert solche Aufrufe. Der Standardwert ist strict – im Falle von Werten, die von der Interoperabilitätsschicht von Blazor zurückgegeben werden, gibt loose den Standardwert des Datentyps zurück (zum Beispiel 0 für int). Eine ausführliche Dokumentation kann unter [6] gefunden werden.

 

Wo geht die Reise hin?

bUnit ist seit fünf Jahren versehen mit der Version 1, sprich seit fünf Jahren ist bUnit mehr oder weniger API-stabil und unterstützt .NET Core 3.1 bis .NET 9. Das ist unabhängig davon, dass manche von diesen Versionen schon nicht mehr offiziell von Microsoft selbst unterstützt werden. Mit so einer langen Laufzeit für eine Version gibt es natürlich Sachen, die eventuell gar nicht mehr so gebraucht werden oder die man heute anders machen würde und genau das ist die Idee für die nächste Version, die sich gerade in der Vorabphase befindet.

Das generelle Thema für Version 2 sind Aufräumarbeiten, ein verbessertes API und einige neue Funktionalitäten und Bugfixes, die größere Änderungen erforderten. Generell wurde viel alter Ballast abgeräumt. Darunter zählen zum Beispiel, dass nur noch .NET 8 und kommende Versionen unterstützt werden.

Viele Abstraktionen (Klassen wie Interfaces) waren für den Fall gedacht, dass es neben Blazor für das Web auch noch andere Modi gibt, die nicht HTML-basiert sind (zum Beispiel Blazor Mobile Bindings [7], die vor ein paar Jahren als Experiment angekündigt wurden). Diese Abstraktionen werden entfernt bzw. zusammengeführt, um ein einheitlicheres Bild zu geben.

 

Vereinheitlichung des API

Das neue Release soll ein einfacheres API bieten – zum Beispiel kannte Version 1 der Bibliothek Funktionen wie Render, SetParametersAndRender und RenderComponent. Diese Funktionen werden in der neuen Version zu einer einzigen Funktion zusammengefasst: Render. Des Weiteren wurden viele Funktionen und Klassen auf konsistente Weise umbenannt und die meisten Typen starten mit Bunit als Präfix. Zum Beispiel wird aus TestContext dann BunitContext oder aus FakeNavigationManager wird BunitNavigationManager. Es soll einfacher werden, neue Dinge zu entdecken und zu verwenden.

 

Rauf und runter im Komponentenbaum

In Version 1 war es ohne weiteres möglich, Kindkomponenten der aktuellen Komponente zu finden (FindComponent), sprich es gab eine Möglichkeit, hinunterzugehen. In Version 2 soll es auch möglich sein, von einer Kindkomponente zur Elternkomponente zu gehen. Technisch gesehen war es in Version 1 so, dass jede Komponente das generierte Markup von sich (cut.Markup) und allen Kindelementen hatte. Dabei war die Komponente selbst immer das Root-Element in dem Baum. Das führt dazu, dass Kindelemente natürlich auch nur sich selbst als Root-Element und damit keine Beziehung mehr zum Elternelement haben. Das führte zu Fehlern, die sehr unverständlich für User:innen waren, zum Beispiel beim Zusammenspiel zweier Komponenten, wobei die Elternkomponente ein form-Element ist, das aus dem Kindelement submittet wird (Listing 7).

 

Listing 7

<form @onsubmit="SubmitForm">
<ChildComponent />
</form>

@code {
private void SubmitForm() { ... }
}

Hier die Kindskomponente: <button type=”submit”>Abschicken</button>. Im Browser würde das Drücken auf Abschicken das Formular senden. Wie der Test hier aussieht, zeigt Listing 8.

 

Listing 8

var cut = Render(@<ParentComponent />);
// Wir suchen die "ChildComponent"
var childComponent = cut.FindComponent<ChildComponent>();

// Ausführen des Submit Events
childComponent.Find("button").Submit();

// Prüfen, ob der Submit Event ausgelöst wurde

 

 

Der Test scheint logisch und korrekt zu sein, aber er wird fehlschlagen. Der Grund ist, dass die childComponent nur das button-Element kennt. Dieses Hindernis wird mit Version 2 überarbeitet und funktioniert dann ohne Probleme. Insgesamt versucht bUnit nicht, sich in Version 2 neu zu erfinden, sondern viele Sachen zu vereinfachen, alte Sachen wegzulassen und den Weg zu ebnen, um in Zukunft einfacher neue Features hinzuzufügen.

 

Weitere Informationen

bUnit ist eine sehr mächtige Bibliothek, die das Testen von Blazor-Komponenten sehr einfach macht. Die offizielle Website bietet eine sehr gute Dokumentation und viele Beispiele, die das Verständnis erleichtern. Die Website ist unter [8] zu finden. Wenn man sich für die Entwicklung von bUnit interessiert, kann man gerne auf der offiziellen GitHub-Seite vorbeischauen [9]. Natürlich sind Bug-Reports, Featureverbesserung und Fragen immer willkommen.

 

Links & Literatur

[1] https://bunit.dev/docs/getting-started/create-test-project.html?tabs=xunit

[2] https://bunit.dev/docs/getting-started/writing-tests.html?tabs=xunit#write-tests-in-cs-or-razor-files

[3] https://anglesharp.github.io

[4] https://bunit.dev/docs/test-doubles/index.html

[5] https://bunit.dev/docs/extensions/bunit-generators.html

[6| https://bunit.dev/docs/test-doubles/emulating-ijsruntime.html

[7] https://learn.microsoft.com/en-us/mobile-blazor-bindings/

[8] https://bunit.dev

[9] https://github.com/bUnit-dev/bUnit

Top Articles About Blog

Ihr aktueller Zugang zur .NET- und Microsoft-Welt.
Der BASTA! Newsletter:

Behind the Tracks

.NET Framework & C#
Visual Studio, .NET, Git, C# & mehr

Agile & DevOps
Agile Methoden, wie Scrum oder Kanban und Tools wie Visual Studio, Azure DevOps usw.

Web Development
Alle Wege führen ins Web

Data Access & Storage
Alles rund um´s Thema Data

JavaScript
Leichtegewichtig entwickeln

UI Technology
Alles rund um UI- und UX-Aspekte

Microservices & APIs
Services, die sich über APIs via REST und JavaScript nutzen lassen

Security
Tools und Methoden für sicherere Applikationen

Cloud & Azure
Cloud-basierte & Native Apps