如何单元测试BackgroundWorker + PRISM InteractionRequest?

时间:2013-02-06 17:19:02

标签: c# unit-testing mvvm prism backgroundworker

我的WPF MVVM应用程序中有一个重复发生的模式,它具有以下结构。

public class MyViewModel : NotificationObject
{
    private readonly IService _DoSomethingService;

    private bool _IsBusy;
    public bool IsBusy
    {
        get { return _IsBusy; }
        set
        {
            if (_IsBusy != value)
            (
                _IsBusy = value;
                RaisePropertyChanged(() => IsBusy);
            )
        }
    }

    public ICommand DisplayInputDialogCommand { get; private set; }
    public InteractionRequest<Notification> Error_InteractionRequest { get; private set; }
    public InteractionRequest<Confirmation> GetInput_InteractionRequest { get; private set; }

    // ctor
    public MyViewModel(IService service)
    {
        _DoSomethingService = service;

        DisplayInputDialogCommand  = new DelegateCommand(DisplayInputDialog);
        Error_InteractionRequest = new InteractionRequest<Notification>();
        Input_InteractionRequest = new InteractionRequest<Confirmation>();
    }

    private void DisplayInputDialog()
    {
        Input_InteractionRequest.Raise(
            new Confirmation() {
                Title = "Please provide input...",
                Content = new InputViewModel()
            },
            ProcessInput
        );
    }

    private void ProcessInput(Confirmation context)
    {
        if (context.Confirmed)
        {
            IsBusy = true;

            BackgroundWorker bg = new BackgroundWorker();
            bg.DoWork += new DoWorkEventHandler(DoSomethingWorker_DoWork);
            bg.RunWorkerCompleted += new RunWorkerCompletedEventHandler(DoSomethingWorker_RunWorkerCompleted);
            bg.RunWorkerAsync();
        }
    }

    private void DoSomethingWorker_DoWork(object sender, DoWorkEventArgs e)
    {
        _DoSomethingService.DoSomething();
    }

    private void DoSomethingWorker_RunWorkerCompleted(object sender, RunWorkerCompletedEventArgs e)
    {
        IsBusy = false;

        if (e.Error != null)
        {
            Error_InteractionRequest.Raise(
                new Confirmation() {
                    Title = "Error",
                    Content = e.Error.Message
                }
            );
        }
    }
}

本质上,该模式描述了面向对话的工作流,该工作流允许用户在不锁定UI的情况下启动(并提供输入)长时间运行的操作。此模式的具体示例可能是“另存为...”操作,其中用户单击“另存为...”按钮,然后在弹出对话框中键入文本文本值,然后单击对话框确定按钮,然后观看旋转动画,同时他们的数据以指定的文件名保存。

在提供的代码示例中,启动此工作流程将执行以下操作。

  1. 提升Input_InteractionRequest Raised事件,在UI中显示一个对话框,以收集用户输入。

  2. 调用ProcessInput回调(在用户完成对话框时触发)。

  3. 检查Confirmed上下文的InteractionRequest属性,以确定对话框是否已确认或取消。

  4. 如果确认......

    1. 设置IsBusy标志。

    2. 启动BackgroundWorker以执行长时间运行的_DoSomethingService.DoSomething()操作。

    3. 取消设置IsBusy标志。

    4. 如果DoSomething_DoWork发生错误,请提升Error_InteractionRequest Raised事件,在UI中显示一个消息框,以通知用户操作不成功。

  5. 我想最大化这种模式的单元测试覆盖率,但我不太确定如何处理它。我想避免直接对非公共成员进行单元测试,因为这种模式的具体实现可能会随着时间的推移而发生变化,实际上在我的应用程序中实例也各不相同。我考虑过以下几种选择,但它们似乎都不合适。

    1. BackgroundWorker替换为IBackgroundWorker并通过ctor注入。在测试期间使用同步IBackgroundWorker以确保在调用DoWork / RunWorkerCompleted方法之前未完成单元测试。这需要大量重构,并且不会解决InteractionRequest回调的测试问题。

    2. 使用System.Threading.Thread.Sleep(int)允许在断言阶段之前完成BackgroundWorker操作。我不喜欢这个,因为它很慢,我仍然不知道如何在InteractionRequest回调中测试代码路径。

    3. BackgroundWorker方法和InteractionRequest回调重构为Humble Objects,可以同步并独立测试。这似乎很有希望,但构建它让我感到难过。

    4. 单元测试DoSomethingWorker_DoWorkDoSomethingWorker_RunWorkerCompletedProcessInput同步且独立。这将为我提供所需的覆盖范围,但我会针对特定的实现而不是公共接口进行测试。

    5. 单元测试和/或重构上述模式以提供最大代码覆盖率的最佳方法是什么?

2 个答案:

答案 0 :(得分:7)

编辑:请参阅下面的更新,了解更简单的替代方案(仅限.NET 4.0+)。

通过在接口后面抽象BackgroundWorker的机制,然后按照question中的描述对该接口进行测试,可以轻松测试此模式。一旦界面后面隐藏了BackgroundWorker的怪癖,测试InteractionRequest变得很简单。

这是我决定使用的界面。

public interface IDelegateWorker
{
    void Start<TInput, TResult>(Func<TInput, TResult> onStart, Action<TResult> onComplete, TInput parm);
}

此接口公开一个接受以下参数的Start方法。

  1. Func<TInput, TResult> onStart - 与BackgroundWorker.DoWork相比。您可以在此处执行后台操作的主要工作。此委托应接受TInput类型的单个参数,并返回类型TResult的值,该值应传递给onComplete委托。

  2. Action<TResult> onComplete - 与BackgroundWorker.RunWorkerCompleted相比。 onStart委托完成后将调用此委托。这是您执行任何后处理工作的地方。此委托应接受TResult类型的单个参数。

  3. TInput parm - 传递给onStart委托的初始值(如果onStart委托不需要输入,则返回null)。可以将参数值传递给Backgroundworker.RunWorkerAsync(object argument)方法。

  4. 然后,您可以使用依赖注入将BackgroundWorker实例替换为IDelegateWorker的实例。例如,重写的MyViewModel现在看起来像这样。

    public class MyViewModel : NotificationObject
    {
        // Dependencies
        private readonly IService _doSomethingService;
        private readonly IDelegateWorker _delegateWorker; // new
    
        private bool _IsBusy;
        public bool IsBusy
        {
            get { return _IsBusy; }
            set
            {
                if (_IsBusy != value)
                {
                    _IsBusy = value;
                    RaisePropertyChanged(() => IsBusy);
                }
            }
        }
    
        public ICommand DisplayInputDialogCommand { get; private set; }
        public InteractionRequest<Notification> ErrorDialogInteractionRequest { get; private set; }
        public InteractionRequest<Confirmation> InputDialogInteractionRequest { get; private set; }
    
        // ctor
        public MyViewModel(IService service, IDelegateWorker delegateWorker /* new */)
        {
            _doSomethingService = service;
            _delegateWorker = delegateWorker; // new
    
            DisplayInputDialogCommand = new DelegateCommand(DisplayInputDialog);
            ErrorDialogInteractionRequest = new InteractionRequest<Notification>();
            InputDialogInteractionRequest = new InteractionRequest<Confirmation>();
        }
    
        private void DisplayInputDialog()
        {
            InputDialogInteractionRequest.Raise(
                new Confirmation()
                {
                    Title = "Please provide input...",
                    Content = new DialogContentViewModel()
                },
                ProcessInput
            );
        }
    
        private void ProcessInput(Confirmation context)
        {
            if (context.Confirmed)
            {
                IsBusy = true;
    
                // New - BackgroundWorker now abstracted behind IDelegateWorker interface.
                _delegateWorker.Start<object, TaskResult<object>>(
                        ProcessInput_onStart,
                        ProcessInput_onComplete,
                        null
                    );
            }
        }
    
        private TaskResult<object> ProcessInput_onStart(object parm)
        {
            TaskResult<object> result = new TaskResult<object>();
            try
            {
                result.Result = _doSomethingService.DoSomething();
            }
            catch (Exception ex)
            {
                result.Error = ex;
            }
            return result;
        }
    
        private void ProcessInput_onComplete(TaskResult<object> tr)
        {
            IsBusy = false;
    
            if (tr.Error != null)
            {
                ErrorDialogInteractionRequest.Raise(
                    new Confirmation()
                    {
                        Title = "Error",
                        Content = tr.Error.Message
                    }
                );
            }
        }
    
        // Helper Class
        public class TaskResult<T>
        {
            public Exception Error;
            public T Result;
        }
    }
    

    这种技术允许您在测试和生成异步实现时通过向BackgroundWorker注入IDelegateWorker的同步(或模拟)实现来避免MyViewModel类的怪癖。例如,您可以在测试时使用此实现。

    public class DelegateWorker : IDelegateWorker
    {
        public void Start<TInput, TResult>(Func<TInput, TResult> onStart, Action<TResult> onComplete, TInput parm)
        {
            TResult result = default(TResult);
    
            if (onStart != null)
                result = onStart(parm);
    
            if (onComplete != null)
                onComplete(result);
        }
    }
    

    您可以将此实施用于生产。

    public class ASyncDelegateWorker : IDelegateWorker
    {
        public void Start<TInput, TResult>(Func<TInput, TResult> onStart, Action<TResult> onComplete, TInput parm)
        {
            BackgroundWorker bg = new BackgroundWorker();
            bg.DoWork += (s, e) =>
            {
                if (onStart != null)
                    e.Result = onStart((TInput)e.Argument);
            };
    
            bg.RunWorkerCompleted += (s, e) =>
            {
                if (onComplete != null)
                    onComplete((TResult)e.Result);
            };
    
            bg.RunWorkerAsync(parm);
        }
    }
    

    有了这个基础设施,您应该能够按如下方式测试InteractionRequest的所有方面。请注意,我使用的是 MSTest Moq ,并且根据Visual Studio代码覆盖率工具实现了100%的覆盖率,尽管这个数字对我来说有点怀疑。

    [TestClass()]
    public class MyViewModelTest
    {
        [TestMethod()]
        public void DisplayInputDialogCommand_OnExecute_ShowsDialog()
        {
            // Arrange
            Mock<IService> mockService = new Mock<IService>();
            Mock<IDelegateWorker> mockWorker = new Mock<IDelegateWorker>();
            MyViewModel vm = new MyViewModel(mockService.Object, mockWorker.Object);
            InteractionRequestTestHelper<Confirmation> irHelper
                = new InteractionRequestTestHelper<Confirmation>(vm.InputDialogInteractionRequest);
    
            // Act
            vm.DisplayInputDialogCommand.Execute(null);
    
            // Assert
            Assert.IsTrue(irHelper.RequestRaised);
        }
    
        [TestMethod()]
        public void DisplayInputDialogCommand_OnExecute_DialogHasCorrectTitle()
        {
            // Arrange
            const string INPUT_DIALOG_TITLE = "Please provide input...";
            Mock<IService> mockService = new Mock<IService>();
            Mock<IDelegateWorker> mockWorker = new Mock<IDelegateWorker>();
            MyViewModel vm = new MyViewModel(mockService.Object, mockWorker.Object);
            InteractionRequestTestHelper<Confirmation> irHelper
                = new InteractionRequestTestHelper<Confirmation>(vm.InputDialogInteractionRequest);
    
            // Act
            vm.DisplayInputDialogCommand.Execute(null);
    
            // Assert
            Assert.AreEqual(irHelper.Title, INPUT_DIALOG_TITLE);
        }
    
        [TestMethod()]
        public void DisplayInputDialogCommand_OnExecute_SetsIsBusyWhenDialogConfirmed()
        {
            // Arrange
            Mock<IService> mockService = new Mock<IService>();
            Mock<IDelegateWorker> mockWorker = new Mock<IDelegateWorker>();
            MyViewModel vm = new MyViewModel(mockService.Object, mockWorker.Object);
            vm.InputDialogInteractionRequest.Raised += (s, e) =>
            {
                Confirmation context = e.Context as Confirmation;
                context.Confirmed = true;
                e.Callback();
            };
    
            // Act
            vm.DisplayInputDialogCommand.Execute(null);
    
            // Assert
            Assert.IsTrue(vm.IsBusy);
        }
    
        [TestMethod()]
        public void DisplayInputDialogCommand_OnExecute_CallsDoSomethingWhenDialogConfirmed()
        {
            // Arrange
            Mock<IService> mockService = new Mock<IService>();
            IDelegateWorker worker = new DelegateWorker();
            MyViewModel vm = new MyViewModel(mockService.Object, worker);
            vm.InputDialogInteractionRequest.Raised += (s, e) =>
            {
                Confirmation context = e.Context as Confirmation;
                context.Confirmed = true;
                e.Callback();
            };
    
            // Act
            vm.DisplayInputDialogCommand.Execute(null);
    
            // Assert
            mockService.Verify(s => s.DoSomething(), Times.Once());
        }
    
        [TestMethod()]
        public void DisplayInputDialogCommand_OnExecute_ClearsIsBusyWhenDone()
        {
            // Arrange
            Mock<IService> mockService = new Mock<IService>();
            IDelegateWorker worker = new DelegateWorker();
            MyViewModel vm = new MyViewModel(mockService.Object, worker);
            vm.InputDialogInteractionRequest.Raised += (s, e) =>
            {
                Confirmation context = e.Context as Confirmation;
                context.Confirmed = true;
                e.Callback();
            };
    
            // Act
            vm.DisplayInputDialogCommand.Execute(null);
    
            // Assert
            Assert.IsFalse(vm.IsBusy);
        }
    
        [TestMethod()]
        public void DisplayInputDialogCommand_OnExecuteThrowsError_ShowsErrorDialog()
        {
            // Arrange
            Mock<IService> mockService = new Mock<IService>();
            mockService.Setup(s => s.DoSomething()).Throws(new Exception());
            DelegateWorker worker = new DelegateWorker();
            MyViewModel vm = new MyViewModel(mockService.Object, worker);
            vm.InputDialogInteractionRequest.Raised += (s, e) =>
            {
                Confirmation context = e.Context as Confirmation;
                context.Confirmed = true;
                e.Callback();
            };
            InteractionRequestTestHelper<Notification> irHelper
                = new InteractionRequestTestHelper<Notification>(vm.ErrorDialogInteractionRequest);
    
            // Act
            vm.DisplayInputDialogCommand.Execute(null);
    
            // Assert
            Assert.IsTrue(irHelper.RequestRaised);
        }
    
        [TestMethod()]
        public void DisplayInputDialogCommand_OnExecuteThrowsError_ShowsErrorDialogWithCorrectTitle()
        {
            // Arrange
            const string ERROR_TITLE = "Error";
            Mock<IService> mockService = new Mock<IService>();
            mockService.Setup(s => s.DoSomething()).Throws(new Exception());
            DelegateWorker worker = new DelegateWorker();
            MyViewModel vm = new MyViewModel(mockService.Object, worker);
            vm.InputDialogInteractionRequest.Raised += (s, e) =>
            {
                Confirmation context = e.Context as Confirmation;
                context.Confirmed = true;
                e.Callback();
            };
            InteractionRequestTestHelper<Notification> irHelper
                = new InteractionRequestTestHelper<Notification>(vm.ErrorDialogInteractionRequest);
    
            // Act
            vm.DisplayInputDialogCommand.Execute(null);
    
            // Assert
            Assert.AreEqual(irHelper.Title, ERROR_TITLE);
        }
    
        [TestMethod()]
        public void DisplayInputDialogCommand_OnExecuteThrowsError_ShowsErrorDialogWithCorrectErrorMessage()
        {
            // Arrange
            const string ERROR_MESSAGE_TEXT = "do something failed";
            Mock<IService> mockService = new Mock<IService>();
            mockService.Setup(s => s.DoSomething()).Throws(new Exception(ERROR_MESSAGE_TEXT));
            DelegateWorker worker = new DelegateWorker();
            MyViewModel vm = new MyViewModel(mockService.Object, worker);
            vm.InputDialogInteractionRequest.Raised += (s, e) =>
            {
                Confirmation context = e.Context as Confirmation;
                context.Confirmed = true;
                e.Callback();
            };
            InteractionRequestTestHelper<Notification> irHelper
                = new InteractionRequestTestHelper<Notification>(vm.ErrorDialogInteractionRequest);
    
            // Act
            vm.DisplayInputDialogCommand.Execute(null);
    
            // Assert
            Assert.AreEqual((string)irHelper.Content, ERROR_MESSAGE_TEXT);
        }
    
        // Helper Class
        public class InteractionRequestTestHelper<T> where T : Notification
        {
            public bool RequestRaised { get; private set; }
            public string Title { get; private set; }
            public object Content { get; private set; }
    
            public InteractionRequestTestHelper(InteractionRequest<T> request)
            {
                request.Raised += new EventHandler<InteractionRequestedEventArgs>(
                    (s, e) =>
                    {
                        RequestRaised = true;
                        Title = e.Context.Title;
                        Content = e.Context.Content;
                    });
            }
        }
    }
    

    备注:

    1. 另一个选择是使用 TypeMock 隔离(模拟)框架的商业版本。此框架非常适用于遗留代码或代码,否则这些代码或代码不适合单元测试。 TypeMock允许您模拟任何事物。我不会详细说明如何将其用于手头的问题,但仍然值得指出这是一个有效的选择。

    2. 在.NET 4.5中,不推荐使用BackgroundWorker,而是使用async / await模式。如上所述使用IDelegateWorker(或类似的)接口允许整个项目迁移到async / await模式,而无需修改单个ViewModel。

    3. 更新

      在实现上述技术后,我发现了一种更简单的.NET 4.0或更好的方法。要对异步进程进行单元测试,您需要一些方法来检测该进程何时完成,或者您需要能够在测试期间同步运行该进程。

      Microsoft在.NET 4.0中引入了Task Parallel Library (TPL)。该库提供了一组丰富的工具,用于执行远远超出BackgroundWorker类功能的异步操作。实现异步操作的最佳方法是使用TPL,然后从测试中的方法返回Task。以这种方式实现的异步操作的单元测试是微不足道的。

      [TestMethod]
      public void RunATest()
      {
          // Assert.
          var sut = new MyClass();
      
          // Act.
          sut.DoSomethingAsync().Wait();
      
          // Assert.
          Assert.IsTrue(sut.SomethingHappened);
      }
      

      如果将任务暴露给您的单元测试是不可能或不切实际的,那么下一个最佳选择是覆盖任务的计划方式。默认情况下,任务计划在ThreadPool上异步运行。您可以通过在代码中指定自定义调度程序来覆盖此行为。例如,以下代码将使用UI线程运行任务。

      Task.Factory.StartNew(
          () => DoSomething(),
          TaskScheduler.FromCurrentSynchronizationContext());
      

      要以可单元测试的方式实现此功能,请使用依赖注入传递任务调度程序。然后,您的单元测试可以传入一个任务调度程序,该任务调度程序在当前线程上同步执行操作,您的生产应用程序将传入一个任务调度程序,该任务调度程序在ThreadPool上异步运行任务。

      您甚至可以更进一步,通过使用反射覆盖默认任务调度程序来消除依赖注入。这使您的单元测试更加脆弱,但对您测试的实际代码的侵入性较小。有关其工作原理的详细说明,请参阅此blog post

      // Configure the default task scheduler to use the current synchronization context.
      Type taskSchedulerType = typeof(TaskScheduler);
      FieldInfo defaultTaskSchedulerField = taskSchedulerType.GetField("s_defaultTaskScheduler", BindingFlags.SetField | BindingFlags.Static | BindingFlags.NonPublic);
      defaultTaskSchedulerField.SetValue(null, TaskScheduler.FromCurrentSynchronizationContext());
      

      不幸的是,这不会像单元测试组件那样按预期工作。这是因为单元测试(如控制台应用程序)没有SynchronizationContext,您将收到以下错误消息。

      Error: System.InvalidOperationException: The current SynchronizationContext may not be used as a TaskScheduler.

      要解决此问题,您只需在测试设置中设置SynchronizationContext

      // Configure the current synchronization context to process work synchronously.
      SynchronizationContext.SetSynchronizationContext(new SynchronizationContext());
      

      这将消除错误,但您的某些测试可能仍会失败。这是因为默认的SynchronizationContext帖子与ThreadPool异步工作。要覆盖它,只需子类化默认的SynchronizationContext并覆盖Post方法,如下所示。

      public class TestSynchronizationContext : SynchronizationContext
      {
          public override void Post(SendOrPostCallback d, object state)
          {
              Send(d, state);
          }
      }
      

      有了这个,您的测试设置应该类似于下面的代码,并且默认情况下,您所测试的代码中的所有任务都将同步运行。

      // Configure the current synchronization context to process work synchronously.
      SynchronizationContext.SetSynchronizationContext(new TestSynchronizationContext());
      
      // Configure the default task scheduler to use the current synchronization context.
      Type taskSchedulerType = typeof(TaskScheduler);
      FieldInfo defaultTaskSchedulerField = taskSchedulerType.GetField("s_defaultTaskScheduler", BindingFlags.SetField | BindingFlags.Static | BindingFlags.NonPublic);
      defaultTaskSchedulerField.SetValue(null, TaskScheduler.FromCurrentSynchronizationContext());
      

      请注意,这不会阻止使用自定义计划程序启动任务。在这种情况下,您需要使用依赖注入传递该自定义调度程序,然后在测试期间传入同步调度程序。

答案 1 :(得分:1)

好问题。我将尝试你的选项3稍作修改。

  1. 使InteractionRequest可测试,以便测试方法可以选择是否确认或取消操作。因此,这允许测试各个路径。您可以使用IoC技术(控制反转)
  2. 重构DoWork&amp;中的所有逻辑RunWorkerCompleted用于分离方法,这允许独立地测试这些方法(如果需要)。
  3. 然后添加一个新标志IsAsyncFlag以指示是否需要异步执行。运行测试时关闭异步模式。
  4. 有很多强调测试覆盖率。但根据我的经验,100%的测试覆盖率很难实现,它永远不会成为代码质量的同义词。因此,我的重点是识别&amp;编写可以为解决方案增加价值的测试。

    如果您找到了更好的方法,请分享。