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 をロックすることなく実行時間の長い操作を開始 (および入力を提供) できるダイアログ指向のワークフローを記述します。このパターンの具体的な例としては、ユーザーが [名前を付けて保存...] ボタンをクリックし、ポップアップ ダイアログでファイル名のテキスト値をキー入力し、ダイアログの [OK] ボタンをクリックする [名前を付けて保存...] 操作があります。 、指定したファイル名でデータが保存されている間、スピン アニメーションを視聴します。

提供されているコード例では、このワークフローを開始すると、次の操作が実行されます。

  1. Input_InteractionRequest Raisedユーザー入力を収集する目的で UI にダイアログを表示するイベントを発生させます。

  2. コールバックを呼び出しProcessInputます (ユーザーがダイアログを完了するとトリガーされます)。

  3. ConfirmedコンテキストのプロパティをチェックしてInteractionRequest、ダイアログが確認されたかキャンセルされたかを判断します。

  4. 確認したら…

    1. IsBusy フラグを設定します。

    2. a を開始しBackgroundWorkerて長時間実行_DoSomethingService.DoSomething()オペレーションを実行します。

    3. IsBusy フラグを設定解除します。

    4. DoSomething_DoWork でエラーが発生した場合Error_InteractionRequest Raisedは、操作が成功しなかったことをユーザーに通知する目的で、UI にメッセージ ボックスを表示するイベントを発生させます。

このパターンの単体テスト カバレッジを最大化したいのですが、アプローチ方法がよくわかりません。このパターンの特定の実装は時間の経過とともに変化する可能性があり、実際にはアプリケーション全体でインスタンスごとに異なるため、非公開メンバーを直接単体テストすることは避けたいと思います。次のオプションを検討しましたが、どれも適切ではないようです。

  1. に置き換えBackgroundWorkerIBackgroundWorker、ctor 経由で注入します。テスト中に同期を使用して、IBackgroundWorkerDoWork/RunWorkerCompleted メソッドが呼び出される前に単体テストが完了しないようにします。これには多くのリファクタリングが必要であり、コールバックのテストにも対応していませんInteractionRequest

  2. アサーション ステージの前に操作を完了System.Threading.Thread.Sleep(int)できるようにするために使用します。これは遅く、コールバックBackgroundWorkerでコード パスをテストする方法がまだわからないため、これは好きではありません。InteractionRequest

  3. BackgroundWorkerメソッドとInteractionRequestコールバックをHumble Objectsにリファクタリングして、同期的かつ独立してテストできるようにします。これは有望に思えますが、それを構造化すると困惑します。

  4. 単体テストDoSomethingWorker_DoWorkDoSomethingWorker_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ます。これは、バックグラウンド操作の主要な作業を実行する場所です。このデリゲートは、タイプの単一のパラメーターを受け入れ、onCompleteデリゲートに渡されるTInputタイプの値を返す必要があります。TResult

  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時にの同期(またはモック)実装と本番環境の非同期実装を注入することで、クラス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。私は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. もう1つのオプションは、TypeMock分離(モック)フレームワークの商用バージョンを使用することです。このフレームワークは、レガシーコードや単体テストに適していないコードに最適です。TypeMockを使用すると、ほぼすべてをモックできます。これを当面の質問にどのように使用できるかについては詳しく説明しませんが、それでも有効なオプションであることを指摘する価値があります。

  2. .NET 4.5では、 /パターンBackgroundWorkerを優先しての使用は非推奨になりました。上記の(または同様の)インターフェイスを使用すると、単一のViewModelを変更することなく、プロジェクト全体を/パターンに移行できます。asyncawaitIDelegateWorkerasyncawait

アップデート:

上記の手法を実装した後、.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());

これは、タスクがカスタムスケジューラで開始されることを妨げるものではないことに注意してください。このような場合、依存性注入を使用する際にそのカスタムスケジューラを渡してから、テスト中に同期スケジューラを渡す必要があります。

于 2013-03-18T21:48:33.110 に答える