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

Add option to get any unhandled exception caught by the renderer #344

Merged
merged 8 commits into from
Mar 22, 2021
1 change: 1 addition & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -563,4 +563,5 @@ dotnet_diagnostic.SA1649.severity = none # SA1649: File name should match first
# https://rules.sonarsource.com/csharp
dotnet_diagnostic.S125.severity = none # S125: Sections of code should not be commented out
dotnet_diagnostic.S3459.severity = none # S3459: Unassigned members should be removed
dotnet_diagnostic.S3871.severity = none # S3871: Exception types should be "public"
dotnet_diagnostic.S1186.severity = none # S1186: Methods should not be empty
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,30 @@ List of new features.

- Added the ability to pass a "fallback `IServiceProvider`" to the `TestServiceProvider`, available through the `Services` property on a `TestContext`. The fallback service provider enables a few interesting scenarios, such as using an alternative IoC container, or automatically generating mocks of services components under test depend on. See the [Injecting Services into Components Under Test page](https://bunit.egilhansen.com/docs/providing-input/inject-services-into-components) for more details on this feature. By [@thopdev](https://github.com/thopdev) in [#310](https://github.com/egil/bUnit/issues/310).

- Added `Task<Expection> ITestRenderer.UnhandledException` property that returns a `Task<Exception>` that completes when the renderer captures an unhandled exception from a component under test. If a component is missing exception handling of asynchronous operations, e.g. in the `OnInitializedAsync` method, the exception will not break the test, because it happens on another thread. To have a test fail in this scenario, you can await the `UnhandledException` property on the `TestContext.Renderer` property, e.g.:

```csharp
using var ctx = new TestContext();

var cut = ctx.RenderComponent<ComponentThatThrowsDuringAsyncOperation>();

Task<Exception?> waitTimeout = Task.Delay(500).ContinueWith(_ => Task.FromResult<Exception?>(null)).Unwrap();
Exception? unhandledException = await Task.WhenAny<Exception?>(Renderer.UnhandledException, waitTimeout).Unwrap();

Assert.Null(unhandledException);
```

In this example, we await any unhandled exceptions from the renderer, or our wait timeout. The `waitTimeout` ensures that we will not wait forever, in case no unhandled exception is thrown.

NOTE, a better approach is to use the `WaitForState` or `WaitForAssertion` methods, which now also throws unhandled exceptions. Using them, you do not need to set up a wait timeout explicitly.

By [@egil](https://github.com/egil) in [#310](https://github.com/egil/bUnit/issues/344).

### Changed
List of changes in existing functionality.

- `WaitForAssertion` and `WaitForState` now throws unhandled exception caught by the renderer from a component under test. This can happen if a component is awaiting an asynchronous operation that throws, e.g. a API call using a misconfigured `HttpClient`. By [@egil](https://github.com/egil) in [#310](https://github.com/egil/bUnit/issues/344).

### Removed
List of now removed features.

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Runtime.ExceptionServices;
using Bunit.Extensions.WaitForHelpers;

namespace Bunit
Expand All @@ -22,13 +23,21 @@ public static class RenderedFragmentWaitForHelperExtensions
public static void WaitForState(this IRenderedFragmentBase renderedFragment, Func<bool> statePredicate, TimeSpan? timeout = null)
{
using var waiter = new WaitForStateHelper(renderedFragment, statePredicate, timeout);

try
{
waiter.WaitTask.GetAwaiter().GetResult();
}
catch (AggregateException e) when (e.InnerException is not null)
catch (Exception e)
{
throw e.InnerException;
if (e is AggregateException aggregateException && aggregateException.InnerExceptions.Count == 1)
{
ExceptionDispatchInfo.Capture(aggregateException.InnerExceptions[0]).Throw();
}
else
{
ExceptionDispatchInfo.Capture(e).Throw();
}
}
}

Expand All @@ -45,13 +54,21 @@ public static void WaitForState(this IRenderedFragmentBase renderedFragment, Fun
public static void WaitForAssertion(this IRenderedFragmentBase renderedFragment, Action assertion, TimeSpan? timeout = null)
{
using var waiter = new WaitForAssertionHelper(renderedFragment, assertion, timeout);

try
{
waiter.WaitTask.GetAwaiter().GetResult();
}
catch (AggregateException e) when (e.InnerException is not null)
catch (Exception e)
{
throw e.InnerException;
if (e is AggregateException aggregateException && aggregateException.InnerExceptions.Count == 1)
{
ExceptionDispatchInfo.Capture(aggregateException.InnerExceptions[0]).Throw();
}
else
{
ExceptionDispatchInfo.Capture(e).Throw();
}
}
}
}
Expand Down
36 changes: 25 additions & 11 deletions src/bunit.core/Extensions/WaitForHelpers/WaitForHelper.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Bunit.Rendering;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

namespace Bunit.Extensions.WaitForHelpers
Expand All @@ -13,7 +15,7 @@ public abstract class WaitForHelper : IDisposable
{
private readonly object lockObject = new();
private readonly Timer timer;
private readonly TaskCompletionSource<object?> completionSouce;
private readonly TaskCompletionSource<object?> checkPassedCompletionSouce;
private readonly Func<bool> completeChecker;
private readonly IRenderedFragmentBase renderedFragment;
private readonly ILogger logger;
Expand All @@ -40,7 +42,7 @@ public abstract class WaitForHelper : IDisposable
/// Gets the task that will complete successfully if the check passed before the timeout was reached.
/// The task will complete with an <see cref="WaitForFailedException"/> exception if the timeout was reached without the check passing.
/// </summary>
public Task WaitTask => completionSouce.Task;
public Task WaitTask { get; }

/// <summary>
/// Initializes a new instance of the <see cref="WaitForHelper"/> class.
Expand All @@ -50,13 +52,25 @@ protected WaitForHelper(IRenderedFragmentBase renderedFragment, Func<bool> compl
this.renderedFragment = renderedFragment ?? throw new ArgumentNullException(nameof(renderedFragment));
this.completeChecker = completeChecker ?? throw new ArgumentNullException(nameof(completeChecker));
logger = renderedFragment.Services.CreateLogger<WaitForHelper>();
completionSouce = new TaskCompletionSource<object?>();

var renderer = renderedFragment.Services.GetRequiredService<ITestRenderer>();
var renderException = renderer
.UnhandledException
.ContinueWith(x => Task.FromException(x.Result), CancellationToken.None, TaskContinuationOptions.OnlyOnRanToCompletion, TaskScheduler.Current)
.Unwrap();

checkPassedCompletionSouce = new TaskCompletionSource<object?>();
WaitTask = Task.WhenAny(checkPassedCompletionSouce.Task, renderException).Unwrap();

timer = new Timer(OnTimeout, this, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);

OnAfterRender(this, EventArgs.Empty);
this.renderedFragment.OnAfterRender += OnAfterRender;
OnAfterRender(this, EventArgs.Empty);
StartTimer(timeout);
if (!WaitTask.IsCompleted)
{
OnAfterRender(this, EventArgs.Empty);
this.renderedFragment.OnAfterRender += OnAfterRender;
OnAfterRender(this, EventArgs.Empty);
StartTimer(timeout);
}
}

private void StartTimer(TimeSpan? timeout)
Expand Down Expand Up @@ -88,7 +102,7 @@ private void OnAfterRender(object? sender, EventArgs args)
logger.LogDebug(new EventId(1, nameof(OnAfterRender)), $"Checking the wait condition for component {renderedFragment.ComponentId}");
if (completeChecker())
{
completionSouce.TrySetResult(null);
checkPassedCompletionSouce.TrySetResult(null);
logger.LogDebug(new EventId(2, nameof(OnAfterRender)), $"The check completed successfully for component {renderedFragment.ComponentId}");
Dispose();
}
Expand All @@ -104,7 +118,7 @@ private void OnAfterRender(object? sender, EventArgs args)

if (StopWaitingOnCheckException)
{
completionSouce.TrySetException(new WaitForFailedException(CheckThrowErrorMessage, capturedException));
checkPassedCompletionSouce.TrySetException(new WaitForFailedException(CheckThrowErrorMessage, capturedException));
Dispose();
}
}
Expand All @@ -123,7 +137,7 @@ private void OnTimeout(object? state)

logger.LogDebug(new EventId(5, nameof(OnTimeout)), $"The wait for helper for component {renderedFragment.ComponentId} timed out");

completionSouce.TrySetException(new WaitForFailedException(TimeoutErrorMessage, capturedException));
checkPassedCompletionSouce.TrySetException(new WaitForFailedException(TimeoutErrorMessage, capturedException));

Dispose();
}
Expand Down Expand Up @@ -160,7 +174,7 @@ protected virtual void Dispose(bool disposing)
isDisposed = true;
renderedFragment.OnAfterRender -= OnAfterRender;
timer.Dispose();
completionSouce.TrySetCanceled();
checkPassedCompletionSouce.TrySetCanceled();
logger.LogDebug(new EventId(6, nameof(Dispose)), $"The state wait helper for component {renderedFragment.ComponentId} disposed");
}
}
Expand Down
6 changes: 6 additions & 0 deletions src/bunit.core/Rendering/ITestRenderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ namespace Bunit.Rendering
/// </summary>
public interface ITestRenderer
{
/// <summary>
/// Gets a <see cref="Task{Exception}"/>, which completes when an unhandled exception
/// is thrown during the rendering of a component, that is caught by the renderer.
/// </summary>
Task<Exception> UnhandledException { get; }

/// <summary>
/// Gets the <see cref="Dispatcher"/> associated with this <see cref="ITestRenderer"/>.
/// </summary>
Expand Down
33 changes: 26 additions & 7 deletions src/bunit.core/Rendering/TestRenderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,17 @@ namespace Bunit.Rendering
/// <summary>
/// Represents a bUnit <see cref="ITestRenderer"/> used to render Blazor components and fragments during bUnit tests.
/// </summary>
public partial class TestRenderer : Renderer, ITestRenderer
public class TestRenderer : Renderer, ITestRenderer
{
private readonly object renderTreeAccessLock = new();
private readonly Dictionary<int, IRenderedFragmentBase> renderedComponents = new();
private readonly ILogger logger;
private readonly IRenderedComponentActivator activator;
private Exception? unhandledException;
private TaskCompletionSource<Exception> unhandledExceptionTsc = new();
private Exception? capturedUnhandledException;

/// <inheritdoc/>
public Task<Exception> UnhandledException => unhandledExceptionTsc.Task;

/// <inheritdoc/>
public override Dispatcher Dispatcher { get; } = Dispatcher.CreateDefault();
Expand Down Expand Up @@ -55,6 +59,8 @@ public IRenderedComponentBase<TComponent> RenderComponent<TComponent>(ComponentP
if (fieldInfo is null)
throw new ArgumentNullException(nameof(fieldInfo));

ResetUnhandledException();

var result = Dispatcher.InvokeAsync(() => base.DispatchEventAsync(eventHandlerId, fieldInfo, eventArgs));

AssertNoUnhandledExceptions();
Expand Down Expand Up @@ -95,7 +101,17 @@ protected override void ProcessPendingRender()

/// <inheritdoc/>
protected override void HandleException(Exception exception)
=> unhandledException = exception;
{
capturedUnhandledException = exception;

LogUnhandledException(capturedUnhandledException);

if (!unhandledExceptionTsc.TrySetResult(capturedUnhandledException))
{
unhandledExceptionTsc = new TaskCompletionSource<Exception>();
unhandledExceptionTsc.SetResult(capturedUnhandledException);
}
}

/// <inheritdoc/>
protected override Task UpdateDisplayAsync(in RenderBatch renderBatch)
Expand Down Expand Up @@ -124,7 +140,7 @@ protected override Task UpdateDisplayAsync(in RenderBatch renderBatch)

rc.OnRender(renderEvent);

// RC can replace the instance of the component is bound
// RC can replace the instance of the component it is bound
// to while processing the update event.
if (key != rc.ComponentId)
{
Expand Down Expand Up @@ -157,6 +173,8 @@ protected override void Dispose(bool disposing)
private TResult Render<TResult>(RenderFragment renderFragment, Func<int, TResult> activator)
where TResult : IRenderedFragmentBase
{
ResetUnhandledException();

TResult renderedComponent = default!;

var renderTask = Dispatcher.InvokeAsync(() =>
Expand Down Expand Up @@ -280,12 +298,13 @@ private ArrayRange<RenderTreeFrame> GetOrLoadRenderTreeFrame(RenderTreeFrameDict
return framesCollection[componentId];
}

private void ResetUnhandledException() => capturedUnhandledException = null;

private void AssertNoUnhandledExceptions()
{
if (unhandledException is Exception unhandled)
if (capturedUnhandledException is Exception unhandled)
{
unhandledException = null;
LogUnhandledException(unhandled);
capturedUnhandledException = null;

if (unhandled is AggregateException aggregateException && aggregateException.InnerExceptions.Count == 1)
{
Expand Down
3 changes: 3 additions & 0 deletions src/bunit.web/Extensions/RenderedFragmentExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,12 @@ public static IElement Find(this IRenderedFragment renderedFragment, string cssS
{
if (renderedFragment is null)
throw new ArgumentNullException(nameof(renderedFragment));

var result = renderedFragment.Nodes.QuerySelector(cssSelector);

if (result is null)
throw new ElementNotFoundException(cssSelector);

return WrapperFactory.Create(new ElementFactory<IElement>(renderedFragment, result, cssSelector));
}

Expand Down
1 change: 1 addition & 0 deletions tests/.editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ dotnet_diagnostic.MA0004.severity = none # https://github.com/atc-net

# Microsoft - Code Analysis
# https://docs.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/
dotnet_diagnostic.CA1064.severity = suggestion # CA1064: Exceptions should be public
dotnet_diagnostic.CA1707.severity = none # https://github.com/atc-net/atc-coding-rules/blob/main/documentation/CodeAnalyzersRules/MicrosoftCodeAnalysis/CA1707.md
dotnet_diagnostic.CA2007.severity = none # https://github.com/atc-net/atc-coding-rules/blob/main/documentation/CodeAnalyzersRules/MicrosoftCodeAnalysis/CA2007.md

Expand Down
8 changes: 3 additions & 5 deletions tests/bunit.core.tests/ComponentParameterCollectionTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,11 @@

namespace Bunit
{
public class ComponentParameterCollectionTest
public class ComponentParameterCollectionTest : TestContext
{
private static readonly TestContext Context = new();

private static IRenderedComponent<Params> RenderWithRenderFragment(RenderFragment renderFragment)
private IRenderedComponent<Params> RenderWithRenderFragment(RenderFragment renderFragment)
{
var res = (IRenderedFragment)Context.Renderer.RenderFragment(renderFragment);
var res = (IRenderedFragment)Renderer.RenderFragment(renderFragment);
return res.FindComponent<Params>();
}

Expand Down
Loading