65.9K
CodeProject 正在变化。 阅读更多。
Home

如何测试使用 DispatcherTimer 的类

starIconstarIconstarIconstarIconstarIcon

5.00/5 (19投票s)

2007年7月14日

CPOL

11分钟阅读

viewsIcon

135403

downloadIcon

1225

演示了如何为使用 DispatcherTimer 的类创建单元测试。

引言

本文探讨了如何为使用 DispatcherTimer 实现功能的类编写单元测试。我们将回顾测试依赖于 DispatcherTimer 的代码时存在哪些问题,以及如何克服这些障碍。用于实现单元测试的技术已整合到一个名为 TestWithActiveDispatcher 的基类中,这使得使用此功能轻松创建自己的单元测试变得容易。

演示项目

本文附带一个 Visual Studio 解决方案,其中包含三个项目。其中一个项目 ClassLib 是一个 DLL,包含由单元测试测试的类。ConsoleTestApp 项目是一个不使用任何单元测试框架的控制台应用程序。NUnitTests 项目是一个 DLL,其中包含基于 NUnit 2.4 框架构建的单元测试。我包含了基于控制台的应用程序,供那些机器上没有安装 NUnit 2.4 的用户使用。单元测试代码在两个版本之间基本上是相同的。

如果你的机器上没有安装 NUnit,请在 Visual Studio 中打开解决方案资源管理器,右键单击 NUnitTest 项目,然后选择“卸载项目”。这将防止你收到与该项目相关的编译器错误。

背景

Windows Presentation Foundation (WPF) 的线程模型基于“调度器”的概念。每个线程都有一个与之关联的 Dispatcher 对象。Dispatcher 负责监控跨线程操作,并通过其 BeginInvokeInvoke 方法为线程之间进行方法调用封送提供了一种方式。

Dispatcher 包含 WPF 版本的 Windows 消息队列和消息循环。它提供了一个优先级的消息队列,消息循环可以从中提取消息并处理它们。大多数 WPF 类都有与其实例化线程的亲和性。这种线程亲和性基于对象与线程的 Dispatcher 之间的关联。

WPF 有一个专门的、了解调度器的计时器类,名为 DispatcherTimer。当 DispatcherTimer 滴答时,它会在其关联的 Dispatcher 的消息队列中排队一个消息。默认情况下,DispatcherTimer 会将其消息排队到创建它的线程的 Dispatcher。当该 Dispatcher 的消息循环最终处理到它时,计时器的“滴答消息”将被出队,计时器的 Tick 事件的处理方法将被调用。为了这一切正常工作,与 DispatcherTimer 关联的 Dispatcher 必须正在运行;其消息循环必须正在“处理”。

问题所在

在 WPF 应用程序中使用 DispatcherTimer 时,它工作得非常好。你通常不需要知道它如何与 Dispatcher 交互的底层细节。然而,一旦你开始在 WPF 应用程序的自然环境之外使用 DispatcherTimer,它突然就会变成一个相当复杂的类。

一个可能在 WPF 应用程序之外使用 DispatcherTimer 的合理场景是,它恰好在一个单元测试中运行。如果你有一个类使用 DispatcherTimer 来实现其功能,并想为该类创建单元测试,你会发现有几个障碍需要克服才能使单元测试正常运行。

以下是与测试功能依赖于使用 DispatcherTimer 的类相关的几个问题:

  1. 默认情况下,运行单元测试的线程没有活动的 Dispatcher
  2. 激活 Dispatcher 的方法是一个阻塞调用,因此在 Dispatcher 关闭之前,你无法执行该方法调用之后的任何代码。这使得启动 Dispatcher 然后运行需要活动 Dispatcher 的测试代码变得困难。
  3. 在消息队列中,DispatcherTimer 的滴答消息的默认优先级非常低,以至于消息循环永远不会处理滴答消息。在正常的 WPF 应用程序中这不是问题,但在控制台应用程序和运行 NUnit GUI 应用程序时可以观察到这种行为。
  4. 你需要关闭线程的 Dispatcher 才能使单元测试方法完成。一旦关闭线程的 Dispatcher,你就无法重新启动它或为该线程提供新的 Dispatcher。请记住,多个单元测试方法可能需要在同一个线程上运行。
  5. 由于被测试的代码涉及使用计时器,你的测试代码必须能够空闲等待,以便被测试的对象有足够的时间完成其工作。然而,由于使用的 DispatcherTimer 在与测试代码相同的线程上运行,你不能让线程休眠或将其置于简单的“待机”循环中。如果你使用这两种方法中的任何一种,Dispatcher 的消息循环将没有机会处理计时器的滴答消息(因为其运行的线程将处于休眠状态或卡在执行“待机”循环中)。

解决方案

正如你所料,解决这个问题需要一些巧妙的处理。我创建了 TestWithActiveDispatcher 基类来封装繁重的工作,以便我们可以简单地继承它并轻松地创建任意数量涉及 DispatcherTimer 的单元测试。本文的这一部分回顾了 TestWithActiveDispatcher 的高层概念。稍后的“工作原理”部分将展示该类的实现方式。

我认为解释 TestWithActiveDispatcher 工作原理的最清晰方法是使用图像和关于图像的简短说明。以下五个步骤展示了它的工作原理。

步骤 1 – 启动一个工作线程来运行测试方法

Screenshot - step1.png

首先创建一个工作线程,主线程等待它,以便主线程在工作线程死亡之前不会执行。

步骤 2 – 工作线程发布一个延迟调用到测试方法

Screenshot - step2.png

当工作线程启动并运行时,它会在其非活动的 Dispatcher 的消息队列中排队一个消息。该消息是执行包含测试代码的方法的请求。此时,工作线程的 Dispatcher 还没有运行,所以消息只是放在队列中等待 Dispatcher 最终处理它。

步骤 3 – 工作线程启动其 Dispatcher

Screenshot - step3.png

此时,工作线程告诉其 Dispatcher 开始运行。这会激活消息循环,然后消息循环会处理其队列中等待的消息(前一步发布的)。当消息被处理时,它会导致测试方法被执行。测试方法现在在一个拥有活动 Dispatcher 的线程上运行,因此它可以正确地测试使用 DispatcherTimer 的代码。

步骤 4 – DispatcherTimer 利用活动的 Dispatcher

Screenshot - step4.png

现在,被测试的代码正在被测试方法执行,它创建了一个 DispatcherTimer,该计时器将滴答消息放入 Dispatcher 的消息队列中。Dispatcher 的消息循环处理滴答消息并调用处理 Tick 事件的方法。对于被测试的代码来说,它就好像在正常的 WPF 应用程序中执行一样。它有一个活动的 Dispatcher 来处理其滴答消息,一切都很好。

步骤 5 – 测试方法完成,Dispatcher 关闭

Screenshot - step5.png

一旦测试方法确定测试结束,Dispatcher 就会关闭,以便工作线程可以退出。

使用 TestWithActiveDispatcher

在本节中,我们将看到如何编写一个继承自 TestWithActiveDispatcher 的类,并测试一个使用 DispatcherTimer 的简单类。被测试的类称为 TickerTicker 是一个类,它在指定的间隔内,将“Tick!”打印到控制台窗口指定的次数。例如,它可以配置为每两秒打印一次“Tick!”,共三次。

Ticker 类公开的公共 API 如下所示:

interface ITicker
{
  void Start();
  int Ticks { get; }
}

Start 方法告诉 Ticker 开始将“Tick!”打印到控制台。Ticks 属性返回自上次调用 Start 以来 Ticker 滴答的次数。

Ticker 的构造函数要求你传入一个 TickerSettings 对象。该类用于使用各种设置配置 Ticker 实例。这是 TickerSettings 的公共 API:

interface ITickerSettings
{
  TimeSpan Interval { get; }
  int NumberOfTicks { get; }
  DispatcherPriority Priority { get; }
}

我们要创建的测试将验证 Ticker 是否遵循 IntervalNumberOfTicks 设置。以下是基于 NUnit 2.4 框架的设置和测试方法:

/// <summary>
/// Initializes data used in the test.
/// </summary>
[SetUp]
public void ConfigureTickerSettings()
{
  TimeSpan interval = TimeSpan.FromSeconds(1);
  int numberOfTicks = 3;
  DispatcherPriority priority = DispatcherPriority.Normal;

  // VERY IMPORTANT!!!
  // For the DispatcherTimer to tick when it is not running
  // in a normal WPF application, you must give it a priority
  // higher than 'Background' (which is the default priority).
  // In this demo we give it a priority of 'Normal'.
  _settings = new TickerSettings(interval, numberOfTicks, priority);
}

[Test]
public void TickerHonorsIntervalAndNumberOfTicks()
{
  _ticker = null;

  base.BeginExecuteTest();

  Assert.IsNotNull(_ticker, "_ticker should have been assigned a value.");
  Assert.AreEqual(3, _ticker.Ticks);
}

设置方法中的注释指出了一个非常重要事实。如果你要在单元测试中运行 DispatcherTimer,你需要确保给它一个高于默认值“Background”的优先级。如果你不这样做,计时器滴答消息将永远不会被处理。我不知道为什么会这样,但它是真的。

测试方法看起来很空。但是,请记住,我们测试所在的类继承自 TestWithActiveDispatcher 类。当我们调用 BeginExecuteTest 时,它最终会导致对以下重写方法的调用:

protected override void ExecuteTestAsync()
{
  Debug.Assert(base.IsRunningOnWorkerThread);

  // Note: The object which creates a DispatcherTimer
  // must create it with the Dispatcher for the worker
  // thread.  Creating the Ticker on the worker thread
  // ensures that its DispatcherTimer uses the worker
  // thread's Dispatcher.
  _ticker = new Ticker(_settings);

  // Tell the Ticker to start ticking.
  _ticker.Start();

  // Give the Ticker some time to do its work.
  TimeSpan waitTime = this.CalculateWaitTime();
  base.WaitWithoutBlockingDispatcher(waitTime);

  // Let the base class know that the test is over
  // so that it can turn off the worker thread's
  // message pump.
  base.EndExecuteTest();
}

这是执行 Ticker 类的代码。需要注意的是,被测试的 Ticker 实例是在此方法中创建的。由于此方法在 TestWithActiveDispatcher spawned 的工作线程上执行,因此我们需要确保在此方法中创建 Ticker。这样做可以确保当 Ticker 创建其 DispatcherTimer 时,正确的(且活动的)Dispatcher 将与之关联。

Ticker 被告知开始滴答之后,我们需要等待足够的时间让 Ticker 完成其工作。为了实现这一点,我们调用从 TestWithActiveDispatcher 继承的 WaitWithoutBlockingDispatcher 方法,并告诉它等待多长时间。确定等待多长时间的逻辑如下所示:

TimeSpan CalculateWaitTime()
{
  Debug.Assert(base.IsRunningOnWorkerThread);

  // Calculate how much time the Ticker needs to perform
  // all of it's ticks.  Add some extra time to make sure
  // it does not tick more than it should.
  int numTicks = _settings.NumberOfTicks + 1;
  int tickInterval = (int)_settings.Interval.TotalSeconds;
  int secondsToWait = numTicks * tickInterval;
  TimeSpan waitTime = TimeSpan.FromSeconds(secondsToWait);

  return waitTime;
}

最后要说明的一点是,在上面看到的重写的 ExecuteTestAsync 方法的末尾,会调用 EndExecuteTest 方法。这是一个必需的步骤,因为它让 TestWithActiveDispatcher 知道它应该关闭工作线程的 Dispatcher。一旦 Dispatcher 失效,测试就结束了,就可以运行另一个测试方法了。

工作原理

在本节中,我们将看到 TestWithActiveDispatcher 类是如何工作的。阅读本节内容不影响使用该类。本节将实现分解为前面“解决方案”部分中看到的相同五个步骤。

步骤 1 – 启动一个工作线程来运行测试方法

当子类想要运行其测试代码时,它需要调用 BeginExecuteTest 方法。该方法通过启动一个执行 BeginExecuteTestAsync 方法的线程来启动整个过程,然后等待该线程退出。当工作线程退出时,测试就结束了。

/// <summary>
/// Calling this method causes the ExecuteTestAsync override to be
/// invoked in the child class.  Invoke this method to initiate the
/// asynchronous testing.
/// </summary>
protected void BeginExecuteTest()
{
  Debug.Assert(this.IsRunningOnWorkerThread == false);

  // Run the test code on an STA worker thread
  // and then wait for that thread to die before
  // this method continues executing.  We use an
  // STA thread because many WPF classes require it.
  // We must do this work on a worker thread because
  // once a thread's Dispatcher is shut down it cannot
  // be run again.
  Thread thread = new Thread(this.BeginExecuteTestAsync);
  thread.SetApartmentState(ApartmentState.STA);
  thread.Name = WORKER_THREAD_NAME;
  thread.Start();
  thread.Join();
}

步骤 2 和 3 – 工作线程发布一个延迟调用到测试方法并启动其 Dispatcher

此时,我们审查的其余代码在工作线程上执行。BeginExecuteTestAsync 方法负责解决启动 Dispatcher 和执行需要活动 Dispatcher 的测试方法之间的“先有鸡还是先有蛋”的问题(请记住,Dispatcher.Run 是一个阻塞调用)。

void BeginExecuteTestAsync()
{
  Debug.Assert(this.IsRunningOnWorkerThread);

  NoArgDelegate testMethod = new NoArgDelegate(this.ExecuteTestAsync);

  // The call to ExecuteTestAsync must be delayed so
  // that the worker thread's Dispatcher can be started
  // before the method executes.  This is needed because
  // the Dispatcher.Run method does not return until the
  // Dispatcher has been shut down.
  Dispatcher.CurrentDispatcher.BeginInvoke(
    DispatcherPriority.Normal, testMethod);

  // Start the worker thread's message pump so that
  // queued messages are processed.
  Dispatcher.Run();
}

当调用 Dispatcher.Run 返回时,这意味着 Dispatcher 已关闭,测试方法已完成。此时,工作线程退出,步骤 1 中看到的 BeginExecuteTest 方法也完成了。

步骤 4 – DispatcherTimer 利用活动的 Dispatcher

子类必须重写 ExecuteTestAsync 并将测试代码放在那里。这是放置直接或间接创建 DispatcherTimer 代码的正确位置。当调用此方法时,工作线程的 Dispatcher 已经运行并准备好处理消息。

/// <summary>
/// Derived classes must override this method to implement their test logic.
/// Note: this method is invoked on a worker thread with active Dispatcher.
/// </summary>
protected abstract void ExecuteTestAsync();

步骤 5 – 测试方法完成,Dispatcher 关闭

在重写的 ExecuteTestAsync 的末尾,子类需要调用 EndExecuteTest。该方法关闭工作线程的 Dispatcher

/// <summary>
/// Stops the worker thread's message pump.  Derived classes
/// should call this method at the end of their ExecuteTestAsync
/// override to shut down the worker thread's Dispatcher.
/// Note: this method must be invoked from the worker thread.
/// </summary>
protected void EndExecuteTest()
{
  Debug.Assert(this.IsRunningOnWorkerThread);

  Dispatcher disp = Dispatcher.CurrentDispatcher;
  Debug.Assert(disp.HasShutdownStarted == false);

  // Kill the worker thread's Dispatcher so that the
  // message pump is shut down and the thread can die.
  disp.BeginInvokeShutdown(DispatcherPriority.Normal);
}

最后一步 – 在测试执行期间空闲等待

这个类还有一个细节。当被测试的代码运行时,TestWithActiveDispatcher 需要空闲等待,这样它就不会阻止 Dispatcher 的消息循环获得处理消息的机会。这涉及到使用一个古老而有效的邪恶技巧……DoEvents

如果你不熟悉 DoEvents,只需将其视为一种一次性处理消息队列中所有消息的方式。我在这里使用了这种糟糕的技术,以便 DispatcherTimer 的滴答消息有机会被消息循环处理。如果我不使用它,下面看到的 while 循环将主导工作线程,直到循环结束。

WPF 没有自己的 DoEvents 版本,但一位名叫 Zhou Yong 的人在他的博客上发布了一种实现它的智能方法,我将其包装在一个名为 DispatcherHelper 的实用类中。(请参阅下面的“参考”部分获取指向我所指页面的链接)。

/// <summary>
/// Pauses the worker thread for the specified duration, but allows
/// the Dispatcher to continue processing messages in its message queue.
/// Note: this method must be invoked from the worker thread.
/// </summary>
/// <param name="waitTime">The amount of time to pause.</param>
protected void WaitWithoutBlockingDispatcher(TimeSpan waitTime)
{
  Debug.Assert(this.IsRunningOnWorkerThread);

  // Save the time at which the wait began.
  DateTime startTime = DateTime.Now;
  bool wait = true;
  while (wait)
  {
    // Call DoEvents so that all of the messages in
    // the worker thread's Dispatcher message queue
    // are processed.
    DispatcherHelper.DoEvents();

    // Check if the wait is over.
    TimeSpan elapsed = DateTime.Now - startTime;
    wait = elapsed < waitTime;
  }
}

我不会建议在生产代码中使用 DoEvents,除非绝对有必要。DoEvents 就像给你的应用程序状态带来一场严重的地震。由于这只是测试代码,我不太担心。

参考文献

修订历史

  • 2007年7月14日 – 创建文章
© . All rights reserved.