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