WF4: How Tracking Helped Me Write a Better Unit Test

This morning I’ve been working on how to support cancelling a workflow via a CancellationToken.  The details of that are not important right now but what is really cool is how I was able to test this.

Scenario: Caller requests Cancellation via a CancellationToken and the UnhandledExceptionAction is Cancel

Given

  • An activity that contains a CancellationScope
  • The CancellationScope body has an activity that will create a bookmark and go idle
  • The CancellationScope has a CancelHandler with a WriteLine that has a DisplayName "CancelHandlerWriteLine"

When

  • The caller invokes the workflow asynchronously as a task with a CancellationToken
  • and in the idle callback calls CancellationTokenSource.Cancel

Then

  • A TaskCanceledException is thrown
  • The WorkflowApplication is canceled
  • The CancelationScope CancelHandler is invoked
Test Challenges
  • How can I wait until the cancel is completed after handling the exception before verifying?
  • How will I verify that the CancelHandler is invoked?
Solution

To wait until the cancel is completed after handling the exception before verifying I simply create an AutoResetEvent (line 18) and signal it from the WorkflowApplication.Completed event callback (line 19).  Then before verifying the tracking data I wait for this event (line 41)

To verify that the cancel handler was invoked I use the Microsoft.Activities.UnitTesting.Tracking.MemoryTrackingParticipant. This allows me to capture the tracking information into a collection that I can search using AssertTracking.Exists to verify that the activity with the name ExpectedCancelWriteline entered the Closed state.

    1: [TestMethod]
    2: public void ActivityIsCanceledViaTokenShouldInvokeCancelHandler()
    3: {
    4:     const string ExpectedCancelWriteLine = "CancelHandlerWriteLine";
    5:     var workflowApplication =
    6:         new WorkflowApplication(
    7:             new CancellationScope
    8:                 {
    9:                     Body = new TestBookmark<int> { BookmarkName = "TestBookmark" }, 
   10:                     CancellationHandler = new WriteLine { DisplayName = ExpectedCancelWriteLine }
   11:                 });
   12:  
   13:     // Capture tracking events in memory
   14:     var trackingParticipant = new MemoryTrackingParticipant();
   15:     workflowApplication.Extensions.Add(trackingParticipant);
   16:  
   17:     // Use this event to wait until the cancel is completed
   18:     var completedEvent = new AutoResetEvent(false);
   19:     workflowApplication.Completed = args => completedEvent.Set();
   20:  
   21:     try
   22:     {
   23:         var tokenSource = new CancellationTokenSource();
   24:  
   25:         // Run the activity and cancel in the idle callback
   26:         var task = workflowApplication.RunEpisodeAsync(
   27:             (args, bn) =>
   28:                 {
   29:                     Debug.WriteLine("Idle callback - cancel");
   30:                     tokenSource.Cancel();
   31:                     return false;
   32:                 }, 
   33:             UnhandledExceptionAction.Cancel, 
   34:             TimeSpan.FromMilliseconds(1000), 
   35:             tokenSource.Token);
   36:  
   37:         // Exception is thrown when Wait() or Result is accessed
   38:         AssertHelper.Throws<TaskCanceledException>(task);
   39:  
   40:         // Wait for the workflow to complete the cancel
   41:         completedEvent.WaitOne(this.DefaultTimeout);
   42:  
   43:         // Verify the the cancel handler was invoked
   44:         AssertTracking.Exists(
   45:             trackingParticipant.Records, ExpectedCancelWriteLine, ActivityInstanceState.Closed);
   46:     }
   47:     finally
   48:     {
   49:         // Write the tracking records to the test output
   50:         trackingParticipant.Trace();
   51:     }
   52: }
   53:  

When I run this test I also get the Tracking info in the Test Results along with any Debug.WriteLine output to help me sort out what is happening.  The tracking data is nicely formatted thanks to extension methods in Microsoft.Activities.UnitTesting.Tracking that provide a Trace method for each type of tracking record which produces human readable formatting.

 WaitForWorkflow waiting for workflowBusy - check for cancel
 Checking cancel token
 System.Activities.WorkflowApplicationIdleEventArgs
     Bookmarks count 1 (TestBookmark)
 Idle callback - cancel
 Checking cancel token from idle handler
 Cancel requested canceling workflow 
 WaitForWorkflow workflowBusy is signaled - check for cancel
 Checking cancel token
 Cancel requested canceling workflow 
 WorkflowApplication.Cancel
 this.CancellationToken.ThrowIfCancellationRequested()
 *** Tracking data follows ***
 WorkflowInstance for Activity <CancellationScope> state is <Started> at 04:13:53.7852
 Activity <null> is scheduled child activity <CancellationScope> at 04:13:53.7852
 Activity <CancellationScope> state is Executing at 04:13:53.7852
 Activity <CancellationScope> is scheduled child activity <TestBookmark> at 04:13:53.7852
 Activity <TestBookmark> state is Executing at 04:13:53.7852
 {
     Arguments
         BookmarkName: TestBookmark
 }
 WorkflowInstance for Activity <CancellationScope> state is <Idle> at 04:13:53.7852
 Activity <null> cancel is requested for child activity <CancellationScope> at 04:13:53.7852
 Activity <CancellationScope> cancel is requested for child activity <TestBookmark> at 04:13:53.7852
 Activity <TestBookmark> state is Canceled at 04:13:53.8008
 {
     Arguments
         BookmarkName: TestBookmark
         Result: 0
 }
 Activity <CancellationScope> is scheduled child activity <CancelHandlerWriteLine> at 04:13:53.8008
 Activity <CancelHandlerWriteLine> state is Executing at 04:13:53.8008
 {
     Arguments
         Text: 
         TextWriter: 
 }
 Activity <CancelHandlerWriteLine> state is Closed at 04:13:53.8008
 {
     Arguments
         Text: 
         TextWriter: 
 }
 Activity <CancellationScope> state is Canceled at 04:13:53.8008
 WorkflowInstance for Activity <CancellationScope> state is <Canceled> at 04:13:53.8008