bUnit-dev / bUnit

bUnit is a testing library for Blazor components that make tests look, feel, and runs like regular unit tests. bUnit makes it easy to render and control a component under test’s life-cycle, pass parameter and inject services into it, trigger event handlers, and verify the rendered markup from the component using a built-in semantic HTML comparer.
https://bunit.dev
MIT License
1.14k stars 105 forks source link

bUnit fails with System.InvalidOperationException: The render handle is not yet assigned. #1279

Closed iam3yal closed 11 months ago

iam3yal commented 11 months ago

Describe the bug

Up until now I wrote my tests directly with C# and now I've decided to rewrite them using the Razor syntax but then in one of my tests I'm getting an exception.

Here is a reference to the code that fails: https://github.com/iam3yal/RazorSharp/tree/7705905cc13e45e87a7428d695d58243a47d0a9c/src/RazorSharp/Components/Forms/WebInputBase.cs#L487

Here is a reference to the test that works: https://github.com/iam3yal/RazorSharp/tree/7705905cc13e45e87a7428d695d58243a47d0a9c/src/RazorSharp.Tests/Components/Forms/WebInputBaseTests.cs#L247

Here is a link to the issue that supposedly justifies the behaviour of calling EventCallback.InvokeAsync synchronously: https://github.com/dotnet/aspnetcore/issues/25696

So the following test works perfectly fine with no exception:

[Fact]
public async Task Should_fire_ValueChanged_event_when_the_value_was_changed()
{
    var value = "foo";

    var webInput = _ctx.RenderComponent<TestableWebInput<string>>(parameters => parameters.Bind(p => p.Value,
                                                                                                value, newValue => value = newValue,
                                                                                                () => value));

    var element = webInput.Find("input");

    var wasValueChangedFiredAfterFirstRender = value != "foo";

    await element.ChangeAsync(new() { Value = "bar" });

    Assert.Multiple(() => Assert.False(wasValueChangedFiredAfterFirstRender),
                    () => Assert.Equal("bar", webInput.Instance.Value));
}

I rewrote the same test like this but then I'm getting an exception when ValueChanged is fired.

[Fact]
public async Task Should_fire_ValueChanged_event_when_the_value_was_changed()
{
    var value = "foo";

    var webInput = _ctx.Render<TestableWebInput<string>>(@<TestableWebInput
                                                                TValue="string"
                                                                @bind-Value="value">
                                                            </TestableWebInput>);

    var element = webInput.Find("input");

    var wasValueChangedFiredAfterFirstRender = value != "foo";

    await element.ChangeAsync(new() { Value = "bar" });

    Assert.Multiple(() => Assert.False(wasValueChangedFiredAfterFirstRender),
                    () => Assert.Equal("bar", webInput.Instance.Value));
}

That's what the SG generates:

[Fact]
public async Task Should_fire_ValueChanged_event_when_the_value_was_changed()
{
    var value = "foo";

    var webInput = _ctx.Render<TestableWebInput<string>>((__builder2) => {
        __builder2.OpenComponent<global::RazorSharp.Tests.TestDoubles.Testables.TestableWebInput<string>>(18);
        __builder2.AddAttribute(19, "Value", (object)(global::Microsoft.AspNetCore.Components.CompilerServices.RuntimeHelpers.TypeCheck<string>(
        )));
        __builder2.AddAttribute(20, "ValueChanged", (object)(global::Microsoft.AspNetCore.Components.CompilerServices.RuntimeHelpers.TypeCheck<global::Microsoft.AspNetCore.Components.EventCallback<string>>(global::Microsoft.AspNetCore.Components.EventCallback.Factory.Create<string>(this, global::Microsoft.AspNetCore.Components.CompilerServices.RuntimeHelpers.CreateInferredEventCallback(this, __value => value = __value, value)))));
        __builder2.AddAttribute(21, "ValueExpression", (object)(global::Microsoft.AspNetCore.Components.CompilerServices.RuntimeHelpers.TypeCheck<global::System.Linq.Expressions.Expression<global::System.Func<string>>>(() => value)));
        __builder2.CloseComponent();
    });

    var element = webInput.Find("input");

    var wasValueChangedFiredAfterFirstRender = value != "foo";

    await element.ChangeAsync(new() { Value = "bar" });

    Assert.Multiple(() => Assert.False(wasValueChangedFiredAfterFirstRender),
                    () => Assert.Equal("bar", webInput.Instance.Value));
}

Here is the stack trace:

System.InvalidOperationException: The render handle is not yet assigned.

System.InvalidOperationException
The render handle is not yet assigned.
   at Microsoft.AspNetCore.Components.RenderHandle.ThrowNotInitialized()
   at Microsoft.AspNetCore.Components.ComponentBase.StateHasChanged()
   at Microsoft.AspNetCore.Components.ComponentBase.Microsoft.AspNetCore.Components.IHandleEvent.HandleEventAsync(EventCallbackWorkItem callback, Object arg)
   at RazorSharp.Components.Forms.WebInputBase`1.SetValue(TValue value, Boolean shouldInvokeFieldChanged) in /Users/eyal/Development/Projects/RazorSharp/src/RazorSharp/Components/Forms/WebInputBase.cs:line 487
   at RazorSharp.Components.Forms.WebInputBase`1.set_CurrentValue(TValue value) in /Users/eyal/Development/Projects/RazorSharp/src/RazorSharp/Components/Forms/WebInputBase.cs:line 118
   at RazorSharp.Components.Forms.WebInputBase`1.SetParsedValue(String value) in /Users/eyal/Development/Projects/RazorSharp/src/RazorSharp/Components/Forms/WebInputBase.cs:line 446
   at RazorSharp.Components.Forms.WebInputBase`1.set_ValueAsString(String value) in /Users/eyal/Development/Projects/RazorSharp/src/RazorSharp/Components/Forms/WebInputBase.cs:line 98
   at RazorSharp.Components.Forms.WebInputBase`1.<BuildElementRenderTree>b__130_0(String value) in /Users/eyal/Development/Projects/RazorSharp/src/RazorSharp/Components/Forms/WebInputBase.cs:line 236
   at Microsoft.AspNetCore.Components.EventCallbackFactoryBinderExtensions.<>c__DisplayClass64_0`1.<CreateBinderCore>b__0(ChangeEventArgs e)
   at System.RuntimeMethodHandle.InvokeMethod(Object target, Void** arguments, Signature sig, Boolean isConstructor)
   at System.Reflection.MethodInvoker.Invoke(Object obj, IntPtr* args, BindingFlags invokeAttr)
--- End of stack trace from previous location ---
   at Microsoft.AspNetCore.Components.ComponentBase.CallStateHasChangedOnAsyncCompletion(Task task)
   at Microsoft.AspNetCore.Components.RenderTree.Renderer.GetErrorHandledTask(Task taskToHandle, ComponentState owningComponentState)
   at Bunit.Rendering.TestRenderer.AssertNoUnhandledExceptions() in /_/src/bunit.core/Rendering/TestRenderer.cs:line 629
   at Bunit.Rendering.TestRenderer.DispatchEventAsync(UInt64 eventHandlerId, EventFieldInfo fieldInfo, EventArgs eventArgs, Boolean ignoreUnknownEventHandlers) in /_/src/bunit.core/Rendering/TestRenderer.cs:line 170
   at Bunit.Rendering.TestRenderer.DispatchEventAsync(UInt64 eventHandlerId, EventFieldInfo fieldInfo, EventArgs eventArgs) in /_/src/bunit.core/Rendering/TestRenderer.cs:line 118
   at Bunit.TriggerEventDispatchExtensions.TriggerNonBubblingEventAsync(ITestRenderer renderer, IElement element, String eventName, EventArgs eventArgs) in /_/src/bunit.web/EventDispatchExtensions/TriggerEventDispatchExtensions.cs:line 109
   at Bunit.TriggerEventDispatchExtensions.TriggerEventsAsync(ITestRenderer renderer, IElement element, String eventName, EventArgs eventArgs) in /_/src/bunit.web/EventDispatchExtensions/TriggerEventDispatchExtensions.cs:line 93
   at Bunit.TriggerEventDispatchExtensions.<>c__DisplayClass3_0.<TriggerEventAsync>b__0() in /_/src/bunit.web/EventDispatchExtensions/TriggerEventDispatchExtensions.cs:line 80
   at Microsoft.AspNetCore.Components.Rendering.RendererSynchronizationContext.<>c.<<InvokeAsync>b__9_0>d.MoveNext()
--- End of stack trace from previous location ---
   at Bunit.TriggerEventDispatchExtensions.ThrowIfFailedSynchronously(Task result) in /_/src/bunit.web/EventDispatchExtensions/TriggerEventDispatchExtensions.cs:line 215
   at Bunit.TriggerEventDispatchExtensions.TriggerEventAsync(IElement element, String eventName, EventArgs eventArgs) in /_/src/bunit.web/EventDispatchExtensions/TriggerEventDispatchExtensions.cs:line 82
   at Bunit.InputEventDispatchExtensions.ChangeAsync(IElement element, ChangeEventArgs eventArgs) in /_/src/bunit.web/EventDispatchExtensions/InputEventDispatchExtensions.cs:line 36
   at RazorSharp.Components.Forms.WebInputBaseTests.Should_fire_ValueChanged_event_when_the_value_was_changed() in /Users/eyal/Development/Projects/RazorSharp/src/RazorSharp.Tests/Components/Forms/WebInputBaseTests.razor:line 219
   at Xunit.Sdk.TestInvoker`1.<>c__DisplayClass48_0.<<InvokeTestMethodAsync>b__1>d.MoveNext() in /_/src/xunit.execution/Sdk/Frameworks/Runners/TestInvoker.cs:line 276
--- End of stack trace from previous location ---
   at Xunit.Sdk.ExecutionTimer.AggregateAsync(Func`1 asyncAction) in /_/src/xunit.execution/Sdk/Frameworks/ExecutionTimer.cs:line 48
   at Xunit.Sdk.ExceptionAggregator.RunAsync(Func`1 code) in /_/src/xunit.core/Sdk/ExceptionAggregator.cs:line 90

Expected behavior: Should throw no exceptions. Version info:

egil commented 11 months ago

Pretty sure it's because your test class by default inherits from ComponentBase which you don't want when using it to write tests.

Change it to inherit from Test context instead, e.g. @inherits TestContext.

@inherits TestContext
@code {
[Fact]
public async Task Should_fire_ValueChanged_event_when_the_value_was_changed()
{
    var value = "foo";

    var webInput = Render<TestableWebInput<string>>(@<TestableWebInput
                                                                TValue="string"
                                                                @bind-Value="value">
                                                            </TestableWebInput>);

    var element = webInput.Find("input");

    var wasValueChangedFiredAfterFirstRender = value != "foo";

    await element.ChangeAsync(new() { Value = "bar" });

    Assert.Multiple(() => Assert.False(wasValueChangedFiredAfterFirstRender),
                    () => Assert.Equal("bar", webInput.Instance.Value));
}
}
iam3yal commented 11 months ago

@egil Thank you so much this was it! Keep up the amazing work you do. :)

p.s. I'm not sure whether this is documented but it might be worth adding a note about it.

linkdotnet commented 11 months ago

@iam3yal https://bunit.dev/docs/providing-input/passing-parameters-to-components.html?tabs=razor#configure-two-way-with-component-parameters-bind-directive

iam3yal commented 11 months ago

@linkdotnet That's great but it's not SEO friendly simply because it doesn't note the exception so a person looking to resolve the problem might not find it but I guess that now that this issue exists it's sufficient. :)

egil commented 11 months ago

That's a good point. Let's add that to the docs so folks find them when googling.