diff --git a/CHANGELOG.md b/CHANGELOG.md
index 62999cca5..3783df78d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/docs/site/docs/providing-input/seeding-assets.md b/docs/site/docs/providing-input/seeding-assets.md
new file mode 100644
index 000000000..af523698a
--- /dev/null
+++ b/docs/site/docs/providing-input/seeding-assets.md
@@ -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
+
+```
+
+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();
+
+ cut.MarkupMatches(@"
");
+}
+```
+
+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();
+
+ // component iterates Assets and reads each asset's "label" property
+ cut.MarkupMatches(@"css/app.cssjs/app.js");
+}
+```
+
+> [!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.
diff --git a/docs/site/docs/toc.md b/docs/site/docs/toc.md
index 2f4bebfea..53fe7e811 100644
--- a/docs/site/docs/toc.md
+++ b/docs/site/docs/toc.md
@@ -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)
diff --git a/src/bunit/BunitContext.cs b/src/bunit/BunitContext.cs
index cb3aea4d9..6783f63c8 100644
--- a/src/bunit/BunitContext.cs
+++ b/src/bunit/BunitContext.cs
@@ -184,6 +184,23 @@ public void SetRendererInfo(RendererInfo? rendererInfo)
{
Renderer.SetRendererInfo(rendererInfo);
}
+
+ ///
+ /// Adds an asset to the that components
+ /// rendered with this can access through their property.
+ ///
+ ///
+ /// Pass a to map a stable asset key to its (fingerprinted) ,
+ /// i.e. AddAsset("img.abc123.png", label: "img.png") makes Assets["img.png"] return img.abc123.png.
+ /// Adding multiple assets with the same label results in an
+ /// when the property is first accessed.
+ ///
+ /// The url of the asset.
+ /// The label of the asset, used as the lookup key by the indexer. Pass to add the asset without a label.
+ /// Additional properties to associate with the asset.
+ [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
///
diff --git a/src/bunit/Rendering/BunitRenderer.cs b/src/bunit/Rendering/BunitRenderer.cs
index 5a9d47731..88740fcbc 100644
--- a/src/bunit/Rendering/BunitRenderer.cs
+++ b/src/bunit/Rendering/BunitRenderer.cs
@@ -77,6 +77,54 @@ public void SetRendererInfo(RendererInfo? rendererInfo)
{
this.rendererInfo = rendererInfo;
}
+
+ private readonly List resourceAssets = [];
+ private ResourceAssetCollection? resourceAssetCollection;
+
+ ///
+ protected override ResourceAssetCollection Assets
+ => resourceAssets.Count == 0
+ ? ResourceAssetCollection.Empty
+ : resourceAssetCollection ??= new ResourceAssetCollection(resourceAssets);
+
+ ///
+ /// Adds an asset to the returned by the renderers property,
+ /// which components rendered by this renderer can access through their property.
+ ///
+ ///
+ /// Pass a to map a stable asset key to its (fingerprinted) ,
+ /// i.e. AddAsset("img.abc123.png", label: "img.png") makes Assets["img.png"] return img.abc123.png.
+ /// Adding multiple assets with the same label results in an
+ /// when the property is first accessed.
+ ///
+ /// The url of the asset.
+ /// The label of the asset, used as the lookup key by the indexer. Pass to add the asset without a label.
+ /// Additional properties to associate with the asset.
+ [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)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(url);
+ ArgumentNullException.ThrowIfNull(properties);
+
+ var props = new List(properties.Length + 1);
+ if (label is not null)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(label);
+ const string labelMarker = "label";
+
+ if (properties.Any(static property => string.Equals(property.Name, labelMarker, StringComparison.Ordinal)))
+ {
+ throw new ArgumentException("The label property is reserved when a label is provided.", nameof(properties));
+ }
+
+ props.Add(new ResourceAssetProperty(labelMarker, label));
+ }
+
+ props.AddRange(properties);
+
+ resourceAssets.Add(new ResourceAsset(url, props.Count > 0 ? props : null));
+ resourceAssetCollection = null;
+ }
#endif
///
diff --git a/tests/bunit.testassets/Assets/AssetsIndexerComponent.razor b/tests/bunit.testassets/Assets/AssetsIndexerComponent.razor
new file mode 100644
index 000000000..3b3a7cb3d
--- /dev/null
+++ b/tests/bunit.testassets/Assets/AssetsIndexerComponent.razor
@@ -0,0 +1,7 @@
+@{
+#if NET9_0_OR_GREATER
+}
+
+@{
+#endif
+}
diff --git a/tests/bunit.testassets/Assets/AssetsIterationComponent.razor b/tests/bunit.testassets/Assets/AssetsIterationComponent.razor
new file mode 100644
index 000000000..f6dfbe340
--- /dev/null
+++ b/tests/bunit.testassets/Assets/AssetsIterationComponent.razor
@@ -0,0 +1,21 @@
+@{
+#if NET9_0_OR_GREATER
+}
+@foreach (var name in SubresourceNames)
+{
+ @name
+}
+@{
+#endif
+}
+@code {
+#if NET9_0_OR_GREATER
+ private IReadOnlyList SubresourceNames { get; set; } = Array.Empty();
+
+ protected override void OnInitialized()
+ => SubresourceNames = Assets
+ .Select(asset => asset.Properties?.SingleOrDefault(property => property.Name == "label")?.Value)
+ .OfType()
+ .ToList();
+#endif
+}
diff --git a/tests/bunit.testassets/Assets/AssetsPropertiesComponent.razor b/tests/bunit.testassets/Assets/AssetsPropertiesComponent.razor
new file mode 100644
index 000000000..bb4d4ffab
--- /dev/null
+++ b/tests/bunit.testassets/Assets/AssetsPropertiesComponent.razor
@@ -0,0 +1,15 @@
+@{
+#if NET9_0_OR_GREATER
+}
+@foreach (var asset in Assets)
+{
+
+ @foreach (var property in asset.Properties ?? Array.Empty())
+ {
+ - @property.Name=@property.Value
+ }
+
+}
+@{
+#endif
+}
diff --git a/tests/bunit.tests/Rendering/AssetsTest.razor b/tests/bunit.tests/Rendering/AssetsTest.razor
new file mode 100644
index 000000000..3f2dd16aa
--- /dev/null
+++ b/tests/bunit.tests/Rendering/AssetsTest.razor
@@ -0,0 +1,106 @@
+@code{
+ #if NET9_0_OR_GREATER
+}
+@using Bunit.TestAssets.Assets;
+@inherits BunitContext
+@code {
+ [Fact(DisplayName = "Assets defaults to an empty collection where the indexer returns the key unchanged")]
+ public void Test001()
+ {
+ var cut = Render();
+
+ cut.MarkupMatches(@
);
+ }
+
+ [Fact(DisplayName = "Iterating Assets without any added assets renders nothing")]
+ public void Test002()
+ {
+ var cut = Render();
+
+ 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();
+
+ cut.MarkupMatches(@
);
+ }
+
+ [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();
+
+ cut.MarkupMatches(
+ @
+ css/app.css
+ js/app.js
+ );
+ }
+
+ [Fact(DisplayName = "AddAsset without label does not map the asset key")]
+ public void Test005()
+ {
+ AddAsset("img.png");
+
+ var cut = Render();
+
+ cut.MarkupMatches(@
);
+ }
+
+ [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();
+
+ cut.MarkupMatches(
+ @
+ - label=app.js
+ - integrity=sha256-abc
+
);
+ }
+
+ [Fact(DisplayName = "Assets added after the initial render are available to components rendered afterwards")]
+ public void Test007()
+ {
+ var first = Render();
+ first.MarkupMatches(string.Empty);
+
+ AddAsset("img.abc123.png", label: "img.png");
+
+ var second = Render();
+ second.MarkupMatches(@img.png);
+ }
+
+ [Theory(DisplayName = "AddAsset rejects empty or whitespace-only labels")]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("\t")]
+ public void Test008(string label)
+ {
+ var ex = Assert.Throws(() => 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(
+ () => AddAsset("app.js", label: "app.js", new ResourceAssetProperty("label", "other")));
+
+ ex.ParamName.ShouldBe("properties");
+ }
+}
+@code{
+ #endif
+}