Blazor Testing
Test Project Setup
dotnet new xunit -n MyApp.Tests
dotnet add MyApp.Tests package bunit
dotnet add MyApp.Tests package FluentAssertions
dotnet add MyApp.Tests package NSubstitute
dotnet add MyApp.Tests package Microsoft.AspNetCore.Mvc.Testing
dotnet add MyApp.Tests package Testcontainers.PostgreSql # For real DB tests
bUnit Component Tests
public sealed class CounterTests : TestContext
{
[Fact]
public void Counter_StartsAtZero()
{
var cut = RenderComponent<Counter>();
cut.Find("p").TextContent.Should().Contain("0");
}
[Fact]
public void Counter_IncrementsOnClick()
{
var cut = RenderComponent<Counter>();
cut.Find("button").Click();
cut.Find("p").TextContent.Should().Contain("1");
}
}
Testing with Parameters and Services
public sealed class ProductListTests : TestContext
{
[Fact]
public async Task Should_Display_Products()
{
var mockService = Substitute.For<IProductService>();
mockService.GetAllAsync(Arg.Any<CancellationToken>())
.Returns(new List<ProductDto>
{
new(1, "Widget", 9.99m),
new(2, "Gadget", 19.99m)
});
Services.AddSingleton(mockService);
var cut = RenderComponent<ProductList>();
cut.WaitForState(() => cut.FindAll(".product-card").Count > 0);
cut.FindAll(".product-card").Should().HaveCount(2);
cut.Markup.Should().Contain("Widget");
}
}
Testing Forms
[Fact]
public void Form_Validates_Required_Fields()
{
var cut = RenderComponent<CreateProductForm>();
cut.Find("form").Submit();
cut.FindAll(".validation-message").Should().NotBeEmpty();
}
[Fact]
public void Form_Submits_Valid_Data()
{
var mockService = Substitute.For<IProductService>();
Services.AddSingleton(mockService);
var cut = RenderComponent<CreateProductForm>();
cut.Find("#name").Change("New Product");
cut.Find("#price").Change("29.99");
cut.Find("form").Submit();
mockService.Received(1).CreateAsync(
Arg.Is<CreateProductRequest>(r => r.Name == "New Product"),
Arg.Any<CancellationToken>());
}
API Integration Tests
public sealed class ProductApiTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;
public ProductApiTests(WebApplicationFactory<Program> factory)
{
_client = factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
services.RemoveAll<AppDbContext>();
services.AddDbContext<AppDbContext>(opts =>
opts.UseInMemoryDatabase($"Test-{Guid.NewGuid()}"));
});
}).CreateClient();
}
[Fact]
public async Task GetProducts_ReturnsOk()
{
var response = await _client.GetAsync("/api/products");
response.StatusCode.Should().Be(HttpStatusCode.OK);
}
[Theory]
[InlineData("", HttpStatusCode.BadRequest)]
[InlineData("Valid Name", HttpStatusCode.Created)]
public async Task CreateProduct_ValidatesInput(string name, HttpStatusCode expected)
{
var response = await _client.PostAsJsonAsync("/api/products",
new { Name = name, Price = 10.0 });
response.StatusCode.Should().Be(expected);
}
}
Test with Real Database (Testcontainers)
public sealed class ProductDbTests : IAsyncLifetime
{
private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder()
.WithImage("postgres:16")
.Build();
public async Task InitializeAsync() => await _postgres.StartAsync();
public async Task DisposeAsync() => await _postgres.DisposeAsync();
[Fact]
public async Task Can_Create_And_Read_Product()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseNpgsql(_postgres.GetConnectionString())
.Options;
await using var db = new AppDbContext(options);
await db.Database.EnsureCreatedAsync();
db.Products.Add(new Product { Name = "Test", Price = 9.99m });
await db.SaveChangesAsync();
var product = await db.Products.FirstAsync();
product.Name.Should().Be("Test");
}
}