在单元测试中使用WPF Dispatcher

54

我在单元测试时遇到了一个问题,无法使Dispatcher运行我传递给它的委托。当我运行程序时一切正常,但是,在单元测试期间以下代码将无法运行:

this.Dispatcher.BeginInvoke(new ThreadStart(delegate
{
    this.Users.Clear();

    foreach (User user in e.Results)
    {
        this.Users.Add(user);
    }
}), DispatcherPriority.Normal, null);

我在我的ViewModel基类中有这段代码来获取Dispatcher:

if (Application.Current != null)
{
    this.Dispatcher = Application.Current.Dispatcher;
}
else
{
    this.Dispatcher = Dispatcher.CurrentDispatcher;
}

在进行单元测试时,是否需要进行某些初始化工作以使Dispatcher正常运行?因为Dispatcher未能执行委托中的代码。


我没有收到任何错误信息,只是传递给Dispatcher的BeginInvoke从未运行。 - Chris Shepherd
1
说实话,我还没有需要对使用调度程序的视图模型进行单元测试的经验。 调度程序是否没有运行?在您的测试中调用 Dispatcher.CurrentDispatcher.Run() 有帮助吗?我很好奇,如果您得到了结果,请发布它们。 - Anderson Imes
16个回答

93

使用Visual Studio单元测试框架时,您不需要自己初始化Dispatcher。您是完全正确的,Dispatcher不会自动处理其队列。

您可以编写一个简单的帮助方法“DispatcherUtil.DoEvents()”,告诉Dispatcher处理其队列。

C# 代码:

public static class DispatcherUtil
{
    [SecurityPermissionAttribute(SecurityAction.Demand, Flags = SecurityPermissionFlag.UnmanagedCode)]
    public static void DoEvents()
    {
        DispatcherFrame frame = new DispatcherFrame();
        Dispatcher.CurrentDispatcher.BeginInvoke(DispatcherPriority.Background,
            new DispatcherOperationCallback(ExitFrame), frame);
        Dispatcher.PushFrame(frame);
    }

    private static object ExitFrame(object frame)
    {
        ((DispatcherFrame)frame).Continue = false;
        return null;
    }
}

你也可以在WPF应用程序框架(WAF) 中找到这个类。


4
我更喜欢这个答案胜过被采纳的答案,因为这个解决方案可以在按顺序编写测试用例的情况下运行,而被采纳的答案需要以回调为导向的方式编写测试代码。 - Patrick Linskey
1
很遗憾,这对我来说不起作用。对于那些感兴趣的人,这种方法在此处也有文档记录:MSDN DispatcherFrame 'DoEvents' Example - dodgy_coder
1
忽略我的上一条评论 - 它可以正常工作,并且是测试WPF视图模型时这个常见问题的一个好解决方法。 - dodgy_coder
我理解这个方法应该在每次被测试系统需要处理调度程序时都要调用,还是只需要在每个单元测试会话中调用一次? - EngineerSpock
5
你的DoEvents将会执行由其他单元测试安排的调用,导致难以调试的失败情况 :-( 我发现这篇文章是因为有人将DispatcherUtil示例代码的逐字复制添加到我们的单元测试中,并引起了这个问题。我认为,按照@OrionEdwards的建议隐藏调度程序是更好的方法,尽管在单元测试中,我会使用一个带有实际队列和显式出队方法的实现。如果我实现了它,我会在这里添加或编辑一个答案。 - Wim Coenen
值得注意的是,要求一个真正的调度程序有点不妥。你的单元测试应该在隔离的情况下测试代码的每个部分,并模拟所有其他元素。@Orion的答案更符合正确的单元测试最佳实践。在单元测试中,你不应该需要使用真正的调度程序,就像你不会使用真正的数据库连接、真正的http请求等一样。 - Doctor Jones

25

我们通过简单地模拟调度程序并将其封装在一个接口中来解决了这个问题,然后从我们的IOC容器中引入该接口。以下是该接口:

public interface IDispatcher
{
    void Dispatch( Delegate method, params object[] args );
}

这是真实应用程序中在IOC容器中注册的具体实现

[Export(typeof(IDispatcher))]
public class ApplicationDispatcher : IDispatcher
{
    public void Dispatch( Delegate method, params object[] args )
    { UnderlyingDispatcher.BeginInvoke(method, args); }

    // -----

    Dispatcher UnderlyingDispatcher
    {
        get
        {
            if( App.Current == null )
                throw new InvalidOperationException("You must call this method from within a running WPF application!");

            if( App.Current.Dispatcher == null )
                throw new InvalidOperationException("You must call this method from within a running WPF application with an active dispatcher!");

            return App.Current.Dispatcher;
        }
    }
}

以下是一个我们在单元测试中提供给代码的模拟版本:

public class MockDispatcher : IDispatcher
{
    public void Dispatch(Delegate method, params object[] args)
    { method.DynamicInvoke(args); }
}

我们还有一个MockDispatcher的变体,它在后台线程中执行委托,但大多数情况下这并不是必要的。


如何模拟DispatcherInvoke方法? - komizo
@lukaszk,根据您的模拟框架,您可以设置Invoke方法来实际运行传递给它的委托(如果这是您需要的行为)。您不一定需要运行该委托,我有一些测试,只需验证正确的委托是否传递给了模拟对象即可。 - Doctor Jones
对于使用 Moq 的人,这是对我有效的方法:var mockDispatcher = new Mock(); mockDispatcher.Setup(dispatcher => dispatcher.Invoke(It.IsAny())).Callback(action => action()); - user2315856

17

你可以使用 Dispatcher 进行单元测试,只需要使用 DispatcherFrame 即可。以下是我一个使用 DispatcherFrame 强制执行调度程序队列的单元测试示例。

[TestMethod]
public void DomainCollection_AddDomainObjectFromWorkerThread()
{
 Dispatcher dispatcher = Dispatcher.CurrentDispatcher;
 DispatcherFrame frame = new DispatcherFrame();
 IDomainCollectionMetaData domainCollectionMetaData = this.GenerateIDomainCollectionMetaData();
 IDomainObject parentDomainObject = MockRepository.GenerateMock<IDomainObject>();
 DomainCollection sut = new DomainCollection(dispatcher, domainCollectionMetaData, parentDomainObject);

 IDomainObject domainObject = MockRepository.GenerateMock<IDomainObject>();

 sut.SetAsLoaded();
 bool raisedCollectionChanged = false;
 sut.ObservableCollection.CollectionChanged += delegate(object sender, NotifyCollectionChangedEventArgs e)
 {
  raisedCollectionChanged = true;
  Assert.IsTrue(e.Action == NotifyCollectionChangedAction.Add, "The action was not add.");
  Assert.IsTrue(e.NewStartingIndex == 0, "NewStartingIndex was not 0.");
  Assert.IsTrue(e.NewItems[0] == domainObject, "NewItems not include added domain object.");
  Assert.IsTrue(e.OldItems == null, "OldItems was not null.");
  Assert.IsTrue(e.OldStartingIndex == -1, "OldStartingIndex was not -1.");
  frame.Continue = false;
 };

 WorkerDelegate worker = new WorkerDelegate(delegate(DomainCollection domainCollection)
  {
   domainCollection.Add(domainObject);
  });
 IAsyncResult ar = worker.BeginInvoke(sut, null, null);
 worker.EndInvoke(ar);
 Dispatcher.PushFrame(frame);
 Assert.IsTrue(raisedCollectionChanged, "CollectionChanged event not raised.");
}

我是从这里了解到的。


是的,我回来更新一下这个问题,告诉大家最终我是怎么做到的。我想我读了同样的帖子! - Chris Shepherd

6

我通过在单元测试设置中创建一个新的应用程序来解决了这个问题。

然后,任何访问Application.Current.Dispatcher的受测类都会找到一个调度程序。

由于在一个AppDomain中只允许一个应用程序,因此我使用了AssemblyInitialize并将其放入自己的ApplicationInitializer类中。

[TestClass]
public class ApplicationInitializer
{
    [AssemblyInitialize]
    public static void AssemblyInitialize(TestContext context)
    {
        var waitForApplicationRun = new TaskCompletionSource<bool>();
        Task.Run(() =>
        {
            var application = new Application();
            application.Startup += (s, e) => { waitForApplicationRun.SetResult(true); };
            application.Run();
        });
        waitForApplicationRun.Task.Wait();        
    }
    [AssemblyCleanup]
    public static void AssemblyCleanup()
    {
        Application.Current.Dispatcher.Invoke(Application.Current.Shutdown);
    }
}
[TestClass]
public class MyTestClass
{
    [TestMethod]
    public void MyTestMethod()
    {
        // implementation can access Application.Current.Dispatcher
    }
}

我非常喜欢这个! - aydjay

2

对我来说,创建一个DipatcherFrame非常成功:

[TestMethod]
public void Search_for_item_returns_one_result()
{
    var searchService = CreateSearchServiceWithExpectedResults("test", 1);
    var eventAggregator = new SimpleEventAggregator();
    var searchViewModel = new SearchViewModel(searchService, 10, eventAggregator) { SearchText = searchText };

    var signal = new AutoResetEvent(false);
    var frame = new DispatcherFrame();

    // set the event to signal the frame
    eventAggregator.Subscribe(new ProgressCompleteEvent(), () =>
       {
           signal.Set();
           frame.Continue = false;
       });

    searchViewModel.Search(); // dispatcher call happening here

    Dispatcher.PushFrame(frame);
    signal.WaitOne();

    Assert.AreEqual(1, searchViewModel.TotalFound);
}

2

如果你想将jbe的答案中的逻辑应用于任何调度程序(而不仅仅是Dispatcher.CurrentDispatcher),你可以使用以下扩展方法。

public static class DispatcherExtentions
{
    public static void PumpUntilDry(this Dispatcher dispatcher)
    {
        DispatcherFrame frame = new DispatcherFrame();
        dispatcher.BeginInvoke(
            new Action(() => frame.Continue = false),
            DispatcherPriority.Background);
        Dispatcher.PushFrame(frame);
    }
}

使用方法:

Dispatcher d = getADispatcher();
d.PumpUntilDry();

使用当前的调度程序:

Dispatcher.CurrentDispatcher.PumpUntilDry();

我更喜欢这个变体,因为它可以在更多场景中使用,使用的代码更少,语法更直观。

如需了解更多关于DispatcherFrame的背景知识,请查看这篇优秀博客文章


Dispatcher.PushFrame(frame); 使用 Dispatcher.CurrentDispatcher 内部... 所以这样不会起作用。 - ManIkWeet

2
当你调用Dispatcher.BeginInvoke时,你正在指示调度程序在其线程上运行委托当线程处于空闲状态时
在运行单元测试时,主线程将永远不会处于空闲状态。它将运行所有测试,然后终止。
为了使此方面可单元测试,您必须更改底层设计,以便它不使用主线程的调度程序。另一种选择是利用System.ComponentModel.BackgroundWorker在不同的线程上修改用户。(这只是一个例子,根据上下文可能不合适)。

Edit (5个月后) 我写这篇答案时不知道DispatcherFrame。对于这个问题,我很高兴自己错了 - DispatcherFrame非常有用。


1

使用带有Dispatcher支持的专用线程运行测试怎么样?

    void RunTestWithDispatcher(Action testAction)
    {
        var thread = new Thread(() =>
        {
            var operation = Dispatcher.CurrentDispatcher.BeginInvoke(testAction);

            operation.Completed += (s, e) =>
            {
                // Dispatcher finishes queued tasks before shuts down at idle priority (important for TransientEventTest)
                Dispatcher.CurrentDispatcher.BeginInvokeShutdown(DispatcherPriority.ApplicationIdle);
            };

            Dispatcher.Run();
        });

        thread.IsBackground = true;
        thread.TrySetApartmentState(ApartmentState.STA);
        thread.Start();
        thread.Join();
    }

1
我通过将 Dispatcher 包装在自己的 IDispatcher 接口中实现了这一点,然后使用 Moq 来验证对其的调用是否已完成。
IDispatcher 接口:
public interface IDispatcher
{
    void BeginInvoke(Delegate action, params object[] args);
}

真正的调度程序实现:

class RealDispatcher : IDispatcher
{
    private readonly Dispatcher _dispatcher;

    public RealDispatcher(Dispatcher dispatcher)
    {
        _dispatcher = dispatcher;
    }

    public void BeginInvoke(Delegate method, params object[] args)
    {
        _dispatcher.BeginInvoke(method, args);
    }
}

在你的测试类中初始化调度程序:

public ClassUnderTest(IDispatcher dispatcher = null)
{
    _dispatcher = dispatcher ?? new UiDispatcher(Application.Current?.Dispatcher);
}

在单元测试中模拟分派程序(在此情况下,我的事件处理程序为OnMyEventHandler,接受名为myBoolParameter的单个bool参数)。
[Test]
public void When_DoSomething_Then_InvokeMyEventHandler()
{
    var dispatcher = new Mock<IDispatcher>();

    ClassUnderTest classUnderTest = new ClassUnderTest(dispatcher.Object);

    Action<bool> OnMyEventHanlder = delegate (bool myBoolParameter) { };
    classUnderTest.OnMyEvent += OnMyEventHanlder;

    classUnderTest.DoSomething();

    //verify that OnMyEventHandler is invoked with 'false' argument passed in
    dispatcher.Verify(p => p.BeginInvoke(OnMyEventHanlder, false), Times.Once);
}

1
我喜欢这个解决方案,因为它很容易理解。 - Rye bread

1
我正在使用MSTest和Windows Forms技术,采用MVVM模式。 尝试了很多解决方案后,最终我找到了这个(在Vincent Grondin博客上发现的),对我很有帮助。
    internal Thread CreateDispatcher()
    {
        var dispatcherReadyEvent = new ManualResetEvent(false);

        var dispatcherThread = new Thread(() =>
        {
            // This is here just to force the dispatcher 
            // infrastructure to be setup on this thread
            Dispatcher.CurrentDispatcher.BeginInvoke(new Action(() => { }));

            // Run the dispatcher so it starts processing the message 
            // loop dispatcher
            dispatcherReadyEvent.Set();
            Dispatcher.Run();
        });

        dispatcherThread.SetApartmentState(ApartmentState.STA);
        dispatcherThread.IsBackground = true;
        dispatcherThread.Start();

        dispatcherReadyEvent.WaitOne();
        SynchronizationContext
           .SetSynchronizationContext(new DispatcherSynchronizationContext());
        return dispatcherThread;
    }

并像这样使用:

    [TestMethod]
    public void Foo()
    {
        Dispatcher
           .FromThread(CreateDispatcher())
                   .Invoke(DispatcherPriority.Background, new DispatcherDelegate(() =>
        {
            _barViewModel.Command.Executed += (sender, args) => _done.Set();
            _barViewModel.Command.DoExecute();
        }));

        Assert.IsTrue(_done.WaitOne(WAIT_TIME));
    }

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