我的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的情况下启动(并提供输入)长时间运行的操作。此模式的具体示例可能是“另存为...”操作,其中用户单击“另存为...”按钮,然后在弹出对话框中键入文本文本值,然后单击对话框确定按钮,然后观看旋转动画,同时他们的数据以指定的文件名保存。
在提供的代码示例中,启动此工作流程将执行以下操作。
提升Input_InteractionRequest
Raised
事件,在UI中显示一个对话框,以收集用户输入。
调用ProcessInput
回调(在用户完成对话框时触发)。
检查Confirmed
上下文的InteractionRequest
属性,以确定对话框是否已确认或取消。
如果确认......
设置IsBusy标志。
启动BackgroundWorker
以执行长时间运行的_DoSomethingService.DoSomething()
操作。
取消设置IsBusy标志。
如果DoSomething_DoWork发生错误,请提升Error_InteractionRequest
Raised
事件,在UI中显示一个消息框,以通知用户操作不成功。
我想最大化这种模式的单元测试覆盖率,但我不太确定如何处理它。我想避免直接对非公共成员进行单元测试,因为这种模式的具体实现可能会随着时间的推移而发生变化,实际上在我的应用程序中实例也各不相同。我考虑过以下几种选择,但它们似乎都不合适。
将BackgroundWorker
替换为IBackgroundWorker
并通过ctor注入。在测试期间使用同步IBackgroundWorker
以确保在调用DoWork / RunWorkerCompleted方法之前未完成单元测试。这需要大量重构,并且不会解决InteractionRequest
回调的测试问题。
使用System.Threading.Thread.Sleep(int)
允许在断言阶段之前完成BackgroundWorker
操作。我不喜欢这个,因为它很慢,我仍然不知道如何在InteractionRequest
回调中测试代码路径。
将BackgroundWorker
方法和InteractionRequest
回调重构为Humble Objects,可以同步并独立测试。这似乎很有希望,但构建它让我感到难过。
单元测试DoSomethingWorker_DoWork
,DoSomethingWorker_RunWorkerCompleted
和ProcessInput
同步且独立。这将为我提供所需的覆盖范围,但我会针对特定的实现而不是公共接口进行测试。
单元测试和/或重构上述模式以提供最大代码覆盖率的最佳方法是什么?
答案 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
方法。
Func<TInput, TResult> onStart
- 与BackgroundWorker.DoWork
相比。您可以在此处执行后台操作的主要工作。此委托应接受TInput
类型的单个参数,并返回类型TResult
的值,该值应传递给onComplete委托。
Action<TResult> onComplete
- 与BackgroundWorker.RunWorkerCompleted
相比。 onStart委托完成后将调用此委托。这是您执行任何后处理工作的地方。此委托应接受TResult
类型的单个参数。
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
注入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;
});
}
}
}
备注:强>
另一个选择是使用 TypeMock 隔离(模拟)框架的商业版本。此框架非常适用于遗留代码或代码,否则这些代码或代码不适合单元测试。 TypeMock允许您模拟任何事物。我不会详细说明如何将其用于手头的问题,但仍然值得指出这是一个有效的选择。
在.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
,您将收到以下错误消息。
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稍作修改。
有很多强调测试覆盖率。但根据我的经验,100%的测试覆盖率很难实现,它永远不会成为代码质量的同义词。因此,我的重点是识别&amp;编写可以为解决方案增加价值的测试。
如果您找到了更好的方法,请分享。