-
-
Notifications
You must be signed in to change notification settings - Fork 109
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Support controlling render modes and RendererInfo during testing #1596
Changes from all commits
a32bda1
caef2d1
9609439
0e2dbc1
7fc4591
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,157 @@ | ||
--- | ||
uid: render-modes | ||
title: Render modes and RendererInfo | ||
--- | ||
|
||
# Support for render modes and `RendererInfo` | ||
This article explains how to emulate different render modes and `RendererInfo` in bUnit tests. | ||
|
||
Render modes in Blazor Web Apps determine the hosting model and interactivity of components. A render mode can be applied to a component using the `@rendermode` directive. The [`RendererInfo`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.components.rendererinfo?view=aspnetcore-9.0) allows the application to determine the interactivity and location of the component. For more details, see the [Blazor render modes](https://learn.microsoft.com/en-us/aspnet/core/blazor/components/render-modes?view=aspnetcore-9.0) documentation. | ||
|
||
## Setting the render mode for a component under test | ||
Setting the render mode can be done via the <xref:Bunit.ComponentParameterCollectionBuilder`1.SetAssignedRenderMode(Microsoft.AspNetCore.Components.IComponentRenderMode)> method when writing in a C# file. In a razor file use the `@rendermode` directive. Both take an [`IComponentRenderMode`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.components.icomponentrendermode?view=aspnetcore-9.0) object as a parameter. Normally this is one of the following types: | ||
* [`InteractiveAutoRenderMode`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.components.web.interactiveautorendermode?view=aspnetcore-9.0) | ||
* [`InteractiveServerRendeMode`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.components.web.interactiveserverrendermode?view=aspnetcore-9.0) | ||
* [`InteractiveWebAssemblyRenderMode`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.components.web.interactivewebassemblyrendermode?view=aspnetcore-9.0) | ||
|
||
For ease of use the [`RenderMode`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.components.web.rendermode?view=aspnetcore-9.0) class defines all three of them. | ||
|
||
For example `MovieComponent.razor`: | ||
```razor | ||
@if (AssignedRenderMode is null) | ||
{ | ||
// The render mode is Static Server | ||
<form action="/movies"> | ||
<input type="text" name="titleFilter" /> | ||
<input type="submit" value="Search" /> | ||
</form> | ||
} | ||
else | ||
{ | ||
// The render mode is Interactive Server, WebAssembly, or Auto | ||
<input @bind="titleFilter" /> | ||
<button @onclick="FilterMovies">Search</button> | ||
} | ||
``` | ||
|
||
The following example shows how to test the above component to check both render modes: | ||
|
||
# [C# test code](#tab/csharp) | ||
|
||
```csharp | ||
[Fact] | ||
public void InteractiveServer() | ||
{ | ||
// Act | ||
var cut = RenderComponent<MovieComponent>(ps => ps | ||
.SetAssignedRenderMode(RenderMode.InteractiveServer)); | ||
|
||
// Assert | ||
cut.MarkupMatches(""" | ||
<input diff:ignoreAttributes /> | ||
<button>Search</button> | ||
"""); | ||
} | ||
|
||
[Fact] | ||
public void StaticRendering() | ||
{ | ||
// Act | ||
var cut = RenderComponent<MovieComponent>(); | ||
// This is the same behavior as: | ||
// var cut = RenderComponent<MovieComponent>(ps => ps | ||
// .SetAssignedRenderMode(null)); | ||
|
||
// Assert | ||
cut.MarkupMatches(""" | ||
<form action="/movies"> | ||
<input type="text" name="titleFilter" /> | ||
<input type="submit" value="Search" /> | ||
</form> | ||
"""); | ||
} | ||
``` | ||
|
||
# [Razor test code](#tab/razor) | ||
|
||
```razor | ||
@inherits TestContext | ||
@code { | ||
[Fact] | ||
public void InteractiveServer() | ||
{ | ||
// Act | ||
var cut = Render(@<MovieComponent @rendermode="RenderMode.InteractiveServer" />); | ||
|
||
// Assert | ||
cut.MarkupMatches(@<text> | ||
<input diff:ignoreAttributes /> | ||
<button>Search</button> | ||
</text>); | ||
} | ||
|
||
[Fact] | ||
public void StaticRendering() | ||
{ | ||
// Act | ||
var cut = Render(@<MovieComponent />); | ||
|
||
// Assert | ||
cut.MarkupMatches(@<form action="/movies"> | ||
<input type="text" name="titleFilter" /> | ||
<input type="submit" value="Search" /> | ||
</form>); | ||
} | ||
} | ||
``` | ||
|
||
*** | ||
|
||
## Setting the `RendererInfo` during testing | ||
To control the `ComponentBase.RendererInfo` property during testing, use the <xref:Bunit.TestContextBase.SetRendererInfo(Microsoft.AspNetCore.Components.RendererInfo)> method on the `TestContext` class. The `SetRendererInfo` method takes an nullable `RendererInfo` object as a parameter. Passing `null` will set the `ComponentBase.RendererInfo` to `null`. | ||
|
||
A component (`AssistentComponent.razor`) might check if interactivity is given to enable a button: | ||
|
||
```razor | ||
@if (RendererInfo.IsInteractive) | ||
{ | ||
<p>Hey I am your assistant</p> | ||
} | ||
else | ||
{ | ||
<p>Loading...</p> | ||
} | ||
``` | ||
|
||
In the test, you can set the `RendererInfo` to enable or disable the button: | ||
|
||
```csharp | ||
[Fact] | ||
public void SimulatingPreRenderingOnBlazorServer() | ||
{ | ||
// Arrange | ||
SetRendererInfo(new RendererInfo(rendererName: "Static", isInteractive: false)); | ||
|
||
// Act | ||
var cut = RenderComponent<AssistentComponent>(); | ||
|
||
// Assert | ||
cut.MarkupMatches("<p>Loading...</p>"); | ||
} | ||
|
||
[Fact] | ||
public void SimulatingInteractiveServerRendering() | ||
{ | ||
// Arrange | ||
SetRendererInfo(new RendererInfo(rendererMode: "Server", isInteractive: true)); | ||
|
||
// Act | ||
var cut = RenderComponent<AssistentComponent>(); | ||
|
||
// Assert | ||
cut.MarkupMatches("<p>Hey I am your assistant</p>"); | ||
} | ||
``` | ||
|
||
> [!NOTE] | ||
> If a component under test uses the `ComponentBase.RendererInfo` property and the `SetRendererInfo` on `TestContext` hasn't been passed in a `RendererInfo` object, the renderer will throw an exception. |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -19,6 +19,14 @@ public class ComponentParameterCollection : ICollection<ComponentParameter>, IRe | |
/// <inheritdoc /> | ||
public bool IsReadOnly { get; } | ||
|
||
#if NET9_0_OR_GREATER | ||
/// <summary> | ||
/// Gets or sets the <see cref="IComponentRenderMode"/> that will be specified in | ||
/// the render tree for component the parameters are being passed to. | ||
/// </summary> | ||
public IComponentRenderMode? RenderMode { get; set; } | ||
#endif | ||
|
||
/// <summary> | ||
/// Adds a <paramref name="item"/> to the collection. | ||
/// </summary> | ||
|
@@ -104,6 +112,9 @@ void AddComponent(RenderTreeBuilder builder) | |
{ | ||
builder.OpenComponent<TComponent>(0); | ||
AddAttributes(builder); | ||
#if NET9_0_OR_GREATER | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. SetRenderMode would be better as this doesn’t have collection like behavior |
||
builder.AddComponentRenderMode(RenderMode); | ||
#endif | ||
builder.CloseComponent(); | ||
} | ||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -78,7 +78,7 @@ public ComponentParameterCollectionBuilder<TComponent> Add<TChildComponent>(Expr | |
/// <param name="parameterSelector">A lambda function that selects the parameter.</param> | ||
/// <param name="markup">The markup string to pass to the <see cref="RenderFragment"/>.</param> | ||
/// <returns>This <see cref="ComponentParameterCollectionBuilder{TComponent}"/>.</returns> | ||
public ComponentParameterCollectionBuilder<TComponent> Add(Expression<Func<TComponent, RenderFragment?>> parameterSelector, [StringSyntax("Html")]string markup) | ||
public ComponentParameterCollectionBuilder<TComponent> Add(Expression<Func<TComponent, RenderFragment?>> parameterSelector, [StringSyntax("Html")] string markup) | ||
=> Add(parameterSelector, markup.ToMarkupRenderFragment()); | ||
|
||
/// <summary> | ||
|
@@ -266,7 +266,7 @@ public ComponentParameterCollectionBuilder<TComponent> AddChildContent(RenderFra | |
/// </summary> | ||
/// <param name="markup">The markup string to pass the ChildContent parameter wrapped in a <see cref="RenderFragment"/>.</param> | ||
/// <returns>This <see cref="ComponentParameterCollectionBuilder{TComponent}"/>.</returns> | ||
public ComponentParameterCollectionBuilder<TComponent> AddChildContent([StringSyntax("Html")]string markup) | ||
public ComponentParameterCollectionBuilder<TComponent> AddChildContent([StringSyntax("Html")] string markup) | ||
=> AddChildContent(markup.ToMarkupRenderFragment()); | ||
|
||
/// <summary> | ||
|
@@ -344,11 +344,11 @@ public ComponentParameterCollectionBuilder<TComponent> Bind<TValue>( | |
Action<TValue> changedAction, | ||
Expression<Func<TValue>>? valueExpression = null) | ||
{ | ||
#if !NET8_0_OR_GREATER | ||
#if !NET8_0_OR_GREATER | ||
var (parameterName, _, isCascading) = GetParameterInfo(parameterSelector); | ||
#else | ||
#else | ||
var (parameterName, _, isCascading) = GetParameterInfo(parameterSelector, initialValue); | ||
#endif | ||
#endif | ||
|
||
if (isCascading) | ||
throw new ArgumentException("Using Bind with a cascading parameter is not allowed.", parameterName); | ||
|
@@ -397,6 +397,19 @@ static string TrimEnd(string source, string value) | |
: source; | ||
} | ||
|
||
#if NET9_0_OR_GREATER | ||
/// <summary> | ||
/// Sets (or unsets) the <see cref="IComponentRenderMode"/> for the component and child components. | ||
/// </summary> | ||
/// <param name="renderMode">The render mode to assign to the component, e.g. <c>RenderMode.InteractiveServer</c>, or <see langword="null"/>, to not assign a specific render mode.</param> | ||
/// <returns>This <see cref="ComponentParameterCollectionBuilder{TComponent}"/>.</returns> | ||
public ComponentParameterCollectionBuilder<TComponent> SetAssignedRenderMode(IComponentRenderMode? renderMode) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Okay, yeah see comment above |
||
{ | ||
parameters.RenderMode = renderMode; | ||
return this; | ||
} | ||
#endif | ||
|
||
/// <summary> | ||
/// Try to add a <paramref name="value"/> for a parameter with the <paramref name="name"/>, if | ||
/// <typeparamref name="TComponent"/> has a property with that name, AND that property has a <see cref="ParameterAttribute"/> | ||
|
@@ -454,14 +467,14 @@ Expression<Func<TComponent, TValue>> parameterSelector | |
: propInfoCandidate; | ||
|
||
var paramAttr = propertyInfo?.GetCustomAttribute<ParameterAttribute>(inherit: true); | ||
#if !NET8_0_OR_GREATER | ||
#if !NET8_0_OR_GREATER | ||
var cascadingParamAttr = propertyInfo?.GetCustomAttribute<CascadingParameterAttribute>(inherit: true); | ||
|
||
if (propertyInfo is null || (paramAttr is null && cascadingParamAttr is null)) | ||
throw new ArgumentException($"The parameter selector '{parameterSelector}' does not resolve to a public property on the component '{typeof(TComponent)}' with a [Parameter] or [CascadingParameter] attribute.", nameof(parameterSelector)); | ||
|
||
return (propertyInfo.Name, CascadingValueName: cascadingParamAttr?.Name, IsCascading: cascadingParamAttr is not null); | ||
#else | ||
#else | ||
var cascadingParamAttrBase = propertyInfo?.GetCustomAttribute<CascadingParameterAttributeBase>(inherit: true); | ||
|
||
if (propertyInfo is null || (paramAttr is null && cascadingParamAttrBase is null)) | ||
|
@@ -494,7 +507,7 @@ static ArgumentException CreateErrorMessageForSupplyFromQuery( | |
NavigationManager.NavigateTo(uri); | ||
"""); | ||
} | ||
#endif | ||
#endif | ||
} | ||
|
||
private static bool HasChildContentParameter() | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,36 @@ | ||
#if NET9_0_OR_GREATER | ||
namespace Bunit.Rendering; | ||
|
||
/// <summary> | ||
/// Represents an exception that is thrown when a component under test is trying to access the 'RendererInfo' property, which has not been specified. | ||
/// </summary> | ||
public sealed class MissingRendererInfoException : Exception | ||
{ | ||
/// <summary> | ||
/// Initializes a new instance of the <see cref="MissingRendererInfoException"/> class. | ||
/// </summary> | ||
public MissingRendererInfoException() | ||
: base(""" | ||
A component under test is trying to access the 'RendererInfo' property, which has not been specified. Set it via TestContext.Renderer.SetRendererInfo. | ||
|
||
For example: | ||
|
||
public class SomeTestClass : TestContext | ||
{ | ||
[Fact] | ||
public void SomeTestCase() | ||
{ | ||
SetRendererInfo(new RendererInfo("Server", true)); | ||
... | ||
} | ||
} | ||
|
||
The four built in render names are 'Static', 'Server', 'WebAssembly', and 'WebView'. | ||
|
||
Go to https://bunit.dev/docs/interaction/render-modes for more information. | ||
""") | ||
{ | ||
HelpLink = "https://bunit.dev/docs/interaction/render-modes"; | ||
} | ||
} | ||
#endif |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,22 @@ | ||
#if NET9_0_OR_GREATER | ||
namespace Bunit.Rendering; | ||
|
||
/// <summary> | ||
/// Represents an exception that is thrown when a component under test has mismatching render modes assigned between parent and child components. | ||
/// </summary> | ||
public sealed class RenderModeMisMatchException : Exception | ||
{ | ||
/// <summary> | ||
/// Initializes a new instance of the <see cref="MissingRendererInfoException"/> class. | ||
/// </summary> | ||
public RenderModeMisMatchException() | ||
: base(""" | ||
A component under test has mismatching render modes assigned between parent and child components. | ||
Ensure that the render mode of the parent component matches the render mode of the child component. | ||
Learn more about render modes at https://learn.microsoft.com/en-us/aspnet/core/blazor/components/render-modes?view=aspnetcore-9.0#render-mode-propagation. | ||
""") | ||
{ | ||
HelpLink = "https://learn.microsoft.com/en-us/aspnet/core/blazor/components/render-modes?view=aspnetcore-9.0#render-mode-propagation"; | ||
} | ||
} | ||
#endif |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This and the
ToRenderFragment
method should probably have been on the builder, but that we can move in V2, where the types are internal.