Fix Switch activity behavior and add testing utilities for RunWorkflowResult and improve (#7151)

* Add testing utilities for `RunWorkflowResult` and improve `Switch` activity behavior

- Introduced `RunWorkflowResultAssertions` for xUnit-based workflow testing.
- Added `RunWorkflowResultExtensions` for journal-based activity execution context handling.
- Refactored `Switch` activity to track scheduled activities and correctly complete execution when all scheduled activities finish.
- Marked `RunActivityExtensions` methods as obsolete, recommending the use of `WorkflowTestFixture`.

* Update src/common/Elsa.Testing.Shared.Integration/RunActivityExtensions.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/modules/Elsa.Workflows.Core/Activities/Switch.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Add XML documentation to Switch activity callback method (#7152)

* Initial plan

* Add XML documentation to OnChildActivityCompletedAsync method

Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com>
This commit is contained in:
Sipke Schoorstra 2025-12-19 17:05:12 +01:00 committed by GitHub
parent 48b574a411
commit fbe9ca64f0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 547 additions and 40 deletions

View file

@ -10,36 +10,41 @@ namespace Elsa.Testing.Shared;
/// Provides extension methods for <see cref="IServiceProvider"/>.
/// </summary>
[PublicAPI]
[Obsolete("Use WorkflowTestFixture instead.")]
public static class RunActivityExtensions
{
/// <summary>
/// Runs the specified activity.
/// </summary>
/// <param name="services">The service provider.</param>
/// <param name="activity">The activity to run.</param>
/// <param name="cancellationToken">An optional cancellation token.</param>
/// <returns>The result of running the activity.</returns>
public static async Task<RunWorkflowResult> RunActivityAsync(this IServiceProvider services, IActivity activity, CancellationToken cancellationToken = default)
extension(IServiceProvider services)
{
await services.PopulateRegistriesAsync();
var workflowRunner = services.GetRequiredService<IWorkflowRunner>();
var result = await workflowRunner.RunAsync(activity, cancellationToken: cancellationToken);
return result;
}
/// <summary>
/// Runs the specified activity.
/// </summary>
/// <param name="activity">The activity to run.</param>
/// <param name="cancellationToken">An optional cancellation token.</param>
/// <returns>The result of running the activity.</returns>
[Obsolete("Use WorkflowTestFixture instead.")]
public async Task<RunWorkflowResult> RunActivityAsync(IActivity activity, CancellationToken cancellationToken = default)
{
await services.PopulateRegistriesAsync();
var workflowRunner = services.GetRequiredService<IWorkflowRunner>();
var result = await workflowRunner.RunAsync(activity, cancellationToken: cancellationToken);
return result;
}
/// <summary>
/// Runs the specified activity.
/// </summary>
/// <param name="services">The service provider.</param>
/// <param name="activity">The activity to run.</param>
/// <param name="options">An set of options.</param>
/// <param name="cancellationToken">An optional cancellation token.</param>
/// <returns>The result of running the activity.</returns>
public static async Task<RunWorkflowResult> RunActivityAsync(this IServiceProvider services, IActivity activity, RunWorkflowOptions options, CancellationToken cancellationToken = default)
{
await services.PopulateRegistriesAsync();
var workflowRunner = services.GetRequiredService<IWorkflowRunner>();
var result = await workflowRunner.RunAsync(activity, options, cancellationToken);
return result;
/// <summary>
/// Runs the specified activity.
/// </summary>
/// <param name="activity">The activity to run.</param>
/// <param name="options">A set of options.</param>
/// <param name="cancellationToken">An optional cancellation token.</param>
/// <returns>The result of running the activity.</returns>
[Obsolete("Use WorkflowTestFixture instead.")]
public async Task<RunWorkflowResult> RunActivityAsync(IActivity activity, RunWorkflowOptions options, CancellationToken cancellationToken = default)
{
await services.PopulateRegistriesAsync();
var workflowRunner = services.GetRequiredService<IWorkflowRunner>();
var result = await workflowRunner.RunAsync(activity, options, cancellationToken);
return result;
}
}
}

View file

@ -1,12 +1,9 @@
using Elsa.Common.Models;
using Elsa.Workflows;
using Elsa.Workflows.Management;
using Elsa.Workflows.Management.Entities;
using Elsa.Workflows.Management.Models;
using Elsa.Workflows.Models;
using Elsa.Workflows.Options;
using Elsa.Workflows.Runtime;
using Elsa.Workflows.Runtime.Filters;
using Elsa.Workflows.Runtime.Messages;
using Elsa.Workflows.State;
using JetBrains.Annotations;

View file

@ -0,0 +1,122 @@
using Elsa.Workflows;
using Elsa.Workflows.Models;
using JetBrains.Annotations;
using Xunit;
namespace Elsa.Testing.Shared;
/// <summary>
/// Provides assertion methods for <see cref="RunWorkflowResult"/> to facilitate Journal-based testing.
/// These methods integrate with xUnit's assertion framework.
/// </summary>
[PublicAPI]
public static class RunWorkflowResultAssertions
{
/// <param name="result">The workflow result.</param>
extension(RunWorkflowResult result)
{
/// <summary>
/// Asserts that the specified activity was executed (present in the journal).
/// </summary>
/// <param name="activity">The activity that should have been executed.</param>
public void AssertActivityExecuted(IActivity activity)
{
var context = result.GetActivityContext(activity);
Assert.NotNull(context);
}
/// <summary>
/// Asserts that the specified activity was not executed (not present in the journal).
/// </summary>
/// <param name="activity">The activity that should not have been executed.</param>
public void AssertActivityNotExecuted(IActivity activity)
{
var context = result.GetActivityContext(activity);
Assert.Null(context);
}
/// <summary>
/// Asserts that the specified activity completed successfully.
/// </summary>
/// <param name="activity">The activity that should have completed.</param>
public void AssertActivityCompleted(IActivity activity)
{
var context = result.GetActivityContext(activity);
Assert.NotNull(context);
Assert.Equal(ActivityStatus.Completed, context.Status);
}
/// <summary>
/// Asserts that the specified activity has the expected status.
/// </summary>
/// <param name="activity">The activity to check.</param>
/// <param name="expectedStatus">The expected status.</param>
public void AssertActivityStatus(IActivity activity, ActivityStatus expectedStatus)
{
var context = result.GetActivityContext(activity);
Assert.NotNull(context);
Assert.Equal(expectedStatus, context.Status);
}
/// <summary>
/// Asserts that all specified activities were executed.
/// </summary>
/// <param name="activities">The activities that should have been executed.</param>
public void AssertActivitiesExecuted(params IActivity[] activities)
{
foreach (var activity in activities)
result.AssertActivityExecuted(activity);
}
/// <summary>
/// Asserts that all specified activities were not executed.
/// </summary>
/// <param name="activities">The activities that should not have been executed.</param>
public void AssertActivitiesNotExecuted(params IActivity[] activities)
{
foreach (var activity in activities)
result.AssertActivityNotExecuted(activity);
}
/// <summary>
/// Asserts that all specified activities completed successfully.
/// </summary>
/// <param name="activities">The activities that should have completed.</param>
public void AssertActivitiesCompleted(params IActivity[] activities)
{
foreach (var activity in activities)
result.AssertActivityCompleted(activity);
}
/// <summary>
/// Asserts that the specified activity was executed exactly the expected number of times.
/// </summary>
/// <param name="activity">The activity to check.</param>
/// <param name="expectedCount">The expected execution count.</param>
public void AssertActivityExecutionCount(IActivity activity, int expectedCount)
{
var actualCount = result.GetExecutionCount(activity);
Assert.Equal(expectedCount, actualCount);
}
/// <summary>
/// Asserts that the workflow completed successfully.
/// </summary>
public void AssertWorkflowCompleted()
{
Assert.Equal(WorkflowStatus.Finished, result.WorkflowState.Status);
Assert.Equal(WorkflowSubStatus.Finished, result.WorkflowState.SubStatus);
}
/// <summary>
/// Asserts that the workflow has the expected status and substatus.
/// </summary>
/// <param name="expectedStatus">The expected workflow status.</param>
/// <param name="expectedSubStatus">The expected workflow substatus.</param>
public void AssertWorkflowStatus(WorkflowStatus expectedStatus, WorkflowSubStatus expectedSubStatus)
{
Assert.Equal(expectedStatus, result.WorkflowState.Status);
Assert.Equal(expectedSubStatus, result.WorkflowState.SubStatus);
}
}
}

View file

@ -0,0 +1,72 @@
using Elsa.Workflows;
using Elsa.Workflows.Models;
using JetBrains.Annotations;
namespace Elsa.Testing.Shared;
/// <summary>
/// Provides extension methods for <see cref="RunWorkflowResult"/> to facilitate testing with Journal-based assertions.
/// </summary>
[PublicAPI]
public static class RunWorkflowResultExtensions
{
/// <param name="result">The workflow result.</param>
extension(RunWorkflowResult result)
{
/// <summary>
/// Gets the activity execution context for the specified activity from the workflow result's journal.
/// </summary>
/// <param name="activity">The activity to find.</param>
/// <returns>The activity execution context, or null if not found.</returns>
public ActivityExecutionContext? GetActivityContext(IActivity activity) => result.Journal.ActivityExecutionContexts.FirstOrDefault(x => x.Activity == activity);
/// <summary>
/// Checks if the specified activity was executed (present in the journal).
/// </summary>
/// <param name="activity">The activity to check.</param>
/// <returns>True if the activity was executed, false otherwise.</returns>
public bool WasExecuted(IActivity activity) => result.GetActivityContext(activity) != null;
/// <summary>
/// Checks if the specified activity completed successfully.
/// </summary>
/// <param name="activity">The activity to check.</param>
/// <returns>True if the activity completed, false otherwise.</returns>
public bool WasCompleted(IActivity activity)
{
var context = result.GetActivityContext(activity);
return context?.Status == ActivityStatus.Completed;
}
/// <summary>
/// Gets the execution status of the specified activity.
/// </summary>
/// <param name="activity">The activity to check.</param>
/// <returns>The activity status, or null if the activity was not executed.</returns>
public ActivityStatus? GetActivityStatus(IActivity activity) => result.GetActivityContext(activity)?.Status;
/// <summary>
/// Gets all executed activities from the workflow result's journal.
/// </summary>
/// <returns>Collection of executed activities.</returns>
public IEnumerable<IActivity> GetExecutedActivities() => result.Journal.ActivityExecutionContexts.Select(x => x.Activity);
/// <summary>
/// Gets all completed activities from the workflow result's journal.
/// </summary>
/// <returns>Collection of completed activities.</returns>
public IEnumerable<IActivity> GetCompletedActivities() =>
result.Journal.ActivityExecutionContexts
.Where(x => x.Status == ActivityStatus.Completed)
.Select(x => x.Activity);
/// <summary>
/// Counts how many times the specified activity was executed.
/// Useful for activities that may execute multiple times (e.g., in loops).
/// </summary>
/// <param name="activity">The activity to count.</param>
/// <returns>The number of times the activity was executed.</returns>
public int GetExecutionCount(IActivity activity) =>
result.Journal.ActivityExecutionContexts.Count(x => x.Activity == activity);
}
}

View file

@ -3,12 +3,7 @@ using Elsa.Workflows;
using Elsa.Workflows.Management;
using Elsa.Workflows.Management.Entities;
using Elsa.Workflows.Management.Models;
using Elsa.Workflows.Models;
using Elsa.Workflows.Options;
using Elsa.Workflows.Runtime;
using Elsa.Workflows.Runtime.Filters;
using Elsa.Workflows.Runtime.Messages;
using Elsa.Workflows.State;
using JetBrains.Annotations;
using Microsoft.Extensions.DependencyInjection;

View file

@ -56,19 +56,37 @@ public class Switch : Activity
var matchingCases = (await FindMatchingCasesAsync(context.ExpressionExecutionContext)).ToList();
var hasAnyMatches = matchingCases.Any();
var mode = context.Get(Mode);
var results = mode == SwitchMode.MatchFirst ? hasAnyMatches ? new[] { matchingCases.First() } : Array.Empty<SwitchCase>() : matchingCases.ToArray();
var results = mode == SwitchMode.MatchFirst
? hasAnyMatches
? new[] { matchingCases.First() }
: Array.Empty<SwitchCase>()
: matchingCases.ToArray();
var scheduledActivityIds = new HashSet<string>();
if (hasAnyMatches)
{
foreach (var result in results)
{
await context.ScheduleActivityAsync(result.Activity, OnChildActivityCompletedAsync);
}
if (result.Activity == null)
continue;
return;
await context.ScheduleActivityAsync(result.Activity, OnChildActivityCompletedAsync);
scheduledActivityIds.Add(result.Activity.Id);
}
}
else if (Default != null)
{
await context.ScheduleActivityAsync(Default, OnChildActivityCompletedAsync);
scheduledActivityIds.Add(Default.Id);
}
await context.ScheduleActivityAsync(Default, OnChildActivityCompletedAsync);
context.SetProperty("ScheduledActivityIds", scheduledActivityIds);
if (!scheduledActivityIds.Any())
{
await context.CompleteActivityAsync();
}
}
private async Task<IEnumerable<SwitchCase>> FindMatchingCasesAsync(ExpressionExecutionContext context)
@ -89,9 +107,19 @@ public class Switch : Activity
return matchingCases;
}
/// <summary>
/// Tracks the completion of scheduled child activities and completes the Switch activity only after all scheduled children have finished executing.
/// </summary>
private async ValueTask OnChildActivityCompletedAsync(ActivityCompletedContext context)
{
await context.TargetContext.CompleteActivityAsync();
var scheduledActivityIds = context.TargetContext.GetProperty<HashSet<string>>("ScheduledActivityIds");
if (scheduledActivityIds != null
&& scheduledActivityIds.Remove(context.ChildContext.Activity.Id)
&& scheduledActivityIds.Count == 0)
{
await context.TargetContext.CompleteActivityAsync();
}
}
}

View file

@ -0,0 +1,288 @@
using Elsa.Testing.Shared;
using Elsa.Workflows;
using Elsa.Workflows.Activities;
using Xunit.Abstractions;
namespace Elsa.Activities.IntegrationTests.Branching;
/// <summary>
/// Integration tests for Switch activity.
/// </summary>
public class SwitchTests(ITestOutputHelper testOutputHelper)
{
private readonly WorkflowTestFixture _fixture = new(testOutputHelper);
[Fact(DisplayName = "Switch completes immediately when no matches and no activities scheduled")]
public async Task Switch_CompletesImmediately_WhenNoMatchesAndNoActivitiesScheduled()
{
// Arrange
var case1Activity = new WriteLine("Case 1");
var case2Activity = new WriteLine("Case 2");
var switchActivity = new Switch
{
Cases =
{
new("Case 1", () => false, case1Activity),
new("Case 2", () => false, case2Activity)
},
Default = null
};
// Act
var result = await _fixture.RunActivityAsync(switchActivity);
// Assert
result.AssertActivityCompleted(switchActivity);
result.AssertActivitiesNotExecuted(case1Activity, case2Activity);
}
[Fact(DisplayName = "Switch completes immediately when no matches but has null default")]
public async Task Switch_CompletesImmediately_WhenNoMatchesAndDefaultIsNull()
{
// Arrange
var case1Activity = new WriteLine("Case 1");
var switchActivity = new Switch
{
Cases =
{
new("Case 1", () => false, case1Activity)
}
};
// Act
var result = await _fixture.RunActivityAsync(switchActivity);
// Assert
result.AssertActivityCompleted(switchActivity);
result.AssertActivityNotExecuted(case1Activity);
}
[Fact(DisplayName = "Switch schedules default activity when no cases match")]
public async Task Switch_SchedulesDefaultActivity_WhenNoCasesMatch()
{
// Arrange
var case1Activity = new WriteLine("Case 1");
var case2Activity = new WriteLine("Case 2");
var defaultActivity = new WriteLine("Default");
var switchActivity = new Switch
{
Cases =
{
new("Case 1", () => false, case1Activity),
new("Case 2", () => false, case2Activity)
},
Default = defaultActivity
};
// Act
var result = await _fixture.RunActivityAsync(switchActivity);
// Assert
result.AssertActivitiesCompleted(switchActivity, defaultActivity);
result.AssertActivitiesNotExecuted(case1Activity, case2Activity);
}
[Fact(DisplayName = "Switch in MatchFirst mode schedules only first matching case")]
public async Task Switch_InMatchFirstMode_SchedulesOnlyFirstMatchingCase()
{
// Arrange
var case1Activity = new WriteLine("Case 1");
var case2Activity = new WriteLine("Case 2");
var case3Activity = new WriteLine("Case 3");
var switchActivity = new Switch
{
Mode = new(SwitchMode.MatchFirst),
Cases =
{
new("Case 1", () => false, case1Activity),
new("Case 2", () => true, case2Activity),
new("Case 3", () => true, case3Activity)
}
};
// Act
var result = await _fixture.RunActivityAsync(switchActivity);
// Assert
result.AssertActivitiesCompleted(switchActivity, case2Activity);
result.AssertActivitiesNotExecuted(case1Activity, case3Activity);
}
[Fact(DisplayName = "Switch in MatchAny mode schedules all matching cases")]
public async Task Switch_InMatchAnyMode_SchedulesAllMatchingCases()
{
// Arrange
var case1Activity = new WriteLine("Case 1");
var case2Activity = new WriteLine("Case 2");
var case3Activity = new WriteLine("Case 3");
var case4Activity = new WriteLine("Case 4");
var case5Activity = new WriteLine("Case 5");
var switchActivity = new Switch
{
Mode = new(SwitchMode.MatchAny),
Cases =
{
new("Case 1", () => false, case1Activity),
new("Case 2", () => true, case2Activity),
new("Case 3", () => true, case3Activity),
new("Case 4", () => false, case4Activity),
new("Case 5", () => true, case5Activity)
}
};
// Act
var result = await _fixture.RunActivityAsync(switchActivity);
// Assert
result.AssertActivitiesCompleted(switchActivity, case2Activity, case3Activity, case5Activity);
result.AssertActivitiesNotExecuted(case1Activity, case4Activity);
}
[Fact(DisplayName = "Switch completes only after all scheduled activities complete")]
public async Task Switch_CompletesOnlyAfterAllScheduledActivitiesComplete()
{
// Arrange
var activity1 = new WriteLine("Activity 1");
var activity2 = new WriteLine("Activity 2");
var activity3 = new WriteLine("Activity 3");
var switchActivity = new Switch
{
Mode = new(SwitchMode.MatchAny),
Cases =
{
new("Case 1", () => true, activity1),
new("Case 2", () => true, activity2),
new("Case 3", () => true, activity3)
}
};
// Act
var result = await _fixture.RunActivityAsync(switchActivity);
// Assert
result.AssertActivitiesCompleted(switchActivity, activity1, activity2, activity3);
}
[Fact(DisplayName = "Switch completes after default activity completes")]
public async Task Switch_CompletesAfterDefaultActivityCompletes()
{
// Arrange
var case1Activity = new WriteLine("Case 1");
var defaultActivity = new WriteLine("Default Activity");
var switchActivity = new Switch
{
Cases =
{
new("Case 1", () => false, case1Activity)
},
Default = defaultActivity
};
// Act
var result = await _fixture.RunActivityAsync(switchActivity);
// Assert
result.AssertActivitiesCompleted(switchActivity, defaultActivity);
result.AssertActivityNotExecuted(case1Activity);
}
[Fact(DisplayName = "Switch with nested activities completes after all complete")]
public async Task Switch_WithNestedActivities_CompletesAfterAllComplete()
{
// Arrange
var nestedSequence1 = new Sequence
{
Activities =
{
new WriteLine("Nested 1A"),
new WriteLine("Nested 1B")
}
};
var nestedSequence2 = new Sequence
{
Activities =
{
new WriteLine("Nested 2A"),
new WriteLine("Nested 2B")
}
};
var switchActivity = new Switch
{
Mode = new(SwitchMode.MatchAny),
Cases =
{
new("Case 1", () => true, nestedSequence1),
new("Case 2", () => true, nestedSequence2)
}
};
// Act
var result = await _fixture.RunActivityAsync(switchActivity);
// Assert
result.AssertActivitiesCompleted(switchActivity, nestedSequence1, nestedSequence2);
}
[Fact(DisplayName = "Switch in MatchFirst mode with single match")]
public async Task Switch_InMatchFirstMode_WithSingleMatch()
{
// Arrange
var case1Activity = new WriteLine("Case 1");
var case2Activity = new WriteLine("Case 2");
var case3Activity = new WriteLine("Case 3");
var switchActivity = new Switch
{
Mode = new(SwitchMode.MatchFirst),
Cases =
{
new("Case 1", () => false, case1Activity),
new("Case 2", () => false, case2Activity),
new("Case 3", () => true, case3Activity)
}
};
// Act
var result = await _fixture.RunActivityAsync(switchActivity);
// Assert
result.AssertActivitiesCompleted(switchActivity, case3Activity);
result.AssertActivitiesNotExecuted(case1Activity, case2Activity);
}
[Fact(DisplayName = "Switch evaluates conditions with expression context")]
public async Task Switch_EvaluatesConditionsWithExpressionContext()
{
// Arrange
var conditionValue = true;
var case1Activity = new WriteLine("Case 1");
var case2Activity = new WriteLine("Case 2");
var case3Activity = new WriteLine("Case 3");
var switchActivity = new Switch
{
Cases =
{
new("Case 1", _ => false, case1Activity),
new("Case 2", _ => conditionValue, case2Activity),
new("Case 3", _ => false, case3Activity)
}
};
// Act
var result = await _fixture.RunActivityAsync(switchActivity);
// Assert
result.AssertActivitiesCompleted(switchActivity, case2Activity);
result.AssertActivitiesNotExecuted(case1Activity, case3Activity);
}
}