Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ All notable changes to **bUnit** will be documented in this file. The project ad
### Added

- New overloads to WaitForHelpers to have async assertions and predicates. Reported by [@radmorecameron](https://github.com/radmorecameron) in #1833. Fixed by [@linkdotnet](https://github.com/linkdotnet).
- `AddAsset` to `BunitContext` to seed the `ResourceAssetCollection` exposed via `ComponentBase.Assets`. Reported by [LasseHerget](https://github.com/LasseHerget) in #1846. Implemented by [@linkdotnet](https://github.com/linkdotnet).

## [2.7.2] - 2026-03-31

Expand Down
51 changes: 51 additions & 0 deletions docs/site/docs/providing-input/seeding-assets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
---
uid: seeding-assets
title: Seeding static assets (Assets)
---

# Seeding static assets (`Assets`)

This article explains how to seed the `Assets` property of components under test in bUnit. This is supported for .NET 9 and later.

Since .NET 9, components can access static assets mapped by [`MapStaticAssets`](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/map-static-files?view=aspnetcore-9.0) through the [`ComponentBase.Assets`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.components.componentbase.assets?view=aspnetcore-9.0) property, e.g. to resolve fingerprinted URLs:

```razor
<img src="@Assets["img.png"]" />
```

By default, bUnit's renderer returns an empty [`ResourceAssetCollection`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.components.resourceassetcollection?view=aspnetcore-9.0), which matches an app that does not use `MapStaticAssets`. The indexer then returns the passed-in key unchanged, i.e. `Assets["img.png"]` returns `"img.png"`, and iterating over `Assets` yields no items.

## Adding assets

Use the `AddAsset` method on `BunitContext` to add assets before rendering a component. Passing a `label` maps the stable asset key to its (fingerprinted) URL:

```csharp
[Fact]
public void Image_uses_fingerprinted_url()
{
AddAsset("img.abc123.png", label: "img.png");

var cut = Render<ImageComponent>();

cut.MarkupMatches(@"<img src=""img.abc123.png"" />");
}
```

Assets can also carry additional properties, which components can read when iterating the collection:

```csharp
[Fact]
public void Component_lists_subresources()
{
AddAsset("css/app.abc123.css", label: "css/app.css");
AddAsset("js/app.def456.js", label: "js/app.js", new ResourceAssetProperty("integrity", "sha256-..."));

var cut = Render<SubresourceListingComponent>();

// component iterates Assets and reads each asset's "label" property
cut.MarkupMatches(@"<span>css/app.css</span><span>js/app.js</span>");
}
```

> [!NOTE]
> The `"label"` property is the convention `ResourceAssetCollection` uses to build its key → URL mapping, just like `MapStaticAssets` does in production. An asset added without a label is part of the collection when iterating, but the indexer will not map any key to it.
1 change: 1 addition & 0 deletions docs/site/docs/toc.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
## [Controlling the root render tree](xref:root-render-tree)
## [Substituting (mocking) component](xref:substituting-components)
## [Configure 3rd party libraries](xref:configure-3rd-party-libs)
## [Seeding static assets](xref:seeding-assets)

# [Interaction](xref:interaction)
## [Trigger event handlers](xref:trigger-event-handlers)
Expand Down
17 changes: 17 additions & 0 deletions src/bunit/BunitContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,23 @@ public void SetRendererInfo(RendererInfo? rendererInfo)
{
Renderer.SetRendererInfo(rendererInfo);
}

/// <summary>
/// Adds an asset to the <see cref="ResourceAssetCollection"/> that components
/// rendered with this <see cref="BunitContext"/> can access through their <c>Assets</c> property.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider referencing the assets property instead of just mentioning it:

Suggested change
/// rendered with this <see cref="BunitContext"/> can access through their <c>Assets</c> property.
/// rendered with this <see cref="BunitContext"/> can access through their <see cref="ComponentBase.Assets"/> property.

/// </summary>
/// <remarks>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider inheriting the documentation (arguments and remarks) from the BunitRenderer, to avoid redundancy:

	/// <summary>
	/// Adds an asset to the <see cref="ResourceAssetCollection"/> that components
	/// rendered with this <see cref="BunitContext"/> can access through their <see cref="ComponentBase.Assets"/> property.
	/// </summary>
	/// <inheritdoc cref="BunitRenderer.AddAsset"/>

/// Pass a <paramref name="label"/> to map a stable asset key to its (fingerprinted) <paramref name="url"/>,
/// i.e. <c>AddAsset("img.abc123.png", label: "img.png")</c> makes <c>Assets["img.png"]</c> return <c>img.abc123.png</c>.
/// Adding multiple assets with the same label results in an <see cref="InvalidOperationException"/>
/// when the <see cref="Renderer.Assets"/> property is first accessed.
/// </remarks>
Comment on lines +192 to +197
/// <param name="url">The url of the asset.</param>
/// <param name="label">The label of the asset, used as the lookup key by the <see cref="ResourceAssetCollection"/> indexer. Pass <see langword="null"/> to add the asset without a label.</param>
/// <param name="properties">Additional properties to associate with the asset.</param>
[SuppressMessage("Design", "CA1054:URI-like parameters should not be strings", Justification = "Using string to align with ResourceAsset")]
public void AddAsset(string url, string? label = null, params ResourceAssetProperty[] properties)
=> Renderer.AddAsset(url, label, properties);
#endif

/// <summary>
Expand Down
50 changes: 50 additions & 0 deletions src/bunit/Rendering/BunitRenderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,56 @@ public void SetRendererInfo(RendererInfo? rendererInfo)
{
this.rendererInfo = rendererInfo;
}

private readonly List<ResourceAsset> resourceAssets = [];
private ResourceAssetCollection? resourceAssetCollection;

/// <inheritdoc/>
protected override ResourceAssetCollection Assets
=> resourceAssets.Count == 0
? ResourceAssetCollection.Empty
: resourceAssetCollection ??= new ResourceAssetCollection(resourceAssets);

/// <summary>
/// Adds an asset to the <see cref="ResourceAssetCollection"/> returned by the renderers <see cref="Assets"/> property,
/// which components rendered by this renderer can access through their <c>Assets</c> property.
/// </summary>
/// <remarks>
/// Pass a <paramref name="label"/> to map a stable asset key to its (fingerprinted) <paramref name="url"/>,
/// i.e. <c>AddAsset("img.abc123.png", label: "img.png")</c> makes <c>Assets["img.png"]</c> return <c>img.abc123.png</c>.
/// Adding multiple assets with the same label results in an <see cref="InvalidOperationException"/>
/// when the <see cref="Assets"/> property is first accessed.
/// </remarks>
/// <param name="url">The url of the asset.</param>
/// <param name="label">The label of the asset, used as the lookup key by the <see cref="ResourceAssetCollection"/> indexer. Pass <see langword="null"/> to add the asset without a label.</param>
/// <param name="properties">Additional properties to associate with the asset.</param>
[SuppressMessage("Design", "CA1054:URI-like parameters should not be strings", Justification = "Using string to align with ResourceAsset")]
public void AddAsset(string url, string? label = null, params ResourceAssetProperty[] properties)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion

Consider using an IEnumerable for the properties to support all enumerations:

	public void AddAsset(string url, string? label = null, params IEnumerable<ResourceAssetProperty> properties)

That would require...

  1. Since an IEnumerable does not have a length property, we'd need to initialize the props direct with the items:

    var props = new List<ResourceAssetProperty>(properties);

    or

    var props = properties.ToList();
  2. To avoid duplicate enumeration, we'd have to adjust the check for duplicate label property:

    if (props.Any(static property => string.Equals(property.Name, "label", StringComparison.Ordinal)))
  3. We'd have to consolidate the call from the BunitContext.

{
ArgumentException.ThrowIfNullOrEmpty(url);
ArgumentNullException.ThrowIfNull(properties);

var props = new List<ResourceAssetProperty>(properties.Length + 1);
if (label is not null)
{
if (string.IsNullOrWhiteSpace(label))
{
throw new ArgumentException("The label must not be empty or whitespace.", nameof(label));
}

if (properties.Any(static property => string.Equals(property.Name, "label", StringComparison.Ordinal)))
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider declaring a constant for the label property.

{
throw new ArgumentException("The label property is reserved when a label is provided.", nameof(properties));
}

props.Add(new ResourceAssetProperty("label", label));
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Personal preference

IDE0090: Simplify new expression

Suggested change
props.Add(new ResourceAssetProperty("label", label));
props.Add(new("label", label));

Similar suggestion for line 127.

}

props.AddRange(properties);
Comment on lines +104 to +125

resourceAssets.Add(new ResourceAsset(url, props.Count > 0 ? props : null));
resourceAssetCollection = null;
}
#endif

/// <summary>
Expand Down
7 changes: 7 additions & 0 deletions tests/bunit.testassets/Assets/AssetsIndexerComponent.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
@{
#if NET9_0_OR_GREATER
}
<img src="@Assets["img.png"]" />
@{
#endif
}
21 changes: 21 additions & 0 deletions tests/bunit.testassets/Assets/AssetsIterationComponent.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
@{
#if NET9_0_OR_GREATER
}
@foreach (var name in SubresourceNames)
{
<span>@name</span>
}
@{
#endif
}
@code {
#if NET9_0_OR_GREATER
private IReadOnlyList<string> SubresourceNames { get; set; } = Array.Empty<string>();

protected override void OnInitialized()
=> SubresourceNames = Assets
.Select(asset => asset.Properties?.SingleOrDefault(property => property.Name == "label")?.Value)
.OfType<string>()
.ToList();
Comment on lines +16 to +19
#endif
}
15 changes: 15 additions & 0 deletions tests/bunit.testassets/Assets/AssetsPropertiesComponent.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
@{
#if NET9_0_OR_GREATER
}
@foreach (var asset in Assets)
{
<ul data-url="@asset.Url">
@foreach (var property in asset.Properties ?? Array.Empty<ResourceAssetProperty>())
{
<li>@property.Name=@property.Value</li>
}
</ul>
}
@{
#endif
}
106 changes: 106 additions & 0 deletions tests/bunit.tests/Rendering/AssetsTest.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
@code{
#if NET9_0_OR_GREATER
}
@using Bunit.TestAssets.Assets;
@inherits BunitContext
@code {
Comment on lines +1 to +6
[Fact(DisplayName = "Assets defaults to an empty collection where the indexer returns the key unchanged")]
public void Test001()
{
var cut = Render<AssetsIndexerComponent>();

cut.MarkupMatches(@<img src="img.png" />);
}

[Fact(DisplayName = "Iterating Assets without any added assets renders nothing")]
public void Test002()
{
var cut = Render<AssetsIterationComponent>();

cut.MarkupMatches(string.Empty);
}

[Fact(DisplayName = "AddAsset with label maps the asset key to the fingerprinted url")]
public void Test003()
{
AddAsset("img.abc123.png", label: "img.png");

var cut = Render<AssetsIndexerComponent>();

cut.MarkupMatches(@<img src="img.abc123.png" />);
}

[Fact(DisplayName = "Components can iterate added assets and read their label property")]
public void Test004()
{
AddAsset("css/app.abc123.css", label: "css/app.css");
AddAsset("js/app.def456.js", label: "js/app.js");

var cut = Render<AssetsIterationComponent>();

cut.MarkupMatches(
@<text>
<span>css/app.css</span>
<span>js/app.js</span>
</text>);
}

[Fact(DisplayName = "AddAsset without label does not map the asset key")]
public void Test005()
{
AddAsset("img.png");

var cut = Render<AssetsIndexerComponent>();

cut.MarkupMatches(@<img src="img.png" />);
}

[Fact(DisplayName = "AddAsset exposes additional properties to components")]
public void Test006()
{
AddAsset("js/app.js", label: "app.js", new ResourceAssetProperty("integrity", "sha256-abc"));

var cut = Render<AssetsPropertiesComponent>();

cut.MarkupMatches(
@<ul data-url="js/app.js">
<li>label=app.js</li>
<li>integrity=sha256-abc</li>
</ul>);
}

[Fact(DisplayName = "Assets added after the initial render are available to components rendered afterwards")]
public void Test007()
{
var first = Render<AssetsIterationComponent>();
first.MarkupMatches(string.Empty);

AddAsset("img.abc123.png", label: "img.png");

var second = Render<AssetsIterationComponent>();
second.MarkupMatches(@<span>img.png</span>);
}

[Theory(DisplayName = "AddAsset rejects empty or whitespace-only labels")]
[InlineData("")]
[InlineData(" ")]
[InlineData("\t")]
public void Test008(string label)
{
var ex = Assert.Throws<ArgumentException>(() => AddAsset("app.js", label: label));

ex.ParamName.ShouldBe("label");
}

[Fact(DisplayName = "AddAsset rejects explicit label properties when label is provided")]
public void Test009()
{
var ex = Assert.Throws<ArgumentException>(
() => AddAsset("app.js", label: "app.js", new ResourceAssetProperty("label", "other")));

ex.ParamName.ShouldBe("properties");
}
}
@code{
#endif
}
Comment on lines +104 to +106
Loading