Skip to content

Commit 6cb4df6

Browse files
committed
feat: Add Asset handling
1 parent 2f9878d commit 6cb4df6

9 files changed

Lines changed: 267 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ All notable changes to **bUnit** will be documented in this file. The project ad
99
### Added
1010

1111
- 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).
12+
- `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).
1213

1314
## [2.7.2] - 2026-03-31
1415

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
---
2+
uid: seeding-assets
3+
title: Seeding static assets (Assets)
4+
---
5+
6+
# Seeding static assets (`Assets`)
7+
8+
This article explains how to seed the `Assets` property of components under test in bUnit. This is supported for .NET 9 and later.
9+
10+
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:
11+
12+
```razor
13+
<img src="@Assets["img.png"]" />
14+
```
15+
16+
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.
17+
18+
## Adding assets
19+
20+
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:
21+
22+
```csharp
23+
[Fact]
24+
public void Image_uses_fingerprinted_url()
25+
{
26+
AddAsset("img.abc123.png", label: "img.png");
27+
28+
var cut = Render<ImageComponent>();
29+
30+
cut.MarkupMatches(@"<img src=""img.abc123.png"" />");
31+
}
32+
```
33+
34+
Assets can also carry additional properties, which components can read when iterating the collection:
35+
36+
```csharp
37+
[Fact]
38+
public void Component_lists_subresources()
39+
{
40+
AddAsset("css/app.abc123.css", label: "css/app.css");
41+
AddAsset("js/app.def456.js", label: "js/app.js", new ResourceAssetProperty("integrity", "sha256-..."));
42+
43+
var cut = Render<SubresourceListingComponent>();
44+
45+
// component iterates Assets and reads each asset's "label" property
46+
cut.MarkupMatches(@"<span>css/app.css</span><span>js/app.js</span>");
47+
}
48+
```
49+
50+
> [!NOTE]
51+
> 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.

docs/site/docs/toc.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
## [Controlling the root render tree](xref:root-render-tree)
99
## [Substituting (mocking) component](xref:substituting-components)
1010
## [Configure 3rd party libraries](xref:configure-3rd-party-libs)
11+
## [Seeding static assets](xref:seeding-assets)
1112

1213
# [Interaction](xref:interaction)
1314
## [Trigger event handlers](xref:trigger-event-handlers)

src/bunit/BunitContext.cs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,23 @@ public void SetRendererInfo(RendererInfo? rendererInfo)
184184
{
185185
Renderer.SetRendererInfo(rendererInfo);
186186
}
187+
188+
/// <summary>
189+
/// Adds an asset to the <see cref="ResourceAssetCollection"/> that components
190+
/// rendered with this <see cref="BunitContext"/> can access through their <see cref="ComponentBase.Assets"/> property.
191+
/// </summary>
192+
/// <remarks>
193+
/// Pass a <paramref name="label"/> to map a stable asset key to its (fingerprinted) <paramref name="url"/>,
194+
/// i.e. <c>AddAsset("img.abc123.png", label: "img.png")</c> makes <c>Assets["img.png"]</c> return <c>img.abc123.png</c>.
195+
/// Adding multiple assets with the same label results in an <see cref="InvalidOperationException"/>
196+
/// when the <see cref="Renderer.Assets"/> property is first accessed.
197+
/// </remarks>
198+
/// <param name="url">The url of the asset.</param>
199+
/// <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>
200+
/// <param name="properties">Additional properties to associate with the asset.</param>
201+
[SuppressMessage("Design", "CA1054:URI-like parameters should not be strings", Justification = "Using string to align with ResourceAsset")]
202+
public void AddAsset(string url, string? label = null, params ResourceAssetProperty[] properties)
203+
=> Renderer.AddAsset(url, label, properties);
187204
#endif
188205

189206
/// <summary>

src/bunit/Rendering/BunitRenderer.cs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,54 @@ public void SetRendererInfo(RendererInfo? rendererInfo)
7777
{
7878
this.rendererInfo = rendererInfo;
7979
}
80+
81+
private readonly List<ResourceAsset> resourceAssets = [];
82+
private ResourceAssetCollection? resourceAssetCollection;
83+
84+
/// <inheritdoc/>
85+
protected override ResourceAssetCollection Assets
86+
=> resourceAssets.Count == 0
87+
? ResourceAssetCollection.Empty
88+
: resourceAssetCollection ??= new ResourceAssetCollection(resourceAssets);
89+
90+
/// <summary>
91+
/// Adds an asset to the <see cref="ResourceAssetCollection"/> returned by the renderers <see cref="Assets"/> property,
92+
/// which components rendered by this renderer can access through their <see cref="ComponentBase.Assets"/> property.
93+
/// </summary>
94+
/// <remarks>
95+
/// Pass a <paramref name="label"/> to map a stable asset key to its (fingerprinted) <paramref name="url"/>,
96+
/// i.e. <c>AddAsset("img.abc123.png", label: "img.png")</c> makes <c>Assets["img.png"]</c> return <c>img.abc123.png</c>.
97+
/// Adding multiple assets with the same label results in an <see cref="InvalidOperationException"/>
98+
/// when the <see cref="Assets"/> property is first accessed.
99+
/// </remarks>
100+
/// <param name="url">The url of the asset.</param>
101+
/// <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>
102+
/// <param name="properties">Additional properties to associate with the asset.</param>
103+
[SuppressMessage("Design", "CA1054:URI-like parameters should not be strings", Justification = "Using string to align with ResourceAsset")]
104+
public void AddAsset(string url, string? label = null, params ResourceAssetProperty[] properties)
105+
{
106+
ArgumentException.ThrowIfNullOrEmpty(url);
107+
ArgumentNullException.ThrowIfNull(properties);
108+
109+
var props = new List<ResourceAssetProperty>(properties.Length + 1);
110+
if (label is not null)
111+
{
112+
ArgumentException.ThrowIfNullOrWhiteSpace(label);
113+
const string labelMarker = "label";
114+
115+
if (properties.Any(static property => string.Equals(property.Name, labelMarker, StringComparison.Ordinal)))
116+
{
117+
throw new ArgumentException("The label property is reserved when a label is provided.", nameof(properties));
118+
}
119+
120+
props.Add(new ResourceAssetProperty(labelMarker, label));
121+
}
122+
123+
props.AddRange(properties);
124+
125+
resourceAssets.Add(new ResourceAsset(url, props.Count > 0 ? props : null));
126+
resourceAssetCollection = null;
127+
}
80128
#endif
81129

82130
/// <summary>
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
@{
2+
#if NET9_0_OR_GREATER
3+
}
4+
<img src="@Assets["img.png"]" />
5+
@{
6+
#endif
7+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
@{
2+
#if NET9_0_OR_GREATER
3+
}
4+
@foreach (var name in SubresourceNames)
5+
{
6+
<span>@name</span>
7+
}
8+
@{
9+
#endif
10+
}
11+
@code {
12+
#if NET9_0_OR_GREATER
13+
private IReadOnlyList<string> SubresourceNames { get; set; } = Array.Empty<string>();
14+
15+
protected override void OnInitialized()
16+
=> SubresourceNames = Assets
17+
.Select(asset => asset.Properties?.SingleOrDefault(property => property.Name == "label")?.Value)
18+
.OfType<string>()
19+
.ToList();
20+
#endif
21+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
@{
2+
#if NET9_0_OR_GREATER
3+
}
4+
@foreach (var asset in Assets)
5+
{
6+
<ul data-url="@asset.Url">
7+
@foreach (var property in asset.Properties ?? Array.Empty<ResourceAssetProperty>())
8+
{
9+
<li>@property.Name=@property.Value</li>
10+
}
11+
</ul>
12+
}
13+
@{
14+
#endif
15+
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
@code{
2+
#if NET9_0_OR_GREATER
3+
}
4+
@using Bunit.TestAssets.Assets;
5+
@inherits BunitContext
6+
@code {
7+
[Fact(DisplayName = "Assets defaults to an empty collection where the indexer returns the key unchanged")]
8+
public void Test001()
9+
{
10+
var cut = Render<AssetsIndexerComponent>();
11+
12+
cut.MarkupMatches(@<img src="img.png" />);
13+
}
14+
15+
[Fact(DisplayName = "Iterating Assets without any added assets renders nothing")]
16+
public void Test002()
17+
{
18+
var cut = Render<AssetsIterationComponent>();
19+
20+
cut.MarkupMatches(string.Empty);
21+
}
22+
23+
[Fact(DisplayName = "AddAsset with label maps the asset key to the fingerprinted url")]
24+
public void Test003()
25+
{
26+
AddAsset("img.abc123.png", label: "img.png");
27+
28+
var cut = Render<AssetsIndexerComponent>();
29+
30+
cut.MarkupMatches(@<img src="img.abc123.png" />);
31+
}
32+
33+
[Fact(DisplayName = "Components can iterate added assets and read their label property")]
34+
public void Test004()
35+
{
36+
AddAsset("css/app.abc123.css", label: "css/app.css");
37+
AddAsset("js/app.def456.js", label: "js/app.js");
38+
39+
var cut = Render<AssetsIterationComponent>();
40+
41+
cut.MarkupMatches(
42+
@<text>
43+
<span>css/app.css</span>
44+
<span>js/app.js</span>
45+
</text>);
46+
}
47+
48+
[Fact(DisplayName = "AddAsset without label does not map the asset key")]
49+
public void Test005()
50+
{
51+
AddAsset("img.png");
52+
53+
var cut = Render<AssetsIndexerComponent>();
54+
55+
cut.MarkupMatches(@<img src="img.png" />);
56+
}
57+
58+
[Fact(DisplayName = "AddAsset exposes additional properties to components")]
59+
public void Test006()
60+
{
61+
AddAsset("js/app.js", label: "app.js", new ResourceAssetProperty("integrity", "sha256-abc"));
62+
63+
var cut = Render<AssetsPropertiesComponent>();
64+
65+
cut.MarkupMatches(
66+
@<ul data-url="js/app.js">
67+
<li>label=app.js</li>
68+
<li>integrity=sha256-abc</li>
69+
</ul>);
70+
}
71+
72+
[Fact(DisplayName = "Assets added after the initial render are available to components rendered afterwards")]
73+
public void Test007()
74+
{
75+
var first = Render<AssetsIterationComponent>();
76+
first.MarkupMatches(string.Empty);
77+
78+
AddAsset("img.abc123.png", label: "img.png");
79+
80+
var second = Render<AssetsIterationComponent>();
81+
second.MarkupMatches(@<span>img.png</span>);
82+
}
83+
84+
[Theory(DisplayName = "AddAsset rejects empty or whitespace-only labels")]
85+
[InlineData("")]
86+
[InlineData(" ")]
87+
[InlineData("\t")]
88+
public void Test008(string label)
89+
{
90+
var ex = Assert.Throws<ArgumentException>(() => AddAsset("app.js", label: label));
91+
92+
ex.ParamName.ShouldBe("label");
93+
}
94+
95+
[Fact(DisplayName = "AddAsset rejects explicit label properties when label is provided")]
96+
public void Test009()
97+
{
98+
var ex = Assert.Throws<ArgumentException>(
99+
() => AddAsset("app.js", label: "app.js", new ResourceAssetProperty("label", "other")));
100+
101+
ex.ParamName.ShouldBe("properties");
102+
}
103+
}
104+
@code{
105+
#endif
106+
}

0 commit comments

Comments
 (0)