异步void事件处理程序的单元测试

6

我在C# Winforms中实现了MVP(MVC)模式。

我的视图和Presenter如下(没有所有的MVP粘合剂):

public interface IExampleView
{
    event EventHandler<EventArgs> SaveClicked;
    string Message {get; set; }
}

public partial class ExampleView : Form
{
    public event EventHandler<EventArgs> SaveClicked;

    string Message { 
        get { return txtMessage.Text; } 
        set { txtMessage.Text = value; } 
    }

    private void btnSave_Click(object sender, EventArgs e)
    {
        if (SaveClicked != null) SaveClicked.Invoke(sender, e);
    }
}

public class ExamplePresenter
{
    public void OnLoad()
    {
        View.SaveClicked += View_SaveClicked;
    }

    private async void View_SaveClicked(object sender, EventArgs e)
    {
        await Task.Run(() => 
        {
            // Do save
        });

        View.Message = "Saved!"
    }

我正在使用MSTest进行单元测试,同时使用NSubstitute进行模拟。我想在视图中模拟按钮点击以测试控制器的View_SaveClicked代码,如下:

[TestMethod]
public void WhenSaveButtonClicked_ThenSaveMessageShouldBeShown()
{
    // Arrange

    // Act
    View.SaveClicked += Raise.EventWith(new object(), new EventArgs());

    // Assert
    Assert.AreEqual("Saved!", View.Message);
}

我能够成功地使用NSubstitute的Raise.EventWith触发View.SaveClicked事件。然而,问题在于代码立即转到Assert语句,此时Presenter还没有足够时间保存消息,因此Assert失败。
我理解这是为什么,并已经成功地通过添加Thread.Sleep(500)来绕过它,但这不是最佳选择。我也可以更新我的视图以调用presenter.Save()方法,但尽可能使视图对Presenter不可知是我的目标。
因此,我想知道如何改进单元测试,以等待async View_SaveClicked完成,或更改View/Presenter代码以使它们在此情况下更容易进行单元测试。
有任何想法吗?

2个回答

4

如果你只关心单元测试,那么你可以使用自定义的SynchronizationContext,它允许你检测到async void方法的完成。

你可以使用我的 AsyncContext类型 来实现:

[TestMethod]
public void WhenSaveButtonClicked_ThenSaveMessageShouldBeShown()
{
  // Arrange

  AsyncContext.Run(() =>
  {
    // Act
    View.SaveClicked += Raise.EventWith(new object(), new EventArgs());
  });

  // Assert
  Assert.AreEqual("Saved!", View.Message);
}

然而,最好在您自己的代码中避免使用async void(正如我在有关异步最佳实践的MSDN文章中所描述的那样)。我还有一篇博客文章专门介绍了一些 "async event handlers" 的方法。
其中一种方法是用普通委托替换所有的EventHandler<T>事件,并通过await调用它:
public Func<Object, EventArgs, Task> SaveClicked;
private void btnSave_Click(object sender, EventArgs e)
{
  if (SaveClicked != null) await SaveClicked(sender, e);
}

如果你想要一个真正的事件,那么这就不太美观了:

public delegate Task AsyncEventHandler<T>(object sender, T e);
public event AsyncEventHandler<EventArgs> SaveClicked;
private void btnSave_Click(object sender, EventArgs e)
{
  if (SaveClicked != null)
    await Task.WhenAll(
      SaveClicked.GetInvocationList().Cast<AsyncEventHandler<T>>
          .Select(x => x(sender, e)));
}

采用这种方法,任何同步事件处理程序都需要在处理程序结尾返回 Task.CompletedTask
另一种方法是使用“延迟”扩展 EventArgs。虽然不太优美,但对于异步事件处理程序更为惯用。

AsyncContext 看起来很有前途,但需要 .NET 4.6,而我们正在使用 4.5 :( - Langers
有了公共的Func<Object, EventArgs, Task> SaveClicked建议,Presenter和单元测试代码会是什么样子? - Langers
我已经想通了。控制器:View.SaveClicked = View_SaveClicked;private async Task View_SaveClicked(object sender, EventArgs e) { }单元测试:View.SaveClicked(new object(), new EventArgs()).Wait(); - Langers
1
@Langers:不要使用Wait,应该使用await。如果你使用AsyncEx库,AsyncContext在4.5中可用。(https://github.com/StephenCleary/AsyncEx) - Stephen Cleary
我对AsyncContext.Run作为解决这个问题的方法并不确定。第一个(次要)问题是它会阻塞当前线程,第二个(更严重)问题是它会改变async void方法的行为。例如,该方法可能包含在无上下文环境中无害的同步-over-异步代码,但在AsyncContext.Run使用的单线程同步上下文中会导致死锁。我在这里发布了一个可能更好的替代方法。 - undefined

0

正在运行的任务必须有某种类型的工作正在进行,您需要使用某些东西从任务中返回一个值。

似乎 Thread.Sleep 可以帮助缓解这个问题,但是可能需要添加一些逻辑,并从任务中获取一个值。

来自:https://msdn.microsoft.com/en-us/library/mt674882.aspx


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接