编辑:请参阅下面的更新以获得更简单的替代方案(仅限 .NET 4.0+)。
这种模式可以很容易地通过抽象接口背后的机制来测试,然后按照这个问题BackgroundWorker
中的描述针对该接口进行测试。一旦界面背后的怪癖被掩盖,测试就变得简单了。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 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;
});
}
}
}
笔记:
另一种选择是使用商业版的TypeMock隔离(模拟)框架。这个框架非常适合遗留代码或不适合单元测试的代码。TypeMock 允许你模拟任何东西。我不会详细说明如何将其用于手头的问题,但仍然值得指出它是一个有效的选项。
在 .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());
请注意,这不会阻止使用自定义调度程序启动任务。在这种情况下,您需要在使用依赖注入时传递该自定义调度程序,然后在测试期间传递一个同步调度程序。