Skip to content
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

Merged
merged 5 commits into from
Dec 13, 2024
Merged
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
4 changes: 2 additions & 2 deletions .vscode/tasks.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@
{
"label": "Serve Docs (Without Build)",
"type": "shell",
"command": "docfx metadata docs/site/docfx.json && docfx docs/site/docfx.json --serve"
"command": "dotnet docfx metadata docs/site/docfx.json && dotnet docfx docs/site/docfx.json --serve"
},
{
"label": "Serve Docs (With Build for API Documentation)",
"type": "shell",
"command": "dotnet build -c Release && docfx metadata docs/site/docfx.json && docfx docs/site/docfx.json --serve"
"command": "dotnet build -c Release && dotnet docfx metadata docs/site/docfx.json && docfx docs/site/docfx.json --serve"
},
{
"label": "Run all tests (Release Mode)",
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ All notable changes to **bUnit** will be documented in this file. The project ad

## [Unreleased]

### Added
- Added support for `RendererInfo` and `AssignedRenderMode` (`.net9.0`).

## [1.36.0] - 2024-11-12

### Added
Expand Down
1 change: 1 addition & 0 deletions Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
<ImplicitUsings>enable</ImplicitUsings>
<NoWarn>CA1014,NU5104,NETSDK1138,SYSLIB0051</NoWarn>
<CheckEolTargetFramework>false</CheckEolTargetFramework>
<SuppressTfmSupportBuildWarnings>true</SuppressTfmSupportBuildWarnings>

<!-- Used by code coverage -->
<DebugType>full</DebugType>
Expand Down
1 change: 1 addition & 0 deletions docs/site/docs/interaction/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ This section covers the various ways to interact with a component under test, e.
- **<xref:trigger-renders>:** This covers how to manually trigger a render cycle for a component under test.
- **<xref:awaiting-async-state>:** This covers how to await one or more asynchronous changes to the state of a component under test before continuing the test.
- **<xref:dispose-components>:** This covers how to dispose components and their children.
- **<xref:render-modes>:** This covers the different render modes and their interaction with bUnit.
157 changes: 157 additions & 0 deletions docs/site/docs/interaction/render-modes.md
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.
1 change: 1 addition & 0 deletions docs/site/docs/toc.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
## [Trigger renders](xref:trigger-renders)
## [Awaiting an async state change](xref:awaiting-async-state)
## [Disposing components](xref:dispose-components)
## [Render modes and RendererInfo](xref:render-modes)

# [Verifying output](xref:verification)
## [Verify markup](xref:verify-markup)
Expand Down
11 changes: 11 additions & 0 deletions src/bunit.core/ComponentParameterCollection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Copy link
Member

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.

#endif

/// <summary>
/// Adds a <paramref name="item"/> to the collection.
/// </summary>
Expand Down Expand Up @@ -104,6 +112,9 @@ void AddComponent(RenderTreeBuilder builder)
{
builder.OpenComponent<TComponent>(0);
AddAttributes(builder);
#if NET9_0_OR_GREATER
Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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();
}

Expand Down
29 changes: 21 additions & 8 deletions src/bunit.core/ComponentParameterCollectionBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down Expand Up @@ -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>
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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)
Copy link
Member

Choose a reason for hiding this comment

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

Set still seems like the right name. Matches the SetRendererInfo on Renderer.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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"/>
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -494,7 +507,7 @@ static ArgumentException CreateErrorMessageForSupplyFromQuery(
NavigationManager.NavigateTo(uri);
""");
}
#endif
#endif
}

private static bool HasChildContentParameter()
Expand Down
7 changes: 7 additions & 0 deletions src/bunit.core/Rendering/ITestRenderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -78,4 +78,11 @@ IReadOnlyList<IRenderedComponentBase<TComponent>> FindComponents<TComponent>(IRe
/// Disposes all components rendered by the <see cref="ITestRenderer" />.
/// </summary>
void DisposeComponents();

#if NET9_0_OR_GREATER
/// <summary>
/// Sets the <see cref="RendererInfo"/> for the renderer.
/// </summary>
void SetRendererInfo(RendererInfo? rendererInfo);
#endif
}
36 changes: 36 additions & 0 deletions src/bunit.core/Rendering/MissingRendererInfoException.cs
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
22 changes: 22 additions & 0 deletions src/bunit.core/Rendering/RenderModeMisMatchException.cs
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
Loading
Loading