5

我的 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. 启动 aBackgroundWorker以执行长时间运行的_DoSomethingService.DoSomething()操作。

    3. 取消设置 IsBusy 标志。

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

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

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

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

  3. BackgroundWorker将方法和InteractionRequest回调重构为可以同步和独立测试的Humble Objects 。这看起来很有希望,但构建它让我很难过。

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

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

4

2 回答 2

7

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

这种模式可以很容易地通过抽象接口背后的机制来测试,然后按照这个问题BackgroundWorker中的描述针对该接口进行测试。一旦界面背后的怪癖被掩盖,测试就变得简单了。BackgroundWorkerInteractionRequest

这是我决定使用的界面。

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)方法。

然后,您可以使用依赖注入将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这种技术允许您通过在测试时注入同步(或模拟)实现和在生产中注入异步实现来避免类IDelegateWorkerMyViewModel怪癖。例如,您可以在测试时使用此实现。

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 中引入了任务并行库 (TPL) 。这个库提供了一组丰富的工具来执行远远超出BackgroundWorker类能力的异步操作。实现异步操作的最佳方式是使用 TPL,然后Task从您的测试方法中返回 a。然后对以这种方式实现的异步操作进行单元测试是微不足道的。

[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 上异步运行任务。

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

// 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());

不幸的是,这在单元测试程序集中无法按预期工作。这是因为单元测试(如控制台应用程序)没有 a 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());

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

于 2013-03-18T21:48:33.110 回答
1

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

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

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

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

于 2013-02-22T17:58:37.027 回答