Testando Componentes Blazor com bUnit e MSTest
Objective
Um componente Blazor é uma função dos seus parâmetros, dos seus serviços e dos eventos que o usuário dispara, e a maioria dos bugs mora aí: um botão que continua habilitado, uma mensagem de validação que nunca aparece, um delete que ignora o "Cancelar". O bUnit renderiza um componente em memória, sem browser, deixa o teste clicar e digitar, e expõe o markup resultante para as asserções, em milissegundos. O objetivo é testar componentes com bUnit dentro do MSTest: parâmetros e callbacks, formulários, serviços injetados com um fake, interop com JavaScript, cascading values, autorização e renderização assíncrona, evitando os três jeitos de um teste bUnit mentir (conferir antes de a renderização terminar, esconder chamadas JavaScript atrás do modo loose e selecionar por classe CSS), e sabendo o que o bUnit simplesmente não testa.
Use Cases
- Provar que um formulário mostra as mensagens de validação e só dispara o callback para entrada válida.
- Testar um componente que carrega dados no
OnInitializedAsync, incluindo os estados de carregando e de vazio, sem servidor e sem banco. - Fixar quais chamadas JavaScript um componente faz (
confirm, uma função de analytics ou de anúncio) e o comportamento para cada resposta. - Conferir que um
AuthorizeViewesconde uma ação de usuários sem um papel. - Manter os testes de componente verdes durante um redesign de CSS.
Deep Dive
O pacote e o contexto
O bUnit atual é o 2.11.3, um único pacote bunit com target net10.0. Ele não
depende de framework de teste, então fica ao lado do MSTest num projeto de teste
comum:
plaintext<ItemGroup> <PackageReference Include="MSTest" Version="4.5.1" /> <PackageReference Include="bunit" Version="2.11.3" /> </ItemGroup> <ItemGroup> <Using Include="Microsoft.VisualStudio.TestTools.UnitTesting" /> <Using Include="Bunit" /> </ItemGroup>
Tudo passa por um BunitContext: o renderer, uma coleção de serviços, o JSInterop
fake. O setup mais simples é herdar dele:
csharp[TestClass]
public sealed class CounterTests : BunitContext
{
[TestMethod]
public void ClickingTheButtonIncrementsTheCount()
{
var cut = Render<Counter>();
cut.Find("button").Click();
cut.Find("[role=status]").MarkupMatches("<p role=\"status\">Clicked 1 of 3</p>");
}
}O MSTest cria uma instância nova da classe para cada método de teste e a descarta
(dispose) depois, então cada teste ganha o próprio renderer e os próprios
serviços, e [Parallelize(Scope = ExecutionScope.MethodLevel)] continua seguro.
Quando a classe já tem uma classe base, crie o contexto em vez de herdar:
using var ctx = new BunitContext();.
Antes do bUnit 2 a classe se chamava TestContext, que também é o nome do
TestContext do MSTest. Um arquivo que importa os dois namespaces não compila:
texterror CS0104: 'TestContext' is an ambiguous reference between 'Bunit.TestContext' and 'Microsoft.VisualStudio.TestTools.UnitTesting.TestContext'
O bUnit 2 a renomeou para BunitContext, trocou RenderComponent por Render e
renomeou os test doubles Fake* para Bunit*. Posts antigos com
RenderComponent<T> e TestContext são do bUnit 1.
Parâmetros, callbacks e markup
Os parâmetros passam por um builder. Um parâmetro EventCallback aceita uma
lambda, que é como o teste captura o que o componente disparou:
csharpvar reached = new List<int>();
var cut = Render<Counter>(p => p
.Add(c => c.Max, 2)
.Add(c => c.OnLimitReached, (int count) => reached.Add(count)));
cut.Find("button").Click();
cut.Find("button").Click();
Assert.IsTrue(cut.Find("button").HasAttribute("disabled"));
Assert.AreEqual(2, Assert.ContainsSingle(reached));Find recebe um seletor CSS e lança exceção quando nada casa; FindAll devolve
uma lista. MarkupMatches compara HTML semanticamente, então espaços em branco e
a ordem de atributos e classes não importam. Use-o para um fragmento pequeno e
estável. Para o resto, confira a única coisa de que o teste trata (um atributo, um
texto), porque uma comparação do markup inteiro quebra a cada mudança não
relacionada.
Eventos e formulários
Click(), Change(value), Input(value) e Submit() disparam os mesmos eventos
que o browser dispararia. Um EditForm é uma árvore de componentes normal, então
o teste preenche os inputs e envia o formulário:
csharp[TestMethod]
public void AValidSubmitRaisesOnCreateAndClearsTheForm()
{
var cut = RenderForm();
cut.Find("#title").Change("Buy milk");
cut.Find("#priority").Change("5");
cut.Find("form").Submit();
var created = Assert.ContainsSingle(_created);
Assert.AreEqual("Buy milk", created.Title);
Assert.AreEqual(5, created.Priority);
Assert.AreEqual("", cut.Find("#title").GetAttribute("value"));
}O InputText faz bind no change, então Change é o evento certo; um input com
@bind:event="oninput" precisa de Input. O caso inválido confere os dois lados:
a mensagem que o DataAnnotationsValidator produziu está no markup, e o callback
nunca rodou:
csharpcut.Find("form").Submit();
Assert.Contains("Give the task a title.", cut.Markup);
Assert.IsEmpty(_created);Serviços: registre o fake antes da primeira renderização
Um componente que injeta um serviço o recebe de Services, um
IServiceCollection normal. Registre um fake escrito à mão:
csharpServices.AddSingleton<ITaskStore>(store);
var cut = Render<TaskList>();A ordem importa. Depois que uma renderização resolveu um serviço, a coleção fica
fechada, e um AddSingleton posterior lança "New services/implementations cannot
be registered with the Services provider in a BunitContext, after the first
services has been retrieved from it". Registre tudo, depois renderize.
Renderização assíncrona: espere pelo estado, não pelo tempo
O TaskList carrega no OnInitializedAsync. Quando o store responde depois, o
Render retorna com "Loading tasks..." e o componente renderiza de novo quando a
task termina. O store fake do lab segura o GetAllAsync até o teste chamar
Release(), o que deixa os dois momentos explícitos:
csharp[TestMethod]
public void ShowsLoadingUntilTheStoreAnswers()
{
var store = new FakeTaskStore(_milk, _dog) { HoldLoading = true };
var cut = RenderList(store);
Assert.Contains("Loading tasks...", cut.Markup);
store.Release();
cut.WaitForAssertion(() => Assert.HasCount(2, cut.FindAll("li")));
}Assert.HasCount(2, cut.FindAll("li")) logo depois do Release() falha com
"actual count: 0". O WaitForAssertion roda a asserção agora e de novo depois de
cada renderização, e lança WaitForFailedException se ela não passou em um
segundo (o padrão; o segundo argumento muda isso). WaitForState(predicate) faz o
mesmo com uma condição. Nunca use Thread.Sleep.
A versão perigosa passa. Para um store vazio, "não há <li>" também é verdade
enquanto o componente ainda está carregando:
csharpstore.Release();
// Conferir "nenhum <li>" logo de cara passaria com a lista ainda carregando.
cut.WaitForState(() => cut.Markup.Contains("No tasks yet."));
Assert.IsEmpty(cut.FindAll("li"));Sem o WaitForState, o teste passa mesmo quando o OnInitializedAsync nunca
atribui a lista e a página fica presa em "Loading tasks..." para sempre. Espere o
sinal positivo do estado que você espera, depois confira o que precisa estar
ausente.
Interop com JavaScript: estrito por padrão
O bUnit substitui o IJSRuntime pelo dele, e no modo padrão Strict uma chamada
que o teste não planejou lança exceção. A mensagem diz o que adicionar:
textBunit.JSRuntimeUnhandledInvocationException: bUnit's JSInterop has not been configured to handle the call: InvokeAsync<bool>("confirm", "Delete 'Buy milk'?")
Setup<T> responde a um InvokeAsync<T>, casando o identificador e, quando
informados, os argumentos exatos. SetupVoid trata o InvokeVoidAsync, e
VerifyInvoke confere que uma chamada aconteceu:
csharpJSInterop.Setup<bool>("confirm", "Delete 'Buy milk'?").SetResult(false);
var cut = RenderList(store);
cut.Find("button[aria-label='Delete Buy milk']").Click();
Assert.HasCount(2, cut.FindAll("li"));csharpJSInterop.SetupVoid("taskBoard.announce", "Added Walk the dog");
// ... preenche o formulário e envia ...
JSInterop.VerifyInvoke("taskBoard.announce");JSInterop.Mode = JSRuntimeMode.Loose responde toda chamada não planejada com
default(T). É conveniente para um componente que chama JavaScript que não
interessa ao teste, mas também faz o confirm devolver false em silêncio e
esconde se o componente chama JavaScript ou não. Prefira o modo estrito com
setups explícitos, e mude para loose por teste, com um motivo.
Cascading values e autorização
AddCascadingValue envolve o componente num <CascadingValue>, como um layout
pai faria:
csharpvar cut = Render<TaskList>(p => p.AddCascadingValue(new BoardSettings(HideCompleted: true)));Um <AuthorizeView> precisa dos serviços de autorização e do authentication
state em cascata que uma aplicação real fornece. Sem eles, a renderização falha
com "There is no registered service of type
'Microsoft.AspNetCore.Authorization.IAuthorizationPolicyProvider'", mesmo em
testes que não tratam de autorização. AddAuthorization() registra os serviços e
a cascata, e devolve um BunitAuthorizationContext que define o usuário:
csharpprivate readonly BunitAuthorizationContext _auth;
public TaskListTests() => _auth = AddAuthorization();
[TestMethod]
public void OnlyALeadSeesClearCompleted()
{
_auth.SetAuthorized("bob");
var cut = RenderList(new FakeTaskStore(_milk));
Assert.IsFalse(cut.FindAll("button").Any(b => b.TextContent.Trim() == "Clear completed"));
_auth.SetRoles("lead");
cut.Render();
Assert.IsNotNull(ButtonWithText(cut, "Clear completed"));
}SetRoles, SetPolicies e SetClaims cobrem views baseadas em papel, em policy
e em claim. AddAuthorization é uma extensão do contexto, então dentro de uma
classe que herda BunitContext ela é chamada em this.
Selecione o que o usuário vê, não o CSS
Uma tarefa concluída renderiza class="task done text-muted". Um teste que busca
li.done funciona até alguém mudar o estilo da lista. Selecione pelo que um
usuário ou um leitor de tela vê: um aria-label, um id ligado a um <label>,
o texto de um botão:
csharpcut.Find("input[aria-label='Done: Buy milk']").Change(true);
Assert.IsTrue(milk.Done);
Assert.IsTrue(cut.Find("input[aria-label='Done: Buy milk']").HasAttribute("checked"));No lab, renomear a classe para "task is-complete" deixa os 14 testes verdes,
enquanto um teste escrito como cut.FindAll("li.done") fica vermelho numa mudança
que não quebrou nada. Um elemento difícil de selecionar sem classe CSS muitas
vezes está sem nome acessível, o que já é um bug por si só.
Os testes protegem os componentes?
Quebre o código de produção de propósito, uma mudança por vez. Estes são os resultados para a solution do lab:
| Mudança no código de produção | Testes que falham |
|---|---|
OnInitializedAsync aguarda o store mas nunca atribui a lista |
8 testes de TaskList, incluindo os dois de carregamento |
O delete ignora a resposta do confirm |
DecliningTheDeleteKeepsTheTask |
| Marcar uma tarefa não a salva | TickingTheCheckboxMarksTheTaskDoneAndSavesIt |
Roles="lead" removido do AuthorizeView |
OnlyALeadSeesClearCompleted |
O HideCompleted em cascata é ignorado |
HideCompletedFromTheCascadingSettingsHidesDoneTasks |
A chamada taskBoard.announce é removida |
AddingATaskSavesItShowsItAndAnnouncesIt |
<DataAnnotationsValidator /> removido |
os dois testes de formulário inválido |
O contador desabilita em count > Max |
AtTheLimitTheButtonIsDisabledAndTheCallbackGetsTheCount |
| A classe CSS de uma tarefa concluída renomeada | nenhum, como deve ser |
A primeira linha é a que o teste ingênuo do store vazio deixa passar.
O que o bUnit não testa
O bUnit renderiza em memória com o próprio renderer. Ele consegue emular um render
mode (SetAssignedRenderMode, SetRendererInfo) para que um branch em
RendererInfo.IsInteractive seja testável, mas não roda um circuito do Blazor
Server, WebAssembly nem prerendering. Ele não roda JavaScript de verdade (só os
setups que você escreveu), não aplica CSS nem layout, e o
BunitNavigationManager registra as chamadas a NavigateTo sem um browser por
trás. Roteamento entre páginas, uma reconexão do SignalR, um módulo JS que lança
erro ou um botão escondido atrás de outro elemento pedem um teste end-to-end, por
exemplo com Playwright contra a aplicação rodando.
Trade-offs
- bUnit vs Playwright. Testes bUnit rodam em milissegundos e apontam o componente; o Playwright testa a aplicação que o usuário vê, com JS, CSS e render modes reais, e custa segundos por teste e um servidor rodando. Muitos testes bUnit e poucos end-to-end é a divisão usual.
- JSInterop estrito dá mais setup. Cada chamada JS precisa de uma linha, e uma
chamada nova no componente quebra testes antigos. Essa quebra é informação: o
teste descobre que o componente agora depende de JavaScript.csharp
JSInterop.Mode = JSRuntimeMode.Loose; // por teste, quando o JS realmente não importa - Fakes escritos à mão vs mocks. Um store fake com a trava
HoldLoadingdeixa o estado de carregamento explícito e não precisa de biblioteca; uma biblioteca de mock é mais curta para uma resposta avulsa, mas tende a verificar chamadas em vez do resultado renderizado. MarkupMatchesem fragmentos grandes é frágil. A comparação semântica perdoa espaços e ordem de atributos, não uma<div>nova envolvendo tudo. Guarde-o para fragmentos pequenos e estáveis.- Herdar
BunitContextgasta a única classe base. Uma base compartilhada dos seus testes passa a ter de herdarBunitContext, ou o teste cria umBunitContexte chamactx.Render. - Testes em arquivos
.razorsão uma opção. O bUnit também renderiza fragmentos Razor escritos inline (Render(@<Counter Max="2" />)), e a documentação dele define o SDK do projeto de teste comoMicrosoft.NET.Sdk.Razorpara isso. Eles ficam legíveis para componentes cheios de markup. O lab escreve só testes.cs, e o projeto de teste roda noMicrosoft.NET.Sdkcomum. - xUnit e NUnit funcionam igual. O bUnit não depende de framework; com xUnit a
classe de teste herda
BunitContextdo mesmo jeito, e[Fact]substitui[TestMethod].