【问题标题】:How to Unit Test BackgroundWorker + PRISM InteractionRequest?如何对 BackgroundWorker + PRISM InteractionRequest 进行单元测试?
【发布时间】:2013-01-22 00:01:33
【问题描述】:

我的 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. 检查InteractionRequest 上下文的Confirmed 属性以确定对话是确认还是取消。

  4. 如果确认...

    1. 设置 IsBusy 标志。

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

    3. 取消设置 IsBusy 标志。

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

      李>

我想最大化此模式的单元测试覆盖率,但我不太确定如何处理它。我想避免直接对非公共成员进行单元测试,因为这种模式的具体实现可能会随着时间而改变,实际上在我的应用程序中会因实例而异。我考虑了以下选项,但似乎都不合适。

  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同步独立。这将为我提供所需的覆盖范围,但我将针对特定实现而不是公共接口进行测试。

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

【问题讨论】:

  • 注意 - InteractionRequest 是 MS 模式和实践 Prism 库提供的用户交互模式。它允许 MVVM 应用程序中的 ViewModel 触发 View 中的用户交互,例如弹出对话框。当用户交互完成时,调用回调委托(如果提供)以执行特定于交互的任何处理。

标签: c# unit-testing mvvm prism backgroundworker


【解决方案1】:

编辑:请参阅下面的更新以获得更简单的替代方案(仅限 .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&lt;TInput, TResult&gt; onStart - 相当于BackgroundWorker.DoWork。这是您执行后台操作的主要工作的地方。此委托应接受 TInput 类型的单个参数并返回应传递给 onComplete 委托的 TResult 类型的值。

  2. Action&lt;TResult&gt; onComplete - 相当于BackgroundWorker.RunWorkerCompleted。这个委托将在 onStart 委托完成后被调用。这是您执行任何后处理工作的地方。此委托应接受 TResult 类型的单个参数。

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

然后您可以使用依赖注入将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;
    }
}

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

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 的所有方面,如下所示。请注意,我正在使用 MSTestMoq 并且根据 Visual Studio Code Coverage 工具实现了 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。

更新:

在实施上述技术后,我发现了一种更简单的 .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,您将收到以下错误消息。

错误:System.InvalidOperationException:当前 SynchronizationContext 不能用作 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());

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

【讨论】:

    【解决方案2】:

    好问题。我将尝试您的选项 3,并稍作改动。

    1. 使 InteractionRequest 可测试,以便测试方法可以选择是确认还是取消操作。因此,这允许测试各个路径。您可以使用 IoC 技术(控制反转)
    2. 将 DoWork 和 RunWorkerCompleted 中的所有逻辑重构为单独的方法,这允许独立测试这些方法(如果需要)。
    3. 然后添加一个新的标志 IsAsyncFlag 来指示这是否需要异步执行。运行测试时关闭异步模式。

    有很多强调测试覆盖率。但根据我的经验,很难实现 100% 的测试覆盖率,而且它永远不能成为代码质量的代名词。因此,我的重点是识别和编写可以为解决方案增加价值的测试。

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

    【讨论】:

    • 感谢您的回复。非常有兴趣看到您的实现。我仍在努力,并将发布我提出的任何技术。
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 2020-12-22
    • 2012-01-08
    • 1970-01-01
    • 2019-11-02
    • 1970-01-01
    • 1970-01-01
    • 2013-09-30
    相关资源
    最近更新 更多