Home > front end >  Bunit 2 way-binding
Bunit 2 way-binding

Time:09-17

I have a Search component that implements a debounce timer so it doesn't call ValueChanged (and therefore doesn't update the property tied to it immediately).

My Issue

The bUnit test doesn't appear to two way bind my value I am updating.

Test code

private string StringProperty { get; set; }

[Fact]
public async Task AfterDebounce_ValueUpdates()
{
    var myString = "";
    var cut = RenderComponent<Search>(parameters => parameters
            .Add(p => p.Value, StringProperty)
            .Add(p => p.ValueChanged, (s) => myString = s)
        );

    var input = cut.Find("input");
    input.Input("unit test");

    Assert.Equal("unit test", cut.Instance.Value);
    Assert.NotEqual("unit test", myString);
    //Assert.NotEqual("unit test", StringProperty);

    await Task.Delay(5000);

    Assert.Equal("unit test", myString);
    //Assert.Equal("unit test", StringProperty);
}

I would have expected the commented out parts to work (as they are doing the same thing as the ValueChanged to update the property), but they fail.

The component

public class Search : ComponentBase
{    
    [Parameter] public string? Value { get; set; }
    [Parameter] public EventCallback<string> ValueChanged { get; set; }

    [DisallowNull] public ElementReference? Element { get; protected set; }

    private System.Timers.Timer timer = null;
    protected string? CurrentValue {
        get => Value;
        set {
            var hasChanged = !EqualityComparer<string>.Default.Equals(value, Value);
            if (hasChanged)
            {
                Value = value;

                DisposeTimer();
                timer = new System.Timers.Timer(350);
                timer.Elapsed  = TimerElapsed_TickAsync;
                timer.Enabled = true;
                timer.Start();
            }
        }
    }

    private void DisposeTimer()
    {
        if (timer != null)
        {
            timer.Enabled = false;
            timer.Elapsed -= TimerElapsed_TickAsync;
            timer.Dispose();
            timer = null;
        }
    }

    private async void TimerElapsed_TickAsync(
        object sender,
        EventArgs e)
    {
        await ValueChanged.InvokeAsync(Value);
    }

    protected override void BuildRenderTree(RenderTreeBuilder builder)
    {
        builder.OpenElement(10, "input");
        builder.AddAttribute(20, "type", "text");
        builder.AddAttribute(60, "value", BindConverter.FormatValue(CurrentValue));
        builder.AddAttribute(70, "oninput", EventCallback.Factory.CreateBinder<string?>(this, __value => CurrentValue = __value, CurrentValue));
        builder.AddElementReferenceCapture(80, __inputReference => Element = __inputReference);
        builder.CloseElement();
    }
}

How it is used:

It may be used like this where the grid will update whenever Query is updated.

<Search @bind-Value=Query />
<Grid Query=@Query />

@code {
    private string? Query { get; set; }
}

This works fine in practice, but when testing I am having issues.

CodePudding user response:

I tried locally on my machine, and the test passed.

Here is a simplified version of your component, that only calls TimerElapsed_TickAsync one time per value change and not every time the timer runs out (AutoReset defaults to true), and two different ways to write the test that both pass on my machine:

public class Search : ComponentBase, IDisposable
{
    private readonly Timer timer;

    [Parameter] public string? Value { get; set; }
    [Parameter] public EventCallback<string> ValueChanged { get; set; }
    [DisallowNull] public ElementReference? Element { get; protected set; }

    public Search()
    {
        timer = new Timer(350);
        timer.Elapsed  = TimerElapsed_TickAsync;
        timer.Enabled = true;
        timer.AutoReset = false;
    }

    protected string? CurrentValue
    {
        get => Value;
        set
        {
            var hasChanged = !EqualityComparer<string>.Default.Equals(value, Value);
            if (hasChanged)
            {
                RestartTimer();
                Value = value;
            }
        }
    }

    private void RestartTimer()
    {
        if (timer.Enabled)
            timer.Stop();
        timer.Start();
    }

    private void TimerElapsed_TickAsync(object sender, EventArgs e) 
        => ValueChanged.InvokeAsync(Value);

    protected override void BuildRenderTree(RenderTreeBuilder builder)
    {
        builder.OpenElement(10, "input");
        builder.AddAttribute(20, "type", "text");
        builder.AddAttribute(60, "value", BindConverter.FormatValue(CurrentValue));
        builder.AddAttribute(70, "oninput", EventCallback.Factory.CreateBinder<string?>(this, __value => CurrentValue = __value, CurrentValue));
        builder.AddElementReferenceCapture(80, __inputReference => Element = __inputReference);
        builder.CloseElement();
    }

    public void Dispose() => timer.Dispose();
}

And the C# version of the test:

[Fact]
public async Task AfterDebounce_ValueUpdates()
{
    var expected = "test input";
    var count = 0;
    var value = "";
    var cut = RenderComponent<Search>(parameters => parameters
            .Add(p => p.Value, value)
            .Add(p => p.ValueChanged, (s) =>
            {
                value = s;
                count  ;
            })
        );

    cut.Find("input").Input(expected);

    await Task.Delay(350);

    Assert.Equal(1, count);
    Assert.Equal(expected, value);
}

and the .razor version of the test (aka. written in a .razor file):

@inherits TestContext
@code
{
    [Fact]
    public async Task AfterDebounce_ValueUpdates()
    {
        var expected = "test input";
        var value = "";
        var cut = Render(@<Search @bind-Value="value" /> );

        cut.Find("input").Input(expected);

        await Task.Delay(350);

        Assert.Equal(expected, value);
    }
}
  • Related