Automated test for a async Command in MVVM

2019-08-24 07:30发布

问题:

I have an asynchronous Command class, like so:

public AsyncDelegateCommand(Func<Task> execute, Func<bool> canExecute)
{
    this.execute = execute;
    this.canExecute = canExecute;
}

public virtual bool CanExecute(object parameter)
{
    if(executing)
        return false;
    if(canExecute == null)
        return true;
    return canExecute();
}

public async void Execute(object parameter) // Notice "async void"
{
    executing = true;
    CommandManager.InvalidateRequerySuggested();
    if(parameter != null && executeWithParameter != null)
        await executeWithParameter(parameter);
    else if(execute != null)
        await execute();
    executing = false;
    CommandManager.InvalidateRequerySuggested();
}

And it is called like so:

FindProductCommand = new AsyncDelegateCommand(TryToFindProduct, () => CanFindProduct() && connector.HasConnection);

private async Task TryToFindProduct()
{
    //code
}

When I unit tests, it works just fine, since I'm returning instantly from tasks.

However, when writing my integration test, I run into trouble. I am not able to await Execute, since it is void, and I am not able to change it to Task. I end up doing this: :/

findProductViewModel.FindProductCommand.Execute(null);
Thread.Sleep(2000);

var informationViewModel = findProductViewModel.ProductViewModel.ProductInformationViewModel;

Assert.AreEqual("AFG00", informationViewModel.ProductGroup);

Is there a better solution for this test? Maybe something that is reliant on how long it actually takes, and doesn't estimate how long to wait.

回答1:

You can refer to a good blog post by @StephenCleary: https://msdn.microsoft.com/en-us/magazine/dn630647.aspx
async void is generally to be avoided, so he introduces a new interface (and its base implementation) for an async command: IAsyncCommand. This interface contains a method async Task ExecuteAsync(object parameter) that you could await in your tests.

public interface IAsyncCommand : ICommand
{
  Task ExecuteAsync(object parameter);
}

public abstract class AsyncCommandBase : IAsyncCommand
{
  public abstract bool CanExecute(object parameter);
  public abstract Task ExecuteAsync(object parameter);

  public async void Execute(object parameter)
  {
    await ExecuteAsync(parameter);
  }

  public event EventHandler CanExecuteChanged
  {
    add { CommandManager.RequerySuggested += value; }
    remove { CommandManager.RequerySuggested -= value; }
  }

  protected void RaiseCanExecuteChanged()
  {
    CommandManager.InvalidateRequerySuggested();
  }
}

The simplest implementation of such an async command would look like this:

public class AsyncCommand : AsyncCommandBase
{
  private readonly Func<Task> _command;

  public AsyncCommand(Func<Task> command)
  {
    _command = command;
  }

  public override bool CanExecute(object parameter)
  {
    return true;
  }

  public override Task ExecuteAsync(object parameter)
  {
    return _command();
  }
}

But there are more advanced variants that you can find in the linked blog post. You can use the IAsyncCommands in your code all the way, so you can test them. The MVVM framework you use will also be happy, because that interface is based on the ICommand.



回答2:

Is there a better solution for this test? Maybe something that is reliant on how long it actually takes, and doesn't estimate how long to wait.

Certainly: Make the command awaitable and await it. async void methods are bad practice and are only meant to be used for event handlers.

There are awaitable commands available in Mvvm.Async and ReactiveUI that you can use or at least take a look at for reference.



回答3:

If you have a state to check for, you should probably just use SpinWait.SpinUntil:

It will be much more reliable than Thread.Sleepas it allows you to check if a condition is true before continuing.

e.g.

findProductViewModel.FindProductCommand.Execute(null);
SpinWait.SpinUntil(() => findProductViewModel.ProductViewModel.ProductInformationViewModel != null, 5000);

var informationViewModel = findProductViewModel.ProductViewModel.ProductInformationViewModel;

Assert.AreEqual("AFG00", informationViewModel.ProductGroup);