編集:より簡単な代替案については、以下の更新を参照してください(.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
ます。これは、バックグラウンド操作の主要な作業を実行する場所です。このデリゲートは、タイプの単一のパラメーターを受け入れ、onCompleteデリゲートに渡されるTInput
タイプの値を返す必要があります。TResult
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;
});
}
}
}
ノート:
もう1つのオプションは、TypeMock分離(モック)フレームワークの商用バージョンを使用することです。このフレームワークは、レガシーコードや単体テストに適していないコードに最適です。TypeMockを使用すると、ほぼすべてをモックできます。これを当面の質問にどのように使用できるかについては詳しく説明しませんが、それでも有効なオプションであることを指摘する価値があります。
.NET 4.5では、 /パターンBackgroundWorker
を優先しての使用は非推奨になりました。上記の(または同様の)インターフェイスを使用すると、単一のViewModelを変更することなく、プロジェクト全体を/パターンに移行できます。async
await
IDelegateWorker
async
await
アップデート:
上記の手法を実装した後、.NET4.0以降のより簡単なアプローチを発見しました。非同期プロセスを単体テストするには、そのプロセスがいつ完了したかを検出する方法が必要です。または、テスト中にそのプロセスを同期的に実行できる必要があります。
Microsoftは、.NET 4.0でタスク並列ライブラリ(TPL)を導入しました。BackgroundWorker
このライブラリは、クラスの機能をはるかに超える非同期操作を実行するための豊富なツールセットを提供します。Task
非同期操作を実装する最良の方法は、TPLを使用してから、テスト対象のメソッドからを返すことです。このように実装された非同期操作の単体テストは簡単です。
[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());
残念ながら、これは単体テストアセンブリから期待どおりに機能しません。これは、コンソールアプリケーションのような単体テストにはがなく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());
これは、タスクがカスタムスケジューラで開始されることを妨げるものではないことに注意してください。このような場合、依存性注入を使用する際にそのカスタムスケジューラを渡してから、テスト中に同期スケジューラを渡す必要があります。